Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions companion/HELP.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
## ETC Echo
# ETC Echo

This module works with ETC's [Echo Integration Interface](https://www.etcconnect.com/Products/Architectural-Systems/Echo/Interfaces/Echo-Integration-Interface/Features.aspx?LangType=1033) (EII) to control architectural and building lighting.

Note: This module is configured to only control _one_ Echo "Space" at a time, due to the sheer number of parameters required to track all 16 available spaces. The ability to receive feedbacks from multiple modules in parallel does not currently work.

#### Available Commands
## Available Commands

- All "set" actions listed in the install manual for the EII: Set Preset, Set Space Off, Activate Sequence, Deactivate Sequence, Set Specific Zone Intensity
- All "get" actions listed in the install manual, including feedbacks and data gathering

#### Getting Started
## Getting Started

The EII must be configured to talk to the device Companion is running on in order to recieve feedbacks.

Expand Down
60 changes: 44 additions & 16 deletions main.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
import { InstanceBase, InstanceStatus, runEntrypoint } from '@companion-module/base'
import { InstanceBase, InstanceStatus } from '@companion-module/base'
import { configFields } from './src/config.js'
import { upgradeScripts } from './src/upgrades.js'
import { UpdateActions } from './src/actions.js'
import { UpdateFeedbacks } from './src/feedbacks.js'
import { UpdateVariableDefinitions } from './src/variables.js'
import { UpdatePresetDefinitions } from './src/presets.js'
import { createUDPServer } from './src/udp/server.js'
import { EchoInstance } from './src/Echo.js'
import { UDPServer } from './src/Echo/server.js'
import { EchoInstance } from './src/Echo/state.js'

const statusMap = {
connecting: InstanceStatus.Connecting,
ok: InstanceStatus.Ok,
bad_config: InstanceStatus.BadConfig,
connection_failure: InstanceStatus.ConnectionFailure,
}

