diff --git a/CHANGELOG.md b/CHANGELOG.md index fc25cf9dc4..fe1a0ded78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [7.5.0-release47.5](https://github.com/nrkno/sofie-timeline-state-resolver/compare/7.5.0-release47.4...7.5.0-release47.5) (2023-01-16) + +**Note:** Version bump only for package timeline-state-resolver-packages + + + + + +# [7.5.0-release47.4](https://github.com/nrkno/sofie-timeline-state-resolver/compare/7.5.0-release47.3...7.5.0-release47.4) (2023-01-13) + + +### Bug Fixes + +* add optional parameter to HTTPSend timelineObj: paramsType ([979dc61](https://github.com/nrkno/sofie-timeline-state-resolver/commit/979dc61748c4c371a8b17c7fd8c5929c69f747d9)) +* add support for Node 18 ([6242dd6](https://github.com/nrkno/sofie-timeline-state-resolver/commit/6242dd68f54a491aa71bdfd30b066550d6f7e90e)) +* bug fix: HTTPSend device didn't send GET requests ([8315531](https://github.com/nrkno/sofie-timeline-state-resolver/commit/83155314706497a9c630dbde14d5c5d7e57103cf)) +* prevent in place reverse in setDatastore ([473ab71](https://github.com/nrkno/sofie-timeline-state-resolver/commit/473ab713785325c2062db983c8ece80ea5dede4d)) +* track ccg state internally ([fd5596f](https://github.com/nrkno/sofie-timeline-state-resolver/commit/fd5596fcf975a7a122c6fb21946f13c2e97a4233)) + + + + + # [7.5.0-release47.3](https://github.com/nrkno/sofie-timeline-state-resolver/compare/7.5.0-release47.2...7.5.0-release47.3) (2022-11-07) **Note:** Version bump only for package timeline-state-resolver-packages diff --git a/README.md b/README.md index 7317dea3b1..8be12e74f0 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Using the input, it resolves the expected state, diffs the state against current * **Quantel** video server * **[vMix](https://www.vmix.com/)** software vision mixer * **VizRT MediaSequencer** graphics system - using the [v-connection](https://github.com/tv2/v-connection) library +* **NRK Nora** graphics system - an in-house graphics system developed by [NRK](https://www.nrk.no) * Arbitrary [OSC](https://en.wikipedia.org/wiki/Open_Sound_Control) compatible devices * Arbitrary HTTP (REST) compatible devices * Arbitrary TCP-socket compatible devices diff --git a/lerna.json b/lerna.json index 28c3ebf83a..33f207ff71 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "7.5.0-release47.3", + "version": "7.5.0-release47.5", "npmClient": "yarn", "useWorkspaces": true } diff --git a/packages/timeline-state-resolver-types/CHANGELOG.md b/packages/timeline-state-resolver-types/CHANGELOG.md index bafb4d5a4b..323f146a61 100644 --- a/packages/timeline-state-resolver-types/CHANGELOG.md +++ b/packages/timeline-state-resolver-types/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [7.5.0-release47.5](https://github.com/nrkno/sofie-timeline-state-resolver/compare/7.5.0-release47.4...7.5.0-release47.5) (2023-01-16) + +**Note:** Version bump only for package timeline-state-resolver-types + +# [7.5.0-release47.4](https://github.com/nrkno/sofie-timeline-state-resolver/compare/7.5.0-release47.3...7.5.0-release47.4) (2023-01-13) + +### Bug Fixes + +- add optional parameter to HTTPSend timelineObj: paramsType ([979dc61](https://github.com/nrkno/sofie-timeline-state-resolver/commit/979dc61748c4c371a8b17c7fd8c5929c69f747d9)) +- add support for Node 18 ([6242dd6](https://github.com/nrkno/sofie-timeline-state-resolver/commit/6242dd68f54a491aa71bdfd30b066550d6f7e90e)) + # [7.5.0-release47.3](https://github.com/nrkno/sofie-timeline-state-resolver/compare/7.5.0-release47.2...7.5.0-release47.3) (2022-11-07) **Note:** Version bump only for package timeline-state-resolver-types diff --git a/packages/timeline-state-resolver-types/package.json b/packages/timeline-state-resolver-types/package.json index 5ac47ddadc..29b5655640 100644 --- a/packages/timeline-state-resolver-types/package.json +++ b/packages/timeline-state-resolver-types/package.json @@ -1,6 +1,6 @@ { "name": "timeline-state-resolver-types", - "version": "7.5.0-release47.3", + "version": "7.5.0-release47.5", "description": "Have timeline, control stuff", "main": "dist/index.js", "typings": "dist/index.d.ts", diff --git a/packages/timeline-state-resolver-types/src/device.ts b/packages/timeline-state-resolver-types/src/device.ts index 343ab5843d..8328ff430e 100644 --- a/packages/timeline-state-resolver-types/src/device.ts +++ b/packages/timeline-state-resolver-types/src/device.ts @@ -18,6 +18,7 @@ import { HTTPWatcherOptions, VizMSEOptions, VMixOptions, + NoraNRKOptions, } from '.' import { ShotokuOptions } from './shotoku' import { TelemetricsOptions } from './telemetrics' @@ -74,6 +75,7 @@ export type DeviceOptionsAny = | DeviceOptionsVizMSE | DeviceOptionsShotoku | DeviceOptionsTelemetrics + | DeviceOptionsNoraNRK export interface DeviceOptionsAbstract extends DeviceOptionsBase { type: DeviceType.ABSTRACT @@ -137,3 +139,7 @@ export interface DeviceOptionsVMix extends DeviceOptionsBase { export interface DeviceOptionsTelemetrics extends DeviceOptionsBase { type: DeviceType.TELEMETRICS } + +export interface DeviceOptionsNoraNRK extends DeviceOptionsBase { + type: DeviceType.NORA_NRK +} diff --git a/packages/timeline-state-resolver-types/src/index.ts b/packages/timeline-state-resolver-types/src/index.ts index 997a39e30e..29ed663936 100644 --- a/packages/timeline-state-resolver-types/src/index.ts +++ b/packages/timeline-state-resolver-types/src/index.ts @@ -19,6 +19,7 @@ import { TimelineObjVIZMSEAny } from './vizMSE' import { TimelineObjSingularLiveAny } from './singularLive' import { TimelineObjVMixAny } from './vmix' import { TimelineObjOBSAny } from './obs' +import { TimelineObjNoraNRKAny } from './noraNRK' export * from './abstract' export * from './atem' @@ -40,6 +41,7 @@ export * from './singularLive' export * from './vmix' export * from './obs' export * from './telemetrics' +export * from './noraNRK' export * from './device' export * from './mapping' @@ -72,6 +74,7 @@ export enum DeviceType { OBS = 21, SOFIE_CHEF = 22, TELEMETRICS = 23, + NORA_NRK = 24, } export interface TSRTimelineKeyframe extends Timeline.TimelineKeyframe { @@ -144,6 +147,7 @@ export type TSRTimelineObj = | TimelineObjVMixAny | TimelineObjVIZMSEAny | TimelineObjTelemetricsAny + | TimelineObjNoraNRKAny export type TSRTimeline = Array diff --git a/packages/timeline-state-resolver-types/src/noraNRK.ts b/packages/timeline-state-resolver-types/src/noraNRK.ts new file mode 100644 index 0000000000..f756fac2a6 --- /dev/null +++ b/packages/timeline-state-resolver-types/src/noraNRK.ts @@ -0,0 +1,78 @@ +import { Mapping } from './mapping' +import { TSRTimelineObjBase, DeviceType, TimelineDatastoreReferencesContent } from '.' + +export interface MappingNoraNRK extends Mapping { + device: DeviceType.NORA_NRK + + group: string + groupSuffix?: string + channel: string +} + +export enum TimelineContentTypeNoraNRK { + TEMPLATE = 'template', + LAYER = 'layer', +} + +export interface NoraNRKCommandBase { + temporalPriority?: number // default: 0 + /** Commands in the same queue will be sent in order (will wait for the previous to finish before sending next */ + queueId?: string +} + +export interface NoraNRKTemplateCommandContent extends NoraNRKCommandBase { + type: TimelineContentTypeNoraNRK.TEMPLATE + // The payload obejct is defined from the NORA API. + payload: { + manifest?: string + template: { + name: string + event: 'take' | 'takeout' | 'preview' + layer: string + [key: string]: string | number | any + } + [key: string]: string | number | any + } +} + +export interface NoraNRKLayerCommandContent extends NoraNRKCommandBase { + type: TimelineContentTypeNoraNRK.LAYER + // The payload obejct is defined from the NORA API. + payload: { + template: { + event: 'takeout' + layer: string + } + } +} + +type NoraNRKCommandContent = NoraNRKTemplateCommandContent | NoraNRKLayerCommandContent + +export interface NoraNRKOptions { + // Base URL for relative urls + coreUrl?: string + + // API Key to be added as a query argument ?apiKey= + apiKey?: string + + makeReadyCommands?: NoraNRKCommandContent[] + /** Whether a makeReady should be treated as a reset of the device. It should be assumed clean, with the queue discarded, and state reapplied from empty */ + makeReadyDoesReset?: boolean + + /** Minimum time in ms before a command is resent, set to <= 0 or undefined to disable */ + resendTime?: number +} + +export type TimelineObjNoraNRKAny = TimelineObjNoraNRKRequest +export interface TimelineObjNoraNRKBase extends TSRTimelineObjBase { + content: { + deviceType: DeviceType.NORA_NRK + type: TimelineContentTypeNoraNRK + } +} +export interface TimelineObjNoraNRKRequest extends TimelineObjNoraNRKBase { + content: { + deviceType: DeviceType.NORA_NRK + } & NoraNRKCommandContent & + TimelineDatastoreReferencesContent +} diff --git a/packages/timeline-state-resolver/CHANGELOG.md b/packages/timeline-state-resolver/CHANGELOG.md index 2af32bba5e..1be96233b3 100644 --- a/packages/timeline-state-resolver/CHANGELOG.md +++ b/packages/timeline-state-resolver/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [7.5.0-release47.5](https://github.com/nrkno/tv-automation-state-timeline-resolver/compare/7.5.0-release47.4...7.5.0-release47.5) (2023-01-16) + +**Note:** Version bump only for package timeline-state-resolver + + + + + +# [7.5.0-release47.4](https://github.com/nrkno/tv-automation-state-timeline-resolver/compare/7.5.0-release47.3...7.5.0-release47.4) (2023-01-13) + + +### Bug Fixes + +* add optional parameter to HTTPSend timelineObj: paramsType ([979dc61](https://github.com/nrkno/tv-automation-state-timeline-resolver/commit/979dc61748c4c371a8b17c7fd8c5929c69f747d9)) +* add support for Node 18 ([6242dd6](https://github.com/nrkno/tv-automation-state-timeline-resolver/commit/6242dd68f54a491aa71bdfd30b066550d6f7e90e)) +* bug fix: HTTPSend device didn't send GET requests ([8315531](https://github.com/nrkno/tv-automation-state-timeline-resolver/commit/83155314706497a9c630dbde14d5c5d7e57103cf)) +* prevent in place reverse in setDatastore ([473ab71](https://github.com/nrkno/tv-automation-state-timeline-resolver/commit/473ab713785325c2062db983c8ece80ea5dede4d)) +* track ccg state internally ([fd5596f](https://github.com/nrkno/tv-automation-state-timeline-resolver/commit/fd5596fcf975a7a122c6fb21946f13c2e97a4233)) + + + + + # [7.5.0-release47.3](https://github.com/nrkno/tv-automation-state-timeline-resolver/compare/7.5.0-release47.2...7.5.0-release47.3) (2022-11-07) **Note:** Version bump only for package timeline-state-resolver diff --git a/packages/timeline-state-resolver/package.json b/packages/timeline-state-resolver/package.json index 1a62000115..4b8286c1f6 100644 --- a/packages/timeline-state-resolver/package.json +++ b/packages/timeline-state-resolver/package.json @@ -1,6 +1,6 @@ { "name": "timeline-state-resolver", - "version": "7.5.0-release47.3", + "version": "7.5.0-release47.5", "description": "Have timeline, control stuff", "main": "dist/index.js", "typings": "dist/index.d.ts", @@ -90,7 +90,7 @@ "atem-connection": "2.4.0", "atem-state": "^0.12.2", "casparcg-connection": "^5.1.0", - "casparcg-state": "2.1.2", + "casparcg-state": "2.1.3", "debug": "^4.3.1", "deepmerge": "^4.2.2", "emberplus-connection": "^0.1.2", @@ -106,7 +106,7 @@ "sprintf-js": "^1.1.2", "superfly-timeline": "^8.3.1", "threadedclass": "^1.1.1", - "timeline-state-resolver-types": "7.5.0-release47.3", + "timeline-state-resolver-types": "7.5.0-release47.5", "tslib": "^2.3.1", "tv-automation-quantel-gateway-client": "^2.0.5", "underscore": "^1.13.4", diff --git a/packages/timeline-state-resolver/src/conductor.ts b/packages/timeline-state-resolver/src/conductor.ts index 515396ef88..5527d758a7 100644 --- a/packages/timeline-state-resolver/src/conductor.ts +++ b/packages/timeline-state-resolver/src/conductor.ts @@ -51,6 +51,7 @@ import { VizMSEDevice, DeviceOptionsVizMSEInternal } from './integrations/vizMSE import { ShotokuDevice, DeviceOptionsShotokuInternal } from './integrations/shotoku' import { DeviceOptionsSofieChefInternal, SofieChefDevice } from './integrations/sofieChef' import { TelemetricsDevice } from './integrations/telemetrics' +import { NoraNRKDevice, DeviceOptionsNoraNRKInternal } from './integrations/noraNRK' export { DeviceContainer } export { CommandWithContext } @@ -611,6 +612,15 @@ export class Conductor extends EventEmitter { getCurrentTime, threadedClassOptions ) + } else if (deviceOptions.type === DeviceType.NORA_NRK) { + newDevice = await DeviceContainer.create( + '../../dist/integrations/noraNRK/index.js', + 'NoraNRKDevice', + deviceId, + deviceOptions, + getCurrentTime, + threadedClassOptions + ) } else { // @ts-ignore deviceOptions.type is of type "never" const type: any = deviceOptions.type @@ -1493,6 +1503,7 @@ export type DeviceOptionsAnyInternal = | DeviceOptionsShotokuInternal | DeviceOptionsVizMSEInternal | DeviceOptionsTelemetrics + | DeviceOptionsNoraNRKInternal function removeParentFromState(o: TimelineState): TimelineState { for (const key in o) { diff --git a/packages/timeline-state-resolver/src/integrations/noraNRK/__tests__/nora.spec.ts b/packages/timeline-state-resolver/src/integrations/noraNRK/__tests__/nora.spec.ts new file mode 100644 index 0000000000..c7e93a53b4 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/noraNRK/__tests__/nora.spec.ts @@ -0,0 +1,260 @@ +import { Conductor } from '../../../conductor' +import { NoraNRKDevice } from '..' +import { Mappings, DeviceType, MappingNoraNRK, TimelineObjNoraNRKAny } from 'timeline-state-resolver-types' +import { MockTime } from '../../../__tests__/mockTime' +import { ThreadedClass } from 'threadedclass' +import { getMockCall } from '../../../__tests__/lib' + +// let nowActual = Date.now() +describe('NORA Core (NRK)', () => { + const mockTime = new MockTime() + beforeAll(() => { + mockTime.mockDateNow() + }) + beforeEach(() => { + mockTime.init() + }) + test('POST command', async () => { + const commandReceiver0: any = jest.fn(async () => { + return Promise.resolve() + }) + const myLayerMapping0: MappingNoraNRK = { + device: DeviceType.NORA_NRK, + deviceId: 'myNORA', + } + const myLayerMapping: Mappings = { + myLayer0: myLayerMapping0, + } + + const myConductor = new Conductor({ + multiThreadedResolver: false, + getCurrentTime: mockTime.getCurrentTime, + }) + await myConductor.init() + await myConductor.addDevice('myNORA', { + type: DeviceType.NORA_NRK, + options: {}, + commandReceiver: commandReceiver0, + }) + myConductor.setTimelineAndMappings([], myLayerMapping) + await mockTime.advanceTimeToTicks(10100) + + const deviceContainer = myConductor.getDevice('myNORA') + const device = deviceContainer!.device as ThreadedClass + + // Check that no commands has been scheduled: + expect(await device.queue).toHaveLength(0) + + myConductor.setTimelineAndMappings([ + { + id: 'obj0', + enable: { + start: mockTime.now + 1000, // in 1 second + duration: 2000, + }, + layer: 'myLayer0', + content: { + deviceType: DeviceType.NORA_NRK, + + group: 'test', + channel: 'gfx1', + payload: { + manifest: 'nyheter', + template: { + layer: 'super', + name: '01_navn', + event: 'take', + }, + content: { + navn: 'Firstname Lastname', + }, + }, + }, + }, + ]) + await mockTime.advanceTimeToTicks(10990) + expect(commandReceiver0).toHaveBeenCalledTimes(0) + await mockTime.advanceTimeToTicks(11100) + + expect(commandReceiver0).toHaveBeenCalledTimes(1) + expect(commandReceiver0).toBeCalledWith( + expect.anything(), + expect.objectContaining({ + group: 'test', + channel: 'gfx1', + payload: { + manifest: 'nyheter', + template: { + layer: 'super', + name: '01_navn', + event: 'take', + }, + content: { + navn: 'Firstname Lastname', + }, + }, + }), + expect.anything(), + expect.stringContaining('obj0'), + expect.anything() + ) + expect(getMockCall(commandReceiver0, 0, 2)).toMatch(/added/) // context + await mockTime.advanceTimeToTicks(16000) + expect(commandReceiver0).toHaveBeenCalledTimes(1) + }) + test('POST message, ordering of commands', async () => { + const commandReceiver0: any = jest.fn(async () => { + return Promise.resolve() + }) + const myLayerMapping0: MappingNoraNRK = { + device: DeviceType.NORA_NRK, + deviceId: 'myNORA', + } + const myLayerMapping: Mappings = { + myLayer0: myLayerMapping0, + myLayer1: myLayerMapping0, + myLayer2: myLayerMapping0, + } + + const myConductor = new Conductor({ + multiThreadedResolver: false, + getCurrentTime: mockTime.getCurrentTime, + }) + await myConductor.init() + await myConductor.addDevice('myNORA', { + type: DeviceType.NORA_NRK, + options: {}, + commandReceiver: commandReceiver0, + }) + myConductor.setTimelineAndMappings([], myLayerMapping) + await mockTime.advanceTimeToTicks(10100) + + const deviceContainer = myConductor.getDevice('myNORA') + const device = deviceContainer!.device as ThreadedClass + + // Check that no commands has been scheduled: + expect(await device.queue).toHaveLength(0) + + const timeline: Array = [ + { + id: 'obj0', + enable: { + start: mockTime.now + 1000, // in 1 second + duration: 2000, + }, + layer: 'myLayer0', + content: { + deviceType: DeviceType.NORA_NRK, + + group: 'test', + channel: 'gfx1', + payload: { + manifest: 'nyheter', + template: { + layer: 'super', + name: '01_navn', + event: 'take', + }, + content: { + navn: 'Firstname Lastname', + }, + }, + + temporalPriority: 1, + }, + }, + { + id: 'obj1', + enable: { + start: mockTime.now + 1000, // in 1 second + duration: 2000, + }, + layer: 'myLayer1', + content: { + deviceType: DeviceType.NORA_NRK, + + group: 'test', + channel: 'gfx1', + payload: { + manifest: 'nyheter', + template: { + layer: 'super', + name: '02_navn_alt', + event: 'take', + }, + content: { + navn: 'Firstname Lastname', + }, + }, + + temporalPriority: 3, + }, + }, + { + id: 'obj2', + enable: { + start: mockTime.now + 1000, // in 1 second + duration: 2000, + }, + layer: 'myLayer2', + content: { + deviceType: DeviceType.NORA_NRK, + + group: 'test', + channel: 'gfx1', + payload: { + manifest: 'nyheter', + template: { + layer: 'super', + name: '03_navn_alt_2', + event: 'take', + }, + content: { + navn: 'Firstname Lastname', + }, + }, + + temporalPriority: 2, + }, + }, + ] + myConductor.setTimelineAndMappings(timeline) + + await mockTime.advanceTimeToTicks(10990) + expect(commandReceiver0).toHaveBeenCalledTimes(0) + await mockTime.advanceTimeToTicks(11100) + + // Expecting to see the ordering below: + expect(commandReceiver0).toHaveBeenCalledTimes(3) + expect(commandReceiver0).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + payload: expect.objectContaining({ template: expect.objectContaining({ name: '01_navn' }) }), + }), + expect.anything(), + expect.stringContaining('obj0'), + expect.anything() + ) + expect(commandReceiver0).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.objectContaining({ + payload: expect.objectContaining({ template: expect.objectContaining({ name: '03_navn_alt_2' }) }), + }), + expect.anything(), + expect.stringContaining('obj2'), + expect.anything() + ) + expect(commandReceiver0).toHaveBeenNthCalledWith( + 3, + expect.anything(), + expect.objectContaining({ + payload: expect.objectContaining({ template: expect.objectContaining({ name: '02_navn_alt' }) }), + }), + expect.anything(), + expect.stringContaining('obj1'), + expect.anything() + ) + }) +}) diff --git a/packages/timeline-state-resolver/src/integrations/noraNRK/index.ts b/packages/timeline-state-resolver/src/integrations/noraNRK/index.ts new file mode 100644 index 0000000000..4351916ca3 --- /dev/null +++ b/packages/timeline-state-resolver/src/integrations/noraNRK/index.ts @@ -0,0 +1,331 @@ +import * as _ from 'underscore' +import { DeviceWithState, CommandWithContext, DeviceStatus, StatusCode } from './../../devices/device' +import { + DeviceType, + NoraNRKOptions, + NoraNRKCommandContent, + DeviceOptionsNoraNRK, + Mappings, +} from 'timeline-state-resolver-types' +import { DoOnTime, SendMode } from '../../devices/doOnTime' +import got, { OptionsOfTextResponseBody, RequestError } from 'got' + +import { TimelineState, ResolvedTimelineObjectInstance } from 'superfly-timeline' + +import Debug from 'debug' +import { endTrace, startTrace } from '../../lib' +const debug = Debug('timeline-state-resolver:noranrk') + +export interface DeviceOptionsNoraNRKInternal extends DeviceOptionsNoraNRK { + commandReceiver?: CommandReceiver +} +export type CommandReceiver = ( + time: number, + cmd: NoraNRKCommandContent, + context: CommandContext, + timelineObjId: string, + layer?: string +) => Promise +interface Command { + commandName: 'added' | 'changed' | 'removed' + content: NoraNRKCommandContent + context: CommandContext + timelineObjId: string + layer: string +} +type CommandContext = string + +type NoraNRKState = TimelineState + +/** + * This is a Nora (NRK) device, it uses http to control a Nora channel + */ +export class NoraNRKDevice extends DeviceWithState { + private _makeReadyCommands: NoraNRKCommandContent[] + private _resendTime?: number + private _doOnTime: DoOnTime + + private _commandReceiver: CommandReceiver + private targetState = new Map() + // private actualState = new Map() // comes from Nora, streamed over a socket + + constructor(deviceId: string, deviceOptions: DeviceOptionsNoraNRKInternal, getCurrentTime: () => Promise) { + super(deviceId, deviceOptions, getCurrentTime) + if (deviceOptions.options) { + if (deviceOptions.commandReceiver) this._commandReceiver = deviceOptions.commandReceiver + else this._commandReceiver = this._defaultCommandReceiver.bind(this) + } + this._doOnTime = new DoOnTime( + () => { + return this.getCurrentTime() + }, + SendMode.IN_ORDER, + this._deviceOptions + ) + this.handleDoOnTime(this._doOnTime, 'NoraNRK') + } + async init(initOptions: NoraNRKOptions): Promise { + this._makeReadyCommands = initOptions.makeReadyCommands || [] + this._resendTime = initOptions.resendTime && initOptions.resendTime > 1 ? initOptions.resendTime : undefined + + return Promise.resolve(true) // This device doesn't have any initialization procedure + } + /** Called by the Conductor a bit before a .handleState is called */ + prepareForHandleState(newStateTime: number) { + // clear any queued commands later than this time: + this._doOnTime.clearQueueNowAndAfter(newStateTime) + this.cleanUpStates(0, newStateTime) + } + handleState(newState: TimelineState, newMappings: Mappings) { + super.onHandleState(newState, newMappings) + // Handle this new state, at the point in time specified + + const previousStateTime = Math.max(this.getCurrentTime(), newState.time) + const oldState: TimelineState = ( + this.getStateBefore(previousStateTime) || { state: { time: 0, layers: {}, nextEvents: [] } } + ).state + + const convertTrace = startTrace(`device:convertState`, { deviceId: this.deviceId }) + const oldHttpSendState = oldState + const newHttpSendState = this.convertStateToHttpSend(newState) + this.emit('timeTrace', endTrace(convertTrace)) + + const diffTrace = startTrace(`device:diffState`, { deviceId: this.deviceId }) + const commandsToAchieveState: Array = this._diffStates(oldHttpSendState, newHttpSendState) + this.emit('timeTrace', endTrace(diffTrace)) + + // clear any queued commands later than this time: + this._doOnTime.clearQueueNowAndAfter(previousStateTime) + // add the new commands to the queue: + this._addToQueue(commandsToAchieveState, newState.time) + + // store the new state, for later use: + this.setState(newState, newState.time) + } + clearFuture(clearAfterTime: number) { + // Clear any scheduled commands after this time + this._doOnTime.clearQueueAfter(clearAfterTime) + } + async terminate() { + this._doOnTime.dispose() + return Promise.resolve(true) + } + getStatus(): DeviceStatus { + // Good, since this device has no status, really + return { + statusCode: StatusCode.GOOD, + messages: [], + active: this.isActive, + } + } + async makeReady(okToDestroyStuff?: boolean): Promise { + if (okToDestroyStuff) { + const time = this.getCurrentTime() + + this.clearStates() + this._doOnTime.clearQueueAfter(0) + + for (const cmd of this._makeReadyCommands || []) { + await this._commandReceiver(time, cmd, 'makeReady', '') + } + } + } + + get canConnect(): boolean { + return false + } + get connected(): boolean { + return false + } + convertStateToHttpSend(state: TimelineState) { + // convert the timeline state into something we can use + // (won't even use this.mapping) + return state + } + get deviceType() { + return DeviceType.NORA_NRK + } + get deviceName(): string { + return 'NORA Core (NRK) ' + this.deviceId + } + get queue() { + return this._doOnTime.getQueue() + } + /** + * Add commands to queue, to be executed at the right time + */ + private _addToQueue(commandsToAchieveState: Array, time: number) { + _.each(commandsToAchieveState, (cmd: Command) => { + // add the new commands to the queue: + this._doOnTime.queue( + time, + cmd.content.queueId, + (cmd: Command) => { + if (cmd.commandName === 'added' || cmd.commandName === 'changed') { + this.targetState.set(cmd.layer, JSON.stringify(cmd.content)) + return this._commandReceiver(time, cmd.content, cmd.context, cmd.timelineObjId, cmd.layer) + } else { + this.targetState.delete(cmd.layer) + return null + } + }, + cmd + ) + }) + } + /** + * Compares the new timeline-state with the old one, and generates commands to account for the difference + */ + private _diffStates(oldhttpSendState: TimelineState, newhttpSendState: TimelineState): Array { + // in this httpSend class, let's just cheat: + + const commands: Array = [] + + _.each(newhttpSendState.layers, (newLayer: ResolvedTimelineObjectInstance) => { + const noraLayer = (newLayer.content as NoraNRKCommandContent).payload.template.layer + const oldLayer = oldhttpSendState.layers[noraLayer] + if (!oldLayer) { + // added! + commands.push({ + timelineObjId: newLayer.id, + commandName: 'added', + content: newLayer.content as NoraNRKCommandContent, + context: `added: ${newLayer.id}`, + layer: noraLayer, + }) + } else { + // changed? + if (!_.isEqual(oldLayer.content, newLayer.content)) { + // changed! + commands.push({ + timelineObjId: newLayer.id, + commandName: 'changed', + content: newLayer.content as NoraNRKCommandContent, + context: `changed: ${newLayer.id} (previously: ${oldLayer.id})`, + layer: noraLayer, + }) + } + } + }) + // removed + _.each(oldhttpSendState.layers, (oldLayer: ResolvedTimelineObjectInstance) => { + const noraLayer = (oldLayer.content as NoraNRKCommandContent).payload.template.layer + const newLayer = newhttpSendState.layers[noraLayer] + if (!newLayer) { + // removed! + commands.push({ + timelineObjId: oldLayer.id, + commandName: 'removed', + content: oldLayer.content as NoraNRKCommandContent, + context: `removed: ${oldLayer.id}`, + layer: noraLayer, + }) + } + }) + + commands.sort((a, b) => a.layer.localeCompare(b.layer)) + commands.sort((a, b) => { + return (a.content.temporalPriority || 0) - (b.content.temporalPriority || 0) + }) + + return commands + } + + private async _defaultCommandReceiver( + _time: number, + cmd: NoraNRKCommandContent, + context: CommandContext, + timelineObjId: string, + layer?: string + ): Promise { + if (layer) { + const hash = this.targetState.get(layer) + if (JSON.stringify(cmd) !== hash) return Promise.resolve() // command is no longer relevant to state + } + const cwc: CommandWithContext = { + context: context, + command: cmd, + timelineObjId: timelineObjId, + } + this.emitDebug(cwc) + + // later: do an additional check here if a command actually needs to be sent + + let request = got.post + let method = 'POST' + + let rebasedUrl = new URL( + `/renders/${cmd.group}${cmd.groupSuffix ?? ''}/${cmd.channel}`, + this._deviceOptions.options?.coreUrl + ) + let payload: Record = cmd.payload + + // this is a "clear-layer" command + if (cmd.payload.template.event === 'takeout' && !('name' in cmd.payload.template)) { + method = 'PUT' + request = got.put + rebasedUrl = new URL( + `/renders/${cmd.group}${cmd.groupSuffix ?? ''}/${cmd.channel}/${cmd.payload.template.layer}`, + this._deviceOptions.options?.coreUrl + ) + payload = { + template: { + event: 'takeout', + }, + } + } + + if (this._deviceOptions.options?.apiKey) { + rebasedUrl.searchParams.append('apiKey', this._deviceOptions.options?.apiKey) + } + + const t = Date.now() + debug(`${method}: ${rebasedUrl} ${JSON.stringify(cmd.payload)} (${timelineObjId})`) + + try { + const options: OptionsOfTextResponseBody = {} + + options.json = payload ?? undefined + + const response = await request(rebasedUrl, options) + + if (response.statusCode === 200) { + this.emitDebug( + `NoraNRK: ${method}: Good statuscode response on url "${rebasedUrl}": ${response.statusCode} (${context})` + ) + } else { + debug(`Bad response for ${rebasedUrl}: ${response.statusCode}`) + this.emit( + 'warning', + `NoraNRK: ${method}: Bad statuscode response on url "${rebasedUrl}": ${response.statusCode} (${context})` + ) + } + } catch (error) { + const err = error as RequestError // make typescript happy + + this.emit('error', `NoraNRK.response error on ${method} "${rebasedUrl}" (${context})`, err) + this.emit('commandError', err, cwc) + debug(`Failed ${rebasedUrl}: ${error} (${timelineObjId})`) + + if ('code' in err) { + const retryCodes = [ + 'ETIMEDOUT', + 'ECONNRESET', + 'EADDRINUSE', + 'ECONNREFUSED', + 'EPIPE', + 'ENOTFOUND', + 'ENETUNREACH', + 'EHOSTUNREACH', + 'EAI_AGAIN', + ] + + if (retryCodes.includes(err.code) && this._resendTime) { + const timeLeft = Math.max(this._resendTime - (Date.now() - t), 0) + await new Promise((resolve) => setTimeout(() => resolve(), timeLeft)) + this._defaultCommandReceiver(_time, cmd, context, timelineObjId, layer).catch(() => null) // errors will be emitted + } + } + } + } +} diff --git a/yarn.lock b/yarn.lock index 057ef40e9e..48c8d7f880 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2693,10 +2693,10 @@ casparcg-connection@^5.1.0: xml2js "^0.4.19" xmlbuilder "^9.0.7" -casparcg-state@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/casparcg-state/-/casparcg-state-2.1.2.tgz#bfb31e7d5f320818a5653b5172c35874f933b327" - integrity sha512-JGKfRZ1gwqxTNxd6/g66jldc5N7lpQPG54mX6X85/H/KIGVE1M3rixSdlf7Ib/8s0Hh+X4qj7i7ouDetM+ldMQ== +casparcg-state@2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/casparcg-state/-/casparcg-state-2.1.3.tgz#a21d84da19815b9ca31fe4cc31c11907712f22be" + integrity sha512-bhoUwsqMOBQ+zfyrw6TNjp6uTE25N9cU8DqBkEZp7WsixwXlHzxyFWO3i+u1itBKfhNsIw/vM9Y09mmXGY5oZw== dependencies: casparcg-connection "^5.1.0" fast-clone "^1.5.13"