diff --git a/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts b/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts index 71f731ede..9404f15fb 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/template-input/templateInput.utils.ts @@ -54,6 +54,13 @@ const eventStaticPropertiesNext = [ '{{eventNext.delay}}', ]; +const staticAuxProperties = (index: 1 | 2 | 3) => [ + `{{auxtimer${index}.current}}`, + `{{auxtimer${index}.duration}}`, + `{{auxtimer${index}.playback}}`, + `{{auxtimer${index}.direction}}`, +]; + /** * Creates a it of possible autocomplete suggestions * Based on RuntimeState @@ -68,6 +75,9 @@ export function makeAutoCompleteList(customFields: CustomFields): string[] { ...Object.entries(customFields).map(([key]) => `{{eventNow.custom.${key}}}`), ...eventStaticPropertiesNext, ...Object.entries(customFields).map(([key]) => `{{eventNext.custom.${key}}}`), + ...staticAuxProperties(1), + ...staticAuxProperties(2), + ...staticAuxProperties(3), ]; } diff --git a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts index 22461ed15..5d8b66c64 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts @@ -1,7 +1,6 @@ import { PlayableEvent, TimerLifeCycle } from 'ontime-types'; -import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js'; -import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; +import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js'; import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js'; import { testConditions, triggerAutomations } from '../automation.service.js'; @@ -10,6 +9,7 @@ import * as httpClient from '../clients/http.client.js'; import { makeOSCAction, makeHTTPAction } from './testUtils.js'; import { RuntimeState } from '../../../stores/runtimeState.js'; +import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; beforeAll(() => { vi.mock('../../../classes/data-provider/DataProvider.js', () => { @@ -40,9 +40,20 @@ describe('triggerAction()', () => { let oscSpy = vi.spyOn(oscClient, 'emitOSC'); let httpSpy = vi.spyOn(httpClient, 'emitHTTP'); + beforeAll(() => { + vi.mock('../../../stores/EventStore.js', () => { + // Create a small mock store + return { + eventStore: { + poll: vi.fn().mockImplementation(() => makeRuntimeStoreData()), + }, + }; + }); + }) + beforeEach(async () => { - oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {}); - httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {}); + oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => { }); + httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => { }); await deleteAllTriggers(); const oscAutomation = await addAutomation({ @@ -70,26 +81,25 @@ describe('triggerAction()', () => { }); it('should trigger automations for a given action', () => { - const state = makeRuntimeStateData(); - triggerAutomations(TimerLifeCycle.onLoad, state); + triggerAutomations(TimerLifeCycle.onLoad); expect(oscSpy).toHaveBeenCalledTimes(1); expect(httpSpy).not.toBeCalled(); oscSpy.mockReset(); httpSpy.mockReset(); - triggerAutomations(TimerLifeCycle.onStart, state); + triggerAutomations(TimerLifeCycle.onStart); expect(oscClient.emitOSC).not.toBeCalled(); expect(httpSpy).not.toBeCalled(); oscSpy.mockReset(); httpSpy.mockReset(); - triggerAutomations(TimerLifeCycle.onFinish, state); + triggerAutomations(TimerLifeCycle.onFinish); expect(oscSpy).not.toBeCalled(); expect(httpSpy).toHaveBeenCalledTimes(1); oscSpy.mockReset(); httpSpy.mockReset(); - triggerAutomations(TimerLifeCycle.onStop, state); + triggerAutomations(TimerLifeCycle.onStop); expect(oscSpy).not.toBeCalled(); expect(httpSpy).not.toBeCalled(); }); @@ -540,7 +550,7 @@ describe('testConditions()', () => { describe('for all filter rule', () => { it('should return true when all filters are true', () => { - const mockStore = makeRuntimeStateData({ + const mockStore = makeRuntimeStoreData({ clock: 10, eventNow: makeOntimeEvent({ title: 'test', @@ -560,7 +570,7 @@ describe('testConditions()', () => { }); it('should return false if any filters are false', () => { - const mockStore = makeRuntimeStateData({ + const mockStore = makeRuntimeStoreData({ clock: 10, eventNow: makeOntimeEvent({ title: 'test', @@ -582,7 +592,7 @@ describe('testConditions()', () => { describe('for any filter rule', () => { it('should return true when all filters are true', () => { - const mockStore = makeRuntimeStateData({ + const mockStore = makeRuntimeStoreData({ clock: 10, eventNow: makeOntimeEvent({ title: 'test', @@ -602,7 +612,7 @@ describe('testConditions()', () => { }); it('should return true if any filters are true', () => { - const mockStore = makeRuntimeStateData({ + const mockStore = makeRuntimeStoreData({ clock: 10, eventNow: makeOntimeEvent({ title: 'not-test', @@ -622,7 +632,7 @@ describe('testConditions()', () => { }); it('should return false if all filters are false', () => { - const mockStore = makeRuntimeStateData({ + const mockStore = makeRuntimeStoreData({ clock: 10, eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent, }); diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts index e109efe2f..6e7b9ebba 100644 --- a/apps/server/src/api-data/automation/automation.service.ts +++ b/apps/server/src/api-data/automation/automation.service.ts @@ -3,6 +3,7 @@ import { isOntimeAction, isOSCOutput, LogOrigin, + RuntimeStore, TimerLifeCycle, type AutomationFilter, type AutomationOutput, @@ -10,29 +11,30 @@ import { } from 'ontime-types'; import { getPropertyFromPath } from 'ontime-utils'; -import { logger } from '../../classes/Logger.js'; -import { getState, type RuntimeState } from '../../stores/runtimeState.js'; -import { isOntimeCloud } from '../../setup/environment.js'; - import { emitOSC } from './clients/osc.client.js'; import { emitHTTP } from './clients/http.client.js'; import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js'; import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js'; import { toOntimeAction } from './clients/ontime.client.js'; +import { logger } from '../../classes/Logger.js'; +import { isOntimeCloud } from '../../setup/environment.js'; +import { eventStore } from '../../stores/EventStore.js'; + /** * Exposes a method for triggering actions based on a TimerLifeCycle event */ -export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) { +export function triggerAutomations(cycle: TimerLifeCycle) { if (!getAutomationsEnabled()) { return; } + const store = eventStore.poll(); let triggers = getAutomationTriggers(); // get triggers from event - if (state.eventNow?.triggers) { - triggers = triggers.concat(state.eventNow.triggers); + if (store.eventNow?.triggers) { + triggers = triggers.concat(store.eventNow.triggers); } // note: there are no onStop triggers in event @@ -51,9 +53,9 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) { if (!automation || automation.outputs.length === 0) { return; } - const shouldSend = testConditions(automation.filters, automation.filterRule, state); + const shouldSend = testConditions(automation.filters, automation.filterRule, store); if (shouldSend) { - send(automation.outputs, state); + send(automation.outputs, store); } }); } @@ -62,7 +64,8 @@ export function triggerAutomations(cycle: TimerLifeCycle, state: RuntimeState) { * Exposes a method for bypassing the condition check and testing the sending of an output */ export function testOutput(payload: AutomationOutput) { - send([payload]); + const store = eventStore.poll(); + send([payload], store); } /** @@ -71,7 +74,7 @@ export function testOutput(payload: AutomationOutput) { export function testConditions( filters: AutomationFilter[], filterRule: FilterRule, - state: Partial, + store: Partial, ): boolean { if (filters.length === 0) { return true; @@ -86,7 +89,7 @@ export function testConditions( function evaluateCondition(filter: AutomationFilter): boolean { const { field, operator, value } = filter; const lowerCasedValue = value.toLowerCase(); - const fieldValue = getPropertyFromPath(field, state); + const fieldValue = getPropertyFromPath(field, store); // if value is empty string, the user could be meaning to check if the value does not exist // we use loose equality to be able to check for converted values (eg '10' == 10) @@ -115,13 +118,12 @@ export function testConditions( * Handles preparing and sending of the data * Returns a boolean indicating whether a message was sent */ -function send(output: AutomationOutput[], state?: RuntimeState) { - const stateSnapshot = state ?? getState(); +function send(output: AutomationOutput[], store: RuntimeStore) { output.forEach((payload) => { if (isOSCOutput(payload) && !isOntimeCloud) { - emitOSC(payload, stateSnapshot); + emitOSC(payload, store); } else if (isHTTPOutput(payload)) { - emitHTTP(payload, stateSnapshot); + emitHTTP(payload, store); } else if (isOntimeAction(payload)) { toOntimeAction(payload); } else { diff --git a/apps/server/src/api-data/automation/clients/http.client.ts b/apps/server/src/api-data/automation/clients/http.client.ts index 865d24ca1..c66c80c77 100644 --- a/apps/server/src/api-data/automation/clients/http.client.ts +++ b/apps/server/src/api-data/automation/clients/http.client.ts @@ -1,20 +1,20 @@ -import { HTTPOutput, LogOrigin } from 'ontime-types'; +import { DeepReadonly } from 'ts-essentials'; +import { HTTPOutput, LogOrigin, RuntimeStore } from 'ontime-types'; import { logger } from '../../../classes/Logger.js'; -import type { RuntimeState } from '../../../stores/runtimeState.js'; import { parseTemplateNested } from '../automation.utils.js'; /** * Expose possibility to send a message using HTTP protocol */ -export function emitHTTP(output: HTTPOutput, state: RuntimeState) { - const url = preparePayload(output, state); +export function emitHTTP(output: HTTPOutput, store: DeepReadonly) { + const url = preparePayload(output, store); emit(url); } /** Parses the state and prepares payload to be emitted */ -function preparePayload(output: HTTPOutput, state: RuntimeState): string { +function preparePayload(output: HTTPOutput, state: DeepReadonly): string { const parsedUrl = parseTemplateNested(output.url, state); return parsedUrl; } diff --git a/apps/server/src/api-data/automation/clients/osc.client.ts b/apps/server/src/api-data/automation/clients/osc.client.ts index 2e65eb696..c33f38003 100644 --- a/apps/server/src/api-data/automation/clients/osc.client.ts +++ b/apps/server/src/api-data/automation/clients/osc.client.ts @@ -1,29 +1,29 @@ -import { LogOrigin, OSCOutput } from 'ontime-types'; +import { LogOrigin, OSCOutput, RuntimeStore } from 'ontime-types'; import { type OscPacketInput, toBuffer as oscPacketToBuffer } from 'osc-min'; import * as dgram from 'node:dgram'; import { logger } from '../../../classes/Logger.js'; -import { type RuntimeState } from '../../../stores/runtimeState.js'; import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js'; +import { DeepReadonly } from 'ts-essentials'; const udpClient = dgram.createSocket('udp4'); /** * Expose possibility to send a message using OSC protocol */ -export function emitOSC(output: OSCOutput, state: RuntimeState) { - const message = preparePayload(output, state); +export function emitOSC(output: OSCOutput, store: DeepReadonly) { + const message = preparePayload(output, store); emit(output.targetIP, output.targetPort, message); } /** Parses the state and prepares payload to be emitted */ -function preparePayload(output: OSCOutput, state: RuntimeState): OscPacketInput { +function preparePayload(output: OSCOutput, store: DeepReadonly): OscPacketInput { // check for templates in the address - const parsedAddress = parseTemplateNested(output.address, state); + const parsedAddress = parseTemplateNested(output.address, store); // check for templates in the arguments - const parsedArguments = output.args ? parseTemplateNested(output.args, state) : undefined; + const parsedArguments = output.args ? parseTemplateNested(output.args, store) : undefined; // check we have the correct type const oscArguments = stringToOSCArgs(parsedArguments); return { address: parsedAddress, args: oscArguments }; diff --git a/apps/server/src/services/runtime-service/runtime.service.ts b/apps/server/src/services/runtime-service/runtime.service.ts index d77961c1c..3cbbaea34 100644 --- a/apps/server/src/services/runtime-service/runtime.service.ts +++ b/apps/server/src/services/runtime-service/runtime.service.ts @@ -77,11 +77,11 @@ class RuntimeService { if (timerPhaseChanged) { if (newState.timer.phase === TimerPhase.Warning) { process.nextTick(() => { - triggerAutomations(TimerLifeCycle.onWarning, newState); + triggerAutomations(TimerLifeCycle.onWarning); }); } else if (newState.timer.phase === TimerPhase.Danger) { process.nextTick(() => { - triggerAutomations(TimerLifeCycle.onDanger, newState); + triggerAutomations(TimerLifeCycle.onDanger); }); } } @@ -96,7 +96,7 @@ class RuntimeService { } else if (hasTimerFinished) { // if the timer has finished, we need to load next and keep rolling process.nextTick(() => { - triggerAutomations(TimerLifeCycle.onFinish, newState); + triggerAutomations(TimerLifeCycle.onFinish); }); this.handleLoadNext(); this.rollLoaded(newState.offset); @@ -116,7 +116,7 @@ class RuntimeService { // 3. find if we need to process actions related to the timer finishing if (newState.timer.playback === Playback.Play && hasTimerFinished) { process.nextTick(() => { - triggerAutomations(TimerLifeCycle.onFinish, newState); + triggerAutomations(TimerLifeCycle.onFinish); }); // handle end action if there was a timer playing @@ -134,7 +134,7 @@ class RuntimeService { const shouldUpdateTimer = isNewSecond(this.lastIntegrationTimerValue, newState.timer.current); if (shouldUpdateTimer) { process.nextTick(() => { - triggerAutomations(TimerLifeCycle.onUpdate, newState); + triggerAutomations(TimerLifeCycle.onUpdate); }); this.lastIntegrationTimerValue = newState.timer.current ?? -1; @@ -144,7 +144,7 @@ class RuntimeService { const shouldUpdateClock = getShouldClockUpdate(this.lastIntegrationClockUpdate, newState.clock); if (shouldUpdateClock) { process.nextTick(() => { - triggerAutomations(TimerLifeCycle.onClock, newState); + triggerAutomations(TimerLifeCycle.onClock); }); this.lastIntegrationClockUpdate = newState.clock; @@ -209,10 +209,9 @@ class RuntimeService { if (success) { logger.info(LogOrigin.Playback, `Loaded event with ID ${event.id}`); - const newState = runtimeState.getState(); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); - triggerAutomations(TimerLifeCycle.onLoad, newState); + triggerAutomations(TimerLifeCycle.onLoad); }); } return success; @@ -447,7 +446,7 @@ class RuntimeService { if (didStart) { process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStart, newState); - triggerAutomations(TimerLifeCycle.onStart, newState); + triggerAutomations(TimerLifeCycle.onStart); }); } return didStart; @@ -500,7 +499,7 @@ class RuntimeService { const newState = runtimeState.getState(); logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { - triggerAutomations(TimerLifeCycle.onPause, newState); + triggerAutomations(TimerLifeCycle.onPause); }); } @@ -520,7 +519,7 @@ class RuntimeService { logger.info(LogOrigin.Playback, `Play Mode ${newState.timer.playback.toUpperCase()}`); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); - triggerAutomations(TimerLifeCycle.onStop, newState); + triggerAutomations(TimerLifeCycle.onStop); }); return true; @@ -553,7 +552,7 @@ class RuntimeService { const newState = runtimeState.getState(); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStart, newState); - triggerAutomations(TimerLifeCycle.onStart, newState); + triggerAutomations(TimerLifeCycle.onStart); }); } } catch (error) { @@ -584,14 +583,14 @@ class RuntimeService { logger.info(LogOrigin.Playback, `Loaded event with ID ${result.eventId}`); process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStop, previousState); - triggerAutomations(TimerLifeCycle.onLoad, newState); + triggerAutomations(TimerLifeCycle.onLoad); }); } if (result.didStart) { process.nextTick(() => { triggerReportEntry(TimerLifeCycle.onStart, newState); - triggerAutomations(TimerLifeCycle.onStart, newState); + triggerAutomations(TimerLifeCycle.onStart); }); } } catch (error) { diff --git a/apps/server/src/stores/__mocks__/runtimeStore.mocks.ts b/apps/server/src/stores/__mocks__/runtimeStore.mocks.ts new file mode 100644 index 000000000..d18f9e1ad --- /dev/null +++ b/apps/server/src/stores/__mocks__/runtimeStore.mocks.ts @@ -0,0 +1,10 @@ +import { RuntimeStore, runtimeStorePlaceholder } from 'ontime-types'; +import { deepmerge } from 'ontime-utils'; + +const baseStore: RuntimeStore = { + ...runtimeStorePlaceholder +}; + +export function makeRuntimeStoreData(patch?: Partial): RuntimeStore { + return deepmerge(baseStore, patch) as RuntimeStore; +}