class ModuleInstance extends InstanceBase {
export default class ModuleInstance extends InstanceBase {
constructor(internal) {
super(internal)
}

async init(config) {
this.EchoData = new EchoInstance(1) // Init with single space when user first loads module
this.echoData = new EchoInstance()
this.server = new UDPServer(config, this.echoData)

this.server.on('status', (status, msg) => {
this.updateStatus(statusMap[status], msg)
})

this.echoData.on('changed', (field) => {
this.checkFeedbacks('CheckInt', 'SpaceOff', 'ActivePreset') // TODO: scope to specific feedback check
this.setVariableValues(this.buildVariables(field, space))
})

// The following runs when the module is opened for the first time or when the config is changed
this.config = config
Expand All @@ -31,12 +48,8 @@ class ModuleInstance extends InstanceBase {

// When module gets deleted or deactivated
async destroy() {
if (this.udp) {
this.udp.close()
delete this.udp
} else {
this.updateStatus(InstanceStatus.Disconnected)
}
this.server.destroy()
this.updateStatus(InstanceStatus.Disconnected)

this.log('debug', 'destroy')
}
Expand All @@ -49,12 +62,27 @@ class ModuleInstance extends InstanceBase {

this.config = config

if (this.EchoData) {
delete this.EchoData
if (this.echoData) {
delete this.echoData
}
this.EchoData = new EchoInstance()
this.echoData = new EchoInstance()
this.server.connect()
}

createUDPServer(this)
buildVariables(field, space) {
const state = this.echoData.spaces.get(space)
switch (field) {
case 'activePreset':
return { [`space${space}_preset`]: state.preset }
case 'spaceOff':
return { [`space${space}_off`]: state.off }
case 'activeSequence':
return { [`space${space}_sequence`]: state.sequence }
case 'zonesInts':
return { [`space${space}_zones`]: Array.from(state.zones.values()) }
default:
return {}
}
}

// Return config fields for web config
Expand All @@ -79,4 +107,4 @@ class ModuleInstance extends InstanceBase {
}
}

runEntrypoint(ModuleInstance, upgradeScripts)
export const UpgradeScripts = upgradeScripts
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,26 +1,27 @@
{
"name": "etc-echo",
"version": "1.0.1",
"version": "2.0.0",
"main": "main.js",
"type": "module",
"scripts": {
"format": "prettier -w .",
"package": "companion-module-build"
"package": "companion-module-build",
"test": "node --test"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/zklosko/companion-module-etc-echo.git"
"url": "git+https://github.com/bitfocus/companion-module-etc-echo.git"
},
"engines": {
"node": "^22.20",
"yarn": "^4"
},
"dependencies": {
"@companion-module/base": "~1.14.1"
"@companion-module/base": "~2.0.0"
},
"devDependencies": {
"@companion-module/tools": "^2.6.1",
"@companion-module/tools": "~3.1.0",
"prettier": "^3.8.1"
},
"prettier": "@companion-module/tools/.prettierrc.json",
Expand Down
45 changes: 0 additions & 45 deletions src/Echo.js

This file was deleted.

48 changes: 48 additions & 0 deletions src/Echo/parse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createModuleLogger } from '@companion-module/base'

const parseLogger = createModuleLogger('Parse')

const SPACE_NUM = /:\s*(\d+)/

/**
* Parses a raw UDP packet from the Echo and updates echoData accordingly.
* @param {string} dataResponse
* @param {import('./echo-data').EchoData} echoData
*/
export function parseData(dataResponse, echoData) {
const spaceMatch = SPACE_NUM.exec(dataResponse)
const spaceNum = spaceMatch ? Number(spaceMatch[1]) : undefined

// [regex, log label, setter]
const handlers = [
[/E>pst act:\s*\d+,\s*([^\r\n]+)/, 'Preset', echoData.setActivePreset],
[/E>space off:\s*\d+,\s*([^\r\n]+)/, 'Space Off', (space, value) => echoData.setSpaceOff(space, value === '1')],
[/E>seq act:\s*\d+,\s*([^\r\n]+)/, 'Sequence', echoData.setActiveSequence],
]

for (const [regex, label, setter] of handlers) {
const match = regex.exec(dataResponse)
if (match) {
parseLogger.info(`${label} data received`)
setter.call(echoData, spaceNum, match[1])
return
}
}

if (/E>lok:/.test(dataResponse)) {
parseLogger.info('Sync data received')
return
}

const ZONE_INT = /E>zone int:\s*(\d+),\s*(\d+),\s*([^\r\n]+?)(?=E>zone int:|\r|\n|$)/g
const zoneMatches = [...dataResponse.matchAll(ZONE_INT)]
if (zoneMatches.length > 0) {
parseLogger.info('Zone intensity data received')
for (const [, space, zone, value] of zoneMatches) {
echoData.setZoneIntensity(Number(space), Number(zone), value)
}
return
}

parseLogger.warn('Unexpected UDP data received')
}
86 changes: 86 additions & 0 deletions src/Echo/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { UDPHelper, InstanceStatus, createModuleLogger } from '@companion-module/base'
import { EventEmitter } from 'node:events'
import { parseData } from './parse.js'

const serverLogger = createModuleLogger('UDPServer')
const sendLogger = createModuleLogger('Send')

const REQUEST_PREFIX = "E$"
const EOL_CHARACTER = {
CR: '\r',
LF: '\n',
CRLF: '\r\n'
}

export class UDPServer extends EventEmitter {
/**
* @param {{ host: string, port: number, eol: 'CR' | 'LF' | 'CRLF' }} config
* @param {import('./state').EchoInstance} echoData
* @param {(host: string, port: number) => UDPHelper} [createSocket]
* Factory for the underlying socket. Defaults to the real UDPHelper;
* override in tests to inject a fake without opening real sockets.
* @param {(dataResponse: string, echoData: object) => void} [parseFn]
* Defaults to the real parseData; override in tests to isolate
* UDPServer's own behavior from the parser's.
*/
constructor(config, echoData, createSocket = (host, port) => new UDPHelper(host,port), parseFn = parseData) {
super()
this.config = config
this.echoData = echoData
this.createSocket = createSocket
this.parseFn = parseFn
this.udp = undefined
}

connect() {
if (!this.config.host) {
this.emit('status', 'bad_config')
return
}
this.destroy()

this.emit('status', 'connecting')

this.udp = this.createSocket(this.config.host, this.config.port)

// Register emitter listeners
this.udp.on('listening', () => {
this.emit('status', 'ok')
})

this.udp.on('status_change', (status, message) => {
this.emit('status', status, message)
})

this.udp.on('error', (err) => {
this.emit('status', 'connection_failure', err.message)
serverLogger.error('Network error: ' + err.message)
this.destroy()
})

this.udp.on('data', (msg) => {
this.parseFn(msg.toString(), this.echoData)
})
}

destroy() {
if (this.udp) {
this.udp.destroy()
this.udp = undefined
}
}

send(msg) {
if (!this.udp) {
sendLogger.error('Not connected to Echo server!')
return
}

// Format and send UDP message to server
const sendBuf = Buffer.from(`${REQUEST_PREFIX}${msg}${EOL_CHARACTER[this.config.eol]}`, 'latin1')
sendLogger.debug(`sending ${sendBuf.toString()} to ${this.config.host}:${this.config.port}`)
sendLogger.debug(`sending bytes: ${sendBuf.toString('hex')}`)
// this.udp.send(sendBuf, 0, sendBuf.length, this.config.port, this.config.host)
this.udp.send(sendBuf)
}
}
Loading