First Commit

This commit is contained in:
ange committed 2026-10-01 13:51:21 +02:00
1 parent 6562c3b56e
commit 05e9dc59ba
21 files changed
+5418 -1

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
node_modules/
.companion-module-github/
# Companion 3
/pkg
/pkg.tgz
+9
View File
@@ -0,0 +1,9 @@
node_modules/
# Companion 3
/pkg
/pkg.tgz
/*.tgz
.DS_Store
.claude/
+22
View File
@@ -0,0 +1,22 @@
The MIT License
Copyright (c) 2018 Bitfocus AS, William Viker & Håkon Nessjøen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+99
View File
@@ -0,0 +1,99 @@
# companion-module-ross-carbonite
Ross Carbonite module for Bitfocus Companion
# Disclaimer
> [!WARNING]
> __This is a work in progress. NOT TESTED YET AND NOT PRODUCTION READY__.
> This software is provided "as is", without warranty of any kind. The author
> cannot be held liable for any damage, malfunction or loss resulting from its
> use, including during live productions. Use it at your own risk and test it
> before going on air. See [LICENCE](#license)
## Ross Carbonite
Controls a Carbonite switcher series. For now only Carbonite Black Solo (CBF-109) or Carbonite Black Solo 13 (CBF-113) are available. This module uses RossTalk to communicate with teh switcher, and reads tally and source names back with TSL UMD v3.1.
### Control (RossTalk)
The switcher accepts RossTalk on TCP port 7788, nothing has to be enabled on it. Enter the IP address of the frame (192.168.0.123 from the factory) in the module configuration.
**Available actions**
- Trigger GPI
- Fire custom control
- Cut, Auto Transition, Fade to black
- XPT (select a source on Program, Preset, a key, an Aux bus or a MiniME)
- Transition Keyer, Key Mode
- Next Transition: included elements, Transition Rate, Transition Type
- Recall Memory, Store Memory
- Load Set, Save Set
- Load Media-Store
- Set Source Name
- Clip Player, Clip Player: Load Clip
- Initialize Chroma Key
- List supported commands
- Send a custom command
The commands are those of the [Ross RossTalk reference](https://help.rossvideo.com/carbonite-device/Topics/Protocol/RossTalk/CNT/RT-CNT-Comm.html). Older software versions do not know all of them: run **List supported commands** and read the module log to see what your switcher accepts. The switcher gives no answer to a command, so an action that is refused fails silently.
The HDMI inputs come after the SDI inputs: `IN:13` on the Solo 13, `IN:7` to `IN:9` on the Solo.
### Feedback (TSL UMD v3.1)
RossTalk gives no status. For feedback, the switcher has to send TSL UMD to Companion:
1. On the switcher press **MENU > SYSTEM > NEXT > NEXT > Device Config**.
2. Press **Add** and select an ethernet slot.
3. Set **Type** to `SerialTally`, then **SubType** to `TSLUMD_1.0`.
4. Enter the IP address of the computer running Companion.
5. Set **Port** to the _TSL Listen Port_ of this module (5727 by default) and **Transport** to the _TSL Transport_ of this module (UDP by default).
6. Set **ShowUMDId** to `On` and **ShowBusName** to `Off`.
7. Set **1SecUpdate** to `On` so that Companion catches up quickly after a restart.
Open the listen port in the firewall of the computer running Companion.
**Feedbacks**
- **Tally: source on Program / Preview**: the tally the switcher sends for an input or a Media-Store (TSL tally 2 is program, tally 1 is preview).
- **Bus: source selected**: the source selected on ME 1 Background or Preset, on a key, on an Aux bus or on a MiniME. With **ShowUMDId** off the switcher only sends the name of the source, and the feedback is wrong when two sources have the same name. Black and Matte Color share TSL ID 0 and cannot be told apart.
- **TSL: tally of an address**: any tally of any TSL address.
**Variables**
- `source_in1_name` ..., `source_ms1_name` ...: source names set on the switcher.
- `bus_me1_bkgd_source`, `bus_me1_pst_source`, `bus_aux1_source` ...: name of the source selected on the bus, and `..._source_id` for its TSL ID.
The TSL addresses used are those of the Carbonite Black Solo column of the [Ross TSL UMD setup page](https://help.rossvideo.com/carbonite-device/Topics/Devices/UMD/TSL.html).
# Credits
Based on [companion-module-rossvideo-rosstalk](https://github.com/bitfocus/companion-module-rossvideo-rosstalk).
[ROSS®](https://www.rossvideo.com/)\
[Bitfocus](https://bitfocus.io)
# License
The MIT License
> [!WARNING]
>THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
>IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
>FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
>AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
>LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
>OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
>THE SOFTWARE.
see [LICENSE](./LICENSE)
# Author
Ange-Marie MAURIN : [a-m.maurin@a2msystemes.fr](mailto:a-m.maurin@a2msystemes.fr)
# AI assistance
Parts of this module were written with the help of Claude Code ([Anthropic](https://www.anthropic.com)).
All code was reviewed by the author. Hardware testing is still in progress.
+69
View File
@@ -0,0 +1,69 @@
## Ross Carbonite
Controls a Carbonite switcher series. For now only Carbonite Black Solo (CBF-109) or Carbonite Black Solo 13 (CBF-113) are available. This module uses RossTalk to communicate with teh switcher, and reads tally and source names back with TSL UMD v3.1.
### Control (RossTalk)
The switcher accepts RossTalk on TCP port 7788, nothing has to be enabled on it. Enter the IP address of the frame (192.168.0.123 from the factory) in the module configuration.
**Available actions**
- Trigger GPI
- Fire custom control
- Cut, Auto Transition, Fade to black
- XPT (select a source on Program, Preset, a key, an Aux bus or a MiniME)
- Transition Keyer, Key Mode
- Next Transition: included elements, Transition Rate, Transition Type
- Recall Memory, Store Memory
- Load Set, Save Set
- Load Media-Store
- Set Source Name
- Clip Player, Clip Player: Load Clip
- Initialize Chroma Key
- List supported commands
- Send a custom command
The commands are those of the [Ross RossTalk reference](https://help.rossvideo.com/carbonite-device/Topics/Protocol/RossTalk/CNT/RT-CNT-Comm.html). Older software versions do not know all of them: run **List supported commands** and read the module log to see what your switcher accepts. The switcher gives no answer to a command, so an action that is refused fails silently.
The HDMI inputs come after the SDI inputs: `IN:13` on the Solo 13, `IN:7` to `IN:9` on the Solo.
### Feedback (TSL UMD v3.1)
RossTalk gives no status. For feedback, the switcher has to send TSL UMD to Companion:
1. On the switcher press **MENU > SYSTEM > NEXT > NEXT > Device Config**.
2. Press **Add** and select an ethernet slot.
3. Set **Type** to `SerialTally`, then **SubType** to `TSLUMD_1.0`.
4. Enter the IP address of the computer running Companion.
5. Set **Port** to the _TSL Listen Port_ of this module (5727 by default) and **Transport** to the _TSL Transport_ of this module (UDP by default).
6. Set **ShowUMDId** to `On` and **ShowBusName** to `Off`.
7. Set **1SecUpdate** to `On` so that Companion catches up quickly after a restart.
Open the listen port in the firewall of the computer running Companion.
**Feedbacks**
- **Tally: source on Program / Preview**: the tally the switcher sends for an input or a Media-Store (TSL tally 2 is program, tally 1 is preview).
- **Bus: source selected**: the source selected on ME 1 Background or Preset, on a key, on an Aux bus or on a MiniME. With **ShowUMDId** off the switcher only sends the name of the source, and the feedback is wrong when two sources have the same name. Black and Matte Color share TSL ID 0 and cannot be told apart.
- **TSL: tally of an address**: any tally of any TSL address.
**Variables**
- `source_in1_name` ..., `source_ms1_name` ...: source names set on the switcher.
- `bus_me1_bkgd_source`, `bus_me1_pst_source`, `bus_aux1_source` ...: name of the source selected on the bus, and `..._source_id` for its TSL ID.
The TSL addresses used are those of the Carbonite Black Solo column of the [Ross TSL UMD setup page](https://help.rossvideo.com/carbonite-device/Topics/Devices/UMD/TSL.html).
# Author
Ange-Marie MAURIN : [a-m.maurin@a2msystemes.fr](mailto:a-m.maurin@a2msystemes.fr)
# Disclaimer
> [!WARNING]
> __This is a work in progress. NOT TESTED YET AND NOT PRODUCTION READY__.
> This software is provided "as is", without warranty of any kind. The author
> cannot be held liable for any damage, malfunction or loss resulting from its
> use, including during live productions. Use it at your own risk and test it
> before going on air. See [LICENCE](https://github.com/a2mSystemes/companion-module-ross-carbonite/blob/main/LICENSE)
+26
View File
@@ -0,0 +1,26 @@
{
"id": "ross-carbonite",
"name": "ross-carbonite",
"shortname": "carbonite",
"description": "Ross Carbonite : RossTalk control with TSL UMD v3.1 feedback",
"version": "0.0.0",
"license": "MIT",
"repository": "https://a2msystemes-git.duckdns.org/ange/companion-module-ross-carbonite.git",
"bugs": "https://a2msystemes-git.duckdns.org/ange/companion-module-ross-carbonite/issues",
"maintainers": [
{
"name": "Ange-Marie MAURIN",
"email": "a-m.maurin@a2msystemes.fr"
}
],
"legacyIds": [],
"runtime": {
"type": "node22",
"api": "nodejs-ipc",
"apiVersion": "0.0.0",
"entrypoint": "../main.js"
},
"manufacturer": "Ross Video",
"products": ["Carbonite Black Solo", "Carbonite Black Solo 13"],
"keywords": ["Video Mixer", "RossTalk", "TSL UMD", "Carbonite", "Carbonite Black Solo", "Carbonite Black Solo 13"]
}
+201
View File
@@ -0,0 +1,201 @@
const net = require('net')
const { TCPHelper, InstanceBase, InstanceStatus, runEntrypoint } = require('@companion-module/base')
const getConfigFields = require('./src/config')
const updateActions = require('./src/actions')
const updateFeedbacks = require('./src/feedbacks')
const updateVariables = require('./src/variables')
const updatePresets = require('./src/presets')
const upgrades = require('./src/upgrades')
const { TslListener, packetToState } = require('./src/tsl')
class CarboniteBlackSoloInstance extends InstanceBase {
constructor(internal) {
super(internal)
// Last TSL UMD state received for each address
this.tsl = new Map()
this.rossTalkStatus = { status: InstanceStatus.Connecting }
}
async init(config) {
await this.configUpdated(config)
}
async configUpdated(config) {
this.config = config
this.tsl.clear()
updateActions(this)
updateFeedbacks(this)
updateVariables(this)
updatePresets(this)
this.init_tsl()
if (this.config.keepAlive) {
this.setRossTalkStatus(InstanceStatus.Connecting, 'Waiting To Connect')
this.init_tcp()
} else {
this.destroy_tcp()
this.setRossTalkStatus(InstanceStatus.Ok)
}
}
getConfigFields() {
return getConfigFields()
}
setRossTalkStatus(status, message) {
this.rossTalkStatus = { status, message }
this.refreshStatus()
}
// RossTalk drives the status. A TSL problem only downgrades a working connection to a warning.
refreshStatus() {
if (!this.config.host) {
this.updateStatus(InstanceStatus.BadConfig, 'Switcher Frame IP is missing')
} else if (this.rossTalkStatus.status !== InstanceStatus.Ok) {
this.updateStatus(this.rossTalkStatus.status, this.rossTalkStatus.message)
} else if (this.tslError) {
this.updateStatus(InstanceStatus.UnknownWarning, this.tslError)
} else {
this.updateStatus(InstanceStatus.Ok)
}
}
destroy_tcp() {
if (this.socket !== undefined) {
this.socket.destroy()
delete this.socket
}
}
init_tcp() {
this.destroy_tcp()
if (!this.config.host) return
this.log('debug', 'Opening socket.')
this.socket = new TCPHelper(this.config.host, Number(this.config.port) || 7788)
this.socket.on('status_change', (status, message) => {
if (status !== 'unknown_error') {
this.setRossTalkStatus(status, message)
}
})
this.socket.on('error', (err) => {
this.setRossTalkStatus(InstanceStatus.ConnectionFailure, err.code)
this.log('error', 'Network error: ' + err.message)
})
this.socket.on('connect', () => {
this.log('debug', 'Connected')
})
this.socket.on('data', (data) => this.receive(data))
}
sendCommand(cmd) {
if (cmd === undefined || !this.config.host) return
if (!this.config.keepAlive) {
this.sendOnce(cmd)
} else if (this.socket !== undefined && this.socket.isConnected) {
this.log('debug', `sending tcp ${cmd} to ${this.config.host}`)
this.socket.send(cmd + '\r\n')
} else {
this.log('warn', `Socket not connected, ${cmd} was not sent`)
}
}
// One connection per command
sendOnce(cmd) {
const socket = net.connect(Number(this.config.port) || 7788, this.config.host)
socket.setTimeout(5000)
socket.on('connect', () => {
this.log('debug', `sending tcp ${cmd} to ${this.config.host}`)
this.setRossTalkStatus(InstanceStatus.Ok)
socket.end(cmd + '\r\n')
})
socket.on('data', (data) => this.receive(data))
socket.on('timeout', () => socket.destroy(new Error('Connection timed out')))
socket.on('error', (err) => {
this.setRossTalkStatus(InstanceStatus.ConnectionFailure, err.code)
this.log('error', `Network error: ${err.message}, ${cmd} was not sent`)
})
}
// The switcher only answers to HELP
receive(data) {
for (const line of data.toString('latin1').split(/[\r\n]+/)) {
if (line.trim()) this.log('info', 'RossTalk: ' + line.trim())
}
}
init_tsl() {
if (this.tslListener !== undefined) {
this.tslListener.stop()
delete this.tslListener
}
delete this.tslError
if (!this.config.tslEnabled) return
const port = Number(this.config.tslPort)
if (!Number.isInteger(port) || port < 1 || port > 65535) {
this.tslError = 'TSL Listen Port is invalid'
return
}
const rejected = new Set()
this.tslListener = new TslListener({
transport: this.config.tslTransport,
port: port,
allowedHost: this.config.tslFilter ? this.config.host : undefined,
})
this.tslListener.on('listening', () => {
this.log('debug', `Listening for TSL UMD on ${this.config.tslTransport} port ${port}`)
})
this.tslListener.on('error', (err) => {
this.log('error', 'TSL error: ' + err.message)
this.tslError = `TSL: ${err.code ?? err.message}`
this.refreshStatus()
})
this.tslListener.on('rejected', (address) => {
if (rejected.has(address)) return
rejected.add(address)
this.log('warn', `Ignoring TSL data from ${address}: it is not the Switcher Frame IP`)
})
this.tslListener.on('packet', (packet) => this.handleTsl(packet))
this.tslListener.start()
}
handleTsl(packet) {
const state = packetToState(packet)
const previous = this.tsl.get(packet.address)
if (previous && Object.keys(state).every((key) => previous[key] === state[key])) return
this.tsl.set(packet.address, state)
const values = {}
for (const variable of this.tslVariables.get(packet.address) ?? []) {
values[variable.variableId] = state[variable.field] ?? ''
}
this.setVariableValues(values)
this.checkFeedbacks('tally', 'busSource', 'tslTally')
}
// When module gets deleted
async destroy() {
this.destroy_tcp()
if (this.tslListener !== undefined) {
this.tslListener.stop()
delete this.tslListener
}
this.log('debug', 'destroy')
}
}
runEntrypoint(CarboniteBlackSoloInstance, upgrades)
+3534
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ross-carbonite",
"version": "0.1.0",
"main": "main.js",
"scripts": {
"test": "node --test",
"package": "companion-module-build",
"github:prepare": "pwsh -NoProfile -File tools/GithubManagement.ps1 -Action prepare",
"github:commit": "pwsh -NoProfile -File tools/GithubManagement.ps1 -Action commit",
"github:push": "pwsh -NoProfile -File tools/GithubManagement.ps1 -Action push"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/a2msystemes/companion-module-ross-carbonite.git"
},
"engines": {
"node": "^22.8"
},
"dependencies": {
"@companion-module/base": "~1.12.0"
},
"devDependencies": {
"@companion-module/tools": "^2.8.0"
}
}
+477
View File
@@ -0,0 +1,477 @@
const { RESOURCES, ME_CHOICES, getSources, getBuses, range } = require('./model')
const { clean, pad, int, isToken } = require('./rosstalk')
const TOKEN_REGEX = '/^[A-Z]+(:[A-Z0-9]+)*$/'
module.exports = function (self) {
const sources = getSources(self.config.model)
const sourceChoices = sources.map((source) => ({ id: source.id, label: source.label }))
const destChoices = getBuses()
.filter((bus) => bus.dest)
.map((bus) => ({ id: bus.dest, label: bus.label }))
const mnemChoices = sources.filter((source) => source.tally).map((source) => ({ id: source.id, label: source.label }))
const meMemChoices = ME_CHOICES.filter((me) => !me.id.startsWith('MSC'))
const sendCommand = (cmd) => self.sendCommand(cmd)
const text = async (context, value) => clean(await context.parseVariablesInString(String(value ?? '')))
const number = async (context, value, min, max, what) => {
const result = int(await text(context, value), min, max)
if (result === undefined) self.log('warn', `${what} must be a number between ${min} and ${max}`)
return result
}
const token = async (context, value, what) => {
const result = await text(context, value)
if (isToken(result)) return result
self.log('warn', `${what} "${result}" is not a valid RossTalk name`)
return undefined
}
const meOption = {
type: 'dropdown',
label: 'ME',
id: 'mle',
default: 'ME:1',
choices: ME_CHOICES,
}
const keyerOption = {
type: 'textinput',
label: 'Keyer',
id: 'key',
default: '1',
tooltip: `1-${RESOURCES.meKeyers} on the ME, 1-${RESOURCES.miniMeKeyers} on a MiniME`,
useVariables: true,
}
const sourceOption = (id, label, def) => ({
type: 'dropdown',
label: label,
id: id,
default: def,
choices: sourceChoices,
allowCustom: true,
regex: TOKEN_REGEX,
})
const memoryOptions = [
{
type: 'textinput',
label: 'Bank (0-9)',
id: 'bank',
default: '0',
useVariables: true,
},
{
type: 'textinput',
label: 'Memory (0-9)',
id: 'mem',
default: '0',
useVariables: true,
},
{
type: 'multidropdown',
label: 'MEs',
id: 'mles',
default: ['ME:1'],
minSelection: 1,
choices: meMemChoices,
},
]
const memoryCommand = async (verb, event, context) => {
const opt = event.options
const bank = await number(context, opt.bank, 0, 9, 'Bank')
const mem = await number(context, opt.mem, 0, 9, 'Memory')
const mles = Array.isArray(opt.mles) ? opt.mles : []
if (bank === undefined || mem === undefined || mles.length === 0) return
sendCommand(`${verb} ${bank}${mem}:${mles.join(':')}`)
}
const simple = (name, cmd, description) => ({
name: name,
description: description,
options: [],
callback: async () => {
sendCommand(cmd)
},
})
const actions = {
gpi: {
name: 'Trigger GPI',
options: [
{
type: 'textinput',
label: `Number (1-${RESOURCES.gpis})`,
id: 'gpi',
default: '1',
useVariables: true,
},
],
callback: async (event, context) => {
const gpi = await number(context, event.options.gpi, 1, RESOURCES.gpis, 'GPI')
if (gpi !== undefined) sendCommand('GPI ' + pad(gpi, 2))
},
},
cc: {
name: 'Fire custom control',
options: [
{
type: 'textinput',
label: `CC Bank (1-${RESOURCES.ccBanks})`,
id: 'bank',
default: '1',
useVariables: true,
},
{
type: 'textinput',
label: `CC Number (1-${RESOURCES.ccPerBank})`,
id: 'cc',
default: '1',
useVariables: true,
},
],
callback: async (event, context) => {
const bank = await number(context, event.options.bank, 1, RESOURCES.ccBanks, 'CC Bank')
const cc = await number(context, event.options.cc, 1, RESOURCES.ccPerBank, 'CC Number')
if (bank !== undefined && cc !== undefined) sendCommand('CC ' + bank + ':' + pad(cc, 2))
},
},
cut: {
name: 'Cut',
options: [meOption],
callback: async (event) => {
sendCommand('MECUT ' + event.options.mle)
},
},
autotrans: {
name: 'Auto Transition',
options: [meOption],
callback: async (event) => {
sendCommand('MEAUTO ' + event.options.mle)
},
},
ftb: simple('Fade to black', 'FTB'),
xpt: {
name: 'XPT',
description: 'Select a source on a bus',
options: [
{
type: 'dropdown',
label: 'Destination',
id: 'vidDest',
default: 'ME:1:PST',
choices: destChoices,
allowCustom: true,
regex: TOKEN_REGEX,
},
sourceOption('vidSource', 'Source', 'IN:1'),
],
callback: async (event, context) => {
const dst = await token(context, event.options.vidDest, 'Destination')
const src = await token(context, event.options.vidSource, 'Source')
if (dst && src) sendCommand('XPT ' + dst + ':' + src)
},
},
transKey: {
name: 'Transition Keyer',
options: [
meOption,
keyerOption,
{
type: 'dropdown',
label: 'Transition On/Off Air ',
id: 'transD',
default: 'TOGGLE',
choices: [
{ id: 'TOGGLE', label: 'Toggle Keyer' },
{ id: 'ON', label: 'Transition OnAir' },
{ id: 'OFF', label: 'Transition OffAir' },
],
},
{
type: 'dropdown',
label: 'Transition type',
id: 'transT',
default: 'CUT',
choices: [
{ id: 'AUTO', label: 'Auto Transition' },
{ id: 'CUT', label: 'Cut Transition ' },
],
},
],
callback: async (event, context) => {
const opt = event.options
const key = await number(context, opt.key, 1, RESOURCES.meKeyers, 'Keyer')
if (key === undefined) return
const direction = opt.transD === 'TOGGLE' ? '' : opt.transD
sendCommand('KEY' + opt.transT + direction + ' ' + opt.mle + ':' + key)
},
},
keyMode: {
name: 'Key Mode',
options: [
meOption,
keyerOption,
{
type: 'dropdown',
label: 'Mode',
id: 'mode',
default: 'NORMAL',
choices: [
{ id: 'NORMAL', label: 'Normal' },
{ id: 'ADDITIVE', label: 'Additive' },
{ id: 'FULL', label: 'Full' },
],
},
],
callback: async (event, context) => {
const opt = event.options
const key = await number(context, opt.key, 1, RESOURCES.meKeyers, 'Keyer')
if (key !== undefined) sendCommand('KEYMODE ' + opt.mle + ':' + key + ':' + opt.mode)
},
},
transIncl: {
name: 'Next Transition: included elements',
description: 'Replaces the current selection of the next transition area',
options: [
meOption,
{
type: 'multidropdown',
label: 'Include',
id: 'incl',
default: ['B'],
minSelection: 1,
choices: [
{ id: 'B', label: 'Background' },
...range(RESOURCES.meKeyers).map((k) => ({ id: String(k), label: `Key ${k}` })),
],
},
],
callback: async (event) => {
const incl = Array.isArray(event.options.incl) ? event.options.incl : []
if (incl.length > 0) sendCommand('TRANSINCL ' + event.options.mle + ':' + incl.join(':'))
},
},
transRate: {
name: 'Transition Rate',
options: [
meOption,
{
type: 'textinput',
label: 'Rate (frames)',
id: 'rate',
default: '15',
useVariables: true,
},
],
callback: async (event, context) => {
const rate = await number(context, event.options.rate, 1, 999, 'Rate')
if (rate !== undefined) sendCommand('TRANSRATE ' + event.options.mle + ':' + rate)
},
},
transType: {
name: 'Transition Type',
options: [
meOption,
{
type: 'dropdown',
label: 'Type',
id: 'type',
default: 'DISS',
choices: [
{ id: 'DISS', label: 'Dissolve' },
{ id: 'WIPE', label: 'Wipe' },
{ id: 'DVE', label: 'DVE' },
{ id: 'MEDIA', label: 'Media Wipe' },
],
},
],
callback: async (event) => {
sendCommand('TRANSTYPE ' + event.options.mle + ':' + event.options.type)
},
},
MEM: {
name: 'Recall Memory',
options: memoryOptions,
callback: async (event, context) => memoryCommand('MEM', event, context),
},
MEMSAVE: {
name: 'Store Memory',
options: memoryOptions,
callback: async (event, context) => memoryCommand('MEMSAVE', event, context),
},
loadset: {
name: 'Load Set',
description: 'Recalls a set from the USB drive of the switcher',
options: [
{
type: 'textinput',
label: 'Set name',
id: 'set',
useVariables: true,
default: 'set1',
},
],
callback: async (event, context) => {
const set = await text(context, event.options.set)
if (set) sendCommand('LOADSET ' + set)
},
},
saveset: {
name: 'Save Set',
description: 'Stores the switcher settings to a set on the USB drive of the switcher',
options: [
{
type: 'textinput',
label: 'Set name',
id: 'set',
useVariables: true,
default: 'set1',
},
],
callback: async (event, context) => {
const set = await text(context, event.options.set)
if (set) sendCommand('SAVESET ' + set)
},
},
ms: {
name: 'Load Media-Store',
options: [
{
type: 'dropdown',
label: 'Channel',
id: 'channel',
default: 1,
choices: range(RESOURCES.mediaStores).map((n) => ({ id: n, label: `Media-Store ${n}` })),
},
{
type: 'dropdown',
label: 'Location',
id: 'location',
default: 0,
choices: [
{ id: 0, label: 'Internal' },
{ id: 1, label: 'USB' },
],
},
{
type: 'textinput',
label: 'Media ID (0-999)',
id: 'media',
default: '1',
useVariables: true,
},
],
callback: async (event, context) => {
const opt = event.options
const media = await number(context, opt.media, 0, 999, 'Media ID')
if (media !== undefined) sendCommand('MS ' + opt.channel + ':' + opt.location + ':' + pad(media, 3))
},
},
mnem: {
name: 'Set Source Name',
options: [
{
type: 'dropdown',
label: 'Source',
id: 'source',
default: 'IN:1',
choices: mnemChoices,
},
{
type: 'textinput',
label: 'Name (8 characters)',
id: 'name',
default: '',
useVariables: true,
},
],
callback: async (event, context) => {
const name = (await text(context, event.options.name)).substring(0, 8)
if (name) sendCommand('MNEM ' + event.options.source + ':' + name)
},
},
clip: {
name: 'Clip Player',
options: [
{
type: 'dropdown',
label: 'Command',
id: 'cmd',
default: 'CLIPPLAY',
choices: [
{ id: 'CLIPPLAY', label: 'Play' },
{ id: 'CLIPPAUSE', label: 'Pause' },
{ id: 'CLIPEJECT', label: 'Eject' },
{ id: 'CLIPLOOPON', label: 'Loop On' },
{ id: 'CLIPLOOPOFF', label: 'Loop Off' },
],
},
],
callback: async (event) => {
sendCommand(event.options.cmd)
},
},
clipLoad: {
name: 'Clip Player: Load Clip',
options: [
{
type: 'textinput',
label: 'Clip name',
id: 'clip',
default: '',
useVariables: true,
},
],
callback: async (event, context) => {
const clip = await text(context, event.options.clip)
if (clip) sendCommand('CLIPLOAD ' + clip)
},
},
ckInit: simple('Initialize Chroma Key', 'CKINIT 1'),
help: simple('List supported commands', 'HELP', 'The answer of the switcher is written to the module log'),
custom: {
name: 'Send a custom command',
description: 'Refer to RossTalk Guide',
options: [
{
type: 'textinput',
label: 'Command',
id: 'cmd',
useVariables: true,
},
],
callback: async (event, context) => {
const cmd = await text(context, event.options.cmd)
if (cmd) sendCommand(cmd)
},
},
}
self.setActionDefinitions(actions)
}
+81
View File
@@ -0,0 +1,81 @@
const { Regex } = require('@companion-module/base')
const { MODELS, DEFAULT_MODEL } = require('./model')
module.exports = function getConfigFields() {
return [
{
type: 'static-text',
id: 'info',
width: 12,
label: 'Information',
value:
'Control uses RossTalk (TCP, port 7788 on the switcher). Feedback uses TSL UMD v3.1: the switcher must be set up to send TSL to this computer, see the module help.',
},
{
type: 'dropdown',
id: 'model',
label: 'Model',
width: 12,
default: DEFAULT_MODEL,
choices: Object.entries(MODELS).map(([id, model]) => ({ id, label: model.label })),
},
{
type: 'textinput',
id: 'host',
label: 'Switcher Frame IP',
width: 6,
default: '192.168.0.123',
regex: Regex.IP,
},
{
type: 'textinput',
id: 'port',
label: 'RossTalk Port',
width: 6,
default: '7788',
regex: Regex.PORT,
},
{
type: 'checkbox',
id: 'keepAlive',
label: 'TCP Keep Alive',
tooltip: 'TCP Keep Alive: Use a single TCP connection instead of one per command',
width: 12,
default: true,
},
{
type: 'checkbox',
id: 'tslEnabled',
label: 'Receive TSL UMD v3.1 feedback',
width: 12,
default: true,
},
{
type: 'dropdown',
id: 'tslTransport',
label: 'TSL Transport',
width: 6,
default: 'udp',
choices: [
{ id: 'udp', label: 'UDP' },
{ id: 'tcp', label: 'TCP (the switcher connects to Companion)' },
],
},
{
type: 'textinput',
id: 'tslPort',
label: 'TSL Listen Port',
tooltip: 'Port on this computer. Enter the same port in the TSL device of the switcher.',
width: 6,
default: '5727',
regex: Regex.PORT,
},
{
type: 'checkbox',
id: 'tslFilter',
label: 'Only accept TSL data coming from the Switcher Frame IP',
width: 12,
default: true,
},
]
}
+121
View File
@@ -0,0 +1,121 @@
const { combineRgb } = require('@companion-module/base')
const { getSources, getBuses } = require('./model')
module.exports = function (self) {
const sources = getSources(self.config.model)
const buses = getBuses()
const tallyChoices = sources.filter((source) => source.tally).map((source) => ({ id: source.tsl, label: source.label }))
const busChoices = buses.map((bus) => ({ id: bus.tsl, label: bus.label }))
const busSourceChoices = sources
.filter((source) => source.tsl !== undefined)
.map((source) => ({ id: source.id, label: source.label }))
self.setFeedbackDefinitions({
tally: {
type: 'boolean',
name: 'Tally: source on Program / Preview',
description: 'Uses the tally sent by the switcher over TSL UMD for the source',
defaultStyle: {
bgcolor: combineRgb(255, 0, 0),
color: combineRgb(255, 255, 255),
},
options: [
{
type: 'dropdown',
label: 'Source',
id: 'source',
default: 1,
choices: tallyChoices,
},
{
type: 'dropdown',
label: 'Tally',
id: 'tally',
default: 'program',
choices: [
{ id: 'program', label: 'Program' },
{ id: 'preview', label: 'Preview' },
],
},
],
callback: (feedback) => {
const state = self.tsl.get(Number(feedback.options.source))
if (!state) return false
return feedback.options.tally === 'preview' ? state.tally1 : state.tally2
},
},
busSource: {
type: 'boolean',
name: 'Bus: source selected',
description: 'Uses the source name sent by the switcher over TSL UMD for the bus',
defaultStyle: {
bgcolor: combineRgb(255, 0, 0),
color: combineRgb(255, 255, 255),
},
options: [
{
type: 'dropdown',
label: 'Bus',
id: 'bus',
default: 37,
choices: busChoices,
},
{
type: 'dropdown',
label: 'Source',
id: 'source',
default: 'IN:1',
choices: busSourceChoices,
},
],
callback: (feedback) => {
const state = self.tsl.get(Number(feedback.options.bus))
const source = sources.find((s) => s.id === feedback.options.source)
if (!state || !source) return false
// With ShowUMDId the switcher gives the TSL ID of the source, otherwise only names can be compared
if (state.sourceId !== undefined) return state.sourceId === source.tsl
const sourceState = self.tsl.get(source.tsl)
return !!state.name && source.tsl !== 0 && sourceState?.name === state.name
},
},
tslTally: {
type: 'boolean',
name: 'TSL: tally of an address',
description: 'Raw TSL UMD tally, for addresses that the other feedbacks do not cover',
defaultStyle: {
bgcolor: combineRgb(255, 0, 0),
color: combineRgb(255, 255, 255),
},
options: [
{
type: 'number',
label: 'TSL address',
id: 'address',
default: 1,
min: 0,
max: 126,
},
{
type: 'dropdown',
label: 'Tally',
id: 'tally',
default: 'tally2',
choices: [
{ id: 'tally1', label: 'Tally 1 (Preview)' },
{ id: 'tally2', label: 'Tally 2 (Program)' },
{ id: 'tally3', label: 'Tally 3' },
{ id: 'tally4', label: 'Tally 4' },
],
},
],
callback: (feedback) => {
const state = self.tsl.get(Number(feedback.options.address))
return !!state?.[feedback.options.tally]
},
},
})
}
+123
View File
@@ -0,0 +1,123 @@
// Resources of the Carbonite Black Solo family, their RossTalk tokens and their TSL UMD v3.1 addresses.
// TSL addresses come from Ross "TSL UMD Protocol Setup", table "Carbonite Black Solo/Graphite/Carbonite Black".
const MODELS = {
solo13: { label: 'Carbonite Black Solo 13 (CBF-113): 12 SDI + 1 HDMI', sdi: 12, hdmi: 1 },
solo: { label: 'Carbonite Black Solo / 109 (CBF-109): 6 SDI + 3 HDMI', sdi: 6, hdmi: 3 },
}
const DEFAULT_MODEL = 'solo13'
const RESOURCES = {
miniMes: 2,
auxes: 16,
mediaStores: 4,
meKeyers: 4,
miniMeKeyers: 2,
gpis: 24,
ccBanks: 8,
ccPerBank: 32,
}
// ME-source:ME-number, as used by MECUT, MEAUTO, KEYCUT, MEM...
const ME_CHOICES = [
{ id: 'ME:1', label: 'ME 1' },
{ id: 'MME:1', label: 'MiniME 1' },
{ id: 'MME:2', label: 'MiniME 2' },
{ id: 'MSC:1', label: 'MultiScreen 1' },
]
function getModel(id) {
return MODELS[id] ?? MODELS[DEFAULT_MODEL]
}
function range(count) {
return Array.from({ length: count }, (_, i) => i + 1)
}
// Video sources. `id` is the RossTalk token, `tsl` the TSL address of the source (when it has one),
// `key` is used to build variable ids.
function getSources(modelId) {
const model = getModel(modelId)
const sources = []
for (const n of range(model.sdi + model.hdmi)) {
const kind = n <= model.sdi ? 'SDI' : `HDMI ${n - model.sdi}`
sources.push({ id: `IN:${n}`, key: `in${n}`, label: `Input ${n} (${kind})`, name: `IN ${n}`, tsl: n, tally: true })
}
sources.push({ id: 'BK', key: 'bk', label: 'Black', name: 'BK', tsl: 0 })
sources.push({ id: 'BG', key: 'bg', label: 'Matte Color', name: 'BG', tsl: 0 })
for (const n of range(RESOURCES.mediaStores)) {
sources.push({ id: `MS:${n}`, key: `ms${n}`, label: `Media-Store ${n}`, name: `MS ${n}`, tsl: 100 + n, tally: true })
}
sources.push({ id: 'CLIP', key: 'clip', label: 'Clip Player', name: 'CLIP' })
sources.push({ id: 'PGM', key: 'pgm', label: 'Program', name: 'PGM', tsl: 110 })
sources.push({ id: 'PV', key: 'pv', label: 'Preview', name: 'PV', tsl: 111 })
sources.push({ id: 'CLN', key: 'cln', label: 'Clean Feed', name: 'CLN', tsl: 112 })
sources.push({ id: 'ME:1:PGM', key: 'me1_pgm', label: 'ME 1 Program', name: 'ME1 PGM', tsl: 113 })
sources.push({ id: 'ME:1:PV', key: 'me1_pv', label: 'ME 1 Preview', name: 'ME1 PV', tsl: 114 })
sources.push({ id: 'ME:1:CLN', key: 'me1_cln', label: 'ME 1 Clean', name: 'ME1 CLN', tsl: 115 })
sources.push({ id: 'ME:1:BKGD', key: 'me1_bkgd', label: 'ME 1 Background', name: 'ME1 BKGD', tsl: 37 })
sources.push({ id: 'ME:1:PST', key: 'me1_pst', label: 'ME 1 Preset', name: 'ME1 PST', tsl: 38 })
for (const k of range(RESOURCES.meKeyers)) {
const base = 37 + 2 * k
sources.push({ id: `ME:1:KEY:${k}:V`, key: `me1_key${k}_v`, label: `ME 1 Key ${k} Video`, name: `K${k} V`, tsl: base })
sources.push({
id: `ME:1:KEY:${k}:A`,
key: `me1_key${k}_a`,
label: `ME 1 Key ${k} Alpha`,
name: `K${k} A`,
tsl: base + 1,
})
}
sources.push({ id: 'ME:1:MW', key: 'me1_mw', label: 'ME 1 MediaWipe', name: 'ME1 MW' })
sources.push({ id: 'ME:1:MWA', key: 'me1_mwa', label: 'ME 1 MediaWipe Alpha', name: 'ME1 MWA' })
for (const n of range(RESOURCES.miniMes)) {
const base = 87 + 4 * (n - 1)
sources.push({ id: `MME:${n}:PGM`, key: `mme${n}_pgm`, label: `MiniME ${n} Program`, name: `MME${n}`, tsl: 115 + n })
sources.push({ id: `MME:${n}:PV`, key: `mme${n}_pv`, label: `MiniME ${n} Preview`, name: `MME${n} PV` })
sources.push({
id: `MME:${n}:BKGD`,
key: `mme${n}_bkgd`,
label: `MiniME ${n} Background`,
name: `MME${n} BG`,
tsl: base,
})
sources.push({ id: `MME:${n}:PST`, key: `mme${n}_pst`, label: `MiniME ${n} Preset`, name: `MME${n} PST`, tsl: base + 1 })
}
for (const n of range(RESOURCES.auxes)) {
sources.push({ id: `AUX:${n}`, key: `aux${n}`, label: `Aux ${n}`, name: `AUX ${n}`, tsl: 66 + n })
}
sources.push({ id: 'CK:1', key: 'ck1', label: 'Chroma Key 1 Video', name: 'CK 1' })
sources.push({ id: 'CKA:1', key: 'cka1', label: 'Chroma Key 1 Alpha', name: 'CK 1 A' })
return sources
}
// Buses. On a bus address the switcher sends the name of the source selected on that bus.
// `dest` is the RossTalk XPT destination, when the bus can be switched over RossTalk.
function getBuses() {
const buses = [
{ key: 'me1_bkgd', label: 'ME 1 Background (Program)', tsl: 37, dest: 'ME:1:PGM' },
{ key: 'me1_pst', label: 'ME 1 Preset', tsl: 38, dest: 'ME:1:PST' },
]
for (const k of range(RESOURCES.meKeyers)) {
const base = 37 + 2 * k
buses.push({ key: `me1_key${k}`, label: `ME 1 Key ${k}`, tsl: base, dest: `ME:1:KEY:${k}` })
buses.push({ key: `me1_key${k}_alpha`, label: `ME 1 Key ${k} Alpha`, tsl: base + 1 })
}
for (const n of range(RESOURCES.auxes)) {
buses.push({ key: `aux${n}`, label: `Aux ${n}`, tsl: 66 + n, dest: `AUX:${n}` })
}
for (const n of range(RESOURCES.miniMes)) {
const base = 87 + 4 * (n - 1)
buses.push({ key: `mme${n}_bkgd`, label: `MiniME ${n} Background`, tsl: base, dest: `MME:${n}` })
buses.push({ key: `mme${n}_pst`, label: `MiniME ${n} Preset`, tsl: base + 1 })
for (const k of range(RESOURCES.miniMeKeyers)) {
buses.push({ key: `mme${n}_key${k}`, label: `MiniME ${n} Key ${k}`, tsl: base + 1 + k })
}
}
return buses
}
module.exports = { MODELS, DEFAULT_MODEL, RESOURCES, ME_CHOICES, getModel, getSources, getBuses, range }
+84
View File
@@ -0,0 +1,84 @@
const { combineRgb } = require('@companion-module/base')
const { RESOURCES, getSources, range } = require('./model')
const WHITE = combineRgb(255, 255, 255)
const BLACK = combineRgb(0, 0, 0)
const RED = combineRgb(255, 0, 0)
const GREEN = combineRgb(0, 204, 0)
module.exports = function (self) {
const presets = {}
const button = (category, name, text, actionId, options, feedbacks = []) => ({
type: 'button',
category: category,
name: name,
style: {
text: text,
size: 'auto',
color: WHITE,
bgcolor: BLACK,
},
steps: [
{
down: [{ actionId: actionId, options: options }],
up: [],
},
],
feedbacks: feedbacks,
})
for (const source of getSources(self.config.model).filter((s) => s.tally)) {
const text = `$(${self.label}:source_${source.key}_name)`
presets[`pgm_${source.key}`] = button(
'Program',
`${source.label} to Program`,
text,
'xpt',
{ vidDest: 'ME:1:PGM', vidSource: source.id },
[
{
feedbackId: 'tally',
options: { source: source.tsl, tally: 'program' },
style: { bgcolor: RED, color: WHITE },
},
],
)
presets[`pst_${source.key}`] = button(
'Preset',
`${source.label} to Preset`,
text,
'xpt',
{ vidDest: 'ME:1:PST', vidSource: source.id },
[
{
feedbackId: 'tally',
options: { source: source.tsl, tally: 'preview' },
style: { bgcolor: GREEN, color: WHITE },
},
],
)
}
presets.cut = button('Transitions', 'Cut', 'CUT', 'cut', { mle: 'ME:1' })
presets.auto = button('Transitions', 'Auto Transition', 'AUTO', 'autotrans', { mle: 'ME:1' })
presets.ftb = button('Transitions', 'Fade to black', 'FTB', 'ftb', {})
for (const k of range(RESOURCES.meKeyers)) {
presets[`key${k}_cut`] = button('Keyers', `Key ${k} Cut`, `KEY ${k}\\nCUT`, 'transKey', {
mle: 'ME:1',
key: String(k),
transD: 'TOGGLE',
transT: 'CUT',
})
presets[`key${k}_auto`] = button('Keyers', `Key ${k} Auto`, `KEY ${k}\\nAUTO`, 'transKey', {
mle: 'ME:1',
key: String(k),
transD: 'TOGGLE',
transT: 'AUTO',
})
}
self.setPresetDefinitions(presets)
}
+26
View File
@@ -0,0 +1,26 @@
// Helpers to build RossTalk command strings. Commands are case sensitive and sent one per line.
// A command is a single line: line breaks would let a variable smuggle in extra commands.
function clean(value) {
return String(value ?? '')
.replace(/[\r\n]+/g, ' ')
.trim()
}
function pad(number, width) {
return String(number).padStart(width, '0')
}
function int(value, min, max) {
const text = clean(value)
if (!/^\d+$/.test(text)) return undefined
const number = parseInt(text, 10)
return number >= min && number <= max ? number : undefined
}
// ME, AUX:2, ME:1:KEY:2:V ...
function isToken(value) {
return /^[A-Z]+(:[A-Z0-9]+)*$/.test(value)
}
module.exports = { clean, pad, int, isToken }
+146
View File
@@ -0,0 +1,146 @@
// TSL UMD v3.1 receiver. The switcher is the sender: it pushes 18 byte packets over UDP,
// or connects to us over TCP, so this module has to listen.
//
// Packet: byte 0 = 0x80 + address (0-126)
// byte 1 = control: bits 0-3 tally 1-4, bits 4-5 brightness
// bytes 2-17 = 16 ASCII display characters
const dgram = require('dgram')
const net = require('net')
const { EventEmitter } = require('events')
const PACKET_LENGTH = 18
function parsePacket(buf) {
const control = buf[1]
return {
address: buf[0] & 0x7f,
tally1: (control & 0x01) !== 0,
tally2: (control & 0x02) !== 0,
tally3: (control & 0x04) !== 0,
tally4: (control & 0x08) !== 0,
brightness: (control >> 4) & 0x03,
label: buf
.subarray(2, PACKET_LENGTH)
.toString('latin1')
.replace(/\0/g, ' ')
.trim(),
}
}
// What the module keeps for an address. With ShowUMDId the switcher starts the label with the TSL ID
// of the source, as in "3:CAM 3".
function packetToState(packet) {
const match = /^(\d{1,3}):(.*)$/.exec(packet.label)
return {
tally1: packet.tally1,
tally2: packet.tally2,
tally3: packet.tally3,
tally4: packet.tally4,
name: match ? match[2].trim() : packet.label,
sourceId: match ? Number(match[1]) : undefined,
}
}
// Cuts a byte stream into packets. The header is the only byte with bit 7 set, which is used to resynchronise.
class TslFramer {
constructor() {
this.buffer = Buffer.alloc(0)
}
push(chunk) {
this.buffer = Buffer.concat([this.buffer, chunk])
const packets = []
for (;;) {
const start = this.buffer.findIndex((byte) => byte >= 0x80)
if (start === -1) {
this.buffer = Buffer.alloc(0)
break
}
if (start > 0) this.buffer = this.buffer.subarray(start)
// A header inside the packet body means the packet was truncated: restart from that header
const next = this.buffer.subarray(1, PACKET_LENGTH).findIndex((byte) => byte >= 0x80)
if (next !== -1) {
this.buffer = this.buffer.subarray(next + 1)
continue
}
if (this.buffer.length < PACKET_LENGTH) break
packets.push(parsePacket(this.buffer))
this.buffer = this.buffer.subarray(PACKET_LENGTH)
}
return packets
}
}
function normaliseAddress(address) {
return String(address ?? '').replace(/^::ffff:/, '')
}
// Events: 'listening', 'error' (err), 'packet' (packet), 'rejected' (address)
class TslListener extends EventEmitter {
// allowedHost: when set, data from any other address is dropped
constructor({ transport, port, allowedHost }) {
super()
this.transport = transport === 'tcp' ? 'tcp' : 'udp'
this.port = Number(port)
this.allowedHost = allowedHost ? normaliseAddress(allowedHost) : undefined
this.sockets = new Set()
}
isAllowed(address) {
if (!this.allowedHost || normaliseAddress(address) === this.allowedHost) return true
this.emit('rejected', normaliseAddress(address))
return false
}
start() {
if (this.transport === 'udp') {
this.server = dgram.createSocket({ type: 'udp4', reuseAddr: true })
this.server.on('message', (msg, rinfo) => {
if (!this.isAllowed(rinfo.address)) return
for (const packet of new TslFramer().push(msg)) this.emit('packet', packet)
})
this.server.on('error', (err) => this.emit('error', err))
this.server.on('listening', () => this.emit('listening'))
this.server.bind(this.port)
} else {
this.server = net.createServer((socket) => {
if (!this.isAllowed(socket.remoteAddress)) {
socket.destroy()
return
}
const framer = new TslFramer()
this.sockets.add(socket)
socket.on('data', (data) => {
for (const packet of framer.push(data)) this.emit('packet', packet)
})
socket.on('error', () => socket.destroy())
socket.on('close', () => this.sockets.delete(socket))
})
this.server.on('error', (err) => this.emit('error', err))
this.server.on('listening', () => this.emit('listening'))
this.server.listen(this.port)
}
}
stop() {
for (const socket of this.sockets) socket.destroy()
this.sockets.clear()
if (this.server) {
this.server.removeAllListeners()
this.server.on('error', () => {})
try {
this.server.close()
} catch (e) {
// not listening yet
}
delete this.server
}
}
}
module.exports = { PACKET_LENGTH, parsePacket, packetToState, TslFramer, TslListener }
+1
View File
@@ -0,0 +1 @@
module.exports = []
+42
View File
@@ -0,0 +1,42 @@
const { getSources, getBuses } = require('./model')
// Variables fed by TSL UMD, indexed by TSL address
function getTslVariables(modelId) {
const byAddress = new Map()
const add = (address, variable) => {
if (!byAddress.has(address)) byAddress.set(address, [])
byAddress.get(address).push(variable)
}
for (const source of getSources(modelId)) {
if (!source.tally) continue
add(source.tsl, { variableId: `source_${source.key}_name`, name: `${source.label}: name`, field: 'name', initial: source.name })
}
for (const bus of getBuses()) {
add(bus.tsl, { variableId: `bus_${bus.key}_source`, name: `${bus.label}: selected source`, field: 'name', initial: '' })
add(bus.tsl, {
variableId: `bus_${bus.key}_source_id`,
name: `${bus.label}: TSL ID of the selected source`,
field: 'sourceId',
initial: '',
})
}
return byAddress
}
module.exports = function (self) {
self.tslVariables = getTslVariables(self.config.model)
const definitions = []
const values = {}
for (const [address, variables] of self.tslVariables) {
const state = self.tsl.get(address)
for (const variable of variables) {
definitions.push({ variableId: variable.variableId, name: variable.name })
values[variable.variableId] = state?.[variable.field] ?? variable.initial
}
}
self.setVariableDefinitions(definitions)
self.setVariableValues(values)
}
+134
View File
@@ -0,0 +1,134 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const updateActions = require('../src/actions')
const updateFeedbacks = require('../src/feedbacks')
const { getSources, getBuses } = require('../src/model')
const { packetToState } = require('../src/tsl')
function setup(model = 'solo13') {
const sent = []
const warnings = []
const self = {
config: { model },
tsl: new Map(),
log: (level, message) => warnings.push(message),
sendCommand: (cmd) => sent.push(cmd),
setActionDefinitions: (definitions) => (self.actions = definitions),
setFeedbackDefinitions: (definitions) => (self.feedbacks = definitions),
}
updateActions(self)
updateFeedbacks(self)
const context = { parseVariablesInString: async (text) => text.replace('$(var)', '7') }
const run = async (id, options = {}) => {
const defaults = Object.fromEntries(self.actions[id].options.map((option) => [option.id, option.default]))
await self.actions[id].callback({ options: { ...defaults, ...options } }, context)
return sent[sent.length - 1]
}
return { self, sent, warnings, run }
}
test('actions build the documented RossTalk commands', async () => {
const { run } = setup()
assert.equal(await run('gpi', { gpi: '4' }), 'GPI 04')
assert.equal(await run('cc', { bank: '1', cc: '5' }), 'CC 1:05')
assert.equal(await run('cut'), 'MECUT ME:1')
assert.equal(await run('autotrans', { mle: 'MSC:1' }), 'MEAUTO MSC:1')
assert.equal(await run('ftb'), 'FTB')
assert.equal(await run('xpt', { vidDest: 'ME:1:PGM', vidSource: 'IN:6' }), 'XPT ME:1:PGM:IN:6')
assert.equal(await run('xpt', { vidDest: 'AUX:2', vidSource: 'ME:1:CLN' }), 'XPT AUX:2:ME:1:CLN')
assert.equal(await run('transKey', { key: '4', transT: 'AUTO' }), 'KEYAUTO ME:1:4')
assert.equal(await run('transKey', { mle: 'MME:2', key: '1', transD: 'OFF' }), 'KEYCUTOFF MME:2:1')
assert.equal(await run('keyMode', { key: '1' }), 'KEYMODE ME:1:1:NORMAL')
assert.equal(await run('transIncl', { incl: ['B', '2', '3'] }), 'TRANSINCL ME:1:B:2:3')
assert.equal(await run('transRate'), 'TRANSRATE ME:1:15')
assert.equal(await run('transType', { mle: 'MSC:1' }), 'TRANSTYPE MSC:1:DISS')
assert.equal(await run('MEM', { bank: '1', mem: '9', mles: ['ME:1', 'MME:1'] }), 'MEM 19:ME:1:MME:1')
assert.equal(await run('MEMSAVE', { bank: '1', mem: '9' }), 'MEMSAVE 19:ME:1')
assert.equal(await run('loadset'), 'LOADSET set1')
assert.equal(await run('saveset'), 'SAVESET set1')
assert.equal(await run('ms', { media: '2' }), 'MS 1:0:002')
assert.equal(await run('mnem', { source: 'IN:6', name: 'CAM 1' }), 'MNEM IN:6:CAM 1')
assert.equal(await run('clip'), 'CLIPPLAY')
assert.equal(await run('clipLoad', { clip: 'trees' }), 'CLIPLOAD trees')
assert.equal(await run('ckInit'), 'CKINIT 1')
assert.equal(await run('help'), 'HELP')
assert.equal(await run('custom', { cmd: 'GPI 12' }), 'GPI 12')
})
test('variables are parsed and bad values are not sent', async () => {
const { run, sent, warnings } = setup()
assert.equal(await run('gpi', { gpi: '$(var)' }), 'GPI 07')
assert.equal(await run('xpt', { vidDest: 'AUX:$(var)', vidSource: 'IN:1' }), 'XPT AUX:7:IN:1')
const count = sent.length
await run('gpi', { gpi: '25' })
await run('cc', { bank: '9', cc: '1' })
await run('xpt', { vidDest: 'AUX:1', vidSource: 'in 1' })
assert.equal(sent.length, count)
assert.equal(warnings.length, 3)
})
test('a command cannot carry a second line', async () => {
const { run } = setup()
assert.equal(await run('custom', { cmd: 'FTB\r\nMECUT ME:1' }), 'FTB MECUT ME:1')
assert.equal(await run('loadset', { set: 'a\nFTB' }), 'LOADSET a FTB')
})
test('model tables match the Ross TSL mapping', () => {
const solo13 = getSources('solo13')
assert.equal(solo13.filter((s) => s.id.startsWith('IN:')).length, 13)
assert.equal(solo13.find((s) => s.id === 'IN:13').label, 'Input 13 (HDMI 1)')
assert.equal(getSources('solo').filter((s) => s.id.startsWith('IN:')).length, 9)
assert.equal(getSources('solo').find((s) => s.id === 'IN:7').label, 'Input 7 (HDMI 1)')
const tsl = Object.fromEntries(getBuses().map((bus) => [bus.key, bus.tsl]))
assert.equal(tsl.me1_bkgd, 37)
assert.equal(tsl.me1_pst, 38)
assert.equal(tsl.me1_key1, 39)
assert.equal(tsl.me1_key4_alpha, 46)
assert.equal(tsl.aux1, 67)
assert.equal(tsl.aux16, 82)
assert.equal(tsl.mme1_bkgd, 87)
assert.equal(tsl.mme1_key2, 90)
assert.equal(tsl.mme2_bkgd, 91)
assert.equal(tsl.mme2_key2, 94)
assert.equal(solo13.find((s) => s.id === 'MS:4').tsl, 104)
assert.equal(solo13.find((s) => s.id === 'MME:2:PGM').tsl, 117)
const addresses = getBuses().map((bus) => bus.tsl)
assert.equal(new Set(addresses).size, addresses.length)
})
test('feedbacks follow the TSL state', () => {
const { self } = setup()
const state = (label, control = 0) =>
packetToState({ label, tally1: !!(control & 1), tally2: !!(control & 2), tally3: false, tally4: false })
const tally = (source, type) => self.feedbacks.tally.callback({ options: { source, tally: type } })
const bus = (address, source) => self.feedbacks.busSource.callback({ options: { bus: address, source } })
assert.equal(tally(3, 'program'), false)
self.tsl.set(3, state('CAM 3', 2))
self.tsl.set(4, state('CAM 4', 1))
assert.equal(tally(3, 'program'), true)
assert.equal(tally(3, 'preview'), false)
assert.equal(tally(4, 'preview'), true)
// ShowUMDId on: the bus gives the TSL ID of its source
self.tsl.set(37, state('3:CAM 3'))
assert.equal(bus(37, 'IN:3'), true)
assert.equal(bus(37, 'IN:4'), false)
self.tsl.set(67, state('0:BK'))
assert.equal(bus(67, 'BK'), true)
// ShowUMDId off: only the names can be compared
self.tsl.set(38, state('CAM 4'))
assert.equal(bus(38, 'IN:4'), true)
assert.equal(bus(38, 'IN:3'), false)
assert.equal(self.feedbacks.tslTally.callback({ options: { address: 3, tally: 'tally2' } }), true)
assert.equal(self.feedbacks.tslTally.callback({ options: { address: 99, tally: 'tally2' } }), false)
})
+127
View File
@@ -0,0 +1,127 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const dgram = require('dgram')
const net = require('net')
const { once } = require('events')
const { parsePacket, packetToState, TslFramer, TslListener } = require('../src/tsl')
function packet(address, control, label) {
const buf = Buffer.alloc(18, 0x20)
buf[0] = 0x80 + address
buf[1] = control
buf.write(label, 2, 'latin1')
return buf
}
test('parsePacket decodes address, tallies, brightness and label', () => {
const parsed = parsePacket(packet(5, 0b00110010, 'CAM 5'))
assert.deepEqual(parsed, {
address: 5,
tally1: false,
tally2: true,
tally3: false,
tally4: false,
brightness: 3,
label: 'CAM 5',
})
})
test('packetToState splits the TSL ID that ShowUMDId puts in front of the name', () => {
assert.deepEqual(packetToState(parsePacket(packet(37, 0, '3:CAM 3'))), {
tally1: false,
tally2: false,
tally3: false,
tally4: false,
name: 'CAM 3',
sourceId: 3,
})
const plain = packetToState(parsePacket(packet(3, 1, 'CAM 3')))
assert.equal(plain.name, 'CAM 3')
assert.equal(plain.sourceId, undefined)
assert.equal(plain.tally1, true)
})
test('TslFramer reassembles packets split across chunks', () => {
const framer = new TslFramer()
const stream = Buffer.concat([packet(1, 1, 'ONE'), packet(2, 2, 'TWO')])
assert.deepEqual(framer.push(stream.subarray(0, 7)), [])
const first = framer.push(stream.subarray(7, 25))
assert.deepEqual(
first.map((p) => p.label),
['ONE'],
)
const second = framer.push(stream.subarray(25))
assert.deepEqual(
second.map((p) => [p.address, p.label]),
[[2, 'TWO']],
)
})
test('TslFramer resynchronises after garbage and truncated packets', () => {
const framer = new TslFramer()
const stream = Buffer.concat([Buffer.from('junk'), packet(1, 1, 'CUT').subarray(0, 10), packet(9, 2, 'NINE')])
assert.deepEqual(
framer.push(stream).map((p) => [p.address, p.label]),
[[9, 'NINE']],
)
})
test('TslListener receives UDP packets and filters by sender', async () => {
const probe = dgram.createSocket('udp4')
probe.bind(0)
await once(probe, 'listening')
const port = probe.address().port
probe.close()
const listener = new TslListener({ transport: 'udp', port, allowedHost: '127.0.0.1' })
listener.start()
await once(listener, 'listening')
const sender = dgram.createSocket('udp4')
const received = once(listener, 'packet')
sender.send(Buffer.concat([packet(4, 2, 'CAM 4')]), port, '127.0.0.1')
const [parsed] = await received
assert.equal(parsed.address, 4)
assert.equal(parsed.tally2, true)
sender.close()
listener.stop()
const strict = new TslListener({ transport: 'udp', port, allowedHost: '10.9.8.7' })
strict.start()
await once(strict, 'listening')
const sender2 = dgram.createSocket('udp4')
const rejected = once(strict, 'rejected')
sender2.send(packet(4, 2, 'CAM 4'), port, '127.0.0.1')
assert.deepEqual(await rejected, ['127.0.0.1'])
sender2.close()
strict.stop()
})
test('TslListener receives TCP packets', async () => {
const listener = new TslListener({ transport: 'tcp', port: 0 })
listener.start()
await once(listener, 'listening')
const port = listener.server.address().port
const packets = []
listener.on('packet', (p) => packets.push(p))
const client = net.connect(port, '127.0.0.1')
await once(client, 'connect')
const stream = Buffer.concat([packet(37, 0, '2:CAM 2'), packet(2, 2, 'CAM 2')])
client.write(stream.subarray(0, 20))
await new Promise((resolve) => setTimeout(resolve, 50))
client.write(stream.subarray(20))
await new Promise((resolve) => setTimeout(resolve, 50))
assert.deepEqual(
packets.map((p) => [p.address, p.label]),
[
[37, '2:CAM 2'],
[2, 'CAM 2'],
],
)
client.destroy()
listener.stop()
})
+69
View File
@@ -0,0 +1,69 @@
param(
[Parameter(Mandatory)]
[ValidateSet('prepare', 'commit', 'push')]
[string]$Action,
[string]$Message
)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$dest = Join-Path $root '.companion-module-github'
function Invoke-Prepare {
if (-not (Test-Path $dest)) {
Write-Output "Cloning companion-module-github"
git clone https://github.com/a2msystemes/companion-module-ross-carbonite.git $dest
if ($LASTEXITCODE -ne 0) { throw "git clone failed" }
}
$robocopyOptions = @(
'/MIR'
'/NFL', '/NDL'
'/NJH', '/NJS'
)
robocopy $root $dest @robocopyOptions `
/XD .git .github .vscode .claude .companion-module-github node_modules pkg tools `
/XF README.md .gitignore .gitignore-github *.tgz CLAUDE.md
if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($LASTEXITCODE)" }
Copy-Item (Join-Path $root '.gitignore-github') (Join-Path $dest '.gitignore') -Force
}
function Invoke-Commit {
if (-not (git -C $dest status --porcelain)) {
Write-Output "No changes to commit"
return
}
git -C $dest status --short
if ([string]::IsNullOrWhiteSpace($Message)) {
$Message = Read-Host "Commit message (empty to cancel)"
}
if ([string]::IsNullOrWhiteSpace($Message)) {
Write-Output "Commit cancelled"
return
}
git -C $dest add -A
git -C $dest commit -m $Message
if ($LASTEXITCODE -ne 0) { throw "git commit failed" }
}
function Invoke-Push {
git -C $dest push
if ($LASTEXITCODE -ne 0) { throw "git push failed" }
}
switch ($Action) {
'prepare' { Invoke-Prepare }
'commit' { Invoke-Commit }
'push' { Invoke-Push }
}
exit 0