mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-09 01:13:55 +00:00
feat: ontime actions in automation
This commit is contained in:
committed by
Carlos Valente
parent
636f78e21e
commit
5fb30c75c4
+14
-4
@@ -27,7 +27,8 @@
|
||||
.titleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection {
|
||||
.httpSection,
|
||||
.actionSection {
|
||||
display: grid;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
@@ -40,8 +41,10 @@
|
||||
.ruleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection {
|
||||
label, div {
|
||||
.httpSection,
|
||||
.actionSection {
|
||||
label,
|
||||
div {
|
||||
// we use the div as non-interactive placeholder for button cells
|
||||
// it needs to match the size of the label element
|
||||
font-size: calc(1rem - 3px);
|
||||
@@ -51,7 +54,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.titleSection {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -68,6 +70,14 @@
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.actionSection {
|
||||
grid-template-columns: auto 1fr 1fr auto;
|
||||
|
||||
.test {
|
||||
grid-column: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.outputCard {
|
||||
border-left: 0.25rem solid $gray-1200;
|
||||
padding-left: 0.5rem;
|
||||
|
||||
@@ -3,7 +3,16 @@ import { Controller, useFieldArray, useForm } from 'react-hook-form';
|
||||
import { Button, IconButton, Input, Radio, RadioGroup, Select } from '@chakra-ui/react';
|
||||
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
|
||||
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
|
||||
import { Automation, AutomationDTO, HTTPOutput, isHTTPOutput, isOSCOutput, OSCOutput } from 'ontime-types';
|
||||
import {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
HTTPOutput,
|
||||
isHTTPOutput,
|
||||
isOntimeAction,
|
||||
isOSCOutput,
|
||||
OntimeAction,
|
||||
OSCOutput,
|
||||
} from 'ontime-types';
|
||||
|
||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -18,6 +27,7 @@ import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
import OntimeActionForm from './OntimeActionForm';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
@@ -42,6 +52,7 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
register,
|
||||
setError,
|
||||
setFocus,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting, isDirty, isValid },
|
||||
} = useForm<AutomationDTO>({
|
||||
mode: 'onChange',
|
||||
@@ -92,6 +103,11 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
appendOutput({ type: 'http', url: '' });
|
||||
};
|
||||
|
||||
const handleAddnewOntimeAction = () => {
|
||||
// @ts-expect-error -- we dont want to choose an action
|
||||
appendOutput({ type: 'ontime', action: undefined });
|
||||
};
|
||||
|
||||
const handleTestOSCOutput = async (index: number) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
||||
@@ -125,6 +141,19 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestOntimeAction = async (index: number) => {
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as OntimeAction;
|
||||
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
|
||||
await testOutput({
|
||||
...values,
|
||||
type: 'ontime',
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here */
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: AutomationDTO) => {
|
||||
if (isAutomation(automation)) {
|
||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
||||
@@ -374,8 +403,6 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
@@ -423,8 +450,6 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
@@ -432,30 +457,59 @@ export default function AutomationForm(props: AutomationFormProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
icon={<IoTrash />}
|
||||
variant='ontime-ghosted'
|
||||
size='sm'
|
||||
onClick={() => removeOutput(index)}
|
||||
color='#FA5656' // $red-500
|
||||
/>
|
||||
</Panel.InlineElements>
|
||||
</OntimeActionForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// there should be no other output types
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewOSCOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewOSCOutput}>
|
||||
OSC
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-subtle'
|
||||
rightIcon={<IoAdd />}
|
||||
size='sm'
|
||||
onClick={handleAddNewHTTPOutput}
|
||||
isDisabled={false}
|
||||
isLoading={false}
|
||||
>
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddNewHTTPOutput}>
|
||||
HTTP
|
||||
</Button>
|
||||
<Button variant='ontime-subtle' rightIcon={<IoAdd />} size='sm' onClick={handleAddnewOntimeAction}>
|
||||
Ontime action
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { UseFormRegister, UseFormSetValue } from 'react-hook-form';
|
||||
import { Input, Select } from '@chakra-ui/react';
|
||||
import { AutomationDTO, OntimeAction } from 'ontime-types';
|
||||
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
interface OntimeActionFormProps {
|
||||
index: number;
|
||||
register: UseFormRegister<AutomationDTO>;
|
||||
rowErrors?: {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
};
|
||||
value: OntimeAction['action'];
|
||||
setValue: UseFormSetValue<AutomationDTO>;
|
||||
}
|
||||
|
||||
export default function OntimeActionForm(props: PropsWithChildren<OntimeActionFormProps>) {
|
||||
const { index, register, setValue, rowErrors, value, children } = props;
|
||||
const [selectedAction, setSelectedAction] = useState<OntimeAction['action']>(value || 'aux-start');
|
||||
|
||||
const updateSelectedAction = (value: string) => {
|
||||
setSelectedAction(value as OntimeAction['action']);
|
||||
setValue(`outputs.${index}.action`, value as OntimeAction['action']);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx([style.actionSection, selectedAction && style[selectedAction]])}>
|
||||
<input type='hidden' {...register(`outputs.${index}.action`)} value={selectedAction} />
|
||||
<label>
|
||||
Action
|
||||
<Select
|
||||
variant='ontime'
|
||||
size='sm'
|
||||
value={selectedAction}
|
||||
onChange={(event) => updateSelectedAction(event.target.value)}
|
||||
>
|
||||
<option value='aux-start'>Auxiliary timer: start</option>
|
||||
<option value='aux-pause'>Auxiliary timer: pause</option>
|
||||
<option value='aux-stop'>Auxiliary timer: stop</option>
|
||||
<option value='aux-set'>Auxiliary timer: set</option>
|
||||
<option value='message-set'>Timer: timer message</option>
|
||||
<option value='message-secondary'>Timer: timer secondary</option>
|
||||
</Select>
|
||||
<Panel.Error>{rowErrors?.action?.message}</Panel.Error>
|
||||
</label>
|
||||
|
||||
{selectedAction === 'aux-set' && (
|
||||
<label>
|
||||
New time
|
||||
<Input
|
||||
{...register(`outputs.${index}.time`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='eg: 10m5s'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.time?.message}</Panel.Error>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedAction === 'message-set' && (
|
||||
<>
|
||||
<label>
|
||||
Text (leave empty for no change)
|
||||
<Input
|
||||
{...register(`outputs.${index}.text`)}
|
||||
variant='ontime-filled'
|
||||
size='sm'
|
||||
placeholder='eg: Timer is finished'
|
||||
autoComplete='off'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.text?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Visibility
|
||||
<Select variant='ontime' size='sm' {...register(`outputs.${index}.visible`)}>
|
||||
<option value=''>Untouched</option>
|
||||
<option value='true'>Show</option>
|
||||
<option value='false'>Hide</option>
|
||||
</Select>
|
||||
<Panel.Error>{rowErrors?.visible?.message}</Panel.Error>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedAction === 'message-secondary' && (
|
||||
<label>
|
||||
Timer secondary source
|
||||
<Select variant='ontime' size='sm' {...register(`outputs.${index}.secondarySource`)}>
|
||||
<option value='aux'>Auxiliary timer</option>
|
||||
<option value='external'>External</option>
|
||||
<option value='null'>None</option>
|
||||
</Select>
|
||||
<Panel.Error>{rowErrors?.secondarySource?.message}</Panel.Error>
|
||||
</label>
|
||||
)}
|
||||
<div className={style.test}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { parseOutput } from '../automation.validation.js';
|
||||
|
||||
describe('parseOutput', () => {
|
||||
describe('handles OSC outputs', () => {
|
||||
it('parses a valid payload', () => {
|
||||
const payload = {
|
||||
type: 'osc',
|
||||
targetIP: 'localhost',
|
||||
targetPort: 1234,
|
||||
address: '/test',
|
||||
args: 'test',
|
||||
};
|
||||
const result = parseOutput(payload);
|
||||
expect(result).toStrictEqual(payload);
|
||||
});
|
||||
|
||||
it('throws on a invalid payload', () => {
|
||||
const payload = {
|
||||
type: 'osc',
|
||||
targetIP: 1234,
|
||||
targetPort: 1234,
|
||||
address: '/test',
|
||||
args: 'test',
|
||||
};
|
||||
expect(() => parseOutput(payload)).toThrow();
|
||||
});
|
||||
});
|
||||
describe('handles HTTP outputs', () => {
|
||||
it('parses a valid payload', () => {
|
||||
const payload = {
|
||||
type: 'http',
|
||||
url: 'http://asdasdas',
|
||||
};
|
||||
const result = parseOutput(payload);
|
||||
expect(result).toStrictEqual(payload);
|
||||
});
|
||||
|
||||
it('throws on a invalid payload', () => {
|
||||
const payload = {
|
||||
type: 'http',
|
||||
};
|
||||
expect(() => parseOutput(payload)).toThrow();
|
||||
});
|
||||
});
|
||||
describe('handles Ontime outputs', () => {
|
||||
it('parses a valid payload', () => {
|
||||
const auxStart = {
|
||||
type: 'ontime',
|
||||
action: 'aux-start',
|
||||
};
|
||||
expect(parseOutput(auxStart)).toStrictEqual(auxStart);
|
||||
const auxStop = {
|
||||
type: 'ontime',
|
||||
action: 'aux-stop',
|
||||
};
|
||||
expect(parseOutput(auxStop)).toStrictEqual(auxStop);
|
||||
const auxPause = {
|
||||
type: 'ontime',
|
||||
action: 'aux-pause',
|
||||
};
|
||||
expect(parseOutput(auxPause)).toStrictEqual(auxPause);
|
||||
});
|
||||
|
||||
it('removes extra properties', () => {
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'aux-start',
|
||||
time: 10,
|
||||
}),
|
||||
).toStrictEqual({
|
||||
type: 'ontime',
|
||||
action: 'aux-start',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on a invalid payload', () => {
|
||||
const payload = {
|
||||
type: 'ontime',
|
||||
action: 'not-exist',
|
||||
};
|
||||
expect(() => parseOutput(payload)).toThrow();
|
||||
});
|
||||
|
||||
it('parses message-set', () => {
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-set',
|
||||
text: 'test',
|
||||
visible: 'true',
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: 'test',
|
||||
visible: true,
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-set',
|
||||
text: '',
|
||||
visible: 'false',
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: undefined,
|
||||
visible: false,
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-set',
|
||||
text: '',
|
||||
visible: '',
|
||||
}),
|
||||
).toMatchObject({
|
||||
text: undefined,
|
||||
visible: undefined,
|
||||
});
|
||||
expect(() =>
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-set',
|
||||
text: 123,
|
||||
visible: '',
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('parses message-secondary', () => {});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'test',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: null,
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: '',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: null,
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'aux',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: 'aux',
|
||||
});
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: 'external',
|
||||
}),
|
||||
).toMatchObject({
|
||||
secondarySource: 'external',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,13 @@
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
import { Automation, AutomationOutput, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
|
||||
import { Automation, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
import { oscServer } from '../../adapters/OscAdapter.js';
|
||||
|
||||
import * as automationDao from './automation.dao.js';
|
||||
import * as automationService from './automation.service.js';
|
||||
import { oscServer } from '../../adapters/OscAdapter.js';
|
||||
import { parseOutput } from './automation.validation.js';
|
||||
|
||||
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
|
||||
res.json(automationDao.getAutomationSettings());
|
||||
@@ -114,8 +116,9 @@ export async function deleteAutomation(req: Request, res: Response<void | ErrorR
|
||||
|
||||
export function testOutput(req: Request, res: Response<void | ErrorResponse>) {
|
||||
try {
|
||||
const payload = req.body as AutomationOutput;
|
||||
automationService.testOutput(payload);
|
||||
const payload = req.body;
|
||||
const parsed = parseOutput(payload);
|
||||
automationService.testOutput(parsed);
|
||||
res.status(200).send();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
isHTTPOutput,
|
||||
isOntimeAction,
|
||||
isOSCOutput,
|
||||
LogOrigin,
|
||||
type AutomationFilter,
|
||||
type AutomationOutput,
|
||||
type FilterRule,
|
||||
@@ -8,6 +10,7 @@ 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 '../../externals.js';
|
||||
|
||||
@@ -15,6 +18,7 @@ import { emitOSC } from './clients/osc.client.js';
|
||||
import { emitHTTP } from './clients/http.client.js';
|
||||
import { getAutomationsEnabled, getAutomations, getAutomationTriggers } from './automation.dao.js';
|
||||
import { isBooleanEquals, isGreaterThan, isLessThan } from './automation.utils.js';
|
||||
import { toOntimeAction } from './clients/ontime.client.js';
|
||||
|
||||
/**
|
||||
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
||||
@@ -117,12 +121,14 @@ export function testConditions(
|
||||
function send(output: AutomationOutput[], state?: RuntimeState) {
|
||||
const stateSnapshot = state ?? getState();
|
||||
output.forEach((payload) => {
|
||||
if (isOSCOutput(payload)) {
|
||||
if (!isOntimeCloud) {
|
||||
emitOSC(payload, stateSnapshot);
|
||||
}
|
||||
if (isOSCOutput(payload) && !isOntimeCloud) {
|
||||
emitOSC(payload, stateSnapshot);
|
||||
} else if (isHTTPOutput(payload)) {
|
||||
emitHTTP(payload, stateSnapshot);
|
||||
} else if (isOntimeAction(payload)) {
|
||||
toOntimeAction(payload);
|
||||
} else {
|
||||
logger.warning(LogOrigin.Tx, `Unknown output type: ${payload}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FilterRule, MaybeNumber } from 'ontime-types';
|
||||
import { FilterRule, MaybeNumber, OntimeAction } from 'ontime-types';
|
||||
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
|
||||
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
|
||||
|
||||
@@ -12,6 +12,10 @@ export function isFilterRule(value: string): value is FilterRule {
|
||||
return value === 'all' || value === 'any';
|
||||
}
|
||||
|
||||
export function isOntimeActionAction(value: string): value is OntimeAction['action'] {
|
||||
return ['aux-start', 'aux-stop', 'aux-pause', 'aux-set', 'message-set', 'message-secondary'].includes(value);
|
||||
}
|
||||
|
||||
function toOscValue(argString: string): OscArgInput {
|
||||
const argAsNum = Number(argString);
|
||||
// NOTE: number like: 1 2.0 33333
|
||||
|
||||
@@ -3,16 +3,19 @@ import {
|
||||
AutomationFilter,
|
||||
AutomationOutput,
|
||||
HTTPOutput,
|
||||
OntimeAction,
|
||||
OSCOutput,
|
||||
SecondarySource,
|
||||
timerLifecycleValues,
|
||||
} from 'ontime-types';
|
||||
import { parseUserTime } from 'ontime-utils';
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { body, oneOf, param, validationResult } from 'express-validator';
|
||||
|
||||
import * as assert from '../../utils/assert.js';
|
||||
|
||||
import { isFilterOperator, isFilterRule } from './automation.utils.js';
|
||||
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js';
|
||||
|
||||
export const paramContainsId = [
|
||||
param('id').exists(),
|
||||
@@ -131,43 +134,13 @@ function validateFilters(filters: Array<unknown>): filters is AutomationFilter[]
|
||||
|
||||
function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
|
||||
output.forEach((payload) => {
|
||||
assert.isObject(payload);
|
||||
assert.hasKeys(payload, ['type']);
|
||||
const { type } = payload;
|
||||
assert.isString(type);
|
||||
|
||||
if (type === 'osc') {
|
||||
validateOSCOutput(payload);
|
||||
} else if (type === 'http') {
|
||||
validateHttpOutput(payload);
|
||||
} else {
|
||||
throw new Error('Invalid automation');
|
||||
}
|
||||
parseOutput(payload);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateOSCOutput(payload: object): payload is OSCOutput {
|
||||
assert.hasKeys(payload, ['targetIP', 'targetPort', 'address', 'args']);
|
||||
const { targetIP, targetPort, address, args } = payload;
|
||||
assert.isString(targetIP);
|
||||
assert.isNumber(targetPort);
|
||||
assert.isString(address);
|
||||
if (typeof args !== 'string' && typeof args !== 'number') {
|
||||
throw new Error('Invalid automation');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateHttpOutput(payload: object): payload is HTTPOutput {
|
||||
assert.hasKeys(payload, ['url']);
|
||||
const { url } = payload;
|
||||
assert.isString(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const validateTestPayload = [
|
||||
body('type').exists().isIn(['osc', 'http']),
|
||||
body('type').exists().isIn(['osc', 'http', 'ontime']),
|
||||
|
||||
// validation for OSC message
|
||||
oneOf([
|
||||
@@ -182,9 +155,143 @@ export const validateTestPayload = [
|
||||
// validation for HTTP message
|
||||
body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(),
|
||||
|
||||
// validation for Ontime actions
|
||||
body('action').if(body('type').equals('ontime')).isString().trim(),
|
||||
body('text').if(body('type').equals('ontime')).optional().isString().trim(),
|
||||
body('time').if(body('type').equals('ontime')).optional().isString().trim(),
|
||||
body('visible').if(body('type').equals('ontime')).optional().isString().trim(),
|
||||
body('secondarySource').if(body('type').equals('ontime')).optional().isString().trim(),
|
||||
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
next();
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitises an output object
|
||||
* @Throws if the output is invalid
|
||||
*/
|
||||
export function parseOutput(maybeOutput: unknown): AutomationOutput {
|
||||
assert.isObject(maybeOutput);
|
||||
assert.hasKeys(maybeOutput, ['type']);
|
||||
|
||||
const { type } = maybeOutput;
|
||||
assert.isString(type);
|
||||
|
||||
if (type === 'osc') {
|
||||
return parseOSCOutput(maybeOutput);
|
||||
} else if (type === 'http') {
|
||||
return parseHTTPOutput(maybeOutput);
|
||||
} else if (type === 'ontime') {
|
||||
return parseOntimeAction(maybeOutput);
|
||||
} else {
|
||||
throw new Error('Invalid automation output');
|
||||
}
|
||||
}
|
||||
|
||||
function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
|
||||
assert.hasKeys(maybeOSCOutput, ['targetIP', 'targetPort', 'address', 'args']);
|
||||
assert.isString(maybeOSCOutput.targetIP);
|
||||
assert.isNumber(maybeOSCOutput.targetPort);
|
||||
assert.isString(maybeOSCOutput.address);
|
||||
assert.isString(maybeOSCOutput.args);
|
||||
|
||||
return {
|
||||
type: 'osc',
|
||||
targetIP: maybeOSCOutput.targetIP,
|
||||
targetPort: maybeOSCOutput.targetPort,
|
||||
address: maybeOSCOutput.address,
|
||||
args: maybeOSCOutput.args,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
|
||||
assert.hasKeys(maybeHTTPOutput, ['url']);
|
||||
assert.isString(maybeHTTPOutput.url);
|
||||
|
||||
return {
|
||||
type: 'http',
|
||||
url: maybeHTTPOutput.url,
|
||||
};
|
||||
}
|
||||
|
||||
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||
assert.hasKeys(maybeOntimeAction, ['action']);
|
||||
assert.isString(maybeOntimeAction.action);
|
||||
|
||||
if (!isOntimeActionAction(maybeOntimeAction.action)) {
|
||||
throw new Error('Invalid Ontime action');
|
||||
}
|
||||
|
||||
// we know we have a valid action, deal with special cases
|
||||
|
||||
if (maybeOntimeAction.action === 'aux-set') {
|
||||
assert.hasKeys(maybeOntimeAction, ['time']);
|
||||
assert.isString(maybeOntimeAction.time);
|
||||
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: 'aux-set',
|
||||
time: parseUserTime(maybeOntimeAction.time),
|
||||
};
|
||||
}
|
||||
|
||||
if (maybeOntimeAction.action === 'message-set') {
|
||||
assert.hasKeys(maybeOntimeAction, ['text', 'visible']);
|
||||
assert.isString(maybeOntimeAction.text);
|
||||
assert.isString(maybeOntimeAction.visible);
|
||||
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: 'message-set',
|
||||
text: indeterminateText(maybeOntimeAction.text),
|
||||
visible: indeterminateBooleanString(maybeOntimeAction.visible),
|
||||
};
|
||||
}
|
||||
|
||||
if (maybeOntimeAction.action === 'message-secondary') {
|
||||
assert.hasKeys(maybeOntimeAction, ['secondarySource']);
|
||||
assert.isString(maybeOntimeAction.secondarySource);
|
||||
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: 'message-secondary',
|
||||
secondarySource: chooseSecondarySource(maybeOntimeAction.secondarySource),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'ontime',
|
||||
action: maybeOntimeAction.action,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to parse a text which may be indeterminate
|
||||
* "some text" -> string
|
||||
* "" -> undefined
|
||||
*/
|
||||
function indeterminateText(value: string): string | undefined {
|
||||
return value === '' ? undefined : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to parse boolean values in transit
|
||||
* "true" -> true
|
||||
* "false" -> false
|
||||
* "" | "null" -> undefined
|
||||
*/
|
||||
function indeterminateBooleanString(value: string): boolean | undefined {
|
||||
return value === '' ? undefined : value === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to validate the secondary source
|
||||
*/
|
||||
function chooseSecondarySource(value: string): SecondarySource {
|
||||
if (value === 'aux') return 'aux';
|
||||
if (value === 'external') return 'external';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { LogOrigin, OntimeAction } from 'ontime-types';
|
||||
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerService.js';
|
||||
import * as messageService from '../../../services/message-service/MessageService.js';
|
||||
|
||||
export function toOntimeAction(action: OntimeAction) {
|
||||
switch (action.action) {
|
||||
// Aux timer actions
|
||||
case 'aux-start':
|
||||
auxTimerService.start();
|
||||
break;
|
||||
case 'aux-stop':
|
||||
auxTimerService.stop();
|
||||
break;
|
||||
case 'aux-pause':
|
||||
auxTimerService.pause();
|
||||
break;
|
||||
case 'aux-set': {
|
||||
auxTimerService.setTime(action.time);
|
||||
break;
|
||||
}
|
||||
|
||||
// Message actions
|
||||
case 'message-set': {
|
||||
messageService.patch({
|
||||
timer: {
|
||||
text: action.text,
|
||||
visible: action.visible,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'message-secondary': {
|
||||
messageService.patch({
|
||||
timer: {
|
||||
secondarySource: action.secondarySource,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
// @ts-expect-error -- this guard checks that we handled all the cases, but we still want to log just in case
|
||||
logger.warning(LogOrigin.Tx, `Unknown action type: ${action.type}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SecondarySource } from '../runtime/MessageControl.type.js';
|
||||
import type { TimerLifeCycle } from './TimerLifecycle.type.js';
|
||||
|
||||
export type AutomationSettings = {
|
||||
@@ -38,7 +39,7 @@ export type AutomationFilter = {
|
||||
value: string; // we use string but would coerce to the field value
|
||||
};
|
||||
|
||||
export type AutomationOutput = OSCOutput | HTTPOutput;
|
||||
export type AutomationOutput = OSCOutput | HTTPOutput | OntimeAction;
|
||||
|
||||
export type OSCOutput = {
|
||||
type: 'osc';
|
||||
@@ -52,3 +53,25 @@ export type HTTPOutput = {
|
||||
type: 'http';
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type OntimeAction =
|
||||
| {
|
||||
type: 'ontime';
|
||||
action: 'aux-start' | 'aux-stop' | 'aux-pause';
|
||||
}
|
||||
| {
|
||||
type: 'ontime';
|
||||
action: 'aux-set';
|
||||
time: number;
|
||||
}
|
||||
| {
|
||||
type: 'ontime';
|
||||
action: 'message-set';
|
||||
text?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'ontime';
|
||||
action: 'message-secondary';
|
||||
secondarySource: SecondarySource;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export type SecondarySource = 'aux' | 'external' | null;
|
||||
|
||||
export type TimerMessage = {
|
||||
text: string;
|
||||
visible: boolean;
|
||||
blink: boolean;
|
||||
blackout: boolean;
|
||||
secondarySource: 'aux' | 'external' | null;
|
||||
secondarySource: SecondarySource;
|
||||
};
|
||||
|
||||
export type MessageState = {
|
||||
|
||||
@@ -26,6 +26,7 @@ export type {
|
||||
FilterRule,
|
||||
HTTPOutput,
|
||||
NormalisedAutomation,
|
||||
OntimeAction,
|
||||
OSCOutput,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
@@ -80,7 +81,7 @@ export type {
|
||||
export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js';
|
||||
export { Playback } from './definitions/runtime/Playback.type.js';
|
||||
export { TimerLifeCycle, timerLifecycleValues } from './definitions/core/TimerLifecycle.type.js';
|
||||
export type { TimerMessage, MessageState } from './definitions/runtime/MessageControl.type.js';
|
||||
export type { TimerMessage, MessageState, SecondarySource } from './definitions/runtime/MessageControl.type.js';
|
||||
|
||||
export type { Runtime } from './definitions/runtime/Runtime.type.js';
|
||||
export type { RuntimeStore } from './definitions/runtime/RuntimeStore.type.js';
|
||||
@@ -104,5 +105,6 @@ export {
|
||||
isKeyOfType,
|
||||
isOSCOutput,
|
||||
isHTTPOutput,
|
||||
isOntimeAction,
|
||||
} from './utils/guards.js';
|
||||
export type { MaybeNumber, MaybeString } from './utils/utils.type.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AutomationOutput, HTTPOutput, OSCOutput } from '../definitions/core/Automation.type.js';
|
||||
import type { AutomationOutput, HTTPOutput, OntimeAction, OSCOutput } from '../definitions/core/Automation.type.js';
|
||||
import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js';
|
||||
import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js';
|
||||
import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js';
|
||||
@@ -41,3 +41,7 @@ export function isOSCOutput(output: AutomationOutput): output is OSCOutput {
|
||||
export function isHTTPOutput(output: AutomationOutput): output is HTTPOutput {
|
||||
return output.type === 'http';
|
||||
}
|
||||
|
||||
export function isOntimeAction(output: AutomationOutput): output is OntimeAction {
|
||||
return output.type === 'ontime';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user