Files
ontime/apps/server/src/api-data/automation/automation.service.ts
T
2026-05-11 19:55:01 +02:00

141 lines
4.1 KiB
TypeScript

import {
type AutomationFilter,
type AutomationOutput,
type FilterRule,
LogOrigin,
RuntimeStore,
TimerLifeCycle,
isHTTPOutput,
isOSCOutput,
isOntimeAction,
} from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils';
import { logger } from '../../classes/Logger.js';
import { isOntimeCloud } from '../../setup/environment.js';
import { eventStore } from '../../stores/EventStore.js';
import { getAutomationTriggers, getAutomations, getAutomationsEnabled } from './automation.dao.js';
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
import { emitHTTP } from './clients/http.client.js';
import { toOntimeAction } from './clients/ontime.client.js';
import { emitOSC } from './clients/osc.client.js';
/**
* Exposes a method for triggering actions based on a TimerLifeCycle event
*/
export function triggerAutomations(cycle: TimerLifeCycle) {
if (!getAutomationsEnabled()) {
return;
}
const store = eventStore.poll();
let triggers = getAutomationTriggers();
// get triggers from event
if (store.eventNow?.triggers) {
triggers = triggers.concat(store.eventNow.triggers);
}
// note: there are no onStop triggers in event
const filteredTrigger = triggers.filter((trigger) => trigger.trigger === cycle);
if (filteredTrigger.length === 0) {
return;
}
const automations = getAutomations();
if (Object.keys(automations).length === 0) {
return;
}
// deduplicate — if the same automation appears multiple times on one lifecycle, fire it once
const seen = new Set<string>();
const uniqueTriggers = filteredTrigger.filter((t) => {
if (seen.has(t.automationId)) return false;
seen.add(t.automationId);
return true;
});
uniqueTriggers.forEach((trigger) => {
const automation = automations[trigger.automationId];
if (!automation || automation.outputs.length === 0) {
return;
}
const shouldSend = testConditions(automation.filters, automation.filterRule, store);
if (shouldSend) {
send(automation.outputs, store);
}
});
}
/**
* Exposes a method for bypassing the condition check and testing the sending of an output
*/
export function testOutput(payload: AutomationOutput) {
const store = eventStore.poll();
send([payload], store);
}
/**
* Checks whether the automation conditions are met
*/
export function testConditions(
filters: AutomationFilter[],
filterRule: FilterRule,
store: Partial<RuntimeStore>,
): boolean {
if (filters.length === 0) {
return true;
}
if (filterRule === 'all') {
return filters.every(evaluateCondition);
}
return filters.some(evaluateCondition);
function evaluateCondition(filter: AutomationFilter): boolean {
const { field, operator, value } = filter;
const lowerCasedValue = value.toLowerCase();
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)
switch (operator) {
case 'equals':
return isEquivalent(fieldValue, lowerCasedValue);
case 'not_equals':
return !isEquivalent(fieldValue, lowerCasedValue);
case 'greater_than':
return isGreaterThan(fieldValue, value);
case 'less_than':
return isLessThan(fieldValue, value);
case 'contains':
return isContained(fieldValue, lowerCasedValue);
case 'not_contains':
return !isContained(fieldValue, lowerCasedValue);
default: {
operator satisfies never;
return false;
}
}
}
}
/**
* Handles preparing and sending of the data
* Returns a boolean indicating whether a message was sent
*/
function send(output: AutomationOutput[], store: RuntimeStore) {
output.forEach((payload) => {
if (isOSCOutput(payload) && !isOntimeCloud) {
emitOSC(payload, store);
} else if (isHTTPOutput(payload)) {
emitHTTP(payload, store);
} else if (isOntimeAction(payload)) {
toOntimeAction(payload);
} else {
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
}
});
}