feat: implement multiple aux timers

This commit is contained in:
Carlos Valente
2025-06-24 07:20:53 +02:00
committed by Carlos Valente
parent d1c58712ae
commit bf8ed8d017
33 changed files with 748 additions and 297 deletions
@@ -149,10 +149,10 @@ describe('parseOutput', () => {
parseOutput({
type: 'ontime',
action: 'message-secondary',
secondarySource: 'aux',
secondarySource: 'aux1',
}),
).toMatchObject({
secondarySource: 'aux',
secondarySource: 'aux1',
});
expect(
parseOutput({
@@ -189,13 +189,17 @@ function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
// we know we have a valid action, deal with special cases
if (maybeOntimeAction.action === 'aux-set') {
if (
maybeOntimeAction.action === 'aux1-set' ||
maybeOntimeAction.action === 'aux2-set' ||
maybeOntimeAction.action === 'aux3-set'
) {
assert.hasKeys(maybeOntimeAction, ['time']);
assert.isString(maybeOntimeAction.time);
return {
type: 'ontime',
action: 'aux-set',
action: maybeOntimeAction.action,
time: parseUserTime(maybeOntimeAction.time),
};
}
@@ -253,7 +257,9 @@ function indeterminateBooleanString(value: string): boolean | undefined {
* Helper function to validate the secondary source
*/
function chooseSecondarySource(value: string): SecondarySource {
if (value === 'aux') return 'aux';
if (value === 'aux1') return 'aux1';
if (value === 'aux2') return 'aux2';
if (value === 'aux3') return 'aux3';
if (value === 'secondary') return 'secondary';
return null;
}
@@ -8,18 +8,32 @@ export function toOntimeAction(action: OntimeAction) {
const actionType = action.action;
switch (actionType) {
// 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;
case 'aux1-start':
return auxTimerService.start(1);
case 'aux1-stop':
return auxTimerService.stop(1);
case 'aux1-pause':
return auxTimerService.pause(1);
case 'aux1-set': {
return auxTimerService.setTime(action.time, 1);
}
case 'aux2-start':
return auxTimerService.start(2);
case 'aux2-stop':
return auxTimerService.stop(2);
case 'aux2-pause':
return auxTimerService.pause(2);
case 'aux2-set': {
return auxTimerService.setTime(action.time, 2);
}
case 'aux3-start':
return auxTimerService.start(3);
case 'aux3-stop':
return auxTimerService.stop(3);
case 'aux3-pause':
return auxTimerService.pause(3);
case 'aux3-set': {
return auxTimerService.setTime(action.time, 3);
}
// Message actions
@@ -18,7 +18,6 @@ import { validateMessage, validateTimerMessage } from '../services/message-servi
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
import { eventStore } from '../stores/EventStore.js';
import * as assert from '../utils/assert.js';
import { isEmptyObject } from '../utils/parserUtils.js';
import { parseProperty } from './integration.utils.js';
import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js';
@@ -218,45 +217,61 @@ const actionHandlers: Record<ApiAction, ActionHandler> = {
runtimeService.addTime(time);
return { payload: 'success' };
},
/* Extra timers */
/**
* Auxiliary timers, payload can be either:
*
* 1. a simple playback command
* {
* "1": "start" | "pause" | "stop"
* }
*
* - or -
*
* 2. a patch object with properties
* {
* "1": {
* duration: "count-down"
* }
* }
*
*/
auxtimer: (payload) => {
assert.isObject(payload);
if (!('1' in payload)) {
const timerIndex = Object.keys(payload).at(0);
if (timerIndex !== '1' && timerIndex !== '2' && timerIndex !== '3') {
throw new Error('Invalid auxtimer index');
}
const command = payload['1'];
const command = payload[timerIndex as keyof typeof payload] as unknown;
const index = Number(timerIndex);
// 1. handle simple playback commands: start, pause, stop
if (typeof command === 'string') {
if (command === SimplePlayback.Start) {
const reply = auxTimerService.start();
return { payload: reply };
switch (command) {
case SimplePlayback.Start:
return { payload: auxTimerService.start(index) };
case SimplePlayback.Pause:
return { payload: auxTimerService.pause(index) };
case SimplePlayback.Stop:
return { payload: auxTimerService.stop(index) };
default:
throw new Error('Invalid command');
}
if (command === SimplePlayback.Pause) {
const reply = auxTimerService.pause();
return { payload: reply };
}
if (command === SimplePlayback.Stop) {
const reply = auxTimerService.stop();
return { payload: reply };
}
} else if (command && typeof command === 'object') {
const reply = { payload: {} };
}
// 2. command can be a patch object: duration, addtime, direction
if (command && typeof command === 'object') {
if ('duration' in command) {
const timeInMs = numberOrError(command.duration);
reply.payload = auxTimerService.setTime(timeInMs);
return { payload: auxTimerService.setTime(numberOrError(command.duration), index) };
}
if ('addtime' in command) {
const timeInMs = numberOrError(command.addtime);
reply.payload = auxTimerService.addTime(timeInMs);
return { payload: auxTimerService.addTime(numberOrError(command.addtime), index) };
}
if ('direction' in command) {
if (command.direction === SimpleDirection.CountUp || command.direction === SimpleDirection.CountDown) {
reply.payload = auxTimerService.setDirection(command.direction);
} else {
throw new Error('Invalid direction payload');
return { payload: auxTimerService.setDirection(command.direction, index) };
}
}
if (!isEmptyObject(reply.payload)) {
return reply;
throw new Error('Invalid direction payload');
}
}
throw new Error('No matching method provided');
+12
View File
@@ -193,6 +193,18 @@ export const startServer = async (): Promise<{ message: string; serverPort: numb
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
},
auxtimer2: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
},
auxtimer3: {
duration: timerConfig.auxTimerDefault,
current: timerConfig.auxTimerDefault,
playback: SimplePlayback.Stop,
direction: SimpleDirection.CountDown,
},
ping: -1,
});
@@ -1,91 +1,165 @@
import { SimpleDirection, SimplePlayback, SimpleTimerState } from 'ontime-types';
import { RuntimeStore, SimpleDirection, SimplePlayback } from 'ontime-types';
import { SimpleTimer } from '../../classes/simple-timer/SimpleTimer.js';
import { eventStore } from '../../stores/EventStore.js';
import { timerConfig } from '../../setup/config.js';
type EmitFn = (state: SimpleTimerState) => void;
type AuxTimerStateUpdate = Partial<Pick<RuntimeStore, 'auxtimer1' | 'auxtimer2' | 'auxtimer3'>>;
type EmitFn = (state: AuxTimerStateUpdate) => void;
type GetTimeFn = () => number;
export class AuxTimerService {
private timer: SimpleTimer;
private aux1: SimpleTimer;
private aux2: SimpleTimer;
private aux3: SimpleTimer;
private interval: NodeJS.Timeout | null = null;
private emit: EmitFn;
protected emit: EmitFn;
private getTime: GetTimeFn;
constructor(emit: EmitFn, getTime: GetTimeFn) {
this.timer = new SimpleTimer(timerConfig.auxTimerDefault);
this.aux1 = new SimpleTimer(timerConfig.auxTimerDefault);
this.aux2 = new SimpleTimer(timerConfig.auxTimerDefault);
this.aux3 = new SimpleTimer(timerConfig.auxTimerDefault);
this.emit = emit;
this.getTime = getTime;
}
/**
* Whether any of the aux timers are currently running
*/
private hasActiveTimers(): boolean {
return (
this.aux1.state.playback === SimplePlayback.Start ||
this.aux2.state.playback === SimplePlayback.Start ||
this.aux3.state.playback === SimplePlayback.Start
);
}
private startInterval() {
this.interval = setInterval(this.update.bind(this), 500);
if (!this.interval) {
this.interval = setInterval(this.update.bind(this), 500);
}
}
/**
* Utility simplifies guarding against multiple intervals being set
*/
private stopInterval() {
if (this.interval) {
if (this.interval && !this.hasActiveTimers()) {
clearInterval(this.interval);
this.interval = null;
}
}
@broadcastReturn
setDirection(direction: SimpleDirection) {
return this.timer.setDirection(direction, this.getTime());
setDirection(direction: SimpleDirection, index: number) {
if (index === 1) return this.aux1.setDirection(direction, this.getTime());
if (index === 2) return this.aux2.setDirection(direction, this.getTime());
return this.aux3.setDirection(direction, this.getTime());
}
@broadcastReturn
start() {
start(index: number) {
this.startInterval();
return this.timer.start(this.getTime());
if (index === 1) return this.aux1.start(this.getTime());
if (index === 2) return this.aux2.start(this.getTime());
return this.aux3.start(this.getTime());
}
@broadcastReturn
pause() {
this.stopInterval();
return this.timer.pause(this.getTime());
}
pause(index: number) {
// First pause the timer
let result;
if (index === 1) result = this.aux1.pause(this.getTime());
else if (index === 2) result = this.aux2.pause(this.getTime());
else result = this.aux3.pause(this.getTime());
@broadcastReturn
stop() {
this.stopInterval();
return this.timer.stop();
}
@broadcastReturn
setTime(duration: number) {
return this.timer.setTime(duration);
}
@broadcastReturn
addTime(millis: number) {
if (this.timer.state.playback === SimplePlayback.Start) {
this.timer.addTime(millis);
return this.timer.update(this.getTime());
// Then check if we need to keep the interval running
if (!this.hasActiveTimers()) {
this.stopInterval();
}
return this.timer.addTime(millis);
return result;
}
@broadcastReturn
stop(index: number) {
// First stop the timer
let result;
if (index === 1) result = this.aux1.stop();
else if (index === 2) result = this.aux2.stop();
else result = this.aux3.stop();
// Then check if we need to keep the interval running
if (!this.hasActiveTimers()) {
this.stopInterval();
}
return result;
}
@broadcastReturn
setTime(duration: number, index: number) {
if (index === 1) return this.aux1.setTime(duration);
if (index === 2) return this.aux2.setTime(duration);
return this.aux3.setTime(duration);
}
@broadcastReturn
addTime(millis: number, index: number) {
const aux = index === 1 ? this.aux1 : index === 2 ? this.aux2 : this.aux3;
if (aux.state.playback === SimplePlayback.Start) {
aux.addTime(millis);
return aux.update(this.getTime());
}
return aux.addTime(millis);
}
private update() {
return this.timer.update(this.getTime());
/**
* The update function affects any running timers,
* so we decide to emit a patch object rather
* than using the decorator which would emit individual updates.
*/
const patch: AuxTimerStateUpdate = {};
const timeNow = this.getTime();
if (this.aux1.state.playback === SimplePlayback.Start) {
patch.auxtimer1 = this.aux1.update(timeNow);
}
if (this.aux2.state.playback === SimplePlayback.Start) {
patch.auxtimer2 = this.aux2.update(timeNow);
}
if (this.aux3.state.playback === SimplePlayback.Start) {
patch.auxtimer3 = this.aux3.update(timeNow);
}
if (Object.keys(patch).length > 0) {
this.emit(patch);
}
}
}
function broadcastReturn(_target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
function broadcastReturn(_target: object, _propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
descriptor.value = function (this: AuxTimerService, ...args: unknown[]) {
const result = originalMethod.apply(this, args);
// @ts-expect-error -- we can access private properties from the decorator
(this as AuxTimerService).emit(result);
const index = args[args.length - 1] as number;
this.emit({ [`auxtimer${index}`]: result });
return result;
};
return descriptor;
}
const emit = (state: SimpleTimerState) => eventStore.set('auxtimer1', state);
const emit = (state: AuxTimerStateUpdate) => {
for (const [key, value] of Object.entries(state)) {
eventStore.set(key as keyof RuntimeStore, value);
}
};
const timeNow = () => Date.now();
export const auxTimerService = new AuxTimerService(emit, timeNow);
@@ -33,7 +33,7 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
* Asserts that the secondary value is one of the permitted values
*/
function assertSecondary(source: unknown): source is TimerMessage['secondarySource'] {
return source === 'aux' || source === 'secondary' || source === null;
return source === 'aux1' || source === 'aux2' || source === 'aux3' || source === 'secondary' || source === null;
}
/**