companion-rgblink-openapi-connector
Version:
This is POC of connector between modules for Bitfocus Companion (https://bitfocus.io/companion) and RGBlink (https://www.rgblink.com/) devices managed by RGBLink OpenAPI
449 lines (384 loc) • 15.9 kB
text/typescript
import { LogLevel, UDPHelper } from "@companion-module/base";
const MAX_COMMANDS_WAITING_FOR_RESPONSES_FOR_POLLING: number = 5
const COMMANDS_EXPIRE_TIME_SECONDS: number = 15
export type Hex = '00' | '01' | '02' | '03' | '04' | '05' | '06' | '07' | '08' | '09' | '0A' | '0B' | '0C' | '0D' | '0E' | '0F' | '10' | '11' | '12' | '13' | '14' | '15' | '16' | '17' | '18' | '19' | '1A' | '1B' | '1C' | '1D' | '1E' | '1F' | '20' | '21' | '22' | '23' | '24' | '25' | '26' | '27' | '28' | '29' | '2A' | '2B' | '2C' | '2D' | '2E' | '2F' | '30' | '31' | '32' | '33' | '34' | '35' | '36' | '37' | '38' | '39' | '3A' | '3B' | '3C' | '3D' | '3E' | '3F' | '40' | '41' | '42' | '43' | '44' | '45' | '46' | '47' | '48' | '49' | '4A' | '4B' | '4C' | '4D' | '4E' | '4F' | '50' | '51' | '52' | '53' | '54' | '55' | '56' | '57' | '58' | '59' | '5A' | '5B' | '5C' | '5D' | '5E' | '5F' | '60' | '61' | '62' | '63' | '64' | '65' | '66' | '67' | '68' | '69' | '6A' | '6B' | '6C' | '6D' | '6E' | '6F' | '70' | '71' | '72' | '73' | '74' | '75' | '76' | '77' | '78' | '79' | '7A' | '7B' | '7C' | '7D' | '7E' | '7F' | '80' | '81' | '82' | '83' | '84' | '85' | '86' | '87' | '88' | '89' | '8A' | '8B' | '8C' | '8D' | '8E' | '8F' | '90' | '91' | '92' | '93' | '94' | '95' | '96' | '97' | '98' | '99' | '9A' | '9B' | '9C' | '9D' | '9E' | '9F' | 'A0' | 'A1' | 'A2' | 'A3' | 'A4' | 'A5' | 'A6' | 'A7' | 'A8' | 'A9' | 'AA' | 'AB' | 'AC' | 'AD' | 'AE' | 'AF' | 'B0' | 'B1' | 'B2' | 'B3' | 'B4' | 'B5' | 'B6' | 'B7' | 'B8' | 'B9' | 'BA' | 'BB' | 'BC' | 'BD' | 'BE' | 'BF' | 'C0' | 'C1' | 'C2' | 'C3' | 'C4' | 'C5' | 'C6' | 'C7' | 'C8' | 'C9' | 'CA' | 'CB' | 'CC' | 'CD' | 'CE' | 'CF' | 'D0' | 'D1' | 'D2' | 'D3' | 'D4' | 'D5' | 'D6' | 'D7' | 'D8' | 'D9' | 'DA' | 'DB' | 'DC' | 'DD' | 'DE' | 'DF' | 'E0' | 'E1' | 'E2' | 'E3' | 'E4' | 'E5' | 'E6' | 'E7' | 'E8' | 'E9' | 'EA' | 'EB' | 'EC' | 'ED' | 'EE' | 'EF' | 'F0' | 'F1' | 'F2' | 'F3' | 'F4' | 'F5' | 'F6' | 'F7' | 'F8' | 'F9' | 'FA' | 'FB' | 'FC' | 'FD' | 'FE' | 'FF';
export interface Logger {
log(level: LogLevel, message: string): void;
}
export class PollingCommand {
CMD: Hex
DAT1: Hex
DAT2: Hex
DAT3: Hex
DAT4: Hex
constructor(CMD: Hex, DAT1: Hex, DAT2: Hex, DAT3: Hex, DAT4: Hex) {
if (CMD.length != 2 || DAT1.length != 2 || DAT2.length != 2 || DAT3.length != 2 || DAT4.length != 2) {
console.log(`Bad command params: CMD:'${CMD}' DAT1:'${DAT1}' DAT2:'${DAT2}' DAT3:'${DAT3}' DAT4:'${DAT4}'`)
}
this.CMD = CMD
this.DAT1 = DAT1
this.DAT2 = DAT2
this.DAT3 = DAT3
this.DAT4 = DAT4
}
}
export class SentCommand {
sentDate: number
command: string
constructor(command: string, sentDate: number) {
this.command = command
this.sentDate = sentDate
}
}
export class SentCommandStorage {
private commandsSentWithoutResponse: SentCommand[] = []
private expireTimeSeconds: number = COMMANDS_EXPIRE_TIME_SECONDS
public registerSentCommand(cmd: string) {
//console.log('OUT ' + cmd)
this.internalRememberCommand(cmd)
}
public registerReceivedCommand(cmd: string) {
//console.log('IN ' + cmd)
this.internalCompareResponseWithSentCommands(cmd)
}
public getCountElementsWithoutRespond(): number {
return this.commandsSentWithoutResponse.length
}
public deleteExpiredCommands(): SentCommand[] {
const currentMs: number = new Date().getTime()
let deleted: SentCommand[] = []
//for (let i = 0; i < this.commandsSentWithoutResponse.length; i++) {
let i = this.commandsSentWithoutResponse.length
while (i--) {
const sent: SentCommand = this.commandsSentWithoutResponse[i]
if (sent.sentDate + this.expireTimeSeconds * 1000 < currentMs) {
deleted = deleted.concat(this.commandsSentWithoutResponse.splice(i, 1))
}
}
return deleted
}
public deleteAll(): void {
this.commandsSentWithoutResponse = []
}
public setExpireTimeSecons(seconds: number) {
this.expireTimeSeconds = seconds
}
private internalRememberCommand(cmd: string): void {
//console.log('Storing ' + cmd + '...')
this.commandsSentWithoutResponse.push(new SentCommand(cmd, new Date().getTime()))
}
private internalCompareResponseWithSentCommands(receivedCmd: string) {
const receivedCmdId: string = this.internalGetCmdId(receivedCmd)
let found = false
for (let i = 0; i < this.commandsSentWithoutResponse.length; i++) {
const sent: SentCommand = this.commandsSentWithoutResponse[i]
const sentCmdId: string = this.internalGetCmdId(sent.command)
if (receivedCmdId == sentCmdId) {
//console.log('Found sent command for response ' + receivedCmd)
found = true
this.commandsSentWithoutResponse.splice(i, 1)
break
}
}
if (!found) {
console.log('No sent command matching received response: ' + receivedCmd)
}
}
private internalGetCmdId(cmd: string): string {
return cmd.substr(2, 6)
}
}
export class ApiConfig {
host: string
port: number
polling: boolean
logEveryCommand: boolean
constructor(host: string, port: number, polling: boolean, logEveryCommand: boolean) {
this.host = host
this.port = port
this.polling = polling
this.logEveryCommand = logEveryCommand
}
}
type StringToListMap<T> = Record<string, T[]>;
export class RGBLinkApiConnector {
public static EVENT_NAME_ON_DATA_API = 'on_data'
public static EVENT_NAME_ON_DATA_API_NOT_STANDARD_LENGTH: string = 'on_data_not_standard_length'
public static EVENT_NAME_ON_CONNECTION_OK = 'on_connection_ok'
public static EVENT_NAME_ON_CONNECTION_WARNING = 'on_connection_warning'
public static EVENT_NAME_ON_CONNECTION_ERROR = 'on_connection_error'
private PARSE_INT_HEX_MODE: number = 16
private config: ApiConfig
private logProvider: Logger | undefined
private socket: UDPHelper | undefined
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private eventsListeners: StringToListMap<any> = {};
private nextSn: number = 0
private intervalHandler100ms: NodeJS.Timeout
// createTime = new Date().getTime()
private sentCommandStorage = new SentCommandStorage()
private pollingQueue: PollingCommand[] = []
private pollingCommands: PollingCommand[] = []
constructor(config: ApiConfig, pollingCommands: PollingCommand[]) {
this.config = config
this.pollingCommands = pollingCommands
if (this.config && this.config.host) {
this.createSocket(this.config.host, this.config.port)
}
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self: RGBLinkApiConnector = this;
//this.intervalHandler100ms = setInterval(self.onEvery100Miliseconds, 100)
this.intervalHandler100ms = setInterval(function () {
self.onEvery100Miliseconds()
}, 100)
}
public enableLog(logProvider: Logger) {
this.logProvider = logProvider
}
public disableLog() {
this.logProvider = undefined
}
protected myDebug(msg: string) {
this.internalMyLog('debug', msg)
}
protected myWarn(msg: string) {
this.internalMyLog('warn', msg)
}
private internalMyLog(level: LogLevel, msg: string) {
try {
if (this.logProvider) {
this.logProvider.log(level, msg)
} else {
console.log(msg)
}
} catch (ex) {
console.log(ex) // is it log anything?
}
}
private onEvery100Miliseconds() {
this.doPolling()
}
private doPolling(force: boolean = false) {
// send polling commands - which asks about device status
// don't wait for more than 5 commands (rgblink requirements described near SN field in API specification)
// remove commands with no response in 15 seconds
const deleted: SentCommand[] = this.sentCommandStorage.deleteExpiredCommands()
if (deleted.length > 0) {
deleted.forEach((sendCom) => {
this.myWarn('Expired command (without response):' + sendCom.command)
this.emit(
RGBLinkApiConnector.EVENT_NAME_ON_CONNECTION_WARNING,
'The device did not respond to the command within ' + COMMANDS_EXPIRE_TIME_SECONDS + ' seconds'
)
})
}
try {
let commandsRequested = false
for (let i = 0; i < MAX_COMMANDS_WAITING_FOR_RESPONSES_FOR_POLLING; i++) {
if (this.sentCommandStorage.getCountElementsWithoutRespond() >= 5) {
// do not send more polling commands, if we wait for 5 or more responses
// this.myDebug(
// 'Skip more polling commands, current queue:' +
// this.sentCommandStorage.getCountElementsWithoutRespond() +
// ' added new ' +
// i
// )
break
}
// if there is not polling commands, try to generate a new one, but only once
if (this.pollingQueue.length == 0 && commandsRequested == false) {
// Do NOT get new commands, if polling is disabled (hower, commands in queue will be send, this help to get device status after connect)
if ((this.config && this.config.polling) || force) {
this.pollingQueue = [...this.pollingCommands]
}
commandsRequested = true
}
if (this.pollingQueue.length == 0) {
// if still no polling commands, stop doing anything
break
}
const command: PollingCommand = this.pollingQueue.shift() as PollingCommand
this.sendCommand(command.CMD, command.DAT1, command.DAT2, command.DAT3, command.DAT4)
if (this.pollingQueue.length == 0 && commandsRequested == true) {
// all sent, now more for sent, break
break
}
}
} catch (ex) {
console.log(ex)
}
}
// readStatusAfterConnect() {
// this.sentCommandStorage.deleteAll()
// this.doPolling(true)
// }
public onHostPortUpdate(host: string, port: number) {
this.createSocket(host, port)
}
private createSocket(host: string, port: number) {
this.myDebug('RGBLinkApiConnector: creating socket ' + host + ':' + port + '...')
this.config.host = host
this.config.port = port
if (this.socket !== undefined) {
this.socket.destroy()
delete this.socket
}
if (host && port) {
this.socket = new UDPHelper(host, port)
this.socket.on('status_change', (status, message) => {
this.myDebug('RGBLinkApiConnector: udp status_change:' + status + ' ' + message)
})
this.socket.on('error', (err) => {
this.myDebug('RGBLinkApiConnector: udp error:' + err)
})
this.socket.on('data', (message) => {
if (this.config && this.config.logEveryCommand) {
this.myDebug('FEEDBACK: ' + message)
}
this.onDataReceived(message)
})
// this.sendConnectMessage()
// this.readStatusAfterConnect()
}
}
public onDataReceived(message: Buffer) {
try {
if (message.length !== 19) {
this.emit(RGBLinkApiConnector.EVENT_NAME_ON_DATA_API_NOT_STANDARD_LENGTH, [message])
} else {
this.validateReceivedDataAndEmitIfValid(message)
}
} catch (ex) {
console.log(ex)
}
}
protected logFeedback(redeableMsg: string, info: string): void {
if (this.config && this.config.logEveryCommand) {
this.myDebug(`Feedback: ${redeableMsg} ${info}`)
}
}
public onDestroy() {
if (this.socket !== undefined) {
this.socket.destroy()
}
clearInterval(this.intervalHandler100ms)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public on(event: string, listener: any) {
if (typeof this.eventsListeners[event] !== 'object') {
this.eventsListeners[event] = []
}
this.eventsListeners[event].push(listener)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected emit(event: string, args: any) {
if (typeof this.eventsListeners[event] === 'object') {
const listeners = this.eventsListeners[event].slice()
if (!Array.isArray(args)) {
args = [args]
}
for (let i = 0; i < listeners.length; i++) {
listeners[i].apply(this, args)
}
}
}
private sendCommandNative(cmd: string) {
console.log('sendCommandNative ' + cmd)
//let self = this
try {
if (cmd !== undefined && cmd != '') {
if (this.socket !== undefined) {
this.socket.send(cmd).then(function () {
// self.myLog('sent?')
})
if (this.config && this.config.logEveryCommand) {
this.myDebug('SENT : ' + cmd)
}
this.sentCommandStorage.registerSentCommand(cmd)
} else {
this.myDebug("Can't send command, socket undefined!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!")
}
}
} catch (ex) {
console.log(ex)
}
}
public setPolling(polling: boolean) {
this.config.polling = polling
}
public setLogEveryCommand(logEveryCommand: boolean) {
this.config.logEveryCommand = logEveryCommand
}
protected sendCommand(CMD: Hex, DAT1: Hex, DAT2: Hex, DAT3: Hex, DAT4: Hex) {
const ADDR: Hex = '00'
this.sendCommandWithAddr(ADDR, CMD, DAT1, DAT2, DAT3, DAT4)
}
private sendCommandWithAddr(ADDR: Hex, CMD: Hex, DAT1: Hex, DAT2: Hex, DAT3: Hex, DAT4: Hex) {
const SN: Hex = this.byteToTwoSignHex(this.nextSn)
this.incrementNextSn()
const checksum: Hex = this.calculateChecksum(ADDR, SN, CMD, DAT1, DAT2, DAT3, DAT4)
const cmd: string = '<T' + ADDR + SN + CMD + DAT1 + DAT2 + DAT3 + DAT4 + checksum + '>'
this.sendCommandNative(cmd)
}
public byteToTwoSignHex(b: number): Hex {
let out = parseInt(b as unknown as string).toString(this.PARSE_INT_HEX_MODE).toUpperCase()
while (out.length < 2) {
out = '0' + out
}
return out as Hex
}
private incrementNextSn() {
this.nextSn++
if (this.nextSn > 255) {
this.nextSn = 0
}
}
validateReceivedDataAndEmitIfValid(message: Buffer) {
const redeableMsg: string = message.toString('utf8').toUpperCase()
this.sentCommandStorage.registerReceivedCommand(redeableMsg)
// Checksum checking
const checksumInMessage: Hex = redeableMsg.substr(16, 2) as Hex
const ADDR: Hex = redeableMsg.substr(2, 2) as Hex
const SN: Hex = redeableMsg.substr(4, 2) as Hex
const CMD: Hex = redeableMsg.substr(6, 2) as Hex
const DAT1: Hex = redeableMsg.substr(8, 2) as Hex
const DAT2: Hex = redeableMsg.substr(10, 2) as Hex
const DAT3: Hex = redeableMsg.substr(12, 2) as Hex
const DAT4: Hex = redeableMsg.substr(14, 2) as Hex
const calculatedChecksum = this.calculateChecksum(ADDR, SN, CMD, DAT1, DAT2, DAT3, DAT4)
if (checksumInMessage != calculatedChecksum) {
this.emit(RGBLinkApiConnector.EVENT_NAME_ON_CONNECTION_WARNING, 'Incorrect checksum ' + redeableMsg)
this.myDebug('redeableMsg Incorrect checksum: ' + checksumInMessage + ' != ' + calculatedChecksum)
return
}
if (redeableMsg[0] != '<' || redeableMsg[1] != 'F' || redeableMsg[18] != '>') {
this.emit(RGBLinkApiConnector.EVENT_NAME_ON_CONNECTION_WARNING, 'Message is not a feedback:' + redeableMsg)
return
}
if (redeableMsg.includes('FFFFFFFF')) {
this.emit(RGBLinkApiConnector.EVENT_NAME_ON_CONNECTION_WARNING, 'Feedback with error:' + redeableMsg)
return
}
// end of validate section
this.emit(RGBLinkApiConnector.EVENT_NAME_ON_DATA_API, [ADDR, SN, CMD, DAT1, DAT2, DAT3, DAT4])
}
//TODO make me private
public calculateChecksum(ADDR: Hex, SN: Hex, CMD: Hex, DAT1: Hex, DAT2: Hex, DAT3: Hex, DAT4: Hex): Hex {
let sum = 0
sum += parseInt(ADDR, this.PARSE_INT_HEX_MODE)
sum += parseInt(SN, this.PARSE_INT_HEX_MODE)
sum += parseInt(CMD, this.PARSE_INT_HEX_MODE)
sum += parseInt(DAT1, this.PARSE_INT_HEX_MODE)
sum += parseInt(DAT2, this.PARSE_INT_HEX_MODE)
sum += parseInt(DAT3, this.PARSE_INT_HEX_MODE)
sum += parseInt(DAT4, this.PARSE_INT_HEX_MODE)
let checksum = (sum % 256).toString(this.PARSE_INT_HEX_MODE).toUpperCase()
while (checksum.length < 2) {
checksum = '0' + checksum
}
return checksum as Hex
}
public getNumberOfCommandsWithoutRespond() {
return this.sentCommandStorage.getCountElementsWithoutRespond()
}
}