Automation should use event store for data (#1962)

* fix: automation should use RuntimeStore for data

* add: aux timer template help
This commit is contained in:
Alex Christoffer Rasmussen
2026-03-02 11:54:46 -08:00
committed by GitHub
parent c1fcdf7065
commit 358ad79ae4
7 changed files with 87 additions and 56 deletions
@@ -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,
});
@@ -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<RuntimeState>,
store: Partial<RuntimeStore>,
): 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 {
@@ -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<RuntimeStore>) {
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<RuntimeStore>): string {
const parsedUrl = parseTemplateNested(output.url, state);
return parsedUrl;
}
@@ -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<RuntimeStore>) {
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<RuntimeStore>): 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 };