diff --git a/packages/connector/src/MosConnection.ts b/packages/connector/src/MosConnection.ts index dcab6a88..6f6f080c 100644 --- a/packages/connector/src/MosConnection.ts +++ b/packages/connector/src/MosConnection.ts @@ -40,6 +40,7 @@ export class MosConnection extends EventEmitter implements private _mosDevices: { [ncsID: string]: MosDevice } = {} private _initialized = false private _isListening = false + private _isOpenMediaHotStandby = false // private _isListening: Promise @@ -81,6 +82,7 @@ export class MosConnection extends EventEmitter implements */ async connect(connectionOptions: IMOSDeviceConnectionOptions): Promise { if (!this._initialized) throw Error('Not initialized, run .init() first!') + this._isOpenMediaHotStandby = connectionOptions.secondary?.openMediaHotStandby ?? false // Connect to MOS-device: const primary = new NCSServerConnection( @@ -90,9 +92,21 @@ export class MosConnection extends EventEmitter implements connectionOptions.primary.timeout, connectionOptions.primary.heartbeatInterval, this._debug, - this.mosTypes.strict + this.mosTypes.strict, + this._isOpenMediaHotStandby ) - let secondary: NCSServerConnection | null = null + const secondary = connectionOptions.secondary + ? new NCSServerConnection( + connectionOptions.secondary.id, + connectionOptions.secondary.host, + this._conf.mosID, + connectionOptions.secondary.timeout, + connectionOptions.secondary.heartbeatInterval, + this._debug, + this.mosTypes.strict, + this._isOpenMediaHotStandby + ) + : null this._ncsConnections[connectionOptions.primary.host] = primary primary.on('rawMessage', (type: string, message: string) => { @@ -130,16 +144,7 @@ export class MosConnection extends EventEmitter implements ) } - if (connectionOptions.secondary) { - secondary = new NCSServerConnection( - connectionOptions.secondary.id, - connectionOptions.secondary.host, - this._conf.mosID, - connectionOptions.secondary.timeout, - connectionOptions.secondary.heartbeatInterval, - this._debug, - this.mosTypes.strict - ) + if (secondary && connectionOptions.secondary) { this._ncsConnections[connectionOptions.secondary.host] = secondary secondary.on('rawMessage', (type: string, message: string) => { this.emit('rawMessage', 'secondary', type, message) @@ -506,7 +511,8 @@ export class MosConnection extends EventEmitter implements undefined, undefined, this._debug, - this.mosTypes.strict + this.mosTypes.strict, + this._isOpenMediaHotStandby ) this._ncsConnections[remoteAddress] = primary diff --git a/packages/connector/src/__tests__/OpenMediaHotStandby.spec.ts b/packages/connector/src/__tests__/OpenMediaHotStandby.spec.ts index 853d3ff9..af29ac99 100644 --- a/packages/connector/src/__tests__/OpenMediaHotStandby.spec.ts +++ b/packages/connector/src/__tests__/OpenMediaHotStandby.spec.ts @@ -128,7 +128,8 @@ describe('Hot Standby Feature', () => { if (primary && secondary) { expect(primary.getConnectedStatus().connected).toBe(true) - expect(secondary.getConnectedStatus().connected).toBe(true) + // Hot standby only connects one connection at a time: + expect(secondary.getConnectedStatus().connected).toBe(false) // Verify heartbeat states expect(primary.isHearbeatEnabled()).toBe(true) @@ -142,7 +143,7 @@ describe('Hot Standby Feature', () => { if (primary && secondary) { expect(primary.connected).toBe(true) - expect(secondary.connected).toBe(true) + expect(secondary.connected).toBe(false) // Disconnect primary connection: await discconnectPrimary() @@ -172,7 +173,8 @@ describe('Hot Standby Feature', () => { if (primary && secondary) { // Initially, primary should be connected and secondary should be connected but with heartbeats disabled expect(primary.connected).toBe(true) - expect(secondary.connected).toBe(true) + // Hot standby only connects one connection at a time: + expect(secondary.connected).toBe(false) expect(primary.isHearbeatEnabled()).toBe(true) expect(secondary.isHearbeatEnabled()).toBe(false) @@ -204,7 +206,8 @@ describe('Hot Standby Feature', () => { if (primary && secondary) { // Initially, both should be connected with primary heartbeats enabled and secondary disabled expect(primary.connected).toBe(true) - expect(secondary.connected).toBe(true) + // Hot standby only connects one connection at a time: + expect(secondary.connected).toBe(false) expect(primary.isHearbeatEnabled()).toBe(true) expect(secondary.isHearbeatEnabled()).toBe(false) @@ -234,9 +237,10 @@ describe('Hot Standby Feature', () => { expect(secondary).toBeTruthy() if (primary && secondary) { - // Initial setup - primary connected and secondary connected but with heartbeats disabled + // Initial setup - primary connected and secondary not but with heartbeats disabled expect(primary.connected).toBe(true) - expect(secondary.connected).toBe(true) + // Hot standby only connects one connection at a time: + expect(secondary.connected).toBe(false) // First disconnect primary to force secondary active await discconnectPrimary() diff --git a/packages/connector/src/connection/NCSServerConnection.ts b/packages/connector/src/connection/NCSServerConnection.ts index 94c0ac09..e4901082 100644 --- a/packages/connector/src/connection/NCSServerConnection.ts +++ b/packages/connector/src/connection/NCSServerConnection.ts @@ -11,6 +11,7 @@ export interface ClientDescription { heartbeatConnected: boolean client: MosSocketClient clientDescription: MosModel.PortType + isConnected: () => boolean } export interface INCSServerConnection { @@ -47,6 +48,7 @@ export class NCSServerConnection extends EventEmitter private _heartBeatsTimer?: NodeJS.Timeout private _heartBeatsInterval: number + private _isOpenMediaHotStandby = false constructor( id: string, @@ -55,7 +57,8 @@ export class NCSServerConnection extends EventEmitter timeout: number | undefined, heartbeatsInterval: number | undefined, debug: boolean, - strict: boolean + strict: boolean, + isOpenMediaHotStandby: boolean ) { super() this._id = id @@ -66,6 +69,7 @@ export class NCSServerConnection extends EventEmitter this._connected = false this._debug = debug ?? false this._strict = strict ?? false + this._isOpenMediaHotStandby = isOpenMediaHotStandby ?? false } get timeout(): number { return this._timeout @@ -87,6 +91,9 @@ export class NCSServerConnection extends EventEmitter heartbeatConnected: false, client: client, clientDescription: clientDescription, + isConnected: () => { + return client.isConnected + }, } client.on('rawMessage', (type: string, message: string) => { this.emit('rawMessage', type, message) @@ -214,13 +221,28 @@ export class NCSServerConnection extends EventEmitter connected: false, status: 'Not connected', } + + // Check if we have any clients at all + const clientCount = Object.keys(this._clients).length + if (clientCount === 0) { + return { + connected: false, + status: 'No clients available', + } + } + + let isConnectedToSomeDevice = false + let notConnectedStatus: string | undefined = undefined Object.values(this._clients).forEach((client) => { - if (client.useHeartbeats && !client.heartbeatConnected) { + if (client.isConnected()) { + isConnectedToSomeDevice = true + } + if (client.useHeartbeats && !client.heartbeatConnected && !this._isOpenMediaHotStandby) { notConnectedStatus = `No heartbeats on port ${client.clientDescription}` } }) - if (!notConnectedStatus) { + if (!notConnectedStatus && isConnectedToSomeDevice) { return { connected: true, status: 'Connected', @@ -228,7 +250,7 @@ export class NCSServerConnection extends EventEmitter } else { return { connected: false, - status: notConnectedStatus, + status: notConnectedStatus || 'No heartbeats', } } } diff --git a/packages/connector/src/connection/mosSocketClient.ts b/packages/connector/src/connection/mosSocketClient.ts index b4e174ce..df2387b2 100644 --- a/packages/connector/src/connection/mosSocketClient.ts +++ b/packages/connector/src/connection/mosSocketClient.ts @@ -131,6 +131,10 @@ export class MosSocketClient extends EventEmitter { } } + get isConnected(): boolean { + return this._connected + } + /** */ disconnect(): void { this.dispose() diff --git a/packages/mos-dummy-device/.eslintrc.js b/packages/mos-dummy-device/.eslintrc.js new file mode 100644 index 00000000..2f74b819 --- /dev/null +++ b/packages/mos-dummy-device/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: '../../.eslintrc', + rules: { + 'no-console': 'off', + }, +} diff --git a/packages/mos-dummy-device/README.md b/packages/mos-dummy-device/README.md new file mode 100644 index 00000000..500826ac --- /dev/null +++ b/packages/mos-dummy-device/README.md @@ -0,0 +1,132 @@ +# MOS Dummy Device + +A dummy MOS (Media Object Server) server for testing MOS clients with failover capabilities. This server implements the MOS protocol and allows you to simulate connection issues and manage rundowns through JSON files. + +The mos dummy device is primary made for debugging MOS server connections and failovers. +For quick ingesting MOS rundowns use quick-mos + +## Features + +- MOS protocol support Profiles 0, 1, 2, 3, and 4 +- JSON-based rundown downs in the `rundowns` directory +- Hot reloading of rundowns from the filesystem +- Simulation of server outages for failover testing +- Command-line interface for manual testing + +## Installation +Install dependencies: + +```bash +yarn install +``` + +### Development mode +To run in development mode with automatic reloading: + +```bash +yarn dev +``` + +you can add a --secondary flag to run a secondary server on a different port: + +```bash +yarn dev --secondary +``` + +Or with file watching: + +```bash +yarn run watch +``` + +### Run on same machine as client: +To run on same machine as client, remember to set the clients acceptsConnections to false (here shown in Sofie): + +```bash +mos: { + self: { + debug: debug, + // mosID: 'sofie.tv.automation', + mosID: 'N/A', // set by Core + acceptsConnections: false, // default:true + // accepsConnectionsFrom: ['127.0.0.1'], + profiles: { + '0': true, + '1': true, + '2': true, + '3': false, + '4': false, + '5': false, + '6': false, + '7': false, + }, + offspecFailover: true, + }, +``` + + +### Rundown Management + +Rundowns are stored as JSON files in the `rundowns` directory. The server watches this directory for changes: + +- Adding a new `.json` file creates a new rundown +- Modifying a file updates the rundown +- Deleting a file removes the rundown + +### Rundown JSON Format + +Here's an example of a rundown JSON file: + +```json +{ + "ID": "EXAMPLE_RO", + "Slug": "Example Rundown", + "DefaultChannel": "A", + "Stories": [ + { + "ID": "EXAMPLE_RO_STORY_1", + "Slug": "Story 1", + "Number": "1", + "Items": [ + { + "ID": "EXAMPLE_RO_STORY_1_ITEM_1", + "Slug": "Item 1 in Story 1", + "ObjectID": "OBJ_EXAMPLE_RO_STORY_1_ITEM_1", + "MOSID": "DUMMY.MOS.SERVER", + "ObjectSlug": "Item 1 in Story 1", + "Duration": 1000, + "TimeBase": 100 + } + ] + } + ] +} +``` + +### Command-Line Interface + +While the server is running, you can use the following commands: + +- `outage [duration_ms]` - Simulate a server outage for the specified duration (default: 5000ms) +- `exit` - Shutdown the server and exit + +Example: `outage 10000` will simulate a 10-second outage. + +## Configuration + +You can modify the configuration in the `SERVER_CONFIG` object in `src/index.ts`: + +- `mosID`: The MOS ID of the server +- `acceptsConnections`: Whether to accept incoming connections +- `profiles`: The MOS profiles to support +- `debug`: Enable/disable debug logging +- `ports`: The ports to use for MOS communication + +## Testing Failover + +To test failover with your MOS client: + +1. Start the server +2. Connect your MOS client to the server +3. Use the `outage` command to simulate an outage +4. Observe how your client handles the disconnection and reconnection diff --git a/packages/mos-dummy-device/package.json b/packages/mos-dummy-device/package.json new file mode 100644 index 00000000..9eff85f8 --- /dev/null +++ b/packages/mos-dummy-device/package.json @@ -0,0 +1,19 @@ +{ + "name": "mos-dummy-server", + "version": "1.0.0", + "description": "A dummy MOS server for testing MOS clients with failover capabilities", + "main": "dist/index.js", + "scripts": { + "dev": "ts-node src/index.ts" + }, + "license": "MIT", + "dependencies": { + "@mos-connection/connector": "^4.2.2", + "chokidar": "^3.5.3" + }, + "devDependencies": { + "@types/node": "^20.10.5", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/packages/mos-dummy-device/rundowns/EXAMPLE_RO.json b/packages/mos-dummy-device/rundowns/EXAMPLE_RO.json new file mode 100644 index 00000000..88f3dbae --- /dev/null +++ b/packages/mos-dummy-device/rundowns/EXAMPLE_RO.json @@ -0,0 +1,82 @@ +{ + "ID": "EXAMPLE_RO", + "Slug": "Example Rundown", + "DefaultChannel": "A", + "Stories": [ + { + "ID": "EXAMPLE_RO_STORY_1", + "Slug": "Opening Story", + "Number": "1", + "Items": [ + { + "ID": "EXAMPLE_RO_STORY_1_ITEM_1", + "Slug": "Intro Graphic", + "ObjectID": "OBJ_INTRO_GRAPHIC", + "MOSID": "DUMMY.MOS.SERVER", + "ObjectSlug": "Opening Title Graphic", + "Duration": 500, + "TimeBase": 100 + }, + { + "ID": "EXAMPLE_RO_STORY_1_ITEM_2", + "Slug": "Presenter Introduction", + "ObjectID": "OBJ_PRESENTER_CAM1", + "MOSID": "DUMMY.MOS.SERVER", + "ObjectSlug": "Camera 1 - Wide", + "Duration": 1000, + "TimeBase": 100 + } + ] + }, + { + "ID": "EXAMPLE_RO_STORY_2", + "Slug": "News Story", + "Number": "2", + "Items": [ + { + "ID": "EXAMPLE_RO_STORY_2_ITEM_1", + "Slug": "News Intro", + "ObjectID": "OBJ_NEWS_GRAPHIC", + "MOSID": "DUMMY.MOS.SERVER", + "ObjectSlug": "News Title Card", + "Duration": 300, + "TimeBase": 100 + }, + { + "ID": "EXAMPLE_RO_STORY_2_ITEM_2", + "Slug": "News Package", + "ObjectID": "OBJ_NEWS_PKG_1", + "MOSID": "DUMMY.MOS.SERVER", + "ObjectSlug": "News Package Video", + "Duration": 3000, + "TimeBase": 100 + } + ] + }, + { + "ID": "EXAMPLE_RO_STORY_3", + "Slug": "Closing Story", + "Number": "3", + "Items": [ + { + "ID": "EXAMPLE_RO_STORY_3_ITEM_1", + "Slug": "Closing Remarks", + "ObjectID": "OBJ_PRESENTER_CAM2", + "MOSID": "DUMMY.MOS.SERVER", + "ObjectSlug": "Camera 2 - Close Up", + "Duration": 1500, + "TimeBase": 100 + }, + { + "ID": "EXAMPLE_RO_STORY_3_ITEM_2", + "Slug": "Outro Graphic", + "ObjectID": "OBJ_OUTRO_GRAPHIC", + "MOSID": "DUMMY.MOS.SERVER", + "ObjectSlug": "Closing Credits", + "Duration": 800, + "TimeBase": 100 + } + ] + } + ] + } \ No newline at end of file diff --git a/packages/mos-dummy-device/src/index.ts b/packages/mos-dummy-device/src/index.ts new file mode 100644 index 00000000..5db4d747 --- /dev/null +++ b/packages/mos-dummy-device/src/index.ts @@ -0,0 +1,487 @@ +import { MosConnection, ConnectionConfig, getMosTypes, IMOSRunningOrder, MosDevice } from '@mos-connection/connector' +import * as fs from 'fs' +import * as path from 'path' +import * as chokidar from 'chokidar' + +const SERVER_CONFIG = { + mosID: 'DUMMY.MOS.PRIMARY', + acceptsConnections: true, + profiles: { + '0': true, // Profile 0 is mandatory + '1': true, // Basic object exchange + '2': true, // Running order/playlist exchange + '3': true, // Advanced object-based workflow + '4': true, // Advanced rundown functionality + }, + debug: true, + ports: { + lower: 10540, // Default MOS ports + upper: 10541, + query: 10542, + }, + openRelay: true, +} + +class DummyMosServer { + private mosConnection: MosConnection + private rundownsDir: string + private devicesMap: Map = new Map() + private rundowns: Map = new Map() + private isServerOnline = true + private mosTypes = getMosTypes(false) // Non-strict mode + private primaryDevice = false + + constructor(rundownsDir: string) { + this.rundownsDir = rundownsDir + + this.mosConnection = new MosConnection(new ConnectionConfig(SERVER_CONFIG)) + // Set up device connection callback + this.mosConnection.onConnection(this.handleNewConnection.bind(this)) + + this.setupRundownWatcher() + this.loadRundowns() + } + + public async start(): Promise { + console.log('Starting dummy MOS server...') + + await this.mosConnection.init() + + console.log(`MOS server started with ID: ${SERVER_CONFIG.mosID}`) + console.log( + `Listening on ports: ${SERVER_CONFIG.ports.lower}, ${SERVER_CONFIG.ports.upper}, ${SERVER_CONFIG.ports.query}` + ) + } + + // Handle new MOS device connections + private handleNewConnection(mosDevice: MosDevice): void { + console.log( + `New connection from device: ${this.primaryDevice ? mosDevice.idPrimary : mosDevice.idSecondary || ''}` + ) + + // Store reference to the device + this.devicesMap.set(this.primaryDevice ? mosDevice.idPrimary : mosDevice.idSecondary || '', mosDevice) + + // Set up callbacks for Profile 0 + mosDevice.onRequestMachineInfo(async () => { + console.log('Received machine info request') + return { + manufacturer: this.mosTypes.mosString128.create('DummyMosServer'), + model: this.mosTypes.mosString128.create('Testing Device'), + hwRev: this.mosTypes.mosString128.create('1.0'), + swRev: this.mosTypes.mosString128.create('1.0.0'), + DOM: this.mosTypes.mosString128.create('2023-01-01'), + SN: this.mosTypes.mosString128.create('DUMMY001'), + ID: this.mosTypes.mosString128.create(SERVER_CONFIG.mosID), + time: this.mosTypes.mosTime.create(new Date()), + mosRev: this.mosTypes.mosString128.create('2.8.5'), + supportedProfiles: { + deviceType: 'MOS', + profile0: true, + profile1: true, + profile2: true, + profile3: true, + profile4: true, + }, + } + }) + + // Set up callbacks for Profile 2 + mosDevice.onRequestRunningOrder(async (roId) => { + const roIdStr = this.mosTypes.mosString128.stringify(roId) + console.log(`Received request for running order: ${roIdStr}`) + + if (this.rundowns.has(roIdStr)) { + console.log(`Returning running order: ${roIdStr}`) + return this.rundowns.get(roIdStr) || null + } + + console.log(`Running order not found: ${roIdStr}`) + return null + }) + + // Set up Profile 4 callbacks + mosDevice.onRequestAllRunningOrders(async () => { + console.log('Received request for all running orders') + return Array.from(this.rundowns.values()) + }) + + // Send all rundowns to the device on connection + this.sendAllRunningOrders(mosDevice) + } + + // Load all rundowns from the rundowns directory + private loadRundowns(): void { + try { + if (!fs.existsSync(this.rundownsDir)) { + fs.mkdirSync(this.rundownsDir, { recursive: true }) + } + + const files = fs.readdirSync(this.rundownsDir).filter((file) => file.endsWith('.json')) + + files.forEach((file) => { + try { + const filePath = path.join(this.rundownsDir, file) + const content = fs.readFileSync(filePath, 'utf8') + const rundown = JSON.parse(content) as IMOSRunningOrder + + // Ensure ID is set correctly + if (!rundown.ID) { + const id = path.basename(file, '.json') + rundown.ID = this.mosTypes.mosString128.create(id) + } + + // Process and validate rundown (convert strings to MOS types) + const processedRundown = this.processRundown(rundown) + + // Add to rundowns map + const roId = this.mosTypes.mosString128.stringify(processedRundown.ID) + this.rundowns.set(roId, processedRundown) + + console.log(`Loaded rundown: ${roId}`) + } catch (error) { + console.error(`Error loading rundown from ${file}:`, error) + } + }) + + console.log(`Loaded ${this.rundowns.size} rundowns`) + } catch (error) { + console.error('Error loading rundowns:', error) + } + } + + // Watch the rundowns directory for changes + private setupRundownWatcher(): void { + const watcher = chokidar.watch(this.rundownsDir, { + persistent: true, + ignoreInitial: true, + }) + + watcher + .on('add', this.handleRundownFileAdded.bind(this)) + .on('change', this.handleRundownFileChanged.bind(this)) + .on('unlink', this.handleRundownFileRemoved.bind(this)) + + console.log(`Watching directory: ${this.rundownsDir} for rundown changes`) + } + + // Handle new rundown file + private handleRundownFileAdded(filePath: string): void { + if (!filePath.endsWith('.json')) return + + try { + console.log(`New rundown file detected: ${filePath}`) + const content = fs.readFileSync(filePath, 'utf8') + const rundown = JSON.parse(content) as IMOSRunningOrder + + // Ensure ID is set correctly + if (!rundown.ID) { + const id = path.basename(filePath, '.json') + rundown.ID = this.mosTypes.mosString128.create(id) + } + + // Process and validate rundown + const processedRundown = this.processRundown(rundown) + + // Add to rundowns map + const roId = this.mosTypes.mosString128.stringify(processedRundown.ID) + this.rundowns.set(roId, processedRundown) + + console.log(`Added new rundown: ${roId}`) + + // Send to all connected devices + this.broadcastRunningOrder(processedRundown) + } catch (error) { + console.error(`Error processing new rundown file: ${filePath}`, error) + } + } + + // Handle rundown file changes + private handleRundownFileChanged(filePath: string): void { + if (!filePath.endsWith('.json')) return + + try { + console.log(`Rundown file changed: ${filePath}`) + const content = fs.readFileSync(filePath, 'utf8') + const rundown = JSON.parse(content) as IMOSRunningOrder + + // Process and validate rundown + const processedRundown = this.processRundown(rundown) + + // Update in rundowns map + const roId = this.mosTypes.mosString128.stringify(processedRundown.ID) + this.rundowns.set(roId, processedRundown) + + console.log(`Updated rundown: ${roId}`) + + // Send to all connected devices + this.broadcastRunningOrder(processedRundown, true) + } catch (error) { + console.error(`Error processing changed rundown file: ${filePath}`, error) + } + } + + // Handle rundown file removals + private handleRundownFileRemoved(filePath: string): void { + if (!filePath.endsWith('.json')) return + + try { + const id = path.basename(filePath, '.json') + console.log(`Rundown file removed: ${filePath} (ID: ${id})`) + + // Remove from rundowns map + if (this.rundowns.has(id)) { + this.rundowns.delete(id) + console.log(`Removed rundown: ${id}`) + + // Notify all connected devices + this.broadcastRunningOrderDelete(id) + } + } catch (error) { + console.error(`Error handling removed rundown file: ${filePath}`, error) + } + } + + // Process a rundown to ensure all required MOS types are properly formatted + private processRundown(rundown: IMOSRunningOrder): IMOSRunningOrder { + // Deep copy to avoid modifying the original + const result = JSON.parse(JSON.stringify(rundown)) + + // Convert string values to MOS types + if (typeof result.ID === 'string') { + result.ID = this.mosTypes.mosString128.create(result.ID) + } + + if (typeof result.Slug === 'string') { + result.Slug = this.mosTypes.mosString128.create(result.Slug) + } + + if (typeof result.DefaultChannel === 'string') { + result.DefaultChannel = this.mosTypes.mosString128.create(result.DefaultChannel) + } + + // Process each story + if (Array.isArray(result.Stories)) { + result.Stories.forEach((story: any) => { + if (typeof story.ID === 'string') { + story.ID = this.mosTypes.mosString128.create(story.ID) + } + + if (typeof story.Slug === 'string') { + story.Slug = this.mosTypes.mosString128.create(story.Slug) + } + + // Process items in each story + if (Array.isArray(story.Items)) { + story.Items.forEach((item: any) => { + if (typeof item.ID === 'string') { + item.ID = this.mosTypes.mosString128.create(item.ID) + } + + if (typeof item.Slug === 'string') { + item.Slug = this.mosTypes.mosString128.create(item.Slug) + } + + if (typeof item.ObjectID === 'string') { + item.ObjectID = this.mosTypes.mosString128.create(item.ObjectID) + } + }) + } + }) + } + + return result + } + + // Send a running order to all connected devices + private broadcastRunningOrder(rundown: IMOSRunningOrder, isUpdate = false): void { + if (this.devicesMap.size === 0) { + console.log('No connected devices to broadcast to') + return + } + + for (const [id, device] of this.devicesMap) { + try { + if (isUpdate) { + console.log( + `Sending updated running order to device ${id}: ${this.mosTypes.mosString128.stringify( + rundown.ID + )}` + ) + device + .sendReplaceRunningOrder(rundown) + .catch((err) => console.error(`Error sending updated rundown to device ${id}:`, err)) + } else { + console.log( + `Sending new running order to device ${id}: ${this.mosTypes.mosString128.stringify(rundown.ID)}` + ) + device + .sendCreateRunningOrder(rundown) + .catch((err) => console.error(`Error sending new rundown to device ${id}:`, err)) + } + } catch (error) { + console.error(`Error broadcasting rundown to device ${id}:`, error) + } + } + } + + // Notify all connected devices about a deleted running order + private broadcastRunningOrderDelete(roId: string): void { + if (this.devicesMap.size === 0) { + console.log('No connected devices to broadcast to') + return + } + + const mosRoId = this.mosTypes.mosString128.create(roId) + + for (const [id, device] of this.devicesMap) { + try { + console.log(`Sending running order delete notification to device ${id}: ${roId}`) + device + .sendDeleteRunningOrder(mosRoId) + .catch((err) => console.error(`Error sending rundown delete to device ${id}:`, err)) + } catch (error) { + console.error(`Error broadcasting rundown delete to device ${id}:`, error) + } + } + } + + // Send all loaded rundowns to a newly connected device + private sendAllRunningOrders(device: MosDevice): void { + // This part has not been implemented, quickTSR works for now + return + if (this.rundowns.size === 0) { + console.log('No rundowns to send to new device') + return + } + + console.log( + `Sending ${this.rundowns.size} rundowns to device: ${ + this.primaryDevice ? device.idPrimary : device.idSecondary || '' + }` + ) + + for (const [id, rundown] of this.rundowns) { + try { + console.log( + `Sending rundown to device ${ + this.primaryDevice ? device.idPrimary : device.idSecondary || '' + }: ${id}` + ) + device + .sendCreateRunningOrder(rundown) + .catch((err) => + console.error( + `Error sending rundown to device ${ + this.primaryDevice ? device.idPrimary : device.idSecondary || '' + }:`, + err + ) + ) + } catch (error) { + console.error( + `Error sending rundown to device ${ + this.primaryDevice ? device.idPrimary : device.idSecondary || '' + }:`, + error + ) + } + } + } + + // Simulate a server outage + public simulateOutage(durationMs = 5000): void { + if (!this.isServerOnline) { + console.log('Server is already offline') + return + } + + console.log(`Simulating server outage for ${durationMs}ms`) + this.isServerOnline = false + + setTimeout(() => { + console.log('Recovering from simulated outage') + this.isServerOnline = true + }, durationMs) + } + + // Shutdown the server + public async shutdown(): Promise { + console.log('Shutting down MOS server...') + + try { + await this.mosConnection.dispose() + console.log('MOS server stopped') + } catch (error) { + console.error('Error shutting down MOS server:', error) + } + } +} + +// Main function +async function main() { + const rundownsDir = path.join(process.cwd(), 'rundowns') + + if (!fs.existsSync(rundownsDir) || fs.readdirSync(rundownsDir).filter((f) => f.endsWith('.json')).length === 0) { + console.log(`No rundowns found in ${rundownsDir}. Creating example rundown...`) + } + + // Is the server secondary: + if (process.argv.includes('--secondary')) { + SERVER_CONFIG.mosID = 'DUMMY.MOS.SERVER.SECONDARY' + SERVER_CONFIG.ports.lower = 10640 + SERVER_CONFIG.ports.upper = 10641 + SERVER_CONFIG.ports.query = 10642 + } + + const server = new DummyMosServer(rundownsDir) + // Create and start the MOS server + await server.start() + + // Handle process termination + process.on('SIGINT', async () => { + console.log('Received SIGINT signal') + await server.shutdown() + process.exit(0) + }) + + process.on('SIGTERM', async () => { + console.log('Received SIGTERM signal') + await server.shutdown() + process.exit(0) + }) + + // Command line interface for manual testing + console.log('\nCommands:') + console.log(' outage [duration_ms] - Simulate server outage') + console.log(' exit - Shutdown the server and exit') + + // Simple command processing + process.stdin.on('data', async (data) => { + const input = data.toString().trim() + const args = input.split(' ') + const command = args[0].toLowerCase() + + switch (command) { + case 'outage': + const duration = parseInt(args[1]) || 5000 + server.simulateOutage(duration) + break + + case 'exit': + await server.shutdown() + process.exit(0) + break + + default: + console.log('Unknown command:', command) + break + } + }) +} + +// Run the application +main().catch((error) => { + console.error('Fatal error:', error) + process.exit(1) +}) diff --git a/packages/mos-dummy-device/tsconfig.json b/packages/mos-dummy-device/tsconfig.json new file mode 100644 index 00000000..f1b35155 --- /dev/null +++ b/packages/mos-dummy-device/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "lib": ["es2020"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"], + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.spec.ts"] + } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index a00eb8ce..84f0d1ec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -962,7 +962,7 @@ __metadata: languageName: node linkType: hard -"@mos-connection/connector@4.2.2, @mos-connection/connector@workspace:packages/connector": +"@mos-connection/connector@4.2.2, @mos-connection/connector@^4.2.2, @mos-connection/connector@workspace:packages/connector": version: 0.0.0-use.local resolution: "@mos-connection/connector@workspace:packages/connector" dependencies: @@ -6622,6 +6622,18 @@ __metadata: languageName: unknown linkType: soft +"mos-dummy-server@workspace:packages/mos-dummy-device": + version: 0.0.0-use.local + resolution: "mos-dummy-server@workspace:packages/mos-dummy-device" + dependencies: + "@mos-connection/connector": ^4.2.2 + "@types/node": ^20.10.5 + chokidar: ^3.5.3 + ts-node: ^10.9.2 + typescript: ^5.3.3 + languageName: unknown + linkType: soft + "mos-examples@workspace:packages/examples": version: 0.0.0-use.local resolution: "mos-examples@workspace:packages/examples" @@ -9153,6 +9165,44 @@ __metadata: languageName: node linkType: hard +"ts-node@npm:^10.9.2": + version: 10.9.2 + resolution: "ts-node@npm:10.9.2" + dependencies: + "@cspotcode/source-map-support": ^0.8.0 + "@tsconfig/node10": ^1.0.7 + "@tsconfig/node12": ^1.0.7 + "@tsconfig/node14": ^1.0.0 + "@tsconfig/node16": ^1.0.2 + acorn: ^8.4.1 + acorn-walk: ^8.1.1 + arg: ^4.1.0 + create-require: ^1.1.0 + diff: ^4.0.1 + make-error: ^1.1.1 + v8-compile-cache-lib: ^3.0.1 + yn: 3.1.1 + peerDependencies: + "@swc/core": ">=1.2.50" + "@swc/wasm": ">=1.2.50" + "@types/node": "*" + typescript: ">=2.7" + peerDependenciesMeta: + "@swc/core": + optional: true + "@swc/wasm": + optional: true + bin: + ts-node: dist/bin.js + ts-node-cwd: dist/bin-cwd.js + ts-node-esm: dist/bin-esm.js + ts-node-script: dist/bin-script.js + ts-node-transpile-only: dist/bin-transpile.js + ts-script: dist/bin-script-deprecated.js + checksum: fde256c9073969e234526e2cfead42591b9a2aec5222bac154b0de2fa9e4ceb30efcd717ee8bc785a56f3a119bdd5aa27b333d9dbec94ed254bd26f8944c67ac + languageName: node + linkType: hard + "tsconfig-paths@npm:^4.1.2": version: 4.2.0 resolution: "tsconfig-paths@npm:4.2.0" @@ -9296,6 +9346,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:^5.3.3": + version: 5.8.2 + resolution: "typescript@npm:5.8.2" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 7f9e3d7ac15da6df713e439e785e51facd65d6450d5f51fab3e8d2f2e3f4eb317080d895480b8e305450cdbcb37e17383e8bf521e7395f8b556e2f2a4730ed86 + languageName: node + linkType: hard + "typescript@patch:typescript@^3 || ^4#~builtin, typescript@patch:typescript@~4.9.5#~builtin": version: 4.9.5 resolution: "typescript@patch:typescript@npm%3A4.9.5#~builtin::version=4.9.5&hash=289587" @@ -9306,6 +9366,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@^5.3.3#~builtin": + version: 5.8.2 + resolution: "typescript@patch:typescript@npm%3A5.8.2#~builtin::version=5.8.2&hash=5da071" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: a58d19ff9811c1764a299dd83ca20ed8020f0ab642906dafc880121b710751227201531fdc99878158205c356ac79679b0b61ac5b42eda0e28bfb180947a258d + languageName: node + linkType: hard + "uglify-js@npm:^3.1.4": version: 3.17.4 resolution: "uglify-js@npm:3.17.4"