mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-10 01:43:43 +00:00
V2 osc (#313)
* chore: upgrade relevant deps * fix: electron app to tray * chore: cleanup dictionary * feat: handle several messages in a event * ux: disable irrelevant buttons in browser * feat: parse and validate subscriptions * feat: create UI for OSC Integration * fix: cleanup logger behaviour * feat: allow OSC settings to be changed at runtime
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
"version": "2.0.0-alpha",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.4.5",
|
||||
"@chakra-ui/react": "^2.5.1",
|
||||
"@dnd-kit/core": "^6.0.6",
|
||||
"@dnd-kit/sortable": "^7.0.1",
|
||||
"@dnd-kit/utilities": "^3.2.1",
|
||||
@@ -12,8 +12,8 @@
|
||||
"@react-icons/all-files": "^4.1.0",
|
||||
"@sentry/react": "^7.28.1",
|
||||
"@sentry/tracing": "^7.24.1",
|
||||
"@tanstack/react-query": "^4.18.0",
|
||||
"@tanstack/react-query-devtools": "^4.18.0",
|
||||
"@tanstack/react-query": "^4.26.1",
|
||||
"@tanstack/react-query-devtools": "^4.26.1",
|
||||
"autosize": "^5.0.2",
|
||||
"axios": "^1.2.0",
|
||||
"color": "^4.2.3",
|
||||
@@ -26,7 +26,7 @@
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-fast-compare": "^3.2.0",
|
||||
"react-hook-form": "^7.43.1",
|
||||
"react-hook-form": "^7.43.5",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-table": "^7.7.0",
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sentry/vite-plugin": "^0.3.0",
|
||||
"@tanstack/eslint-plugin-query": "^4.15.1",
|
||||
"@tanstack/eslint-plugin-query": "^4.26.2",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.1.1",
|
||||
"@testing-library/user-event": "^14.1.1",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { OSC_SETTINGS } from '../api/apiConstants';
|
||||
import { getOSC } from '../api/ontimeApi';
|
||||
import { getOSC, postOSC } from '../api/ontimeApi';
|
||||
import { oscPlaceholderSettings } from '../models/OscSettings';
|
||||
import { ontimeQueryClient } from '../queryClient';
|
||||
|
||||
export default function useOscSettings() {
|
||||
const { data, status, isError, refetch } = useQuery({
|
||||
@@ -11,10 +13,19 @@ export default function useOscSettings() {
|
||||
queryFn: getOSC,
|
||||
placeholderData: oscPlaceholderSettings,
|
||||
retry: 5,
|
||||
retryDelay: (attempt) => attempt * 2500,
|
||||
retryDelay: (attempt: number) => attempt * 2500,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
networkMode: 'always',
|
||||
});
|
||||
|
||||
return { data, status, isError, refetch };
|
||||
return { data: data! as unknown as OSCSettings, status, isError, refetch };
|
||||
}
|
||||
|
||||
export function useOscSettingsMutation() {
|
||||
const { isLoading, mutateAsync } = useMutation({
|
||||
mutationFn: postOSC,
|
||||
onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data),
|
||||
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }),
|
||||
});
|
||||
return { isLoading, mutateAsync };
|
||||
}
|
||||
|
||||
@@ -12,5 +12,12 @@ export const oscPlaceholderSettings: PlaceholderSettings = {
|
||||
targetIP: '',
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {},
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,7 +40,6 @@ export function useEmitLog() {
|
||||
text,
|
||||
};
|
||||
|
||||
addLog(log);
|
||||
socketSendJson('ontime-log', log);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ export default function MenuBar(props: MenuBarProps) {
|
||||
clickHandler={() => actionHandler('max')}
|
||||
tooltip='Show full window'
|
||||
aria-label='Show full window'
|
||||
isDisabled={!isElectron}
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
@@ -126,6 +127,7 @@ export default function MenuBar(props: MenuBarProps) {
|
||||
clickHandler={() => actionHandler('min')}
|
||||
tooltip='Minimise to tray'
|
||||
aria-label='Minimise to tray'
|
||||
isDisabled={!isElectron}
|
||||
/>
|
||||
<div className={style.gap} />
|
||||
<TooltipActionBtn
|
||||
|
||||
@@ -4,13 +4,24 @@
|
||||
.headerNotes {
|
||||
font-size: $text-body-size;
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
padding: 0 $section-spacing;
|
||||
color: $modal-note-color;
|
||||
margin-bottom: $section-spacing;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
color: $blue-500;
|
||||
transition-property: color;
|
||||
transition-duration: $transition-time-action;
|
||||
|
||||
&:hover {
|
||||
color: $ontime-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 8px 0;
|
||||
margin: $element-spacing 0;
|
||||
border: 0;
|
||||
border-top: 1px solid $gray-100;
|
||||
}
|
||||
@@ -21,21 +32,29 @@
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.splitSection {
|
||||
@mixin sectionSpacing {
|
||||
border-radius: $component-border-radius-md;
|
||||
padding: $element-spacing;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 3px;
|
||||
padding: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: $blue-50;
|
||||
}
|
||||
}
|
||||
|
||||
.splitSection {
|
||||
@include sectionSpacing;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entryRow {
|
||||
@include sectionSpacing;
|
||||
gap: $element-inner-spacing;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
|
||||
&.main {
|
||||
@@ -44,7 +63,7 @@
|
||||
}
|
||||
|
||||
@mixin subsection {
|
||||
font-size: 14px;
|
||||
font-size: $inner-section-text-size;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -59,19 +78,23 @@
|
||||
}
|
||||
|
||||
.buttonSection {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
margin-top: $section-spacing;
|
||||
padding-top: $section-spacing;
|
||||
padding-left: -24px;
|
||||
border-top: 1px solid $gray-100;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.buttonSection {
|
||||
button:first-of-type {
|
||||
margin-right: auto;
|
||||
}
|
||||
gap: $section-spacing;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.shiftRight {
|
||||
align-self: flex-end;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.showPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Button, ModalFooter } from '@chakra-ui/react';
|
||||
|
||||
import styles from './Modal.module.scss';
|
||||
|
||||
export default function ModalSubmitFooter() {
|
||||
return (
|
||||
<ModalFooter className={styles.buttonSection}>
|
||||
<Button variant='ghosted' paddingLeft={0} color='#6c6c6c'>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button colorScheme='gray'>Cancel</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
type='submit'
|
||||
// disabled={isSubmitting}
|
||||
isLoading={false}
|
||||
padding='0 2.5em'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@chakra-ui/react';
|
||||
import styles from './Modal.module.scss';
|
||||
import { Modal, ModalCloseButton, ModalContent, ModalHeader, ModalOverlay } from '@chakra-ui/react';
|
||||
|
||||
interface ModalWrapperProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
|
||||
import ModalWrapper from '../ModalWrapper';
|
||||
|
||||
import OscIntegrationSettings from './OscIntegrationSettings';
|
||||
import OscSettingsModal from './OscSettingsModal';
|
||||
import OscIntegration from './OscIntegration';
|
||||
import OscSettings from './OscSettings';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
@@ -12,26 +12,30 @@ interface IntegrationModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const oscDocsUrl = 'https://cpvalente.gitbook.io/ontime/control-and-feedback/osc';
|
||||
|
||||
export default function IntegrationModal(props: IntegrationModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
return (
|
||||
<ModalWrapper title='Integration Settings' isOpen={isOpen} onClose={onClose}>
|
||||
<div className={styles.headerNotes}>
|
||||
Manage settings related to protocol integrations. <br />
|
||||
Changes take effect on app restart.
|
||||
Manage settings related to protocol integrations
|
||||
<a href={oscDocsUrl} target='_blank' rel='noreferrer'>
|
||||
Read the docs
|
||||
</a>
|
||||
</div>
|
||||
<Tabs variant='ontime' size='sm' isLazy>
|
||||
<TabList>
|
||||
<Tab>OSC</Tab>
|
||||
<Tab>Old OSC</Tab>
|
||||
<Tab>OSC Integration</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<OscIntegrationSettings />
|
||||
<OscSettings />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<OscSettingsModal />
|
||||
<OscIntegration />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, ModalFooter } from '@chakra-ui/react';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
interface OntimeModalFooterProps {
|
||||
formId: string;
|
||||
handleRevert: () => void;
|
||||
isDirty: boolean;
|
||||
isValid: boolean;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
export default function OntimeModalFooter(props: OntimeModalFooterProps) {
|
||||
const { formId, handleRevert, isDirty, isValid, isSubmitting } = props;
|
||||
|
||||
const disableRevert = !isDirty;
|
||||
const disableSubmit = isSubmitting || !isDirty || !isValid;
|
||||
|
||||
return (
|
||||
<ModalFooter className={styles.buttonSection} paddingInlineStart={0} paddingInlineEnd={0} paddingBottom={0}>
|
||||
<Button isDisabled={disableRevert} variant='ontime-ghost-on-light' size='sm' onClick={handleRevert}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
form={formId}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={disableSubmit}
|
||||
variant='ontime-filled'
|
||||
padding='0 2em'
|
||||
size='sm'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { ModalBody } from '@chakra-ui/react';
|
||||
import type { OSCSettings, OscSubscription } from 'ontime-types';
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
import { generateId } from 'ontime-utils';
|
||||
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
|
||||
import { oscPlaceholderSettings, PlaceholderSettings } from '../../../common/models/OscSettings';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
|
||||
import OntimeModalFooter from './OntimeModalFooter';
|
||||
import OscSubscriptionRow from './OscSubscriptionRow';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
type OntimeCycle = keyof typeof TimerLifeCycle;
|
||||
|
||||
const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = {
|
||||
onLoad: {
|
||||
title: 'On Load',
|
||||
subtitle: 'Triggers when a timer is loaded',
|
||||
},
|
||||
onStart: {
|
||||
title: 'On Start',
|
||||
subtitle: 'Triggers when a timer starts',
|
||||
},
|
||||
onPause: {
|
||||
title: 'On Pause',
|
||||
subtitle: 'Triggers when a running timer is paused',
|
||||
},
|
||||
onStop: {
|
||||
title: 'On Stop',
|
||||
subtitle: 'Triggers when a running timer is stopped',
|
||||
},
|
||||
onUpdate: {
|
||||
title: 'On Update',
|
||||
subtitle: 'Triggers when timers are updated (at least once a second, can be more)',
|
||||
},
|
||||
onFinish: {
|
||||
title: 'On Finish',
|
||||
subtitle: 'Triggers when a running reaches 0',
|
||||
},
|
||||
};
|
||||
|
||||
export default function OscIntegration() {
|
||||
const { data } = useOscSettings();
|
||||
const { mutateAsync } = useOscSettingsMutation();
|
||||
const { emitError } = useEmitLog();
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, isDirty, isValid },
|
||||
} = useForm<PlaceholderSettings>({
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
});
|
||||
|
||||
const resetForm = () => data?.subscriptions || oscPlaceholderSettings.subscriptions;
|
||||
const [subscriptionState, setSubscription] = useState<OscSubscription>(() => resetForm());
|
||||
|
||||
const [showSection, setShowSection] = useState<OntimeCycle>(TimerLifeCycle.onLoad);
|
||||
|
||||
const deleteSubscriptionEntry = (cycle: OntimeCycle, id: string) => {
|
||||
setSubscription((prev) => {
|
||||
const newData = { ...prev };
|
||||
newData[cycle] = [...prev[cycle].filter((el) => el.id !== id)];
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const addNewSubscriptionEntry = async (cycle: OntimeCycle) => {
|
||||
setSubscription((prev) => {
|
||||
const newData = { ...prev };
|
||||
newData[cycle] = [...prev[cycle], { id: generateId(), message: '', enabled: false }];
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (values: OSCSettings | PlaceholderSettings) => {
|
||||
try {
|
||||
// @ts-expect-error -- we know of the type mismatch, not pertinent here
|
||||
await mutateAsync(values);
|
||||
} catch (error) {
|
||||
emitError(`Error setting OSC: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const subscriptionKeys = Object.keys(subscriptionState);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSubscriptions'>
|
||||
<ModalBody>
|
||||
{subscriptionKeys.map((cycle, idx) => {
|
||||
return (
|
||||
<>
|
||||
<OscSubscriptionRow
|
||||
key={cycle}
|
||||
cycle={cycle as TimerLifeCycle}
|
||||
title={sectionText[cycle as TimerLifeCycle].title}
|
||||
subtitle={sectionText[cycle as TimerLifeCycle].subtitle}
|
||||
visible={showSection === cycle}
|
||||
setShowSection={setShowSection}
|
||||
subscriptionOptions={subscriptionState[cycle as TimerLifeCycle]}
|
||||
handleDelete={deleteSubscriptionEntry}
|
||||
handleAddNew={addNewSubscriptionEntry}
|
||||
register={register}
|
||||
/>
|
||||
{idx < subscriptionKeys.length - 1 && <hr className={styles.divider} />}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</ModalBody>
|
||||
<OntimeModalFooter
|
||||
formId='oscSubscriptions'
|
||||
handleRevert={resetForm}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+22
-29
@@ -1,16 +1,18 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, FormControl, Input, ModalBody, ModalFooter, Switch } from '@chakra-ui/react';
|
||||
import { FormControl, Input, ModalBody, Switch } from '@chakra-ui/react';
|
||||
|
||||
import { postOSC } from '../../../common/api/ontimeApi';
|
||||
import useOscSettings from '../../../common/hooks-query/useOscSettings';
|
||||
import useOscSettings, { useOscSettingsMutation } from '../../../common/hooks-query/useOscSettings';
|
||||
import { PlaceholderSettings } from '../../../common/models/OscSettings';
|
||||
import { useEmitLog } from '../../../common/stores/logger';
|
||||
import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex';
|
||||
|
||||
import OntimeModalFooter from './OntimeModalFooter';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
export default function OscIntegrationSettings() {
|
||||
export default function OscSettings() {
|
||||
const { data } = useOscSettings();
|
||||
const { mutateAsync } = useOscSettingsMutation();
|
||||
const { emitError } = useEmitLog();
|
||||
const {
|
||||
handleSubmit,
|
||||
@@ -23,8 +25,6 @@ export default function OscIntegrationSettings() {
|
||||
values: data,
|
||||
});
|
||||
|
||||
const disableSubmit = isSubmitting || !isDirty || !isValid;
|
||||
|
||||
const onSubmit = async (values: PlaceholderSettings) => {
|
||||
const numericPortIn = Number(values.portIn);
|
||||
const numericPortOut = Number(values.portOut);
|
||||
@@ -41,17 +41,20 @@ export default function OscIntegrationSettings() {
|
||||
};
|
||||
|
||||
try {
|
||||
await postOSC(parsedValues);
|
||||
await mutateAsync(parsedValues);
|
||||
} catch (error) {
|
||||
emitError(`Error setting OSC: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => reset(data);
|
||||
const resetForm = () => {
|
||||
// @ts-expect-error -- we know the types dont match
|
||||
reset(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='test'>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSettings'>
|
||||
<ModalBody>
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
@@ -77,6 +80,7 @@ export default function OscIntegrationSettings() {
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portIn', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
|
||||
@@ -116,6 +120,7 @@ export default function OscIntegrationSettings() {
|
||||
width='140px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('targetIP', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
@@ -142,6 +147,7 @@ export default function OscIntegrationSettings() {
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portOut', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' },
|
||||
@@ -154,27 +160,14 @@ export default function OscIntegrationSettings() {
|
||||
/>
|
||||
</FormControl>
|
||||
</ModalBody>
|
||||
{/*<ModalSubmitFooter />*/}
|
||||
</form>
|
||||
<ModalFooter className={styles.buttonSection}>
|
||||
<Button variant='ontime-ghost-on-light' size='sm' onClick={resetForm}>
|
||||
Revert to saved
|
||||
</Button>
|
||||
<Button variant='ontime-subtle-on-light' size='sm'>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='ontime-filled'
|
||||
type='submit'
|
||||
form='test'
|
||||
disabled={disableSubmit}
|
||||
isLoading={isSubmitting}
|
||||
padding='0 2em'
|
||||
size='sm'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
<OntimeModalFooter
|
||||
formId='oscSettings'
|
||||
handleRevert={resetForm}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { FormControl, FormLabel, Input, ModalBody } from '@chakra-ui/react';
|
||||
import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline';
|
||||
|
||||
import { useEmitLog } from '@/common/stores/logger';
|
||||
|
||||
import { postOSC } from '../../../common/api/ontimeApi';
|
||||
import EnableBtn from '../../../common/components/buttons/EnableBtn';
|
||||
import useOscSettings from '../../../common/hooks-query/useOscSettings';
|
||||
import { oscPlaceholderSettings } from '../../../common/models/OscSettings';
|
||||
import { inputProps, portInputProps } from '../modalHelper';
|
||||
import SubmitContainer from '../SubmitContainer';
|
||||
|
||||
import style from '../Modals.module.scss';
|
||||
|
||||
// currently defined endpoints
|
||||
// temporary
|
||||
const oscCycleEndpoints = [
|
||||
{
|
||||
title: 'On Event Start',
|
||||
message: '/ontime/eventNumber',
|
||||
value: '8 | int',
|
||||
},
|
||||
{
|
||||
title: 'On Update',
|
||||
message: '/ontime/time',
|
||||
value: '10:12:12 | string',
|
||||
},
|
||||
{
|
||||
title: 'On Update',
|
||||
message: '/ontime/overtime',
|
||||
value: '0-1 | int',
|
||||
},
|
||||
{
|
||||
title: 'On Update',
|
||||
message: '/ontime/title',
|
||||
value: 'Title of running event | string',
|
||||
},
|
||||
{
|
||||
title: 'On Finish',
|
||||
message: '/ontime/finished',
|
||||
value: '-',
|
||||
},
|
||||
];
|
||||
const oscTriggerEndpoints = [
|
||||
{
|
||||
title: 'On Start',
|
||||
message: '/ontime/play',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Pause',
|
||||
message: '/ontime/pause',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Previous',
|
||||
message: '/ontime/prev',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Next',
|
||||
message: '/ontime/next',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Reload',
|
||||
message: '/ontime/reload',
|
||||
value: '-',
|
||||
},
|
||||
{
|
||||
title: 'On Stop',
|
||||
message: '/ontime/stop',
|
||||
value: '-',
|
||||
},
|
||||
];
|
||||
|
||||
export default function OscSettingsModal() {
|
||||
const { data, status, refetch } = useOscSettings();
|
||||
const { emitError } = useEmitLog();
|
||||
const [formData, setFormData] = useState(oscPlaceholderSettings);
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Set formdata from server state
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (data == null) return;
|
||||
if (changed) return;
|
||||
setFormData({ ...data });
|
||||
}, [changed, data]);
|
||||
|
||||
/**
|
||||
* Validate and submit data
|
||||
*/
|
||||
const submitHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
const f = formData;
|
||||
const e = { status: false, message: '' };
|
||||
|
||||
// Validate fields
|
||||
if (f.port < 1024 || f.port > 65535) {
|
||||
// Port in incorrect range
|
||||
e.status = true;
|
||||
e.message += 'OSC IN Port in incorrect range (1024 - 65535)';
|
||||
} else if (f.portOut < 1024 || f.portOut > 65535) {
|
||||
// Port in incorrect range
|
||||
e.status = true;
|
||||
e.message += 'OSC OUT Port in incorrect range (1024 - 65535)';
|
||||
} else if (f.port === f.portOut) {
|
||||
// Cant use the same port
|
||||
e.status = true;
|
||||
e.message += 'OSC IN and OUT Ports cant be the same';
|
||||
}
|
||||
|
||||
// set fields with error
|
||||
if (e.status) {
|
||||
emitError(`Invalid Input: ${e.message}`);
|
||||
} else {
|
||||
try {
|
||||
await postOSC(formData);
|
||||
} catch (error) {
|
||||
emitError(`Error setting OSC: ${error}`);
|
||||
} finally {
|
||||
await refetch();
|
||||
setChanged(false);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
},
|
||||
[emitError, formData, refetch],
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverts local state equals to server state
|
||||
*/
|
||||
const revert = useCallback(async () => {
|
||||
setChanged(false);
|
||||
await refetch();
|
||||
}, [refetch]);
|
||||
|
||||
/**
|
||||
* Handles change of input field in local state
|
||||
* @param {string} field - object parameter to update
|
||||
* @param {(string | number | boolean)} value - new object parameter value
|
||||
*/
|
||||
const handleChange = useCallback(
|
||||
(field, value) => {
|
||||
const temp = { ...formData };
|
||||
temp[field] = value;
|
||||
setFormData(temp);
|
||||
setChanged(true);
|
||||
},
|
||||
[formData],
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalBody className={style.modalBody}>
|
||||
<p className={style.notes}>
|
||||
Options related to Open Sound Control
|
||||
<br />
|
||||
🔥 Changes take effect after app restart 🔥
|
||||
</p>
|
||||
<form onSubmit={submitHandler}>
|
||||
<div className={style.modalFields}>
|
||||
<div className={style.hSeparator}>OSC Input (Control ontime over OSC)</div>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl id='oscInEnabled'>
|
||||
<FormLabel htmlFor='oscInEnabled'>
|
||||
OSC Enable
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Enable / Disable control
|
||||
</span>
|
||||
</FormLabel>
|
||||
<EnableBtn
|
||||
active={formData.enabled}
|
||||
text={formData.enabled ? 'OSC IN Enabled' : 'OSC IN Disabled'}
|
||||
actionHandler={() => handleChange('enabled', !formData.enabled)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id='portIn'>
|
||||
<FormLabel htmlFor='portIn'>
|
||||
OSC In Port
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Port - Default 8888
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...portInputProps}
|
||||
name='port'
|
||||
placeholder='8888'
|
||||
value={formData.port}
|
||||
onChange={(event) => handleChange('port', parseInt(event.target.value, 10))}
|
||||
style={{ width: '6em' }}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className={style.hSeparator}>OSC Output (feedback)</div>
|
||||
<div className={style.modalInline}>
|
||||
<FormControl id='targetIP'>
|
||||
<FormLabel htmlFor='targetIP'>
|
||||
OSC Out Target IP
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Default 127.0.0.1
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...inputProps}
|
||||
size='sm'
|
||||
name='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
autoComplete='off'
|
||||
value={formData.targetIP}
|
||||
onChange={(event) => handleChange('targetIP', event.target.value)}
|
||||
isDisabled={submitting}
|
||||
style={{ width: '12em', textAlign: 'right' }}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id='portOut'>
|
||||
<FormLabel htmlFor='portOut'>
|
||||
OSC Out Port
|
||||
<span className={style.labelNote}>
|
||||
<br />
|
||||
Default 9999
|
||||
</span>
|
||||
</FormLabel>
|
||||
<Input
|
||||
{...portInputProps}
|
||||
name='portOut'
|
||||
placeholder='9999'
|
||||
value={formData.portOut}
|
||||
onChange={(event) => handleChange('portOut', parseInt(event.target.value, 10))}
|
||||
style={{ width: '6em', textAlign: 'left' }}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className={style.blockNotes}>
|
||||
<span className={style.inlineFlex}>
|
||||
<IoInformationCircleOutline color='#2b6cb0' fontSize='2em' />
|
||||
OSC Feedback messages
|
||||
</span>
|
||||
<span>
|
||||
In future OSC feedback will be user defined. <br />
|
||||
For now this is the list of OSC messages sent from ontime
|
||||
</span>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Cycle
|
||||
</td>
|
||||
<td className={style.labelNote}>Message</td>
|
||||
<td className={style.labelNote}>Value (example | type)</td>
|
||||
</tr>
|
||||
{oscCycleEndpoints.map((e) => (
|
||||
<tr key={e.message}>
|
||||
<td>{e.title}</td>
|
||||
<td>{e.message}</td>
|
||||
<td>{e.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className={style.labelNote} style={{ width: '30%' }}>
|
||||
Trigger
|
||||
</td>
|
||||
<td className={style.labelNote}>Message</td>
|
||||
<td className={style.labelNote}>Value (example | type)</td>
|
||||
</tr>
|
||||
{oscTriggerEndpoints.map((e) => (
|
||||
<tr key={e.message}>
|
||||
<td>{e.title}</td>
|
||||
<td>{e.message}</td>
|
||||
<td>{e.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<SubmitContainer revert={revert} submitting={submitting} changed={changed} status={status} />
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button, IconButton, Input, Switch } from '@chakra-ui/react';
|
||||
import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp';
|
||||
import { IoRemove } from '@react-icons/all-files/io5/IoRemove';
|
||||
import { OscSubscriptionOptions, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import style from '../../../common/components/collapse-bar/CollapseBar.module.scss';
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
interface OscSubscriptionRowProps {
|
||||
cycle: TimerLifeCycle;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
visible: boolean;
|
||||
setShowSection: (cycle: TimerLifeCycle) => void;
|
||||
subscriptionOptions: OscSubscriptionOptions[];
|
||||
handleDelete: (cycle: TimerLifeCycle, id: string) => void;
|
||||
handleAddNew: (cycle: TimerLifeCycle) => void;
|
||||
register: object;
|
||||
}
|
||||
|
||||
export default function OscSubscriptionRow(props: OscSubscriptionRowProps) {
|
||||
const { cycle, title, subtitle, visible, setShowSection, subscriptionOptions, handleDelete, handleAddNew, register } =
|
||||
props;
|
||||
|
||||
const hasTooManyOptions = subscriptionOptions.length >= 3;
|
||||
const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`;
|
||||
const registerPrefix = `subscriptions.${cycle}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>{title}</span>
|
||||
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
<FiChevronUp className={visible ? style.moreCollapsed : style.moreExpanded} />
|
||||
</div>
|
||||
{visible && (
|
||||
<>
|
||||
{subscriptionOptions.map((option, idx) => (
|
||||
<div key={option.id} className={styles.entryRow}>
|
||||
<input type='hidden' {...register(`${registerPrefix}[${idx}].id`)} value={option.id} />
|
||||
<Switch size='sm' {...register(`${registerPrefix}[${idx}].enabled`)} />
|
||||
<Input
|
||||
placeholder='OSC Message'
|
||||
size='xs'
|
||||
variant='ontime-filled-on-light'
|
||||
{...register(`${registerPrefix}[${idx}].message`)}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<IoRemove />}
|
||||
onClick={() => handleDelete(cycle, option.id)}
|
||||
aria-label='delete'
|
||||
size='xs'
|
||||
colorScheme='red'
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={() => handleAddNew(cycle)}
|
||||
className={styles.shiftRight}
|
||||
isDisabled={hasTooManyOptions}
|
||||
size='xs'
|
||||
colorScheme='blue'
|
||||
variant='outline'
|
||||
padding='0 2em'
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -70,4 +70,5 @@ export const ontimeGhostOnLight = {
|
||||
export const ontimeButtonSubtleWhite = {
|
||||
...ontimeButtonSubtle,
|
||||
color: '#f6f6f6', // $gray-50
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@ export const ontimeModal = {
|
||||
},
|
||||
body: {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
closeButton: {
|
||||
color: '#202020', // $gray-50
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const commonStyles = {
|
||||
borderRadius: '3px',
|
||||
fontWeight: '400',
|
||||
backgroundColor: '#262626', // $gray-1250
|
||||
backgroundColor: '#262626', // $gray-1250
|
||||
color: '#e2e2e2', // $gray-200
|
||||
border: '1px solid transparent',
|
||||
_hover: {
|
||||
@@ -21,6 +20,21 @@ export const ontimeInputFilled = {
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeInputFilledOnLight = {
|
||||
field: {
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid transparent',
|
||||
_hover: {
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #D2DDFF', // $blue-200
|
||||
},
|
||||
_focus: {
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #578AF4', // $blue-500
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeTextAreaFilled = {
|
||||
...commonStyles,
|
||||
};
|
||||
@@ -28,7 +42,7 @@ export const ontimeTextAreaFilled = {
|
||||
export const ontimeTextAreaFilledOnLight = {
|
||||
borderRadius: '3px',
|
||||
fontWeight: '400',
|
||||
backgroundColor: '#ececec', // $gray-100
|
||||
backgroundColor: '#ececec', // $gray-100
|
||||
color: '#202020', // $gray-1200
|
||||
border: '1px solid transparent',
|
||||
_hover: {
|
||||
@@ -40,4 +54,4 @@ export const ontimeTextAreaFilledOnLight = {
|
||||
border: '1px solid #578AF4', // $blue-500
|
||||
},
|
||||
_placeholder: { color: '#9d9d9d' }, // $gray-500
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,7 +15,12 @@ import { ontimeModal } from './ontimeModal';
|
||||
import { ontimeSelect } from './ontimeSelect';
|
||||
import { lightSwitch, ontimeSwitch } from './ontimeSwitch';
|
||||
import { ontimeTab } from './ontimeTab';
|
||||
import { ontimeInputFilled, ontimeTextAreaFilled, ontimeTextAreaFilledOnLight } from './ontimeTextInputs';
|
||||
import {
|
||||
ontimeInputFilled,
|
||||
ontimeInputFilledOnLight,
|
||||
ontimeTextAreaFilled,
|
||||
ontimeTextAreaFilledOnLight,
|
||||
} from './ontimeTextInputs';
|
||||
import { ontimeTooltip } from './ontimeTooltip';
|
||||
|
||||
const theme = extendTheme({
|
||||
@@ -52,6 +57,7 @@ const theme = extendTheme({
|
||||
},
|
||||
variants: {
|
||||
'ontime-filled': { ...ontimeInputFilled },
|
||||
'ontime-filled-on-light': { ...ontimeInputFilledOnLight },
|
||||
},
|
||||
},
|
||||
Modal: {
|
||||
|
||||
@@ -252,7 +252,7 @@ ipcMain.on('set-window', (event, arg) => {
|
||||
win.maximize();
|
||||
break;
|
||||
case 'to-tray':
|
||||
win.maximize();
|
||||
win.hide();
|
||||
break;
|
||||
case 'show-dev':
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
|
||||
@@ -15,13 +15,12 @@
|
||||
"express-validator": "^6.14.2",
|
||||
"lowdb": "^5.0.5",
|
||||
"multer": "^1.4.4",
|
||||
"nanoid": "^4.0.0",
|
||||
"node-osc": "^8.0.9",
|
||||
"node-osc": "^8.0.10",
|
||||
"node-xlsx": "^0.21.0",
|
||||
"ontime-utils": "workspace:*",
|
||||
"passport": "^0.6.0",
|
||||
"passport-local": "~1.0.0",
|
||||
"ws": "^8.12.1"
|
||||
"ws": "^8.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.17",
|
||||
|
||||
@@ -85,10 +85,14 @@ export class SocketServer implements IAdapter {
|
||||
|
||||
if (type === 'hello') {
|
||||
ws.send('hi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'ontime-log') {
|
||||
console.log('attempted adding to log');
|
||||
if (payload.level && payload.origin && payload.text) {
|
||||
logger.emit(payload.level, payload.origin, payload.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -108,7 +112,7 @@ export class SocketServer implements IAdapter {
|
||||
}
|
||||
|
||||
// message is any serializable value
|
||||
send(message: any) {
|
||||
send(message: unknown) {
|
||||
this.wss?.clients.forEach((client) => {
|
||||
if (client !== this.wss && client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(message));
|
||||
|
||||
@@ -25,10 +25,10 @@ import { dbLoadingProcess } from './modules/loadDb.js';
|
||||
|
||||
// Services
|
||||
import { eventTimer } from './services/TimerService.js';
|
||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||
import { OscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
import { logger } from './classes/Logger.js';
|
||||
import { eventLoader } from './classes/event-loader/EventLoader.js';
|
||||
import { integrationService } from './services/integration-service/IntegrationService.js';
|
||||
import { logger } from './classes/Logger.js';
|
||||
import { oscIntegration } from './services/integration-service/OscIntegration.js';
|
||||
|
||||
console.log(`Starting Ontime version ${ONTIME_VERSION}`);
|
||||
|
||||
@@ -179,7 +179,6 @@ export const startIntegrations = async (config?: { osc: OSCSettings }) => {
|
||||
return 'OSC Invalid configuration';
|
||||
}
|
||||
|
||||
const oscIntegration = new OscIntegration();
|
||||
const { success, message } = oscIntegration.init(osc);
|
||||
logger.info('RX', message);
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { mergeObject } from '../utils/parserUtils.js';
|
||||
import { PlaybackService } from '../services/PlaybackService.js';
|
||||
import { eventStore } from '../stores/EventStore.js';
|
||||
import { resolveDbPath } from '../setup.js';
|
||||
import { oscIntegration } from '../services/integration-service/OscIntegration.js';
|
||||
import { logger } from '../classes/Logger.js';
|
||||
|
||||
// Create controller for GET request to '/ontime/poll'
|
||||
// Returns data for current state
|
||||
@@ -42,7 +44,7 @@ export const dbDownload = async (req, res) => {
|
||||
* @param file
|
||||
* @param req
|
||||
* @param res
|
||||
* @param options
|
||||
* @param [options]
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAndParse = async (file, req, res, options) => {
|
||||
@@ -247,7 +249,7 @@ export const postViewSettings = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/osc'
|
||||
// Create controller for GET request to '/ontime/osc'
|
||||
// Returns -
|
||||
export const getOSC = async (req, res) => {
|
||||
const osc = DataProvider.getOsc();
|
||||
@@ -262,8 +264,14 @@ export const postOSC = async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await DataProvider.setOsc(req.body);
|
||||
res.send(req.body).status(200);
|
||||
const oscSettings = req.body;
|
||||
await DataProvider.setOsc(oscSettings);
|
||||
|
||||
// TODO: this update could be more granular, checking that relevant data was changed
|
||||
const { message } = oscIntegration.init(oscSettings);
|
||||
logger.info('RX', message);
|
||||
|
||||
res.send(oscSettings).status(200);
|
||||
} catch (error) {
|
||||
res.status(400).send(error);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { body, check, validationResult } from 'express-validator';
|
||||
import { validateOscSubscription } from '../utils/parserFunctions.js';
|
||||
|
||||
/**
|
||||
* @description Validates object for POST /ontime/views
|
||||
@@ -70,6 +71,9 @@ export const validateOSC = [
|
||||
body('targetIP').exists().isIP(),
|
||||
body('enabledIn').exists().isBoolean(),
|
||||
body('enabledOut').exists().isBoolean(),
|
||||
body('subscriptions')
|
||||
.isObject()
|
||||
.custom((value) => validateOscSubscription(value)),
|
||||
(req, res, next) => {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() });
|
||||
|
||||
@@ -41,30 +41,12 @@ export const dbModel: DatabaseModel = {
|
||||
enabledIn: false,
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onStart: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onPause: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onStop: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onUpdate: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onFinish: {
|
||||
message: '',
|
||||
enabled: false,
|
||||
},
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onUpdate: [],
|
||||
onFinish: [],
|
||||
},
|
||||
},
|
||||
http: {
|
||||
|
||||
@@ -2,8 +2,10 @@ import { ArgumentType, Client, Message } from 'node-osc';
|
||||
import { OSCSettings, OscSubscription } from 'ontime-types';
|
||||
|
||||
import IIntegration, { TimerLifeCycleKey } from './IIntegration.js';
|
||||
import { parseTemplate } from './integrationUtils.js';
|
||||
import { parseTemplateNested } from './integrationUtils.js';
|
||||
import { isObject } from '../../utils/varUtils.js';
|
||||
import { dbModel } from '../../models/dataModel.js';
|
||||
import { validateOscSubscription } from '../../utils/parserFunctions.js';
|
||||
|
||||
type Action = TimerLifeCycleKey | string;
|
||||
|
||||
@@ -17,7 +19,7 @@ export class OscIntegration implements IIntegration {
|
||||
|
||||
constructor() {
|
||||
this.oscClient = null;
|
||||
this.subscriptions = {};
|
||||
this.subscriptions = dbModel.osc.subscriptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,6 +41,8 @@ export class OscIntegration implements IIntegration {
|
||||
};
|
||||
}
|
||||
try {
|
||||
// this allows re-calling the init function during runtime
|
||||
this.oscClient?.close();
|
||||
this.oscClient = new Client(targetIP, portOut);
|
||||
return {
|
||||
success: true,
|
||||
@@ -54,7 +58,9 @@ export class OscIntegration implements IIntegration {
|
||||
}
|
||||
|
||||
initSubscriptions(subscriptionOptions: OscSubscription) {
|
||||
this.subscriptions = { ...this.subscriptions, ...subscriptionOptions };
|
||||
if (validateOscSubscription(subscriptionOptions)) {
|
||||
this.subscriptions = { ...subscriptionOptions };
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(action: Action, state?: object) {
|
||||
@@ -73,11 +79,15 @@ export class OscIntegration implements IIntegration {
|
||||
}
|
||||
|
||||
// check subscriptions for action
|
||||
const { enabled, message } = this.subscriptions?.[action] || {};
|
||||
if (enabled) {
|
||||
const parsedMessage = parseTemplate(message, state || {});
|
||||
this.emit('address/', parsedMessage);
|
||||
}
|
||||
const eventSubscriptions = this.subscriptions?.[action] || [];
|
||||
|
||||
eventSubscriptions.forEach((sub) => {
|
||||
const { enabled, message } = sub;
|
||||
if (enabled && message) {
|
||||
const parsedMessage = parseTemplateNested(message, state || {});
|
||||
this.emit(parsedMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
emit(path: string, payload?: ArgumentType) {
|
||||
@@ -98,7 +108,7 @@ export class OscIntegration implements IIntegration {
|
||||
if (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `error is here ${JSON.stringify(error)}`,
|
||||
message: `Error sending message: ${JSON.stringify(error)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -116,3 +126,5 @@ export class OscIntegration implements IIntegration {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const oscIntegration = new OscIntegration();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { parseTemplate } from './integrationUtils.js';
|
||||
import { parseTemplate, parseTemplateNested } from './integrationUtils.js';
|
||||
|
||||
describe('integrationUtils', () => {
|
||||
describe('parseTemplate()', () => {
|
||||
it('correctly parses a given string', () => {
|
||||
const mockState = { test: 'this' };
|
||||
const testString = 'That should replace {{test}}';
|
||||
@@ -51,3 +51,46 @@ describe('integrationUtils', () => {
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTemplateNested()', () => {
|
||||
it('parses string with a single-level variable name', () => {
|
||||
const store = { timer: 10 };
|
||||
const templateString = '/test/{{timer}}';
|
||||
const result = parseTemplateNested(templateString, store);
|
||||
expect(result).toEqual('/test/10');
|
||||
});
|
||||
|
||||
it('parses string with a nested variable name', () => {
|
||||
const store = { timer: { clock: 10 } };
|
||||
const templateString = '/timer/{{timer.clock}}';
|
||||
const result = parseTemplateNested(templateString, store);
|
||||
expect(result).toEqual('/timer/10');
|
||||
});
|
||||
|
||||
it('parses string with multiple variables', () => {
|
||||
const mockState = { test1: 'that', test2: 'this' };
|
||||
const testString = '{{test1}} should replace {{test2}}';
|
||||
const expected = `${mockState.test1} should replace ${mockState.test2}`;
|
||||
|
||||
const result = parseTemplateNested(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('correctly parses a string without templates', () => {
|
||||
const testString = 'That should replace {test}';
|
||||
|
||||
const result = parseTemplateNested(testString, {});
|
||||
expect(result).toStrictEqual(testString);
|
||||
});
|
||||
|
||||
it('handles scenarios with missing variables', () => {
|
||||
// by failing to provide a value, we give visibility to
|
||||
// potential issues in the given string
|
||||
const mockState = { test1: 'that', test2: 'this' };
|
||||
const testString = '{{test1}} should replace {{test2}}, but not {{test3}}';
|
||||
const expected = `${mockState.test1} should replace ${mockState.test2}, but not {{test3}}`;
|
||||
|
||||
const result = parseTemplateNested(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,3 +16,22 @@ export function parseTemplate(template: string, state: object): string {
|
||||
|
||||
return parsedTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a templated string to values in a nested object
|
||||
*/
|
||||
export function parseTemplateNested(template: string, state: object): string {
|
||||
let parsedTemplate = template;
|
||||
let match;
|
||||
while ((match = placeholderRegex.exec(template)) !== null) {
|
||||
const variableName = match[1];
|
||||
const variableParts = variableName.split('.');
|
||||
// iterate through variable parts, and look for the property in the state object
|
||||
const value = variableParts.reduce((obj, key) => obj && obj[key], state);
|
||||
if (value !== undefined) {
|
||||
parsedTemplate = parsedTemplate.replace(match[0], value);
|
||||
}
|
||||
}
|
||||
|
||||
return parsedTemplate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { validateOscSubscription } from '../parserFunctions.js';
|
||||
|
||||
test('validateOscSubscription()', () => {
|
||||
it('should return true when given a valid OscSubscription', () => {
|
||||
const validSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: false }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(validSubscription);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when given undefined', () => {
|
||||
const result = validateOscSubscription(undefined);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given null', () => {
|
||||
const result = validateOscSubscription(null);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an empty object', () => {
|
||||
const result = validateOscSubscription({});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an empty array', () => {
|
||||
const result = validateOscSubscription([]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an object that is not an OscSubscription', () => {
|
||||
const invalidObject = { foo: 'bar' };
|
||||
|
||||
const result = validateOscSubscription(invalidObject);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an OscSubscription with a missing property', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
// Missing onStop
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: false }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when given an OscSubscription with an invalid property value', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: 'test', enabled: false }],
|
||||
onPause: [{ id: '3', message: 'test', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if the message field is empty', () => {
|
||||
const invalidSubscription = {
|
||||
onLoad: [{ id: '1', message: 'test', enabled: true }],
|
||||
onStart: [{ id: '2', message: '', enabled: false }],
|
||||
onPause: [{ id: '3', message: '', enabled: true }],
|
||||
onStop: [{ id: '4', message: 'test', enabled: false }],
|
||||
onUpdate: [{ id: '5', message: 'test', enabled: true }],
|
||||
onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }],
|
||||
};
|
||||
|
||||
const result = validateOscSubscription(invalidSubscription);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -976,7 +976,6 @@ const adjective = [
|
||||
'some',
|
||||
'spherical',
|
||||
'sophisticated',
|
||||
'sore',
|
||||
'sorrowful',
|
||||
'soulful',
|
||||
'soupy',
|
||||
@@ -1248,7 +1247,6 @@ const adjective = [
|
||||
'worrisome',
|
||||
'worse',
|
||||
'worst',
|
||||
'worthless',
|
||||
'worthwhile',
|
||||
'worthy',
|
||||
'wrathful',
|
||||
@@ -1473,7 +1471,6 @@ const object = [
|
||||
'studio',
|
||||
'topic',
|
||||
'collection',
|
||||
'depression',
|
||||
'imagination',
|
||||
'passion',
|
||||
'percentage',
|
||||
@@ -1514,7 +1511,6 @@ const object = [
|
||||
'steak',
|
||||
'union',
|
||||
'agreement',
|
||||
'cancer',
|
||||
'currency',
|
||||
'employment',
|
||||
'engineering',
|
||||
@@ -1527,7 +1523,6 @@ const object = [
|
||||
'republic',
|
||||
'seat',
|
||||
'tradition',
|
||||
'virus',
|
||||
'actor',
|
||||
'classroom',
|
||||
'delivery',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { generateId } from 'ontime-utils';
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
import { OSCSettings, OscSubscription, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js';
|
||||
import { dbModel } from '../models/dataModel.js';
|
||||
@@ -148,6 +148,28 @@ export const parseViewSettings = (data, enforce) => {
|
||||
return newViews;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses and validates subscription object
|
||||
* @param data
|
||||
*/
|
||||
export const validateOscSubscription = (data: OscSubscription) => {
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
const timerKeys = Object.keys(TimerLifeCycle);
|
||||
for (const key of timerKeys) {
|
||||
if (!(key in data) || !Array.isArray(data[key])) {
|
||||
return false;
|
||||
}
|
||||
for (const subscription of data[key]) {
|
||||
if (!subscription.id || typeof subscription.message !== 'string' || typeof subscription.enabled !== 'boolean') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse osc portion of an entry
|
||||
*/
|
||||
@@ -159,13 +181,17 @@ export const parseOsc = (
|
||||
console.log('Found OSC definition, importing...');
|
||||
|
||||
const loadedConfig = data?.osc || {};
|
||||
const validatedSubscriptions = validateOscSubscription(loadedConfig.subscriptions)
|
||||
? loadedConfig.subscriptions
|
||||
: dbModel.osc.subscriptions;
|
||||
|
||||
return {
|
||||
portIn: loadedConfig.portIn ?? dbModel.osc.portIn,
|
||||
portOut: loadedConfig.portOut ?? dbModel.osc.portOut,
|
||||
targetIP: loadedConfig.targetIP ?? dbModel.osc.targetIP,
|
||||
enabledIn: loadedConfig.enabledIn ?? dbModel.osc.enabledIn,
|
||||
enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut,
|
||||
subscriptions: loadedConfig.subscriptions ?? dbModel.osc.subscriptions,
|
||||
subscriptions: validatedSubscriptions,
|
||||
};
|
||||
} else if (enforce) {
|
||||
console.log(`Created OSC object in db`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TimerLifeCycle } from './TimerLifecycle.type';
|
||||
import { TimerLifeCycleKey } from './TimerLifecycle.type.js';
|
||||
|
||||
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
|
||||
export type OscSubscription = { [key in TimerLifeCycleKey]?: { message: string; enabled: boolean } };
|
||||
export type OscSubscriptionOptions = { id: string; message: string; enabled: boolean };
|
||||
export type OscSubscription = { [key in TimerLifeCycleKey]: OscSubscriptionOptions[] };
|
||||
|
||||
export interface OSCSettings {
|
||||
portIn: number;
|
||||
|
||||
@@ -6,3 +6,5 @@ export enum TimerLifeCycle {
|
||||
onUpdate = 'onUpdate',
|
||||
onFinish = 'onFinish',
|
||||
}
|
||||
|
||||
export type TimerLifeCycleKey = keyof typeof TimerLifeCycle;
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
SupportedEvent,
|
||||
} from './definitions/core/OntimeEvent.type.js';
|
||||
import { OntimeRundown, OntimeRundownEntry } from './definitions/core/Rundown.type.js';
|
||||
import { OSCSettings, OscSubscription } from './definitions/core/OscSettings.type.js';
|
||||
import { OSCSettings, OscSubscription, OscSubscriptionOptions } from './definitions/core/OscSettings.type.js';
|
||||
import { Playback } from './definitions/runtime/Playback.type.js';
|
||||
import { Loaded } from './definitions/runtime/Playlist.type.js';
|
||||
import { Log, LogLevel, LogMessage } from './definitions/runtime/Logger.type.js';
|
||||
@@ -50,7 +50,7 @@ export type { Alias };
|
||||
export type { UserFields };
|
||||
|
||||
// ---> OSC
|
||||
export type { OscSubscription, OSCSettings };
|
||||
export type { OscSubscription, OSCSettings, OscSubscriptionOptions };
|
||||
|
||||
// ---> HTTP
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"luxon": "^3.3.0",
|
||||
"nanoid": "^4.0.0"
|
||||
"nanoid": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.2.0",
|
||||
|
||||
Generated
+312
-309
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user