diff --git a/assets/linux/50-companion-desktop.rules b/assets/linux/50-companion-desktop.rules index dab8dd0fb0..43eaab78a5 100644 --- a/assets/linux/50-companion-desktop.rules +++ b/assets/linux/50-companion-desktop.rules @@ -17,6 +17,7 @@ SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="666" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="008f", MODE:="666", TAG+="uaccess" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="666", TAG+="uaccess" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009a", MODE:="666", TAG+="uaccess" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00aa", MODE:="666", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE:="666", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="666", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="666", TAG+="uaccess" @@ -27,6 +28,7 @@ KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="666 KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="008f", MODE:="666", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="666", TAG+="uaccess" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009a", MODE:="666", TAG+="uaccess" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00aa", MODE:="666", TAG+="uaccess" # xkeys SUBSYSTEM=="usb", ATTRS{idVendor}=="05f3", MODE:="666", TAG+="uaccess" diff --git a/assets/linux/50-companion-headless.rules b/assets/linux/50-companion-headless.rules index f7ecc81981..0d81dfbdc6 100644 --- a/assets/linux/50-companion-headless.rules +++ b/assets/linux/50-companion-headless.rules @@ -17,6 +17,7 @@ SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="666" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="008f", MODE:="666", GROUP="companion" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="666", GROUP="companion" SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009a", MODE:="666", GROUP="companion" +SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00aa", MODE:="666", GROUP="companion" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0060", MODE:="666", GROUP="companion" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0063", MODE:="666", GROUP="companion" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="006c", MODE:="666", GROUP="companion" @@ -27,6 +28,7 @@ KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0086", MODE:="666 KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="008f", MODE:="666", GROUP="companion" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="0090", MODE:="666", GROUP="companion" KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="009a", MODE:="666", GROUP="companion" +KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", ATTRS{idProduct}=="00aa", MODE:="666", GROUP="companion" # xkeys SUBSYSTEM=="usb", ATTRS{idVendor}=="05f3", MODE:="666", GROUP="companion" diff --git a/companion/lib/Resources/Util.js b/companion/lib/Resources/Util.js index ecb0dad5c7..0b3829a73e 100644 --- a/companion/lib/Resources/Util.js +++ b/companion/lib/Resources/Util.js @@ -306,7 +306,7 @@ export async function transformButtonImage(render, rotation, targetWidth, target const imageRsRotation = translateRotation(rotation) if (imageRsRotation !== null) image = image.rotate(imageRsRotation) - image = image.scale(targetWidth, targetHeight) + image = image.scale(targetWidth, targetHeight, imageRs.ResizeMode.Fit) // pad, in case a button is non-square const dimensions = image.getCurrentDimensions() diff --git a/companion/lib/Service/SurfaceDiscovery.js b/companion/lib/Service/SurfaceDiscovery.js index 88318fd510..1b0c37c385 100644 --- a/companion/lib/Service/SurfaceDiscovery.js +++ b/companion/lib/Service/SurfaceDiscovery.js @@ -3,6 +3,7 @@ import ServiceBase from './Base.js' import { Bonjour, Browser } from '@julusian/bonjour-service' import systeminformation from 'systeminformation' import got from 'got' +import { StreamDeckTcpDiscoveryService } from '@elgato-stream-deck/tcp' const SurfaceDiscoveryRoom = 'surfaces:discovery' @@ -34,6 +35,10 @@ export class ServiceSurfaceDiscovery extends ServiceBase { * @type {Browser | undefined} */ #satelliteBrowser + /** + * @type {StreamDeckTcpDiscoveryService | undefined} + */ + #streamDeckDiscovery /** * @type {NodeJS.Timeout | undefined} @@ -44,12 +49,14 @@ export class ServiceSurfaceDiscovery extends ServiceBase { * @param {import('../Registry.js').default} registry - the application core */ constructor(registry) { - super(registry, 'Service/Satellite', 'discoveryEnabled', null) + super(registry, 'Service/SurfaceDiscovery', 'discoveryEnabled', null) this.init() } listen() { + this.currentState = true + if (!this.#satelliteBrowser) { try { this.#satelliteBrowser = this.#bonjour.find({ type: 'companion-satellite', protocol: 'tcp' }) @@ -70,12 +77,45 @@ export class ServiceSurfaceDiscovery extends ServiceBase { this.#satelliteBrowser.on('srv-update', (newService, oldService) => { this.#updateSatelliteService(oldService, newService) }) - - this.currentState = true } catch (e) { this.logger.debug(`ERROR failed to start searching for companion satellite devices`) } } + + if (!this.#streamDeckDiscovery) { + try { + this.#streamDeckDiscovery = new StreamDeckTcpDiscoveryService() + + this.#streamDeckDiscovery.on('up', (streamdeck) => { + const uiService = this.#convertStreamDeckForUi(streamdeck) + if (!uiService) return + + this.logger.debug( + `Found streamdeck tcp device ${streamdeck.name} at ${streamdeck.address}:${streamdeck.port}` + ) + + this.io.emitToRoom(SurfaceDiscoveryRoom, 'surfaces:discovery:update', { + type: 'update', + info: uiService, + }) + }) + this.#streamDeckDiscovery.on('down', (streamdeck) => { + const uiService = this.#convertStreamDeckForUi(streamdeck) + if (!uiService) return + + this.io.emitToRoom(SurfaceDiscoveryRoom, 'surfaces:discovery:update', { + type: 'remove', + itemId: uiService.id, + }) + }) + + setImmediate(() => { + this.#streamDeckDiscovery?.query() + }) + } catch (e) { + this.logger.debug(`ERROR failed to start searching for streamdeck tcp devices`) + } + } } /** @@ -132,21 +172,44 @@ export class ServiceSurfaceDiscovery extends ServiceBase { } } + /** + * + * @param {import('@elgato-stream-deck/tcp').StreamDeckTcpDefinition} streamdeck + * @returns {import('@companion-app/shared/Model/Surfaces.js').ClientDiscoveredSurfaceInfo | null} + */ + #convertStreamDeckForUi(streamdeck) { + if (!streamdeck.isPrimary) return null + + return { + id: `streamdeck:${streamdeck.serialNumber ?? streamdeck.name}`, + + surfaceType: 'streamdeck', + + name: streamdeck.name, + address: streamdeck.address, + port: streamdeck.port, + + modelName: streamdeck.modelName, + serialnumber: streamdeck.serialNumber, + } + } + /** * Kill the socket, if exists. * @access protected * @override */ disableModule() { - if (this.#satelliteBrowser) { + if (this.currentState) { + this.currentState = false try { - this.currentState = false - - for (const service of this.#satelliteBrowser.services) { - this.#forgetSatelliteService(service) + if (this.#satelliteBrowser) { + for (const service of this.#satelliteBrowser.services) { + this.#forgetSatelliteService(service) + } + this.#satelliteBrowser.stop() + this.#satelliteBrowser = undefined } - this.#satelliteBrowser.stop() - this.#satelliteBrowser = undefined clearTimeout(this.#satelliteExpireInterval) this.#satelliteExpireInterval = undefined @@ -155,6 +218,17 @@ export class ServiceSurfaceDiscovery extends ServiceBase { } catch (/** @type {any} */ e) { this.logger.silly(`Could not stop searching for satellite devices: ${e.message}`) } + + try { + if (this.#streamDeckDiscovery) { + this.#streamDeckDiscovery.destroy() + this.#streamDeckDiscovery = undefined + } + + this.logger.info(`Stopped searching for streamdeck tcp devices`) + } catch (/** @type {any} */ e) { + this.logger.silly(`Could not stop searching for streamdeck tcp devices: ${e.message}`) + } } } @@ -177,6 +251,13 @@ export class ServiceSurfaceDiscovery extends ServiceBase { } } + if (this.#streamDeckDiscovery) { + for (const service of this.#streamDeckDiscovery.knownStreamDecks) { + const uiService = this.#convertStreamDeckForUi(service) + if (uiService) services[uiService.id] = uiService + } + } + return services }) client.onPromise('surfaces:discovery:leave', () => { diff --git a/companion/lib/Surface/Controller.js b/companion/lib/Surface/Controller.js index 43edf1c137..f26f3cef75 100644 --- a/companion/lib/Surface/Controller.js +++ b/companion/lib/Surface/Controller.js @@ -46,6 +46,7 @@ import FrameworkMacropadDriver from './USB/FrameworkMacropad.js' import MystrixDriver from './USB/203SystemsMystrix.js' import CoreBase from '../Core/Base.js' import { SurfaceGroup } from './Group.js' +import { SurfaceOutboundController } from './Outbound.js' import { SurfaceUSBBlackmagicController } from './USB/BlackmagicController.js' import { VARIABLE_UNKNOWN_VALUE } from '../Variables/Util.js' @@ -113,12 +114,20 @@ class SurfaceController extends CoreBase { */ #runningRefreshDevices = false + /** + * @type {SurfaceOutboundController} + * @readonly + */ + #outboundController + /** * @param {import('../Registry.js').default} registry */ constructor(registry) { super(registry, 'Surface/Controller') + this.#outboundController = new SurfaceOutboundController(this, registry.db, registry.io) + this.#surfacesAllLocked = !!this.userconfig.getKey('link_lockouts') setImmediate(() => { @@ -142,6 +151,7 @@ class SurfaceController extends CoreBase { this.#refreshDevices().catch(() => { this.logger.warn('Initial USB scan failed') }) + this.#outboundController.init() this.updateDevicesList() @@ -320,6 +330,8 @@ class SurfaceController extends CoreBase { * @access public */ clientConnect(client) { + this.#outboundController.clientConnect(client) + client.onPromise('emulator:startup', (id) => { const fullId = EmulatorRoom(id) @@ -641,6 +653,10 @@ class SurfaceController extends CoreBase { * @returns {ClientDevicesListItem[]} */ getDevicesList() { + const remoteConnections = Object.values(this.#outboundController.storage) + + const usedRemoteConnectionIds = new Set() + /** * * @param {string} id @@ -660,14 +676,27 @@ class SurfaceController extends CoreBase { isConnected: !!surfaceHandler, displayName: getSurfaceName(config, id), location: null, + remoteConnectionId: null, } if (surfaceHandler) { let location = surfaceHandler.panel.info.location if (location && location.startsWith('::ffff:')) location = location.substring(7) + let remotePort = surfaceHandler.panel.info.remotePort surfaceInfo.location = location || null surfaceInfo.configFields = surfaceHandler.panel.info.configFields || [] + + // Translate remote connections info + if (config?.integrationType === 'elgato-streamdeck-tcp') { + const matchingRemote = remoteConnections.find( + (s) => s.type === 'elgato' && s.address === location && s.port === remotePort + ) + if (matchingRemote) { + surfaceInfo.remoteConnectionId = matchingRemote.id + usedRemoteConnectionIds.add(matchingRemote.id) + } + } } return surfaceInfo @@ -753,6 +782,36 @@ class SurfaceController extends CoreBase { } } + for (const connection of remoteConnections) { + if (usedRemoteConnectionIds.has(connection.id)) continue + + const tcpId = `tcp://${connection.address}:${connection.port}` + + /** @type {ClientDevicesListItem} */ + const groupResult = { + id: tcpId, + index: undefined, + displayName: `${connection.displayName || connection.type} - Offline`, + isAutoGroup: true, + surfaces: [ + { + id: tcpId, + type: 'Unknown Elgato Stream Deck (TCP)', + integrationType: config?.integrationType || '', + name: config?.name || '', + // location: 'Offline', + configFields: [], + isConnected: false, + displayName: connection.displayName, + location: null, + remoteConnectionId: connection.id, + }, + ], + } + result.push(groupResult) + // groupsMap.set(groupId, groupResult) + } + return result } @@ -967,6 +1026,24 @@ class SurfaceController extends CoreBase { } } + /** + * + * @param {import('@elgato-stream-deck/tcp').StreamDeckTcp} streamdeck + */ + async addStreamdeckTcpDevice(streamdeck) { + const fakePath = `tcp://${streamdeck.remoteAddress}:${streamdeck.remotePort}` + + this.removeDevice(fakePath) + + const device = await ElgatoStreamDeckDriver.fromTcp(fakePath, streamdeck) + + this.#createSurfaceHandler(fakePath, 'elgato-streamdeck-tcp', device) + + setImmediate(() => this.updateDevicesList()) + + return device + } + /** * Add a satellite device * @param {import('./IP/Satellite.js').SatelliteDeviceInfo} deviceInfo @@ -1202,6 +1279,8 @@ class SurfaceController extends CoreBase { } } + this.#outboundController.quit() + this.#surfaceHandlers.clear() this.updateDevicesList() } diff --git a/companion/lib/Surface/Handler.js b/companion/lib/Surface/Handler.js index 817d87bf37..96c0e645fb 100644 --- a/companion/lib/Surface/Handler.js +++ b/companion/lib/Surface/Handler.js @@ -75,6 +75,7 @@ const PINCODE_NUMBER_POSITIONS_SKIP_FIRST_COL = [ * type: string * configFields: import('@companion-app/shared/Model/Surfaces.js').CompanionSurfaceConfigField[] * location?: string + * remotePort?: number * }} SurfacePanelInfo * @typedef {{ * info: SurfacePanelInfo diff --git a/companion/lib/Surface/Outbound.js b/companion/lib/Surface/Outbound.js new file mode 100644 index 0000000000..5ff219fa96 --- /dev/null +++ b/companion/lib/Surface/Outbound.js @@ -0,0 +1,201 @@ +import { nanoid } from 'nanoid' +import LogController from '../Log/Controller.js' +import { DEFAULT_TCP_PORT, StreamDeckTcpConnectionManager } from '@elgato-stream-deck/tcp' +import { StreamDeckJpegOptions } from './USB/ElgatoStreamDeck.js' + +const OutboundSurfacesRoom = 'surfaces:outbound' + +export class SurfaceOutboundController { + /** + * The logger for this class + * @type {import('winston').Logger} + * @access protected + */ + #logger = LogController.createLogger('SurfaceOutboundController') + + /** + * @type {import('./Controller.js').default} + * @readonly + */ + #controller + + /** + * The core database library + * @type {import('../Data/Database.js').default} + * @access protected + * @readonly + */ + #db + + /** + * The core interface client + * @type {import('../UI/Handler.js').default} + * @access protected + * @readonly + */ + #io + + /** + * @type {Record} + */ + #storage = {} + + #streamdeckTcpConnectionManager = new StreamDeckTcpConnectionManager({ + jpegOptions: StreamDeckJpegOptions, + autoConnectToSecondaries: true, + }) + + /** + * @type {Record} + */ + get storage() { + return this.#storage + } + + /** + * + * @param {import('./Controller.js').default} controller + * @param {import('../Data/Database.js').default} db + * @param {import('../UI/Handler.js').default} io + */ + constructor(controller, db, io) { + this.#controller = controller + this.#db = db + this.#io = io + + this.#streamdeckTcpConnectionManager.on('connected', (streamdeck) => { + this.#logger.info( + `Connected to TCP Streamdeck ${streamdeck.remoteAddress}:${streamdeck.remotePort} (${streamdeck.PRODUCT_NAME})` + ) + + this.#controller.addStreamdeckTcpDevice(streamdeck).catch((e) => { + this.#logger.error(`Failed to add TCP Streamdeck: ${e}`) + // TODO - how to handle? + // streamdeck.close() + }) + }) + this.#streamdeckTcpConnectionManager.on('error', (error) => { + this.#logger.error(`Error from TCP Streamdeck: ${error}`) + }) + } + + #saveToDb() { + this.#db.setKey('outbound_surfaces', this.#storage) + } + + /** + * Initialize the module, loading the configuration from the db + * @access public + */ + init() { + this.#storage = this.#db.getKey('outbound_surfaces', {}) + + for (const surfaceInfo of Object.values(this.#storage)) { + try { + if (surfaceInfo.type === 'elgato') { + this.#streamdeckTcpConnectionManager.connectTo(surfaceInfo.address, surfaceInfo.port) + } else { + throw new Error(`Remote surface type "${surfaceInfo.type}" is not supported`) + } + } catch (e) { + this.#logger.error(`Unable to setup remote surface at ${surfaceInfo.address}:${surfaceInfo.port}: ${e}`) + } + } + } + + /** + * Setup a new socket client's events + * @param {import('../UI/Handler.js').ClientSocket} client - the client socket + * @access public + */ + clientConnect(client) { + client.onPromise('surfaces:outbound:subscribe', async () => { + client.join(OutboundSurfacesRoom) + + return this.#storage + }) + client.onPromise('surfaces:outbound:unsubscribe', async () => { + client.leave(OutboundSurfacesRoom) + }) + client.onPromise('surfaces:outbound:add', async (type, address, port, name) => { + if (type !== 'elgato') throw new Error(`Surface type "${type}" is not supported`) + + // Ensure port number is defined + if (!port) port = DEFAULT_TCP_PORT + + // check for duplicate + const existingAddressAndPort = Object.values(this.#storage).find( + (surfaceInfo) => surfaceInfo.address === address && surfaceInfo.port === port + ) + if (existingAddressAndPort) throw new Error('Specified address and port is already defined') + + this.#logger.info(`Adding new Remote Streamdeck at ${address}:${port} (${name})`) + + const id = nanoid() + /** @type {import('@companion-app/shared/Model/Surfaces.js').OutboundSurfaceInfo} */ + const newInfo = { + id, + type: 'elgato', + address, + port, + displayName: name ?? '', + } + this.#storage[id] = newInfo + this.#saveToDb() + + this.#io.emitToRoom(OutboundSurfacesRoom, 'surfaces:outbound:update', [ + { + type: 'add', + itemId: id, + + info: newInfo, + }, + ]) + setImmediate(() => this.#controller.updateDevicesList()) + + this.#streamdeckTcpConnectionManager.connectTo(address, port) + + return id + }) + + client.onPromise('surfaces:outbound:remove', async (id) => { + const surfaceInfo = this.#storage[id] + if (!surfaceInfo) return // Not found, pretend all was ok + + delete this.#storage[id] + this.#saveToDb() + + this.#io.emitToRoom(OutboundSurfacesRoom, 'surfaces:outbound:update', [ + { + type: 'remove', + itemId: id, + }, + ]) + setImmediate(() => this.#controller.updateDevicesList()) + + this.#streamdeckTcpConnectionManager.disconnectFrom(surfaceInfo.address, surfaceInfo.port) + }) + + client.onPromise('surfaces:outbound:set-name', async (id, name) => { + const surfaceInfo = this.#storage[id] + if (!surfaceInfo) throw new Error('Surface not found') + + surfaceInfo.displayName = name ?? '' + this.#saveToDb() + + this.#io.emitToRoom(OutboundSurfacesRoom, 'surfaces:outbound:update', [ + { + type: 'add', + itemId: id, + + info: surfaceInfo, + }, + ]) + setImmediate(() => this.#controller.updateDevicesList()) + }) + } + + quit() { + this.#streamdeckTcpConnectionManager.disconnectFromAll() + } +} diff --git a/companion/lib/Surface/USB/ElgatoStreamDeck.js b/companion/lib/Surface/USB/ElgatoStreamDeck.js index a59a8f82fc..34487e9f66 100644 --- a/companion/lib/Surface/USB/ElgatoStreamDeck.js +++ b/companion/lib/Surface/USB/ElgatoStreamDeck.js @@ -31,6 +31,12 @@ import { } from '../CommonConfigFields.js' const setTimeoutPromise = util.promisify(setTimeout) +/** @type {import('@elgato-stream-deck/node').JPEGEncodeOptions} */ +export const StreamDeckJpegOptions = { + quality: 95, + subsampling: 1, // 422 +} + /** * @param {import('@elgato-stream-deck/node').StreamDeck} streamDeck * @return {import('@companion-app/shared/Model/Surfaces.js').CompanionSurfaceConfigField[]} @@ -47,6 +53,14 @@ function getConfigFields(streamDeck) { fields.push(LegacyRotationConfigField, ...LockConfigFields) + if (streamDeck.HAS_NFC_READER) + fields.push({ + id: 'nfc', + type: 'custom-variable', + label: 'Variable to store last read NFC tag to', + tooltip: '', + }) + return fields } @@ -65,21 +79,30 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { config = {} /** - * Xkeys panel - * @type {import('@elgato-stream-deck/node').StreamDeck} + * Streamdeck panel + * @type {import('@elgato-stream-deck/node').StreamDeck | import('@elgato-stream-deck/tcp').StreamDeckTcp} * @access private * @readonly */ #streamDeck + /** + * Whether to cleanup the deck on quit + */ + #shouldCleanupOnQuit = true + /** * @param {string} devicePath - * @param {import('@elgato-stream-deck/node').StreamDeck} streamDeck + * @param {import('@elgato-stream-deck/node').StreamDeck | import('@elgato-stream-deck/tcp').StreamDeckTcp} streamDeck */ constructor(devicePath, streamDeck) { super() - this.#logger = LogController.createLogger(`Surface/USB/ElgatoStreamdeck/${devicePath}`) + const tcpStreamdeck = 'tcpEvents' in streamDeck ? streamDeck : null + + const protocol = tcpStreamdeck ? 'TCP' : 'USB' + + this.#logger = LogController.createLogger(`Surface/${protocol}/ElgatoStreamdeck/${devicePath}`) this.config = { brightness: 100, @@ -88,7 +111,7 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { this.#streamDeck = streamDeck - this.#logger.debug(`Adding elgato-streamdeck ${this.#streamDeck.PRODUCT_NAME} USB device: ${devicePath}`) + this.#logger.debug(`Adding elgato-streamdeck ${this.#streamDeck.PRODUCT_NAME} ${protocol} device: ${devicePath}`) /** @type {import('../Handler.js').SurfacePanelInfo} */ this.info = { @@ -96,21 +119,17 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { devicePath: devicePath, configFields: getConfigFields(this.#streamDeck), deviceId: '', // set in #init() + location: undefined, // set later + remotePort: undefined, // set later } const allRowValues = this.#streamDeck.CONTROLS.map((control) => control.row) const allColumnValues = this.#streamDeck.CONTROLS.map((button) => button.column) - const gridSpan = { - // minRow: Math.min(...allRowValues), - maxRow: Math.max(...allRowValues), - // minCol: Math.min(...allColumnValues), - maxCol: Math.max(...allColumnValues), - } - + // Future: maybe this should consider the min values too, but that requires handling in a bunch of places here this.gridSize = { - columns: gridSpan.maxCol + 1, - rows: gridSpan.maxRow + 1, + columns: Math.max(...allColumnValues) + 1, + rows: Math.max(...allRowValues) + 1, } this.write_queue = new ImageWriteQueue( @@ -219,6 +238,9 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { await setTimeoutPromise(20) } } + } else if (control.type === 'encoder' && control.hasLed) { + const color = render.style ? colorToRgb(render.bgcolor) : { r: 0, g: 0, b: 0 } + await this.#streamDeck.setEncoderColor(control.index, color.r, color.g, color.b) } } ) @@ -228,6 +250,22 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { this.emit('remove') }) + if (tcpStreamdeck) { + // Don't call `close` upon quit, that gets handled automatically + this.#shouldCleanupOnQuit = false + + this.info.location = tcpStreamdeck.remoteAddress + this.info.remotePort = tcpStreamdeck.remotePort + + tcpStreamdeck.tcpEvents.on('disconnected', () => { + this.#logger.info( + `Lost connection to TCP Streamdeck ${tcpStreamdeck.remoteAddress}:${tcpStreamdeck.remotePort} (${this.#streamDeck.PRODUCT_NAME})` + ) + + this.emit('remove') + }) + } + this.#streamDeck.on('down', (control) => { this.emit('click', control.column, control.row, true) }) @@ -239,6 +277,11 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { this.#streamDeck.on('rotate', (control, amount) => { this.emit('rotate', control.column, control.row, amount > 0) }) + this.#streamDeck.on('nfcRead', (tag) => { + const variableId = this.config.nfc + if (!variableId) return + this.emit('setCustomVariable', variableId, tag) + }) const lcdPress = ( /** @type {import('@elgato-stream-deck/node').StreamDeckLcdSegmentControlDefinition} */ control, @@ -303,6 +346,31 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { } } + /** + * Wrap a tcp streamdeck + * @param {string} fakePath + * @param {import('@elgato-stream-deck/tcp').StreamDeckTcp} streamdeck + */ + static async fromTcp(fakePath, streamdeck) { + const self = new SurfaceUSBElgatoStreamDeck(fakePath, streamdeck) + + /** @type {any} */ + let errorDuringInit = null + const tmpErrorHandler = (/** @type {any} */ error) => { + errorDuringInit = errorDuringInit || error + } + + // Ensure that any hid error during the init call don't cause a crash + self.on('error', tmpErrorHandler) + + await self.#init() + + if (errorDuringInit) throw errorDuringInit + self.off('error', tmpErrorHandler) + + return self + } + /** * Process the information from the GUI and what is saved in database * @param {Record} config @@ -320,6 +388,8 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { } quit() { + if (!this.#shouldCleanupOnQuit) return + this.#streamDeck .resetToLogo() .catch((e) => { @@ -327,7 +397,9 @@ class SurfaceUSBElgatoStreamDeck extends EventEmitter { }) .then(() => { //close after the clear has been sent - this.#streamDeck.close() + this.#streamDeck.close().catch(() => { + // Ignore error + }) }) } diff --git a/companion/package.json b/companion/package.json index af2546d9e7..98c6dfd481 100644 --- a/companion/package.json +++ b/companion/package.json @@ -49,6 +49,7 @@ "@companion-app/shared": "*", "@companion-module/base": "~1.10.0", "@elgato-stream-deck/node": "^7.0.0-0", + "@elgato-stream-deck/tcp": "^7.0.0-0", "@julusian/bonjour-service": "^1.3.0-2", "@julusian/image-rs": "^1.1.1", "@julusian/jpeg-turbo": "^2.1.0", diff --git a/package.json b/package.json index d5d7c21e3f..325a501baf 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,10 @@ "app-builder-bin": "npm:@julusian/app-builder-bin@4.0.1", "node-gyp-build": "github:julusian/node-gyp-build#cross-install-support", "osc/serialport": "^10.5.0", - "react-select": "npm:@julusian/react-select@^5.8.1-1" + "react-select": "npm:@julusian/react-select@^5.8.1-1", + "@elgato-stream-deck/core": "portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/core", + "@elgato-stream-deck/node": "portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/node", + "@elgato-stream-deck/node-lib": "portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/node-lib", + "@elgato-stream-deck/tcp": "portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/tcp" } } diff --git a/shared-lib/lib/Model/Surfaces.ts b/shared-lib/lib/Model/Surfaces.ts index fe4b0fb6c7..3541969b0a 100644 --- a/shared-lib/lib/Model/Surfaces.ts +++ b/shared-lib/lib/Model/Surfaces.ts @@ -17,6 +17,7 @@ export interface ClientSurfaceItem { isConnected: boolean displayName: string location: string | null + remoteConnectionId: string | null } export interface ClientDevicesListItem { @@ -55,7 +56,30 @@ export interface SurfacesUpdateUpdateOp { patch: JsonPatchOperation[] } -export interface ClientDiscoveredSurfaceInfo { +export interface OutboundSurfaceInfo { + id: string + displayName: string + type: 'elgato' + address: string + port: number | undefined +} + +export type OutboundSurfacesUpdate = OutboundSurfacesUpdateRemoveOp | OutboundSurfacesUpdateAddOp + +export interface OutboundSurfacesUpdateRemoveOp { + type: 'remove' + itemId: string +} +export interface OutboundSurfacesUpdateAddOp { + type: 'add' + itemId: string + + info: OutboundSurfaceInfo +} + +export type ClientDiscoveredSurfaceInfo = ClientDiscoveredSurfaceInfoSatellite | ClientDiscoveredSurfaceInfoStreamDeck + +export interface ClientDiscoveredSurfaceInfoSatellite { id: string surfaceType: 'satellite' @@ -67,6 +91,19 @@ export interface ClientDiscoveredSurfaceInfo { apiEnabled: boolean } +export interface ClientDiscoveredSurfaceInfoStreamDeck { + id: string + + surfaceType: 'streamdeck' + + name: string + address: string + port: number + + modelName: string + serialnumber: string | undefined +} + export type SurfacesDiscoveryUpdate = SurfaceDiscoveryUpdateRemoveOp | SurfaceDiscoveryUpdateUpdateOp export interface SurfaceDiscoveryUpdateRemoveOp { diff --git a/shared-lib/lib/SocketIO.ts b/shared-lib/lib/SocketIO.ts index 9e1668f606..e0c6c2f3b6 100644 --- a/shared-lib/lib/SocketIO.ts +++ b/shared-lib/lib/SocketIO.ts @@ -18,10 +18,12 @@ import type { } from './Model/Common.js' import type { ClientDevicesListItem, - ClientDiscoveredSurfaceInfo, - CompanionExternalAddresses, + OutboundSurfaceInfo, + OutboundSurfacesUpdate, SurfaceGroupConfig, SurfacePanelConfig, + ClientDiscoveredSurfaceInfo, + CompanionExternalAddresses, SurfacesDiscoveryUpdate, SurfacesUpdate, } from './Model/Surfaces.js' @@ -250,6 +252,12 @@ export interface ClientToBackendEventsMap { companionAddress: string ) => string | null + 'surfaces:outbound:subscribe': () => Record + 'surfaces:outbound:unsubscribe': () => void + 'surfaces:outbound:add': (type: string, address: string, port: number | undefined, name?: string) => string + 'surfaces:outbound:remove': (id: string) => void + 'surfaces:outbound:set-name': (surfaceId: string, name: string) => void + 'emulator:startup': (emulatorId: string) => EmulatorConfig 'emulator:press': (emulatorId: string, column: number, row: number) => void 'emulator:release': (emulatorId: string, column: number, row: number) => void @@ -338,6 +346,7 @@ export interface BackendToClientEventsMap { 'connections:patch': (patch: JsonPatchOperation[] | false) => void 'modules:patch': (patch: ModuleInfoUpdate) => void 'surfaces:update': (patch: SurfacesUpdate[]) => void + 'surfaces:outbound:update': (patch: OutboundSurfacesUpdate[]) => void 'triggers:update': (change: TriggersUpdate) => void 'action-definitions:update': (change: ActionDefinitionUpdate) => void 'feedback-definitions:update': (change: FeedbackDefinitionUpdate) => void diff --git a/webui/src/ContextData.tsx b/webui/src/ContextData.tsx index da2d2b658c..a3c550f9f0 100644 --- a/webui/src/ContextData.tsx +++ b/webui/src/ContextData.tsx @@ -26,6 +26,7 @@ import { UserConfigStore } from './Stores/UserConfigStore.js' import { VariablesStore } from './Stores/VariablesStore.js' import { useCustomVariablesSubscription } from './Hooks/useCustomVariablesSubscription.js' import { useVariablesSubscription } from './Hooks/useVariablesSubscription.js' +import { useOutboundSurfacesSubscription } from './Hooks/useOutboundSurfacesSubscription.js' interface ContextDataProps { children: (progressPercent: number, loadingComplete: boolean) => React.JSX.Element | React.JSX.Element[] @@ -73,6 +74,7 @@ export function ContextData({ children }: Readonly) { const pagesReady = usePagesInfoSubscription(socket, rootStore.pages) const userConfigReady = useUserConfigSubscription(socket, rootStore.userConfig) const surfacesReady = useSurfacesSubscription(socket, rootStore.surfaces) + const outboundSurfacesReady = useOutboundSurfacesSubscription(socket, rootStore.surfaces) const variablesReady = useVariablesSubscription(socket, rootStore.variablesStore) const customVariablesReady = useCustomVariablesSubscription(socket, rootStore.variablesStore) @@ -140,6 +142,7 @@ export function ContextData({ children }: Readonly) { customVariablesReady, userConfigReady, surfacesReady, + outboundSurfacesReady, pagesReady, triggersListReady, activeLearnRequestsReady, diff --git a/webui/src/Hooks/useOutboundSurfacesSubscription.ts b/webui/src/Hooks/useOutboundSurfacesSubscription.ts new file mode 100644 index 0000000000..80bb04a674 --- /dev/null +++ b/webui/src/Hooks/useOutboundSurfacesSubscription.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' +import { CompanionSocketType, socketEmitPromise } from '../util.js' +import type { OutboundSurfacesUpdate } from '@companion-app/shared/Model/Surfaces.js' +import type { SurfacesStore } from '../Stores/SurfacesStore.js' + +export function useOutboundSurfacesSubscription( + socket: CompanionSocketType, + store: SurfacesStore, + setLoadError?: ((error: string | null) => void) | undefined, + retryToken?: string +): boolean { + const [ready, setReady] = useState(false) + + useEffect(() => { + setLoadError?.(null) + store.resetOutboundSurfaces(null) + setReady(false) + + socketEmitPromise(socket, 'surfaces:outbound:subscribe', []) + .then((surfaces) => { + setLoadError?.(null) + store.resetOutboundSurfaces(surfaces) + setReady(true) + }) + .catch((e) => { + setLoadError?.(`Failed to load outbound surfaces list`) + console.error('Failed to load outbound surfaces list:', e) + store.resetOutboundSurfaces(null) + }) + + const updateSurfaces = (changes: OutboundSurfacesUpdate[]) => { + for (const change of changes) { + store.applyOutboundSurfacesChange(change) + } + } + + socket.on('surfaces:outbound:update', updateSurfaces) + + return () => { + store.resetOutboundSurfaces(null) + + socket.off('surfaces:outbound:update', updateSurfaces) + + socketEmitPromise(socket, 'surfaces:outbound:unsubscribe', []).catch((e) => { + console.error('Failed to unsubscribe to outbound surfaces list', e) + }) + } + }, [socket, store, setLoadError, retryToken]) + + return ready +} diff --git a/webui/src/Hooks/useSurfacesSubscription.ts b/webui/src/Hooks/useSurfacesSubscription.ts index ac714d155c..bd062cbb04 100644 --- a/webui/src/Hooks/useSurfacesSubscription.ts +++ b/webui/src/Hooks/useSurfacesSubscription.ts @@ -13,31 +13,31 @@ export function useSurfacesSubscription( useEffect(() => { setLoadError?.(null) - store.reset(null) + store.resetSurfaces(null) setReady(false) socketEmitPromise(socket, 'surfaces:subscribe', []) .then((surfaces) => { setLoadError?.(null) - store.reset(surfaces) + store.resetSurfaces(surfaces) setReady(true) }) .catch((e) => { setLoadError?.(`Failed to load surfaces list`) console.error('Failed to load surfaces list:', e) - store.reset(null) + store.resetSurfaces(null) }) const updateSurfaces = (changes: SurfacesUpdate[]) => { for (const change of changes) { - store.applyChange(change) + store.applySurfacesChange(change) } } socket.on('surfaces:update', updateSurfaces) return () => { - store.reset(null) + store.resetSurfaces(null) socket.off('surfaces:update', updateSurfaces) diff --git a/webui/src/Stores/SurfacesStore.tsx b/webui/src/Stores/SurfacesStore.tsx index 80152034fc..77d8d85577 100644 --- a/webui/src/Stores/SurfacesStore.tsx +++ b/webui/src/Stores/SurfacesStore.tsx @@ -1,5 +1,10 @@ -import { ClientDevicesListItem, SurfacesUpdate } from '@companion-app/shared/Model/Surfaces.js' -import { action, observable } from 'mobx' +import type { + ClientDevicesListItem, + OutboundSurfaceInfo, + SurfacesUpdate, + OutboundSurfacesUpdate, +} from '@companion-app/shared/Model/Surfaces.js' +import { action, observable, toJS } from 'mobx' import { assertNever } from '../util.js' import { applyPatch } from 'fast-json-patch' import { cloneDeep } from 'lodash-es' @@ -7,7 +12,9 @@ import { cloneDeep } from 'lodash-es' export class SurfacesStore { readonly store = observable.map() - public reset = action((newData: Record | null): void => { + readonly outboundSurfaces = observable.map() + + public resetSurfaces = action((newData: Record | null): void => { this.store.clear() if (newData) { @@ -19,7 +26,7 @@ export class SurfacesStore { } }) - public applyChange = action((change: SurfacesUpdate) => { + public applySurfacesChange = action((change: SurfacesUpdate) => { const changeType = change.type switch (change.type) { case 'add': @@ -41,4 +48,43 @@ export class SurfacesStore { break } }) + + public resetOutboundSurfaces = action((newData: Record | null): void => { + this.outboundSurfaces.clear() + + if (newData) { + for (const [id, item] of Object.entries(newData)) { + if (item) { + this.outboundSurfaces.set(id, item) + } + } + } + }) + + public applyOutboundSurfacesChange = action((change: OutboundSurfacesUpdate) => { + const changeType = change.type + switch (change.type) { + case 'add': + this.outboundSurfaces.set(change.itemId, change.info) + break + case 'remove': + this.outboundSurfaces.delete(change.itemId) + break + default: + console.error(`Unknown remote surfaces change change: ${changeType}`) + assertNever(change) + break + } + }) + + public getOutboundStreamDeckSurface = (address: string, port: number): OutboundSurfaceInfo | undefined => { + for (const surface of this.outboundSurfaces.values()) { + console.log('check', toJS(surface)) + + if (surface.type === 'elgato' && surface.address === address && (surface.port ?? 5343) === port) { + return surface + } + } + return undefined + } } diff --git a/webui/src/Surfaces/AddOutboundSurfaceModal.tsx b/webui/src/Surfaces/AddOutboundSurfaceModal.tsx new file mode 100644 index 0000000000..b7f1c033d7 --- /dev/null +++ b/webui/src/Surfaces/AddOutboundSurfaceModal.tsx @@ -0,0 +1,153 @@ +import React, { + ChangeEvent, + FormEvent, + forwardRef, + useCallback, + useContext, + useImperativeHandle, + useState, +} from 'react' +import { CAlert, CButton, CForm, CFormInput, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' +import { socketEmitPromise, SocketContext, PreventDefaultHandler } from '../util.js' +import { CModalExt } from '../Components/CModalExt.js' + +export interface AddOutboundSurfaceModalRef { + show(): void +} +interface AddOutboundSurfaceModalProps { + // Nothing +} + +interface FormInfo { + name: string + address: string + port: number | undefined +} + +export const AddOutboundSurfaceModal = forwardRef( + function SurfaceEditModal(_props, ref) { + const socket = useContext(SocketContext) + + const [show, setShow] = useState(false) + const [running, setRunning] = useState(false) + const [saveError, setSaveError] = useState(null) + + const [info, setInfo] = useState(null) + + const doClose = useCallback(() => setShow(false), []) + const onClosed = useCallback(() => { + setInfo(null) + setRunning(false) + setSaveError(null) + }, []) + + const doAction = useCallback( + (e: FormEvent) => { + if (e) e.preventDefault() + + if (!info) return + + setRunning(true) + setSaveError(null) + + socketEmitPromise(socket, 'surfaces:outbound:add', ['elgato', info.address, info.port, info.name]) + .then(() => { + setRunning(false) + setShow(false) + setInfo(null) + setSaveError(null) + }) + .catch((err) => { + console.error('Outbound surface add failed', err) + setRunning(false) + setSaveError(err?.message ?? err) + }) + }, + [info] + ) + + useImperativeHandle( + ref, + () => ({ + show() { + setShow(true) + setInfo({ + name: '', + address: '', + port: undefined, + }) + }, + }), + [] + ) + + const onNameChange = useCallback((e: ChangeEvent) => { + const newName = e.currentTarget.value + setInfo( + (oldInfo) => + oldInfo && { + ...oldInfo, + name: newName, + } + ) + }, []) + + const onAddressChange = useCallback((e: ChangeEvent) => { + const newAddress = e.currentTarget.value + setInfo( + (oldInfo) => + oldInfo && { + ...oldInfo, + address: newAddress, + } + ) + }, []) + const onPortChange = useCallback((e: ChangeEvent) => { + const newPort = Number(e.currentTarget.value) + if (isNaN(newPort)) return + + setInfo( + (oldInfo) => + oldInfo && { + ...oldInfo, + port: newPort, + } + ) + }, []) + + return ( + console.log('show')}> + +
Add Stream Deck Studio
+
+ + + {saveError ? {saveError} : null} + + + + + + + + + + + Cancel + + + Add + + +
+ ) + } +) diff --git a/webui/src/Surfaces/KnownSurfacesTable.tsx b/webui/src/Surfaces/KnownSurfacesTable.tsx index 1d70929ac6..30ad005efe 100644 --- a/webui/src/Surfaces/KnownSurfacesTable.tsx +++ b/webui/src/Surfaces/KnownSurfacesTable.tsx @@ -49,17 +49,36 @@ export const KnownSurfacesTable = observer(function SurfacesPage() { }, []) const forgetSurface = useCallback( - (surfaceId: string) => { - confirmRef.current?.show( - 'Forget Surface', - 'Are you sure you want to forget this surface? Any settings will be lost', - 'Forget', - () => { - socketEmitPromise(socket, 'surfaces:forget', [surfaceId]).catch((err) => { - console.error('fotget failed', err) - }) - } - ) + (surfaceId: string, remoteConnectionId: string | null) => { + if (remoteConnectionId) { + confirmRef.current?.show( + 'Disconnect and Forget Surface', + [ + 'Are you sure you want to disconnect from and forget this surface? Any settings will be lost.', + 'Any surfaces sharing this connection will also be disconnected.', + ], + 'Forget', + () => { + socketEmitPromise(socket, 'surfaces:forget', [surfaceId]).catch((err) => { + console.error('fotget failed', err) + }) + socketEmitPromise(socket, 'surfaces:outbound:remove', [remoteConnectionId]).catch((err) => { + console.error('fotget failed', err) + }) + } + ) + } else { + confirmRef.current?.show( + 'Forget Surface', + 'Are you sure you want to forget this surface? Any settings will be lost', + 'Forget', + () => { + socketEmitPromise(socket, 'surfaces:forget', [surfaceId]).catch((err) => { + console.error('fotget failed', err) + }) + } + ) + } }, [socket] ) @@ -148,7 +167,7 @@ interface ManualGroupRowProps { updateName: (surfaceId: string, name: string) => void configureSurface: (surfaceId: string) => void deleteEmulator: (surfaceId: string) => void - forgetSurface: (surfaceId: string) => void + forgetSurface: (surfaceId: string, remoteConnectionId: string | null) => void } function ManualGroupRow({ group, @@ -207,7 +226,7 @@ interface SurfaceRowProps { updateName: (surfaceId: string, name: string) => void configureSurface: (surfaceId: string) => void deleteEmulator: (surfaceId: string) => void - forgetSurface: (surfaceId: string) => void + forgetSurface: (surfaceId: string, remoteConnectionId: string | null) => void noBorder: boolean } @@ -223,7 +242,10 @@ function SurfaceRow({ const updateName2 = useCallback((val: string) => updateName(surface.id, val), [updateName, surface.id]) const configureSurface2 = useCallback(() => configureSurface(surface.id), [configureSurface, surface.id]) const deleteEmulator2 = useCallback(() => deleteEmulator(surface.id), [deleteEmulator, surface.id]) - const forgetSurface2 = useCallback(() => forgetSurface(surface.id), [forgetSurface, surface.id]) + const forgetSurface2 = useCallback( + () => forgetSurface(surface.id, surface.remoteConnectionId), + [forgetSurface, surface.id] + ) return ( )} + + {surface.remoteConnectionId ? ( + + + + ) : null} ) : ( diff --git a/webui/src/Surfaces/OutboundSurfacesTable.tsx b/webui/src/Surfaces/OutboundSurfacesTable.tsx new file mode 100644 index 0000000000..82c2691f98 --- /dev/null +++ b/webui/src/Surfaces/OutboundSurfacesTable.tsx @@ -0,0 +1,119 @@ +import React, { useCallback, useContext, useRef } from 'react' +import { RootAppStoreContext } from '../Stores/RootAppStore.js' +import { NonIdealState } from '../Components/NonIdealState.js' +import { faAdd, faSearch, faTrash } from '@fortawesome/free-solid-svg-icons' +import { CButton, CButtonGroup } from '@coreui/react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { AddOutboundSurfaceModal, AddOutboundSurfaceModalRef } from './AddOutboundSurfaceModal.js' +import { OutboundSurfaceInfo } from '@companion-app/shared/Model/Surfaces.js' +import { TextInputField } from '../Components/TextInputField.js' +import { socketEmitPromise } from '../util.js' +import { GenericConfirmModal, GenericConfirmModalRef } from '../Components/GenericConfirmModal.js' +import { observer } from 'mobx-react-lite' + +export const OutboundSurfacesTable = observer(function OutboundSurfacesTable() { + const { surfaces, socket } = useContext(RootAppStoreContext) + + const surfacesList = Array.from(surfaces.outboundSurfaces.values()).sort((a, b) => { + return a.address.localeCompare(b.address) + }) + + const addModalRef = useRef(null) + const confirmRef = useRef(null) + + const addSurface = useCallback(() => addModalRef?.current?.show(), []) + + const removeSurface = useCallback( + (surfaceId: string) => { + confirmRef.current?.show('Remove Surface', 'Are you sure you want to remove this surface?', 'Remove', () => { + socketEmitPromise(socket, 'surfaces:outbound:remove', [surfaceId]).catch((err) => { + console.error('fotget failed', err) + }) + }) + }, + [socket] + ) + + const updateName = useCallback( + (surfaceId: string, name: string) => { + socketEmitPromise(socket, 'surfaces:outbound:set-name', [surfaceId, name]).catch((err) => { + console.error('Update name failed', err) + }) + }, + [socket] + ) + + return ( + <> + + + + + + Add Remote Surface + + + + + + + + + + + + + + {surfacesList.map((surfaceInfo) => ( + + ))} + + {surfacesList.length === 0 && ( + + + + )} + +
NameTypeLocation 
+ +
+ + ) +}) + +interface OutboundSurfaceRowProps { + surfaceInfo: OutboundSurfaceInfo + + updateName: (surfaceId: string, name: string) => void + removeSurface: (surfaceId: string) => void +} +function OutboundSurfaceRow({ surfaceInfo, updateName, removeSurface }: OutboundSurfaceRowProps) { + const updateName2 = useCallback((val: string) => updateName(surfaceInfo.id, val), [updateName, surfaceInfo.id]) + const removeSurface2 = useCallback(() => removeSurface(surfaceInfo.id), [removeSurface, surfaceInfo.id]) + + return ( + + + + + + Stream Deck Studio + {/* {surfaceInfo.type} TODO - do this dynamically once there are multiple to support */} + + + {surfaceInfo.address} + {surfaceInfo.port != null ? `:${surfaceInfo.port}` : ''} + + + + Remove + + + + ) +} diff --git a/webui/src/Surfaces/SetupSatelliteModal.tsx b/webui/src/Surfaces/SetupSatelliteModal.tsx index 67064f6044..69109c2976 100644 --- a/webui/src/Surfaces/SetupSatelliteModal.tsx +++ b/webui/src/Surfaces/SetupSatelliteModal.tsx @@ -1,4 +1,7 @@ -import { ClientDiscoveredSurfaceInfo, CompanionExternalAddresses } from '@companion-app/shared/Model/Surfaces.js' +import { + ClientDiscoveredSurfaceInfoSatellite, + CompanionExternalAddresses, +} from '@companion-app/shared/Model/Surfaces.js' import React, { forwardRef, useCallback, useContext, useImperativeHandle, useRef, useState } from 'react' import { socketEmitPromise, SocketContext, LoadingBar } from '../util.js' import { CButton, CForm, CModalBody, CModalFooter, CModalHeader } from '@coreui/react' @@ -6,13 +9,13 @@ import { CModalExt } from '../Components/CModalExt.js' import { DropdownInputField, MenuPortalContext } from '../Components/DropdownInputField.js' export interface SetupSatelliteModalRef { - show(surfaceInfo: ClientDiscoveredSurfaceInfo): void + show(surfaceInfo: ClientDiscoveredSurfaceInfoSatellite): void } export const SetupSatelliteModal = forwardRef(function SetupSatelliteModal(_props, ref) { const socket = useContext(SocketContext) const [show, setShow] = useState(false) - const [data, setData] = useState(null) + const [data, setData] = useState(null) const [externalAddresses, setExternalAddresses] = useState(null) const [selectedAddress, setSelectedAddress] = useState(null) diff --git a/webui/src/Surfaces/SurfaceDiscoveryTable.tsx b/webui/src/Surfaces/SurfaceDiscoveryTable.tsx index 0c967d51fa..925e8fdf08 100644 --- a/webui/src/Surfaces/SurfaceDiscoveryTable.tsx +++ b/webui/src/Surfaces/SurfaceDiscoveryTable.tsx @@ -1,4 +1,9 @@ -import { ClientDiscoveredSurfaceInfo, SurfacesDiscoveryUpdate } from '@companion-app/shared/Model/Surfaces.js' +import { + ClientDiscoveredSurfaceInfo, + ClientDiscoveredSurfaceInfoSatellite, + ClientDiscoveredSurfaceInfoStreamDeck, + SurfacesDiscoveryUpdate, +} from '@companion-app/shared/Model/Surfaces.js' import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' import { socketEmitPromise, assertNever, SocketContext } from '../util.js' import { CButton, CButtonGroup } from '@coreui/react' @@ -7,16 +12,35 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { SetupSatelliteModalRef, SetupSatelliteModal } from './SetupSatelliteModal.js' import { RootAppStoreContext } from '../Stores/RootAppStore.js' import { NonIdealState } from '../Components/NonIdealState.js' +import { observer } from 'mobx-react-lite' -export function SurfaceDiscoveryTable() { +export const SurfaceDiscoveryTable = observer(function SurfaceDiscoveryTable() { const discoveredSurfaces = useSurfaceDiscoverySubscription() - const { userConfig } = useContext(RootAppStoreContext) + const { userConfig, socket } = useContext(RootAppStoreContext) const setupSatelliteRef = useRef(null) - const showSetupSatellite = useCallback((surfaceInfo: ClientDiscoveredSurfaceInfo) => { + const showSetupSatellite = useCallback((surfaceInfo: ClientDiscoveredSurfaceInfoSatellite) => { setupSatelliteRef.current?.show(surfaceInfo) }, []) + const addRemoteStreamDeck = useCallback( + (surfaceInfo: ClientDiscoveredSurfaceInfoStreamDeck) => { + // TODO + socketEmitPromise(socket, 'surfaces:outbound:add', [ + 'elgato', + surfaceInfo.address, + surfaceInfo.port, + surfaceInfo.name, + ]) + .then(() => { + console.log('added streamdeck', surfaceInfo) + }) + .catch((e) => { + console.error('Failed to add streamdeck: ', e) + }) + }, + [socket] + ) return ( <> @@ -34,13 +58,23 @@ export function SurfaceDiscoveryTable() { {userConfig.properties?.discoveryEnabled ? ( <> - {Object.entries(discoveredSurfaces).map(([id, svc]) => - svc ? : null - )} + {Object.entries(discoveredSurfaces).map(([id, svc]) => { + switch (svc?.surfaceType) { + case 'satellite': + return + case 'streamdeck': + return + case undefined: + return null + default: + assertNever(svc) + return null + } + })} {Object.values(discoveredSurfaces).length === 0 && ( - + )} @@ -48,7 +82,7 @@ export function SurfaceDiscoveryTable() { ) : ( - + )} @@ -56,7 +90,7 @@ export function SurfaceDiscoveryTable() { ) -} +}) function useSurfaceDiscoverySubscription() { const socket = useContext(SocketContext) @@ -126,8 +160,8 @@ function useSurfaceDiscoverySubscription() { } interface SatelliteRowProps { - surfaceInfo: ClientDiscoveredSurfaceInfo - showSetupSatellite: (surfaceInfo: ClientDiscoveredSurfaceInfo) => void + surfaceInfo: ClientDiscoveredSurfaceInfoSatellite + showSetupSatellite: (surfaceInfo: ClientDiscoveredSurfaceInfoSatellite) => void } function SatelliteRow({ surfaceInfo, showSetupSatellite }: SatelliteRowProps) { @@ -168,3 +202,36 @@ function SatelliteRow({ surfaceInfo, showSetupSatellite }: SatelliteRowProps) { ) } + +interface StreamDeckRowProps { + surfaceInfo: ClientDiscoveredSurfaceInfoStreamDeck + addRemoteStreamDeck: (surfaceInfo: ClientDiscoveredSurfaceInfoStreamDeck) => void +} + +const StreamDeckRow = observer(function StreamDeckRow({ surfaceInfo, addRemoteStreamDeck }: StreamDeckRowProps) { + const { surfaces } = useContext(RootAppStoreContext) + + const isAlreadyAdded = !!surfaces.getOutboundStreamDeckSurface(surfaceInfo.address, surfaceInfo.port) + + return ( + + {surfaceInfo.name} + {surfaceInfo.modelName} + +

{surfaceInfo.address}

+ + + + addRemoteStreamDeck(surfaceInfo)} + title={isAlreadyAdded ? 'Already added' : 'Add Stream Deck'} + className="btn-undefined" + disabled={isAlreadyAdded} + > + Add Stream Deck + + + + + ) +}) diff --git a/webui/src/Surfaces/index.tsx b/webui/src/Surfaces/index.tsx index 36878ef209..71f8d1be59 100644 --- a/webui/src/Surfaces/index.tsx +++ b/webui/src/Surfaces/index.tsx @@ -9,10 +9,11 @@ import { observer } from 'mobx-react-lite' import { SurfaceDiscoveryTable } from './SurfaceDiscoveryTable.js' import { KnownSurfacesTable } from './KnownSurfacesTable.js' import { NavLink, NavigateFunction, useLocation, useNavigate } from 'react-router-dom' +import { OutboundSurfacesTable } from './OutboundSurfacesTable.js' export const SURFACES_PAGE_PREFIX = '/surfaces' -type SubPageType = 'configured' | 'discover' +type SubPageType = 'configured' | 'discover' | 'outbound' function useSurfacesSubPage(): SubPageType | null | false { const routerLocation = useLocation() @@ -21,7 +22,7 @@ function useSurfacesSubPage(): SubPageType | null | false { const subPage = fragments[0] if (!subPage) return null - if (subPage !== 'configured' && subPage !== 'discover') return null + if (subPage !== 'configured' && subPage !== 'discover' && subPage !== 'outbound') return null return subPage } @@ -61,6 +62,11 @@ export const SurfacesPage = observer(function SurfacesPage() { Discover + + + Remote Surfaces + + @@ -73,6 +79,11 @@ export const SurfacesPage = observer(function SurfacesPage() { + + + + + @@ -155,11 +166,29 @@ function DiscoverSurfacesTab() { return ( <>

- Discovered Companion Satellite instances will be listed here. You can easily configure them to connect to - Companion from here. This supports Companion Satellite version 1.9.0 and later. + Discovered remote surfaces, such as Companion Satellite and Stream Deck Studio will be listed here. You can + easily configure them to connect to Companion from here. +
+ This requires Companion Satellite version 1.9.0 and later.

) } + +function OutboundSurfacesTab() { + return ( + <> +

+ The Stream Deck Studio supports network connection. You can set up the connection from Companion here, or use + the Discovered Surfaces tab. +
+ This is not suitable for all remote surfaces such as Satellite, as that opens the connection to Companion + itself. +

+ + + + ) +} diff --git a/webui/src/UserConfig/SurfacesConfig.tsx b/webui/src/UserConfig/SurfacesConfig.tsx index 10ba649ef0..7944096aad 100644 --- a/webui/src/UserConfig/SurfacesConfig.tsx +++ b/webui/src/UserConfig/SurfacesConfig.tsx @@ -12,7 +12,13 @@ export const SurfacesConfig = observer(function SurfacesConfig(props: UserConfig + Watch for Discoverable Remote Surfaces +
+ Such as Companion Satellite and Stream Deck Studio + + } field="discoveryEnabled" /> diff --git a/yarn.lock b/yarn.lock index 383d915e08..f7fc941671 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1727,30 +1727,49 @@ __metadata: languageName: node linkType: hard -"@elgato-stream-deck/core@npm:7.0.0-0": - version: 7.0.0-0 - resolution: "@elgato-stream-deck/core@npm:7.0.0-0" +"@elgato-stream-deck/core@portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/core::locator=%40companion-app%2Fworkspace%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@elgato-stream-deck/core@portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/core::locator=%40companion-app%2Fworkspace%40workspace%3A." dependencies: - eventemitter3: "npm:^4.0.7" + eventemitter3: "npm:^5.0.1" tslib: "npm:^2.7.0" - checksum: 10c0/ff010af90878b304508157110353e07c7035616de8381c6a9ec748ff430cd738f3ebda2eadd70cbc9ebfa38f778024bd00225cee5ce8278d29ddda077b1166e4 languageName: node - linkType: hard + linkType: soft -"@elgato-stream-deck/node@npm:^7.0.0-0": - version: 7.0.0-0 - resolution: "@elgato-stream-deck/node@npm:7.0.0-0" +"@elgato-stream-deck/node-lib@portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/node-lib::locator=%40companion-app%2Fworkspace%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@elgato-stream-deck/node-lib@portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/node-lib::locator=%40companion-app%2Fworkspace%40workspace%3A." dependencies: - "@elgato-stream-deck/core": "npm:7.0.0-0" - eventemitter3: "npm:^4.0.7" jpeg-js: "npm:^0.4.4" - node-hid: "npm:^3.1.0" - tslib: "npm:^2.7.0" + tslib: "npm:^2.6.3" peerDependencies: "@julusian/jpeg-turbo": ^1.1.2 || ^2.0.0 - checksum: 10c0/52a32a3505d04af8b3aedf4833b1510c05c01e533ae46d0317b4327744ce9595e281f042752262d2fee54b3e1ec4c9533f9629603992bb0122e7ecf84d24cb36 languageName: node - linkType: hard + linkType: soft + +"@elgato-stream-deck/node@portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/node::locator=%40companion-app%2Fworkspace%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@elgato-stream-deck/node@portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/node::locator=%40companion-app%2Fworkspace%40workspace%3A." + dependencies: + "@elgato-stream-deck/core": "npm:7.0.0-0" + "@elgato-stream-deck/node-lib": "npm:7.0.0-0" + eventemitter3: "npm:^5.0.1" + node-hid: "npm:^3.1.0" + tslib: "npm:^2.7.0" + languageName: node + linkType: soft + +"@elgato-stream-deck/tcp@portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/tcp::locator=%40companion-app%2Fworkspace%40workspace%3A.": + version: 0.0.0-use.local + resolution: "@elgato-stream-deck/tcp@portal:/home/julus/Projects/libs/node-elgato-stream-deck/packages/tcp::locator=%40companion-app%2Fworkspace%40workspace%3A." + dependencies: + "@elgato-stream-deck/core": "npm:7.0.0-0" + "@elgato-stream-deck/node-lib": "npm:7.0.0-0" + "@julusian/bonjour-service": "npm:^1.3.0-2" + eventemitter3: "npm:^5.0.1" + tslib: "npm:^2.6.3" + languageName: node + linkType: soft "@emotion/babel-plugin@npm:^11.11.0": version: 11.11.0 @@ -6728,6 +6747,7 @@ asn1@evs-broadcast/node-asn1: "@companion-app/shared": "npm:*" "@companion-module/base": "npm:~1.10.0" "@elgato-stream-deck/node": "npm:^7.0.0-0" + "@elgato-stream-deck/tcp": "npm:^7.0.0-0" "@julusian/bonjour-service": "npm:^1.3.0-2" "@julusian/image-rs": "npm:^1.1.1" "@julusian/jpeg-turbo": "npm:^2.1.0" @@ -14681,7 +14701,7 @@ asn1@evs-broadcast/node-asn1: languageName: node linkType: hard -"tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.7.0": +"tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.6.3, tslib@npm:^2.7.0": version: 2.7.0 resolution: "tslib@npm:2.7.0" checksum: 10c0/469e1d5bf1af585742128827000711efa61010b699cb040ab1800bcd3ccdd37f63ec30642c9e07c4439c1db6e46345582614275daca3e0f4abae29b0083f04a6