First Commit
This commit is contained in:
1 parent
6562c3b56e
commit
05e9dc59ba
21 files changed
+5418
-1
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
node_modules/
|
||||
|
||||
.companion-module-github/
|
||||
# Companion 3
|
||||
/pkg
|
||||
/pkg.tgz
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
|
||||
# Companion 3
|
||||
/pkg
|
||||
/pkg.tgz
|
||||
/*.tgz
|
||||
.DS_Store
|
||||
|
||||
.claude/
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
Generated
+3534
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = []
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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
|
||||
Reference in new issue
Block a user