feat: timer preview

This commit is contained in:
Carlos Valente
2024-09-03 21:30:35 +02:00
committed by Carlos Valente
parent fa709dc9be
commit 4d8bea2940
27 changed files with 581 additions and 288 deletions
@@ -0,0 +1,36 @@
import { handleLegacyMessageConversion } from '../integration.legacy.js';
describe('handleLegacyConversion', () => {
it('should return the payload as is if it is not a legacy message', () => {
expect(handleLegacyMessageConversion({})).toEqual({});
const newPayload = {
timer: {
text: 'text',
visible: true,
blink: true,
blackout: true,
},
external: 'text',
};
expect(handleLegacyMessageConversion(newPayload)).toEqual(newPayload);
});
it('should convert a legacy payload with external message', () => {
expect(handleLegacyMessageConversion({ external: { text: 'text', visible: true } })).toEqual({
external: 'text',
timer: {
secondarySource: 'external',
},
});
expect(handleLegacyMessageConversion({ external: { visible: true } })).toEqual({
timer: {
secondarySource: 'external',
},
});
expect(handleLegacyMessageConversion({ external: { text: 'text' } })).toEqual({
external: 'text',
});
});
});
@@ -1,9 +1,11 @@
import { DeepPartial, MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
import { MessageState, OntimeEvent, SimpleDirection, SimplePlayback } from 'ontime-types';
import { MILLIS_PER_HOUR, MILLIS_PER_SECOND } from 'ontime-utils';
import { DeepPartial } from 'ts-essentials';
import { ONTIME_VERSION } from '../ONTIME_VERSION.js';
import { auxTimerService } from '../services/aux-timer-service/AuxTimerService.js';
import { messageService } from '../services/message-service/MessageService.js';
import * as messageService from '../services/message-service/MessageService.js';
import { validateMessage, validateTimerMessage } from '../services/message-service/messageUtils.js';
import { runtimeService } from '../services/runtime-service/RuntimeService.js';
import { eventStore } from '../stores/EventStore.js';
@@ -14,6 +16,8 @@ import { socket } from '../adapters/WebsocketAdapter.js';
import { throttle } from '../utils/throttle.js';
import { willCauseRegeneration } from '../services/rundown-service/rundownCacheUtils.js';
import { handleLegacyMessageConversion } from './integration.legacy.js';
const throttledUpdateEvent = throttle(updateEvent, 20);
export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') {
@@ -78,9 +82,12 @@ const actionHandlers: Record<string, ActionHandler> = {
message: (payload) => {
assert.isObject(payload);
// TODO: remove this once we feel its been enough time, ontime 3.6.0, 20/09/2024
const migratedPayload = handleLegacyMessageConversion(payload);
const patch: DeepPartial<MessageState> = {
timer: 'timer' in payload ? validateTimerMessage(payload.timer) : undefined,
external: 'external' in payload ? validateMessage(payload.external) : undefined,
timer: 'timer' in migratedPayload ? validateTimerMessage(migratedPayload.timer) : undefined,
external: 'external' in migratedPayload ? validateMessage(migratedPayload.external) : undefined,
};
const newMessage = messageService.patch(patch);
@@ -0,0 +1,67 @@
import { MessageState } from 'ontime-types';
import { DeepPartial } from 'ts-essentials';
export type LegacyMessageState = DeepPartial<{
timer: {
text: string;
visible: boolean;
blink: boolean;
blackout: boolean;
};
external: {
text: string;
visible: boolean;
};
}>;
function isLegacyMessageState(value: object): value is LegacyMessageState {
// @ts-expect-error -- good enough here
return value?.external?.text !== undefined || value?.external?.visible !== undefined;
}
/**
* This function is used to maintain support for legacy data in the /message endpoint
* The previous message endpoint expected a patch of the message state
* @example {
* timer: { blink: boolean, blackout: boolean, text: string, visible: boolean },
* external: { visible: boolean, text: string }
* }
*
* This change is introduced in version 3.6.0
*/
export function handleLegacyMessageConversion(payload: object): object | Partial<MessageState> {
// if it is not a legacy message, we pass it as is
if (!isLegacyMessageState(payload)) {
return payload;
}
/**
* The current migration only needs to handle the cases
* for the deprecated external message controls
*/
// Migrate external message
// 2.1 the user gives us the text and a visible flag
if (payload?.external?.text !== undefined && payload.external.visible !== undefined) {
return {
timer: { secondarySource: payload.external.visible ? 'external' : null },
external: payload.external.text,
} as Partial<MessageState>;
}
// 2.2 the user gives us the text
else if (payload?.external?.text !== undefined) {
return {
external: payload.external.text,
} as Partial<MessageState>;
}
// 2.3 the user gives us the visible flag
else if (payload?.external?.visible !== undefined) {
return {
timer: { secondarySource: payload.external.visible ? 'external' : null },
} as Partial<MessageState>;
}
// there should be no case for us to reach this since
// the type guard would have ensured one of the above states
return payload;
}
+3 -3
View File
@@ -38,7 +38,7 @@ import { populateStyles } from './setup/loadStyles.js';
import { eventStore } from './stores/EventStore.js';
import { runtimeService } from './services/runtime-service/RuntimeService.js';
import { restoreService } from './services/RestoreService.js';
import { messageService } from './services/message-service/MessageService.js';
import * as messageService from './services/message-service/MessageService.js';
import { populateDemo } from './setup/loadDemo.js';
import { getState } from './stores/runtimeState.js';
import { initRundown } from './services/rundown-service/RundownService.js';
@@ -198,7 +198,7 @@ export const startServer = async (
// initialise logging service, escalateErrorFn is only exists in electron
logger.init(escalateErrorFn);
// initialise rundown service
// initialise rundown service
const persistedRundown = getDataProvider().getRundown();
const persistedCustomFields = getDataProvider().getCustomFields();
initRundown(persistedRundown, persistedCustomFields);
@@ -210,7 +210,7 @@ export const startServer = async (
runtimeService.init(maybeRestorePoint);
// eventStore set is a dependency of the services that publish to it
messageService.init(eventStore.set.bind(eventStore));
messageService.init(eventStore.set);
expressServer.listen(serverPort, '0.0.0.0', () => {
const nif = getNetworkInterfaces();
@@ -1,68 +1,63 @@
import { DeepPartial, Message, TimerMessage, MessageState } from 'ontime-types';
import { TimerMessage, MessageState } from 'ontime-types';
import { DeepPartial } from 'ts-essentials';
import { throttle } from '../../utils/throttle.js';
import type { PublishFn } from '../../stores/EventStore.js';
let instance: MessageService | null = null;
const defaultTimer: TimerMessage = {
text: '',
visible: false,
blink: false,
blackout: false,
secondarySource: null,
};
class MessageService {
timer: TimerMessage;
external: Message;
let timer = { ...defaultTimer };
let external = '';
private throttledSet: PublishFn;
private publish: PublishFn | null;
let throttledSet: PublishFn | null = null;
constructor() {
if (instance) {
throw new Error('There can be only one');
}
// eslint-disable-next-line @typescript-eslint/no-this-alias -- this logic is used to ensure singleton
instance = this;
this.throttledSet = () => {
throw new Error('Published called before initialisation');
};
this.clear();
}
clear() {
this.timer = {
text: '',
visible: false,
blink: false,
blackout: false,
};
this.external = {
text: '',
visible: false,
};
}
init(publish: PublishFn) {
this.publish = publish;
this.throttledSet = throttle((key, value) => this.publish?.(key, value), 100);
}
getState(): MessageState {
return {
timer: this.timer,
external: this.external,
};
}
patch(message: DeepPartial<MessageState>) {
if (message.timer) this.timer = { ...this.timer, ...message.timer };
if (message.external) this.external = { ...this.external, ...message.external };
const newState = this.getState();
this.throttledSet('message', newState);
return newState;
}
/**
* Initialises the message service with a publish function
* @param publishFn
*/
export function init(publishFn: PublishFn) {
throttledSet = throttle(publishFn, 100);
}
export const messageService = new MessageService();
/**
* Exposes function to reset the internal state
*/
export function clear() {
timer = { ...defaultTimer };
external = '';
}
/**
* Exposes the internal state of the message service
*/
export function getState(): MessageState {
return {
external,
timer,
};
}
/**
* Utility function allows patching internal object
*/
export function patch(patch: DeepPartial<MessageState>): MessageState {
// we cannot call patch before init
// eslint-disable-next-line no-unused-labels -- dev code path
DEV: {
if (throttledSet === null) {
throw new Error('MessageService.patch() called before init()');
}
}
if ('timer' in patch) timer = { ...timer, ...patch.timer };
if ('external' in patch && patch.external !== undefined) external = patch.external;
const newState = getState();
throttledSet?.('message', newState);
return newState;
}
@@ -1,4 +1,4 @@
import { messageService } from '../MessageService.js';
import * as messageService from '../MessageService.js';
describe('MessageService', () => {
const publishFunction = () => {};
@@ -14,17 +14,14 @@ describe('MessageService', () => {
it('should patch the message state', () => {
const message = {
timer: { text: 'new text', visible: true },
external: { visible: true },
external: 'external',
};
const newState = messageService.patch(message);
expect(newState).toEqual({
timer: { text: 'new text', visible: true, blackout: false, blink: false },
external: {
text: '',
visible: true,
},
timer: { text: 'new text', visible: true, blackout: false, blink: false, secondarySource: null },
external: 'external',
});
});
@@ -36,11 +33,8 @@ describe('MessageService', () => {
const newState = messageService.patch(initialMessage);
expect(newState).toEqual({
timer: { text: 'initial text', visible: true, blackout: false, blink: false },
external: {
text: '',
visible: false,
},
timer: { text: 'initial text', visible: true, blackout: false, blink: false, secondarySource: null },
external: '',
});
});
});
@@ -2,26 +2,7 @@ import { validateMessage, validateTimerMessage } from '../messageUtils.js';
describe('validateMessage()', () => {
it('returns a valid Message object', () => {
const payload = {
text: '12312',
visible: 'true',
};
const expected = {
text: '12312',
visible: true,
};
expect(validateMessage(payload)).toEqual(expected);
});
it('skips keys not given', () => {
const payload = {
visible: 'true',
};
const expected = {
visible: true,
};
expect(validateMessage(payload)).toStrictEqual(expected);
expect(validateMessage('test')).toEqual('test');
});
});
@@ -1,4 +1,4 @@
import { Message, TimerMessage } from 'ontime-types';
import { TimerMessage } from 'ontime-types';
import * as assert from '../../utils/assert.js';
import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
@@ -7,14 +7,8 @@ import { coerceBoolean, coerceString } from '../../utils/coerceType.js';
* Creates a valid Message object from a payload
* @throws if the payload is not an object
*/
export function validateMessage(message: unknown): Partial<Message> {
assert.isObject(message);
const result: Partial<Message> = {};
if ('text' in message) result.text = coerceString(message.text);
if ('visible' in message) result.visible = coerceBoolean(message.visible);
return result;
export function validateMessage(message: unknown): string {
return decodeURI(coerceString(message));
}
/**
@@ -26,10 +20,28 @@ export function validateTimerMessage(message: unknown): Partial<TimerMessage> {
const result: Partial<TimerMessage> = {};
if ('text' in message) result.text = coerceString(message.text);
if ('text' in message) result.text = decodeURI(coerceString(message.text));
if ('visible' in message) result.visible = coerceBoolean(message.visible);
if ('blink' in message) result.blink = coerceBoolean(message.blink);
if ('blackout' in message) result.blackout = coerceBoolean(message.blackout);
if ('secondarySource' in message) result.secondarySource = coerceSecondary(message.secondarySource);
return result;
}
/**
* Asserts that the secondary value is one of the permitted values
*/
function assertSecondary(source: unknown): source is TimerMessage['secondarySource'] {
return source === 'aux' || source === 'external' || source === null;
}
/**
* Ensures that the secondary value is one of the permitted values
*/
function coerceSecondary(source: unknown): TimerMessage['secondarySource'] {
if (!assertSecondary(source)) {
return null;
}
return source;
}