diff --git a/apps/client/package.json b/apps/client/package.json index 0fbd5655f..a062b66ed 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -25,6 +25,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-qr-code": "^2.0.11", "react-router-dom": "^6.3.0", "react-table": "^7.7.0", diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 365622b32..915e91279 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -3,7 +3,7 @@ import axios from 'axios'; import { URLAliasType } from '../models/Alias.type'; import { InfoType } from '../models/Info.types'; import { OntimeSettingsType } from '../models/OntimeSettings.type'; -import { OscSettingsType } from '../models/OscSettings.type'; +import { OSCSettings } from '../models/OscSettings.type'; import { UserFieldsType } from '../models/UserFields.type'; import { ViewSettingsType } from '../models/ViewSettings.type'; @@ -90,7 +90,7 @@ export async function postUserFields(data: UserFieldsType) { * @description HTTP request to retrieve osc settings * @return {Promise} */ -export async function getOSC(): Promise { +export async function getOSC(): Promise { const res = await axios.get(`${ontimeURL}/osc`); return res.data; } @@ -99,7 +99,7 @@ export async function getOSC(): Promise { * @description HTTP request to mutate osc settings * @return {Promise} */ -export async function postOSC(data: OscSettingsType) { +export async function postOSC(data: OSCSettings) { return axios.post(`${ontimeURL}/osc`, data); } diff --git a/apps/client/src/common/models/OscSettings.type.ts b/apps/client/src/common/models/OscSettings.type.ts index 4193dece3..caa5366b8 100644 --- a/apps/client/src/common/models/OscSettings.type.ts +++ b/apps/client/src/common/models/OscSettings.type.ts @@ -1,13 +1,16 @@ -export type OscSettingsType = { - port: string; +import { OSCSettings } from 'ontime-types'; + +// in the placeholder, we pass strings to satisfy input type +export interface PlaceholderSettings extends Omit { + portIn: string; portOut: string; - targetIP: string; - enabled: boolean; } -export const oscPlaceholderSettings: OscSettingsType = { - port: '', +export const oscPlaceholderSettings: PlaceholderSettings = { + portIn: '', portOut: '', targetIP: '', - enabled: false, + enabledIn: false, + enabledOut: false, + subscriptions: {}, }; diff --git a/apps/client/src/common/utils/__tests__/regex.test.ts b/apps/client/src/common/utils/__tests__/regex.test.ts new file mode 100644 index 000000000..3ab98f6ad --- /dev/null +++ b/apps/client/src/common/utils/__tests__/regex.test.ts @@ -0,0 +1,27 @@ +import { isIPAddress, isOnlyNumbers } from '../regex'; + +describe('simple tests for regex', () => { + test('isOnlyNumbers', () => { + const right = ['1231', '1']; + const wrong = ['a', 'asdas1asdas', '11as', '1_', '1.1']; + + right.forEach((t) => { + expect(isOnlyNumbers.test(t)).toBe(true); + }); + wrong.forEach((t) => { + expect(isOnlyNumbers.test(t)).toBe(false); + }); + }); + + test('isIPAddress', () => { + const right = ['0.0.0.0', '127.0.0.1']; + const wrong = ['0', 'testing', '123.0.1']; + + right.forEach((t) => { + expect(isIPAddress.test(t)).toBe(true); + }); + wrong.forEach((t) => { + expect(isIPAddress.test(t)).toBe(false); + }); + }); +}); diff --git a/apps/client/src/common/utils/regex.ts b/apps/client/src/common/utils/regex.ts new file mode 100644 index 000000000..ffc6ad5a7 --- /dev/null +++ b/apps/client/src/common/utils/regex.ts @@ -0,0 +1,2 @@ +export const isOnlyNumbers = /^\d+$/; +export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index 429c77ab7..5468cc67e 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -1,10 +1,11 @@ import { lazy, useEffect } from 'react'; import { Box, useDisclosure } from '@chakra-ui/react'; + import ErrorBoundary from '../../common/components/error-boundary/ErrorBoundary'; import UploadModal from '../../common/components/upload-modal/UploadModal'; -import ModalManager from '../../features/modals/ModalManager'; - +import ModalManager from '../modals/ModalManager'; import MenuBar from '../menu/MenuBar'; +import IntegrationModal from '../modals/integration-modal/IntegrationModal'; import styles from './Editor.module.scss'; @@ -15,16 +16,14 @@ const Info = lazy(() => import('../../features/info/InfoExport')); const EventEditor = lazy(() => import('../../features/event-editor/EventEditorExport')); export default function Editor() { - const { - isOpen: isSettingsOpen, - onOpen: onSettingsOpen, - onClose: onSettingsClose, - } = useDisclosure(); + const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure(); + + const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure(); const { - isOpen: isUploadModalOpen, - onOpen: onUploadModalOpen, - onClose: onUploadModalClose, + isOpen: isIntegrationModalOpen, + onOpen: onIntegrationModalOpen, + onClose: onIntegrationModalClose, } = useDisclosure(); // Set window title @@ -35,10 +34,11 @@ export default function Editor() { return ( <> + -
+
diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index 5eb378852..97433e3ca 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -4,6 +4,8 @@ import { FiHelpCircle } from '@react-icons/all-files/fi/FiHelpCircle'; import { FiMinimize } from '@react-icons/all-files/fi/FiMinimize'; import { FiSave } from '@react-icons/all-files/fi/FiSave'; import { FiUpload } from '@react-icons/all-files/fi/FiUpload'; +import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'; +import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline'; import { IoScan } from '@react-icons/all-files/io5/IoScan'; import { IoSettingsOutline } from '@react-icons/all-files/io5/IoSettingsOutline'; import { downloadRundown } from '../../common/api/ontimeApi'; @@ -20,6 +22,8 @@ interface MenuBarProps { onSettingsClose: () => void; isUploadOpen: boolean; onUploadOpen: () => void; + isIntegrationOpen: boolean; + onIntegrationOpen: () => void; } type Actions = 'min' | 'max' | 'shutdown' | 'help'; @@ -29,42 +33,53 @@ const buttonStyle = { size: 'lg', colorScheme: 'white', _hover: { - background: 'rgba(255, 255, 255, 0.10)' // $white-10 + background: 'rgba(255, 255, 255, 0.10)', // $white-10 }, _active: { - background: 'rgba(255, 255, 255, 0.13)' // $white-13 - } + background: 'rgba(255, 255, 255, 0.13)', // $white-13 + }, }; export default function MenuBar(props: MenuBarProps) { - const { isSettingsOpen, onSettingsOpen, onSettingsClose, isUploadOpen, onUploadOpen } = props; + const { + isSettingsOpen, + onSettingsOpen, + onSettingsClose, + isUploadOpen, + onUploadOpen, + isIntegrationOpen, + onIntegrationOpen, + } = props; const { isElectron, sendToElectron } = useElectronEvent(); - const actionHandler = useCallback((action: Actions) => { - // Stop crashes when testing locally - if (!isElectron) { - if (action === 'help') { - window.open('https://cpvalente.gitbook.io/ontime/'); + const actionHandler = useCallback( + (action: Actions) => { + // Stop crashes when testing locally + if (!isElectron) { + if (action === 'help') { + window.open('https://cpvalente.gitbook.io/ontime/'); + } + } else { + switch (action) { + case 'min': + sendToElectron('set-window', 'to-tray'); + break; + case 'max': + sendToElectron('set-window', 'to-max'); + break; + case 'shutdown': + sendToElectron('shutdown', 'now'); + break; + case 'help': + sendToElectron('send-to-link', 'help'); + break; + default: + break; + } } - } else { - switch (action) { - case 'min': - sendToElectron('set-window', 'to-tray'); - break; - case 'max': - sendToElectron('set-window', 'to-max'); - break; - case 'shutdown': - sendToElectron('shutdown', 'now'); - break; - case 'help': - sendToElectron('send-to-link', 'help'); - break; - default: - break; - } - } - }, [sendToElectron, isElectron]); + }, + [sendToElectron, isElectron], + ); // Handle keyboard shortcuts const handleKeyPress = useCallback( @@ -129,6 +144,15 @@ export default function MenuBar(props: MenuBarProps) { aria-label='Settings' />
+ : } + className={isIntegrationOpen ? style.open : ''} + clickHandler={onIntegrationOpen} + tooltip='Integrations' + aria-label='Integrations' + /> +
} diff --git a/apps/client/src/features/modals/Modal.module.scss b/apps/client/src/features/modals/Modal.module.scss new file mode 100644 index 000000000..4e082ac32 --- /dev/null +++ b/apps/client/src/features/modals/Modal.module.scss @@ -0,0 +1,77 @@ +@use '../../theme/v2Styles' as *; +@use '../../theme/ontimeColours' as *; + +.headerNotes { + font-size: $text-body-size; + width: 100%; + padding: 0 16px; + color: $modal-note-color; + margin-bottom: $section-spacing; +} + +.divider { + margin: 8px 0; + border: 0; + border-top: 1px solid $gray-100; +} + +.sectionContainer { + display: flex; + flex-direction: column; + height: 100% +} + +.splitSection { + display: flex; + justify-content: space-between; + align-items: center; + + border-radius: 3px; + padding: 8px; + + &:hover { + background-color: $blue-50; + } +} + +.sectionTitle { + font-size: 14px; + display: block; + + &.main { + font-weight: 600; + } +} + +@mixin subsection { + font-size: 14px; + display: block; +} + +.sectionSubtitle { + @include subsection; + color: $modal-note-color; +} + +.error { + @include subsection; + color: $error-red; +} + +.buttonSection { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid $gray-100; + display: flex; + gap: 16px; +} + +.buttonSection { + button:first-of-type { + margin-right: auto; + } +} + +.spacer { + flex-grow: 1; +} \ No newline at end of file diff --git a/apps/client/src/features/modals/ModalManager.jsx b/apps/client/src/features/modals/ModalManager.tsx similarity index 59% rename from apps/client/src/features/modals/ModalManager.jsx rename to apps/client/src/features/modals/ModalManager.tsx index 6e350fc04..866c18bfa 100644 --- a/apps/client/src/features/modals/ModalManager.jsx +++ b/apps/client/src/features/modals/ModalManager.tsx @@ -10,17 +10,19 @@ import { TabPanels, Tabs, } from '@chakra-ui/react'; -import PropTypes from 'prop-types'; import AliasesModal from './AliasesModal'; import AppSettingsModal from './AppSettingsModal'; import EventSettingsModal from './EventSettingsModal'; -import IntegrationSettingsModal from './IntegrationSettingsModal'; -import OscSettingsModal from './OscSettingsModal'; import TableOptionsModal from './TableOptionsModal'; import ViewsSettingsModal from './ViewsSettingsModal'; -export default function ModalManager(props) { +interface ModalManagerProps { + isOpen: boolean; + onClose: () => void; +} + +export default function ModalManager(props: ModalManagerProps) { const { isOpen, onClose } = props; return ( - App Settings - Viewers - Event Data - URL Aliases - Cuesheet - OSC - {/*Integration*/} + App Settings + Viewers + Event Data + URL Aliases + Cuesheet @@ -62,20 +62,9 @@ export default function ModalManager(props) { - - - - {/**/} - {/* */} - {/**/} ); } - -ModalManager.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, -}; diff --git a/apps/client/src/features/modals/ModalSubmitFooter.tsx b/apps/client/src/features/modals/ModalSubmitFooter.tsx new file mode 100644 index 000000000..02a57d05d --- /dev/null +++ b/apps/client/src/features/modals/ModalSubmitFooter.tsx @@ -0,0 +1,23 @@ +import { Button, ModalFooter } from '@chakra-ui/react'; + +import styles from './Modal.module.scss'; + +export default function ModalSubmitFooter() { + return ( + + + + + + ); +} diff --git a/apps/client/src/features/modals/ModalWrapper.tsx b/apps/client/src/features/modals/ModalWrapper.tsx new file mode 100644 index 000000000..839695645 --- /dev/null +++ b/apps/client/src/features/modals/ModalWrapper.tsx @@ -0,0 +1,41 @@ +import { PropsWithChildren } from 'react'; +import { + Button, + Modal, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from '@chakra-ui/react'; +import styles from './Modal.module.scss'; + +interface ModalWrapperProps { + isOpen: boolean; + onClose: () => void; + title: string; +} + +export default function ModalWrapper(props: PropsWithChildren) { + const { isOpen, onClose, title, children } = props; + + return ( + + + + {title} + + {children} + + + ); +} diff --git a/apps/client/src/features/modals/Modals.module.scss b/apps/client/src/features/modals/Modals.module.scss index 6a7c74e0a..2aa1ea1e9 100644 --- a/apps/client/src/features/modals/Modals.module.scss +++ b/apps/client/src/features/modals/Modals.module.scss @@ -1,4 +1,5 @@ -@use '../../theme/main' as *; +@use '../../theme/ontimeColours' as *; +@use '../../theme/v2Styles' as *; //////////////////////////////////// main @@ -7,7 +8,7 @@ .notes { font-weight: 400; - color: $light-bg; + color: $action-blue; display: grid; place-items: center; height: 4em; @@ -16,7 +17,7 @@ .modalFields { max-height: 45vh; overflow-y: auto; - scrollbar-color: rgba($light-bg, 0.35) rgba($light-bg, 0.15); + scrollbar-color: rgba($action-blue, 0.35) rgba($action-blue, 0.15); padding-right: 6px; label { @@ -42,7 +43,7 @@ grid-template-columns: 20% 1fr 4em; .placeholder { - background: $light-text; + background: black; width: 100%; height: 24px; } @@ -51,19 +52,19 @@ /* Track */ ::-webkit-scrollbar-track { - background: rgba($light-bg, 0.15); + background: rgba($gray-50, 0.15); border-radius: 3px; } /* Handle */ ::-webkit-scrollbar-thumb { - background: rgba($light-bg, 0.35); + background: rgba($gray-100, 0.35); border-radius: 3px; } /* Handle on hover */ ::-webkit-scrollbar-thumb:hover { - background: rgba($light-bg, 0.45); + background: rgba($gray-200, 0.45); } .modalInline { @@ -99,9 +100,8 @@ padding-top: 2em; display: flex; justify-content: flex-end; - gap: 1em; + gap: 16px; } - } .modalBody > * { @@ -115,7 +115,7 @@ ul.featureList { display: flex; align-items: center; svg { - color: $ontime-accent-text; + color: black; margin-right: 4px; } } @@ -124,7 +124,7 @@ ul.featureList { p { &.notes { text-align: center; - border-color: $light-bg-transparent; + border-color: black; border-width: 0 2px; font-size: 0.9em; margin-bottom: 1em; @@ -139,7 +139,7 @@ span { } .blockNotes { - background-color: $bg-gray; + background-color: $gray-1100; margin: 1em 0; padding: 0.5em; font-size: 0.8em; @@ -147,7 +147,7 @@ span { table { background-color: #fff; - border-left: 4px solid lighten($ontime-pink, 5%); + border-left: 4px solid lighten($ontime-color, 5%); width: 100%; margin: 0.5em 0; border-radius: 2px; @@ -181,12 +181,12 @@ span { } .labelNote { - color: $light-bg; + color: $action-blue; padding-right: 1em; } .labelNoteInline { - color: $light-bg; + color: $action-blue; } .inlineFlex { diff --git a/apps/client/src/features/modals/integration-modal/IntegrationModal.tsx b/apps/client/src/features/modals/integration-modal/IntegrationModal.tsx new file mode 100644 index 000000000..95384f8e2 --- /dev/null +++ b/apps/client/src/features/modals/integration-modal/IntegrationModal.tsx @@ -0,0 +1,40 @@ +import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react'; + +import ModalWrapper from '../ModalWrapper'; + +import OscIntegrationSettings from './OscIntegrationSettings'; +import OscSettingsModal from './OscSettingsModal'; + +import styles from '../Modal.module.scss'; + +interface IntegrationModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function IntegrationModal(props: IntegrationModalProps) { + const { isOpen, onClose } = props; + + return ( + +
+ Manage settings related to protocol integrations.
+ Changes take effect on app restart. +
+ + + OSC + Old OSC + + + + + + + + + + +
+ ); +} diff --git a/apps/client/src/features/modals/integration-modal/OscIntegrationSettings.tsx b/apps/client/src/features/modals/integration-modal/OscIntegrationSettings.tsx new file mode 100644 index 000000000..390d1fbdb --- /dev/null +++ b/apps/client/src/features/modals/integration-modal/OscIntegrationSettings.tsx @@ -0,0 +1,181 @@ +import { useContext } from 'react'; +import { useForm } from 'react-hook-form'; +import { Button, FormControl, Input, ModalBody, ModalFooter, Switch } from '@chakra-ui/react'; + +import { postOSC } from '../../../common/api/ontimeApi'; +import { LoggingContext } from '../../../common/context/LoggingContext'; +import useOscSettings from '../../../common/hooks-query/useOscSettings'; +import { PlaceholderSettings } from '../../../common/models/OscSettings.type'; +import { isIPAddress, isOnlyNumbers } from '../../../common/utils/regex'; + +import styles from '../Modal.module.scss'; + +export default function OscIntegrationSettings() { + const { data } = useOscSettings(); + const { emitError } = useContext(LoggingContext); + const { + handleSubmit, + register, + reset, + setError, + formState: { errors, isSubmitting, isDirty, isValid }, + } = useForm({ + defaultValues: data, + values: data, + }); + + const disableSubmit = isSubmitting || !isDirty || !isValid; + + const onSubmit = async (values: PlaceholderSettings) => { + const numericPortIn = Number(values.portIn); + const numericPortOut = Number(values.portOut); + + if (numericPortIn === numericPortOut) { + setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' }); + return; + } + + const parsedValues = { + ...values, + portIn: numericPortIn, + portOut: numericPortOut, + }; + + try { + await postOSC(parsedValues); + } catch (error) { + emitError(`Error setting OSC: ${error}`); + } + }; + + const resetForm = () => reset(data); + + return ( + <> +
+ +
+
+ OSC Input + Control Ontime with OSC +
+ +
+ + + + + + +
+ +
+
+ + OSC Output + + Ontime data feedback +
+ +
+ + + + + + + + + + +
+ {/**/} + + + + + + + + ); +} diff --git a/apps/client/src/features/modals/OscSettingsModal.jsx b/apps/client/src/features/modals/integration-modal/OscSettingsModal.jsx similarity index 94% rename from apps/client/src/features/modals/OscSettingsModal.jsx rename to apps/client/src/features/modals/integration-modal/OscSettingsModal.jsx index 665def98e..1ca63ae8f 100644 --- a/apps/client/src/features/modals/OscSettingsModal.jsx +++ b/apps/client/src/features/modals/integration-modal/OscSettingsModal.jsx @@ -2,16 +2,15 @@ import { useCallback, useContext, useEffect, useState } from 'react'; import { FormControl, FormLabel, Input, ModalBody } from '@chakra-ui/react'; import { IoInformationCircleOutline } from '@react-icons/all-files/io5/IoInformationCircleOutline'; -import { postOSC } from '../../common/api/ontimeApi'; -import EnableBtn from '../../common/components/buttons/EnableBtn'; -import { LoggingContext } from '../../common/context/LoggingContext'; -import useOscSettings from '../../common/hooks-query/useOscSettings'; -import { oscPlaceholderSettings } from '../../common/models/OscSettings.type'; +import { postOSC } from '../../../common/api/ontimeApi'; +import EnableBtn from '../../../common/components/buttons/EnableBtn'; +import { LoggingContext } from '../../../common/context/LoggingContext'; +import useOscSettings from '../../../common/hooks-query/useOscSettings'; +import { oscPlaceholderSettings } from '../../../common/models/OscSettings.type'; +import { inputProps, portInputProps } from '../modalHelper'; +import SubmitContainer from '../SubmitContainer'; -import { inputProps, portInputProps } from './modalHelper'; -import SubmitContainer from './SubmitContainer'; - -import style from './Modals.module.scss'; +import style from '../Modals.module.scss'; // currently defined endpoints // temporary diff --git a/apps/client/src/features/modals/modalHelper.js b/apps/client/src/features/modals/modalHelper.js index a98c5d2f8..c8ab3e8b4 100644 --- a/apps/client/src/features/modals/modalHelper.js +++ b/apps/client/src/features/modals/modalHelper.js @@ -9,4 +9,4 @@ export const portInputProps = { type: 'number', min: '1024', max: '65535', -}; \ No newline at end of file +}; diff --git a/apps/client/src/index.scss b/apps/client/src/index.scss index f21b86f6d..425c88e7b 100644 --- a/apps/client/src/index.scss +++ b/apps/client/src/index.scss @@ -7,6 +7,13 @@ scrollbar-width: thin; } +// reset box-sizing +*, +*:before, +*:after { + box-sizing: inherit; +} + body, html, .App { diff --git a/apps/client/src/index.tsx b/apps/client/src/index.tsx index 0a0e6a366..7e2274770 100644 --- a/apps/client/src/index.tsx +++ b/apps/client/src/index.tsx @@ -2,7 +2,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import * as Sentry from '@sentry/react'; import { BrowserTracing } from '@sentry/tracing'; -import { TimerType } from 'ontime-types'; import App from './App'; import { ONTIME_VERSION } from './ONTIME_VERSION'; @@ -20,10 +19,6 @@ Sentry.init({ enabled: import.meta.env.PROD, }); -// TODO: apply code and remove, refs PR #290 -const sharedType: TimerType = TimerType.CountDown; -console.log('WIP', sharedType); - root.render( diff --git a/apps/client/src/theme/_v2Styles.scss b/apps/client/src/theme/_v2Styles.scss index f90734141..a192e683f 100644 --- a/apps/client/src/theme/_v2Styles.scss +++ b/apps/client/src/theme/_v2Styles.scss @@ -35,6 +35,8 @@ $bg-container-onlight: $gray-100; $box-shadow-l1: rgba(0, 0, 0, 0.15) 0 3px 3px 0; $box-shadow-l2: rgba(0, 0, 0, 0.15) 0 3px 3px 0; +$modal-note-color: $gray-700; + // interface elements $border-color-ondark: $white-10; $element-inner-spacing: 4px; diff --git a/apps/client/src/theme/ontimeButton.ts b/apps/client/src/theme/ontimeButton.ts index 1c9d33c93..82d15d5b6 100644 --- a/apps/client/src/theme/ontimeButton.ts +++ b/apps/client/src/theme/ontimeButton.ts @@ -1,17 +1,13 @@ -const commonStyles = { - letterSpacing: '0.3px', - fontWeight: '400', - borderRadius: '3px', -}; - export const ontimeButtonFilled = { - ...commonStyles, background: '#2B5ABC', // $blue-700 color: '#fff', // pure-white border: '1px solid #2B5ABC', // $blue-700 _hover: { backgroundColor: '#0A43B9', // $blue-800 border: '1px solid #0A43B9', // $blue-800 + _disabled: { + background: '#2B5ABC', // $blue-700 + }, }, _active: { backgroundColor: '#0036A6', // blue-900 @@ -20,7 +16,6 @@ export const ontimeButtonFilled = { }; export const ontimeButtonOutlined = { - ...commonStyles, backgroundColor: '#2d2d2d', // $gray-1100 color: '#e2e2e2', // $blue-400 border: '1px solid rgba(255, 255, 255, 0.10)', // white-10 @@ -34,7 +29,6 @@ export const ontimeButtonOutlined = { }; export const ontimeButtonSubtle = { - ...commonStyles, backgroundColor: '#303030', // $gray-1050 color: '#779BE7', // $blue-400 border: '1px solid transparent', @@ -47,6 +41,32 @@ export const ontimeButtonSubtle = { }, }; +export const ontimeButtonSubtleOnLight = { + backgroundColor: '#ececec', // $gray-100 + color: '#595959', // $gray-800 + border: '1px solid transparent', + _hover: { + backgroundColor: '#cfcfcf', // $gray-200 + }, + _active: { + backgroundColor: '#ececec', // $gray-200 + borderColor: '#ececec', // $gray-300 + }, +}; + +export const ontimeGhostOnLight = { + backgroundColor: 'transparent', + color: '#595959', // $gray-800 + _hover: { + color: '#595959', // $gray-800 + backgroundColor: '#ececec', // $gray-200 + }, + _active: { + backgroundColor: 'transparent', + borderColor: '#595959', // $gray-800 + }, +}; + export const ontimeButtonSubtleWhite = { ...ontimeButtonSubtle, color: '#f6f6f6', // $gray-50 diff --git a/apps/client/src/theme/ontimeModal.ts b/apps/client/src/theme/ontimeModal.ts new file mode 100644 index 000000000..4c448145d --- /dev/null +++ b/apps/client/src/theme/ontimeModal.ts @@ -0,0 +1,20 @@ +export const ontimeModal = { + header: { + fontWeight: 400, + letterSpacing: '0.3px', + padding: '8px 16px', + fontSize: '20px', + color: '#202020', // $gray-50 + }, + dialog: { + borderRadius: '3px', + padding: 0, + minHeight: 'min(500px, 75vh)', + }, + body: { + padding: 0, + }, + closeButton: { + color: '#202020', // $gray-50 + }, +}; diff --git a/apps/client/src/theme/ontimeSwitch.ts b/apps/client/src/theme/ontimeSwitch.ts index dabd7e6f4..65148c96e 100644 --- a/apps/client/src/theme/ontimeSwitch.ts +++ b/apps/client/src/theme/ontimeSwitch.ts @@ -1,5 +1,4 @@ export const ontimeSwitch = { - container: { }, track: { background: '#2d2d2d', // $gray-1100 border: '1px solid transparent', @@ -8,7 +7,19 @@ export const ontimeSwitch = { }, _focus: { border: '1px solid #578AF4', // $blue-500 - } + }, + }, +}; + +export const lightSwitch = { + track: { + border: '2px solid transparent', + background: '#cfcfcf', // $gray-300 + _checked: { + background: `#578AF4`, // $blue-500 + }, + _focus: { + border: '2px solid #D2DDFF', // $blue-200 + }, }, - thumb: {}, }; diff --git a/apps/client/src/theme/ontimeTab.ts b/apps/client/src/theme/ontimeTab.ts new file mode 100644 index 000000000..c648615b1 --- /dev/null +++ b/apps/client/src/theme/ontimeTab.ts @@ -0,0 +1,16 @@ +export const ontimeTab = { + tab: { + fontWeight: 600, + borderBottom: '2px solid transparent', + color: '#9d9d9d', // $gray-500 + marginBottom: '-2px', + _selected: { + color: '#101010', // $ui-black + border: 'none', + borderBottom: '2px solid #779BE7', // $blue-400 + }, + }, + tablist: { + borderBottom: '2px solid #ececec', // $gray-100 + }, +}; diff --git a/apps/client/src/theme/theme.ts b/apps/client/src/theme/theme.ts index 441020279..b58a7b5b2 100644 --- a/apps/client/src/theme/theme.ts +++ b/apps/client/src/theme/theme.ts @@ -4,24 +4,26 @@ import { ontimeButtonFilled, ontimeButtonOutlined, ontimeButtonSubtle, + ontimeButtonSubtleOnLight, ontimeButtonSubtleWhite, + ontimeGhostOnLight, } from './ontimeButton'; import { ontimeCheckboxOnDark } from './ontimeCheckbox'; import { ontimeEditable } from './ontimeEditable'; import { ontimeMenuOnDark } from './ontimeMenu'; +import { ontimeModal } from './ontimeModal'; import { ontimeSelect } from './ontimeSelect'; -import { ontimeSwitch } from './ontimeSwitch'; -import { - ontimeInputFilled, - ontimeTextAreaFilled, - ontimeTextAreaFilledOnLight, -} from './ontimeTextInputs'; +import { lightSwitch, ontimeSwitch } from './ontimeSwitch'; +import { ontimeTab } from './ontimeTab'; +import { ontimeInputFilled, ontimeTextAreaFilled, ontimeTextAreaFilledOnLight } from './ontimeTextInputs'; import { ontimeTooltip } from './ontimeTooltip'; const theme = extendTheme({ components: { Button: { baseStyle: { + letterSpacing: '0.3px', + fontWeight: '400', borderRadius: '3px', }, variants: { @@ -29,6 +31,8 @@ const theme = extendTheme({ 'ontime-outlined': { ...ontimeButtonOutlined }, 'ontime-subtle': { ...ontimeButtonSubtle }, 'ontime-subtle-white': { ...ontimeButtonSubtleWhite }, + 'ontime-subtle-on-light': { ...ontimeButtonSubtleOnLight }, + 'ontime-ghost-on-light': { ...ontimeGhostOnLight }, }, }, Checkbox: { @@ -38,7 +42,7 @@ const theme = extendTheme({ }, Editable: { variants: { - 'ontime': { ...ontimeEditable }, + ontime: { ...ontimeEditable }, }, }, Input: { @@ -50,6 +54,16 @@ const theme = extendTheme({ 'ontime-filled': { ...ontimeInputFilled }, }, }, + Modal: { + variants: { + ontime: { ...ontimeModal }, + }, + }, + Tabs: { + variants: { + ontime: { ...ontimeTab }, + }, + }, Textarea: { baseStyle: { borderRadius: '3px', @@ -60,16 +74,17 @@ const theme = extendTheme({ }, }, Tooltip: { - baseStyle: { ...ontimeTooltip}, + baseStyle: { ...ontimeTooltip }, }, Switch: { variants: { - 'ontime': { ...ontimeSwitch }, + ontime: { ...ontimeSwitch }, + 'ontime-on-light': { ...lightSwitch }, }, }, Select: { variants: { - 'ontime': { ...ontimeSelect }, + ontime: { ...ontimeSelect }, }, }, Menu: { diff --git a/apps/electron/main.js b/apps/electron/main.js index 98c674717..61a549654 100644 --- a/apps/electron/main.js +++ b/apps/electron/main.js @@ -48,13 +48,13 @@ let tray = null; try { const ontimeServer = require(nodePath) - const { startServer, startOSCServer } = ontimeServer; + const { startDb, startServer, startOSCServer, startIntegrations } = ontimeServer; + + await startDb(); - // Start express server loaded = await startServer(); - - // Start OSC Server await startOSCServer(); + await startIntegrations(); } catch (error) { loaded = error; } diff --git a/apps/server/package.json b/apps/server/package.json index dcf39a455..3192b820e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,7 +10,7 @@ "body-parser": "^1.20.0", "cors": "^2.8.5", "dotenv": "^16.0.1", - "express": "^4.18.1", + "express": "^4.18.2", "express-session": "^1.17.3", "express-validator": "^6.14.2", "lowdb": "^5.0.5", @@ -24,8 +24,9 @@ "socket.io": "^4.5.4" }, "devDependencies": { - "@types/express": "^4.17.15", + "@types/express": "^4.17.17", "@types/node": "^16.11.7", + "@types/node-osc": "^6.0.0", "@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/parser": "^5.48.1", "esbuild": "^0.17.5", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index e18520c4a..2ac95f0cc 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -6,11 +6,11 @@ import cors from 'cors'; // import utils import { join, resolve } from 'path'; -import { config } from './config/config.js'; import { initiateOSC, shutdownOSCServer } from './controllers/OscController.js'; import { initSentry } from './modules/sentry.js'; import { currentDirectory, environment, isProduction, resolvedPath, uiPath } from './setup.js'; import { ONTIME_VERSION } from './ONTIME_VERSION.js'; +import { OSCSettings } from 'ontime-types'; // Import Routes import { router as rundownRouter } from './routes/rundownRouter.js'; @@ -22,12 +22,9 @@ import { router as playbackRouter } from './routes/playbackRouter.js'; import { DataProvider } from './classes/data-provider/DataProvider.js'; import { socketProvider } from './classes/socket/SocketController.js'; import { eventTimer } from './services/TimerService.js'; -import { promise } from './modules/loadDb.js'; -import { TimerType } from 'ontime-types'; - -// TODO: apply code and remove, refs PR #290 -const sharedType: TimerType = TimerType.CountDown; -console.log('WIP', sharedType); +import { dbLoadingProcess } from './modules/loadDb.js'; +import { integrationService } from './services/integration-service/IntegrationService.js'; +import { OscIntegration } from './services/integration-service/OscIntegration.js'; console.log(`Starting Ontime version ${ONTIME_VERSION}`); @@ -77,6 +74,7 @@ app.use((error, response) => { }); /*************** START SERVICES ***************/ + /* Override config * ---------------- * @@ -84,42 +82,35 @@ app.use((error, response) => { * It can be overridden here by the settings in the db * It can also be overridden on call * + * Start order + * ---------------- + * + * The services need to be started in a certain order, + * the enum below enforces that */ -(async () => { - try { - await promise; - } catch (error) { - console.log(error); + +enum OntimeStartOrder { + Error, + InitDB, + InitServer, + InitIO, +} + +let step = OntimeStartOrder.InitDB; +const checkStart = (currentState) => { + if (step !== currentState) { + step = OntimeStartOrder.Error; + throw new Error('Init order error: startDb > startServer > startOsc > startIntegrations'); + } else { + if (step === 1 || step === 2) { + step = step + 1; + } } -})(); +}; -const { osc } = DataProvider.getData(); -const oscIP = osc?.targetIP || config.osc.targetIP; -const oscOutPort = osc?.portOut || config.osc.portOut; -const oscInPort = osc?.port || config.osc.port; -const oscInEnabled = osc?.enabled !== undefined ? osc.enabled : config.osc.inputEnabled; -const serverPort = 4001; // hardcoded for now - -/** - * @description starts OSC server - * @description starts OSC server - * @param overrideConfig - * @return {Promise} - */ -export const startOSCServer = async (overrideConfig = null) => { - if (!oscInEnabled) { - socketServer.info('RX', 'OSC Input Disabled'); - return; - } - - // Setup default port - const oscSettings = { - port: overrideConfig?.port || oscInPort, - }; - - // Start OSC Server - socketServer.info('RX', `Starting OSC Server on port: ${oscInPort}`); - initiateOSC(oscSettings); +export const startDb = async () => { + checkStart(OntimeStartOrder.InitDB); + await dbLoadingProcess; }; // create HTTP server @@ -130,32 +121,67 @@ const expressServer = http.createServer(app); * @return {Promise} */ export const startServer = async () => { - // Start server + checkStart(OntimeStartOrder.InitServer); + + const serverPort = 4001; // hardcoded for now const returnMessage = `Ontime is listening on port ${serverPort}`; expressServer.listen(serverPort, '0.0.0.0'); - // init socket controller - await socketServer.initServer(expressServer); + socketServer.initServer(expressServer); socketServer.info('SERVER', 'Socket initialised'); socketServer.info('SERVER', returnMessage); socketServer.startListener(); + return returnMessage; }; /** - * starts integrations + * @description starts OSC server + * @description starts OSC server * @param overrideConfig * @return {Promise} */ -export const startIntegrations = async (overrideConfig = null) => { - const { http } = DataProvider.getData(); +export const startOSCServer = async (overrideConfig = null) => { + checkStart(OntimeStartOrder.InitIO); - // OSC Config - const oscConfig = { - ip: oscIP, - port: overrideConfig?.port || oscOutPort, + const { osc } = DataProvider.getData(); + + if (!osc.enabledIn) { + socketServer.info('RX', 'OSC Input Disabled'); + return; + } + + // Setup default port + const oscSettings = { + ...osc, + portIn: overrideConfig?.port || osc.portIn, }; + + // Start OSC Server + socketServer.info('RX', `Starting OSC Server on port: ${oscSettings.portIn}`); + initiateOSC(oscSettings); +}; + +/** + * starts integrations + */ +export const startIntegrations = async (config?: { osc: OSCSettings }) => { + checkStart(OntimeStartOrder.InitIO); + + const { osc } = config ?? DataProvider.getData(); + + if (!osc) { + return 'OSC Invalid configuration'; + } + + const oscIntegration = new OscIntegration(); + const { success, message } = oscIntegration.init(osc); + socketServer.info('RX', message); + + if (success) { + integrationService.register(oscIntegration); + } }; /** @@ -170,6 +196,7 @@ export const shutdown = async (exitCode = 0) => { shutdownOSCServer(); eventTimer.shutdown(); socketServer.shutdown(); + integrationService.shutdown(); process.exit(exitCode); }; diff --git a/apps/server/src/classes/data-provider/DataProvider.js b/apps/server/src/classes/data-provider/DataProvider.ts similarity index 78% rename from apps/server/src/classes/data-provider/DataProvider.js rename to apps/server/src/classes/data-provider/DataProvider.ts index ad592bb31..120f4f5d4 100644 --- a/apps/server/src/classes/data-provider/DataProvider.js +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -3,6 +3,7 @@ * and adds logic specific to ontime data */ import { data, db } from '../../modules/loadDb.js'; +import { safeMerge } from './DataProvider.utils.js'; export class DataProvider { static getData() { @@ -39,6 +40,7 @@ export class DataProvider { } static async deleteEvent(eventId) { + // @ts-expect-error -- this will go away once we type db data.rundown = Array.from(data.rundown).filter((e) => e.id !== eventId); await this.persist(); } @@ -49,6 +51,7 @@ export class DataProvider { static async clearRundown() { data.rundown = []; + // @ts-expect-error -- not sure how to type, this is library side await db.write(); } @@ -149,11 +152,12 @@ export class DataProvider { } static async persist() { + // @ts-expect-error -- not sure how to type, this is library side await db.write(); } static async mergeIntoData(newData) { - const mergedData = DataProvider.safeMerge(data, newData); + const mergedData = safeMerge(data, newData); data.event = mergedData.event; data.settings = mergedData.settings; data.osc = mergedData.osc; @@ -163,36 +167,4 @@ export class DataProvider { data.rundown = mergedData.rundown; await this.persist(); } - - /** - * Merges two data objects - * @param {object} existing - * @param {object} newData - */ - static safeMerge(existing, newData) { - const mergedData = { ...existing }; - - if (typeof newData?.rundown !== 'undefined') { - mergedData.rundown = newData.rundown; - } - if (typeof newData?.event !== 'undefined') { - mergedData.event = { ...newData.event }; - } - if (typeof newData?.settings !== 'undefined') { - mergedData.settings = { ...newData.settings }; - } - if (typeof newData?.osc !== 'undefined') { - mergedData.osc = { ...newData.osc }; - } - if (typeof newData?.http !== 'undefined') { - mergedData.http = { ...newData.http }; - } - if (typeof newData?.aliases !== 'undefined') { - mergedData.aliases = [...newData.aliases]; - } - if (typeof newData?.userFields !== 'undefined') { - mergedData.userFields = { ...existing.userFields, ...newData.userFields }; - } - return mergedData; - } } diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts new file mode 100644 index 000000000..6df6c5cff --- /dev/null +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -0,0 +1,39 @@ +/** + * Merges two data objects + * @param {object} existing + * @param {object} newData + */ +export function safeMerge(existing, newData) { + const { rundown, event, settings, osc, http, aliases, userFields } = newData || {}; + return { + ...existing, + rundown: rundown ?? existing.rundown, + event: { ...existing.event, ...event }, + settings: { ...existing.settings, ...settings }, + views: { + overrideStyles: false, + }, + aliases: aliases ?? existing.aliases, + userFields: { + ...existing.userFields, + ...(userFields && Object.fromEntries(Object.entries(userFields).filter(([_, value]) => value !== null))), + }, + osc: { + ...existing.osc, + ...osc, + subscriptions: { + ...existing.osc?.subscriptions, + ...(newData?.osc?.subscriptions || {}), + ...(existing.osc?.subscriptions && newData?.osc?.subscriptions + ? Object.keys(existing.osc.subscriptions).reduce((acc, key) => { + if (!(key in newData.osc.subscriptions)) { + acc[key] = existing.osc.subscriptions[key]; + } + return acc; + }, {}) + : {}), + }, + }, + http: { ...existing.http, ...http }, + }; +} diff --git a/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts b/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts new file mode 100644 index 000000000..5e076965b --- /dev/null +++ b/apps/server/src/classes/data-provider/__test__/DataProvider.test.ts @@ -0,0 +1,302 @@ +import { safeMerge } from '../DataProvider.utils.js'; + +describe('safeMerge', () => { + const existing = { + rundown: [], + event: { + title: 'existing title', + publicUrl: 'existing public URL', + backstageUrl: 'existing backstageUrl', + backstageInfo: 'existing backstageInfo', + endMessage: 'existing endMessage', + }, + settings: { + app: 'ontime', + lock: true, + pinCode: null, + timeFormat: '24', + version: 2, + serverPort: 4001, + }, + views: { + overrideStyles: false, + }, + aliases: [], + userFields: { + user0: 'existing user0', + user1: 'existing user1', + }, + osc: { + portIn: 8888, + portOut: 9999, + targetIP: '127.0.0.1', + 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, + }, + }, + }, + http: { + enabled: true, + user: null, + pwd: null, + }, + }; + + it('returns existing data if new data is not provided', () => { + const mergedData = safeMerge(existing, undefined); + expect(mergedData).toEqual(existing); + }); + + it('merges the rundown key', () => { + const newData = { + rundown: [{ name: 'item 1' }, { name: 'item 2' }], + }; + const mergedData = safeMerge(existing, newData); + expect(mergedData.rundown).toEqual(newData.rundown); + }); + + it('merges the event key', () => { + const newData = { + event: { + title: 'new title', + publicInfo: 'new public info', + }, + }; + const mergedData = safeMerge(existing, newData); + expect(mergedData.event).toEqual({ + title: 'new title', + publicUrl: 'existing public URL', + publicInfo: 'new public info', + backstageUrl: 'existing backstageUrl', + backstageInfo: 'existing backstageInfo', + endMessage: 'existing endMessage', + }); + }); + + it('merges the settings key', () => { + const newData = { + settings: { + serverPort: 3000, + lock: '1234', + }, + }; + const mergedData = safeMerge(existing, newData); + expect(mergedData.settings).toEqual({ + app: 'ontime', + version: 2, + serverPort: 3000, + lock: '1234', + pinCode: null, + timeFormat: '24', + }); + }); + + it('merges the osc key', () => { + const newData = { + osc: { + portIn: 7777, + subscriptions: { + onStart: { + message: 'new message', + enabled: true, + }, + }, + }, + }; + const mergedData = safeMerge(existing, newData); + expect(mergedData.osc).toEqual({ + portIn: 7777, + portOut: 9999, + targetIP: '127.0.0.1', + enabledIn: false, + enabledOut: false, + subscriptions: { + onLoad: { + message: '', + enabled: false, + }, + onStart: { + message: 'new message', + enabled: true, + }, + onPause: { + message: '', + enabled: false, + }, + onStop: { + message: '', + enabled: false, + }, + onUpdate: { + message: '', + enabled: false, + }, + onFinish: { + message: '', + enabled: false, + }, + }, + }); + }); + + it('should merge the aliases key when present', () => { + const existingData = { + rundown: [], + event: { + title: '', + publicUrl: '', + publicInfo: '', + backstageUrl: '', + backstageInfo: '', + endMessage: '', + }, + settings: { + app: 'ontime', + version: 2, + serverPort: 4001, + lock: null, + pinCode: null, + timeFormat: '24', + }, + views: { + overrideStyles: false, + }, + aliases: [], + userFields: { + user0: 'user0', + user1: 'user1', + user2: 'user2', + user3: 'user3', + user4: 'user4', + user5: 'user5', + user6: 'user6', + user7: 'user7', + user8: 'user8', + user9: 'user9', + }, + osc: { + portIn: 8888, + portOut: 9999, + targetIP: '127.0.0.1', + 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, + }, + }, + }, + http: { + user: null, + pwd: null, + messages: { + onLoad: { + url: '', + enabled: false, + }, + onStart: { + url: '', + enabled: false, + }, + onUpdate: { + url: '', + enabled: false, + }, + onPause: { + url: '', + enabled: false, + }, + onStop: { + url: '', + enabled: false, + }, + onFinish: { + url: '', + enabled: false, + }, + }, + enabled: true, + }, + }; + + const newData = { + aliases: ['alias1', 'alias2'], + }; + + const mergedData = safeMerge(existingData, newData); + + expect(mergedData.aliases).toEqual(newData.aliases); + }); + + it('merges userFields into existing object', () => { + const existing = { + userFields: { + user0: 'Alice', + user1: 'Bob', + }, + }; + + const newData = { + userFields: { + user2: 'Charlie', + user3: 'David', + user4: null, + }, + }; + + const expected = { + user0: 'Alice', + user1: 'Bob', + user2: 'Charlie', + user3: 'David', + }; + + const result = safeMerge(existing, newData); + expect(result.userFields).toEqual(expected); + }); +}); diff --git a/apps/server/src/classes/event-loader/EventLoader.js b/apps/server/src/classes/event-loader/EventLoader.js index 980b1ca96..9e9c24fd5 100644 --- a/apps/server/src/classes/event-loader/EventLoader.js +++ b/apps/server/src/classes/event-loader/EventLoader.js @@ -1,4 +1,4 @@ -import { DataProvider } from '../data-provider/DataProvider.js'; +import { DataProvider } from '../data-provider/DataProvider.ts'; import { getRollTimers } from '../../services/rollUtils.js'; let instance; diff --git a/apps/server/src/classes/integration/Http.js b/apps/server/src/classes/integration/Http.js deleted file mode 100644 index 6e3fa2e30..000000000 --- a/apps/server/src/classes/integration/Http.js +++ /dev/null @@ -1,53 +0,0 @@ -import * as http from 'http'; - -/** - * @description Class contains logic towards outgoing HTTP communications - * @class - */ -export class HTTPIntegration { - constructor() { - // nothing to do here - } - - /** - * @description Initializes oscClient - * @param {object} httpConfig - Http configurations options - */ - init(httpConfig) {} - - /** - * @description Sends http get request from predefined messages - * @param {string} path - complete http path - */ - async send(path) { - if (path == null) { - console.log('HTTP ERROR: Message undefined'); - return; - } - - const options = new URL(path); - let str = ''; - - const req = http.request(options, (res) => { - console.log(`statusCode: ${res.statusCode}`); - - res.on('data', function (chunk) { - str += chunk; - }); - - res.on('end', function () { - console.log(str); - }); - }); - - req.on('error', (error) => { - console.error(error); - }); - - req.end(); - } - - shutdown() { - /* Nothing to shutdown */ - } -} diff --git a/apps/server/src/classes/integration/Osc.js b/apps/server/src/classes/integration/Osc.js deleted file mode 100644 index fdfd42d8b..000000000 --- a/apps/server/src/classes/integration/Osc.js +++ /dev/null @@ -1,169 +0,0 @@ -import { Client, Message } from 'node-osc'; - -/** - * @description Class contains logic towards outgoing OSC communications - * @class - */ -export class OSCIntegration { - constructor() { - // OSC Client - this.ADDRESS = '/ontime'; - this.oscClient = null; - } - - /** - * @description Returns list of implemented messages - * @returns {object} implemented messages - */ - get implemented() { - return { - play: 'play', - pause: 'pause', - stop: 'stop', - previous: 'prev', - next: 'next', - reload: 'reload', - finished: 'finished', - time: 'time', - overtime: 'overtime', - title: 'title', - eventNumber: 'eventNumber', - presenter: 'presenter', - }; - } - - /** - * @description Initializes oscClient - * @param {object} oscConfig - oscClient configuration options - * @param {string} oscConfig.ip - oscClient object - * @param {number} oscConfig.port - OSC Destination Port - */ - init(oscConfig) { - const { ip, port } = oscConfig; - const validateType = typeof ip !== 'string' || typeof port !== 'number'; - const validateNull = ip == null || port == null; - - if (validateType || validateNull) { - return { - success: false, - message: `Config options incorrect`, - }; - } - try { - this.oscClient = new Client(ip, port); - return { - success: true, - message: `Initialised OSC Client at ${ip}:${port}`, - }; - } catch (error) { - this.oscClient = null; - return { - success: false, - message: `Failed initialising OSC Client: ${error}`, - }; - } - } - - /** - * @description Sends osc from predefined messages - * @param {string} messageType - message to be sent - * @param {string} [payload] - optional payload required in some message types - */ - async send(messageType, payload) { - const reply = { - success: true, - message: 'OSC Message sent', - }; - - if (this.oscClient == null) { - reply.success = false; - reply.message = 'Client not initialised'; - return reply; - } - - if (messageType == null) { - reply.success = false; - reply.message = 'Message undefined'; - return reply; - } - - // only specify special cases - switch (payload) { - case 'overtime': { - // Whether timer is negative - this.oscClient.send(`${this.ADDRESS}/overtime`, payload, (err) => { - if (err) { - reply.success = false; - reply.message = err; - } - }); - break; - } - case 'title': { - if (payload != null && payload !== '') { - // Send Title of current event - this.oscClient.send(`${this.ADDRESS}/title`, payload, (err) => { - if (err) { - reply.success = false; - reply.message = err; - } - }); - } else { - reply.success = false; - reply.message = 'Missing message data'; - } - break; - } - case 'eventNumber': { - if (payload != null && payload !== '') { - // Send event number of current event - this.oscClient.send(`${this.ADDRESS}/eventNumber`, payload, (err) => { - if (err) { - reply.success = false; - reply.message = err; - } - }); - } else { - reply.success = false; - reply.message = 'Missing message data'; - } - break; - } - case 'presenter': { - if (payload != null && payload !== '') { - // Send timer data on current event - this.oscClient.send(`${this.ADDRESS}/presenter`, payload, (err) => { - if (err) { - reply.success = false; - reply.message = err; - } - }); - } else { - reply.success = false; - reply.message = 'Missing message data'; - } - break; - } - default: { - // catch all for messages, allows to add new messages - // but should be used with the integrations definition - const message = new Message(`${this.ADDRESS}/${messageType}`); - if (payload != null) message.append(payload); - this.oscClient.send(message, (err) => { - if (err) { - reply.success = false; - reply.message = err; - } - }); - break; - } - } - return reply; - } - - shutdown() { - // Shutdown client object - this.oscClient.close(); - this.oscClient = null; - } -} diff --git a/apps/server/src/classes/integration/__tests__/Osc.test.js b/apps/server/src/classes/integration/__tests__/Osc.test.js deleted file mode 100644 index 6dd1ce746..000000000 --- a/apps/server/src/classes/integration/__tests__/Osc.test.js +++ /dev/null @@ -1,163 +0,0 @@ - -import { OSCIntegration } from '../Osc'; -import { Server } from 'node-osc'; - -test('Class initialises correctly', () => { - const osc = new OSCIntegration(); - expect(osc.ADDRESS).toBe('/ontime'); - expect(osc.oscClient).toBe(null); - - // defined objects - expect(osc.implemented.play).toBeDefined(); - expect(osc.implemented.pause).toBeDefined(); - expect(osc.implemented.stop).toBeDefined(); - expect(osc.implemented.previous).toBeDefined(); - expect(osc.implemented.next).toBeDefined(); - expect(osc.implemented.reload).toBeDefined(); - expect(osc.implemented.finished).toBeDefined(); - expect(osc.implemented.time).toBeDefined(); - expect(osc.implemented.overtime).toBeDefined(); - expect(osc.implemented.title).toBeDefined(); - expect(osc.implemented.eventNumber).toBeDefined(); - expect(osc.implemented.presenter).toBeDefined(); - - // initialise client succeeds - const { ip, port } = { ip: '127.0.0.1', port: 12345 }; - const init = osc.init({ ip, port }); - expect(init.message).toBe(`Initialised OSC Client at ${ip}:${port}`); - expect(init.success).toBe(true); - expect(osc.oscClient).not.toBe(null); - - // object shutdown as expected - osc.shutdown(); - expect(osc.oscClient).toBe(null); -}); - -describe('OSC fails to initialise when incorrect data is given', () => { - it('IP of wrong type', () => { - const osc = new OSCIntegration(); - const init = osc.init({ ip: 123, port: 8888 }); - expect(init.message).toBe('Config options incorrect'); - expect(init.success).toBe(false); - expect(osc.oscClient).toBe(null); - }); - - it('IP is null', () => { - const osc = new OSCIntegration(); - const init = osc.init({ ip: null, port: 8888 }); - expect(init.message).toBe('Config options incorrect'); - expect(init.success).toBe(false); - expect(osc.oscClient).toBe(null); - }); - - it('Port of wrong type', () => { - const osc = new OSCIntegration(); - const init = osc.init({ ip: 'localhost', port: 'test' }); - expect(init.message).toBe('Config options incorrect'); - expect(init.success).toBe(false); - expect(osc.oscClient).toBe(null); - }); - - it('Port is null', () => { - const osc = new OSCIntegration(); - const init = osc.init({ ip: 'localhost', port: null }); - expect(init.message).toBe('Config options incorrect'); - expect(init.success).toBe(false); - expect(osc.oscClient).toBe(null); - }); -}); - -test('Test messages sending', async () => { - const testPort = 9999; - const testIP = 'localhost'; - const testPayload = 'test'; - const osc = new OSCIntegration(); - - const messages = []; - - // prepare dummy server to receive messages - const oscServer = new Server(testPort, testIP); - - oscServer.on('message', (m) => { - messages.push({ yay: m }); - }); - - // try and send a message before initialising - const test = await osc.send('test'); - expect(test.success).toBe(false); - expect(test.message).toBe('Client not initialised'); - - // initialise osc - osc.init({ ip: testIP, port: testPort }); - - // try and send unrecognised message - const test2 = await osc.send('test'); - expect(test2.success).toBe(true); - - // send play message - const playAddress = osc.implemented.play; - const playSent = await osc.send(playAddress); - expect(playSent.success).toBe(true); - - // send pause message - const pauseAddress = osc.implemented.pause; - const pauseSent = await osc.send(pauseAddress); - expect(pauseSent.success).toBe(true); - - // send stop message - const stopAddress = osc.implemented.stop; - const stopSent = await osc.send(stopAddress); - expect(stopSent.success).toBe(true); - - // send previous message - const previousAddress = osc.implemented.previous; - const previousSent = await osc.send(previousAddress); - expect(previousSent.success).toBe(true); - - // send next message - const nextAddress = osc.implemented.next; - const nextSent = await osc.send(nextAddress); - expect(nextSent.success).toBe(true); - - // send reload message - const reloadAddress = osc.implemented.reload; - const reloadSent = await osc.send(reloadAddress); - expect(reloadSent.success).toBe(true); - - // send finished message - const finishedAddress = osc.implemented.finished; - const finishedSent = await osc.send(finishedAddress); - expect(finishedSent.success).toBe(true); - - // send time message - const timeAddress = osc.implemented.time; - const timeSent = await osc.send(timeAddress); - expect(timeSent.success).toBe(true); - - // send overtime message - const overtimeAddress = osc.implemented.overtime; - const overtimeSent = await osc.send(overtimeAddress, testPayload); - expect(overtimeSent.success).toBe(true); - - // send title message - const titleAddress = osc.implemented.title; - const titleSent = await osc.send(titleAddress, testPayload); - expect(titleSent.success).toBe(true); - - // send eventNumber message - const eventNumberAddress = osc.implemented.eventNumber; - const eventNumberSent = await osc.send(eventNumberAddress, testPayload); - expect(eventNumberSent.success).toBe(true); - - // send timer message - const presenterAddress = osc.implemented.presenter; - const presenterSent = await osc.send(presenterAddress, testPayload); - expect(presenterSent.success).toBe(true); - - // cleanup - await osc.shutdown(); - await oscServer.close(); - - // see messagesObject - // expect(messages.length).toBe(5); -}); diff --git a/apps/server/src/classes/socket/SocketController.js b/apps/server/src/classes/socket/SocketController.js index a85eb1722..bfc48ba09 100644 --- a/apps/server/src/classes/socket/SocketController.js +++ b/apps/server/src/classes/socket/SocketController.js @@ -7,7 +7,7 @@ import { messageManager } from '../message-manager/MessageManager.js'; import { PlaybackService } from '../../services/PlaybackService.js'; import { ADDRESS_MESSAGE_CONTROL } from './socketConfig.js'; -import { eventTimer, TimerService } from '../../services/TimerService.js'; +import { eventTimer, TimerService } from '../../services/TimerService.ts'; import { EventLoader, eventLoader } from '../event-loader/EventLoader.js'; class SocketController { diff --git a/apps/server/src/config/config.js b/apps/server/src/config/config.js index 90f6758cb..1a4915d25 100644 --- a/apps/server/src/config/config.js +++ b/apps/server/src/config/config.js @@ -1,25 +1,8 @@ export const config = { - timer: { - refresh: 1000, - }, - server: { - port: 4001, - }, database: { testdb: 'test-db', directory: 'preloaded-db', filename: 'db.json', tablename: 'events', }, - osc: { - port: 8888, - portOut: 9999, - targetIP: '127.0.0.1', - inputEnabled: true, - }, - http: { - user: '', - pwd: '', - enabled: true, - }, }; diff --git a/apps/server/src/controllers/OscController.js b/apps/server/src/controllers/OscController.ts similarity index 92% rename from apps/server/src/controllers/OscController.js rename to apps/server/src/controllers/OscController.ts index 15b845df4..5354eb9c5 100644 --- a/apps/server/src/controllers/OscController.js +++ b/apps/server/src/controllers/OscController.ts @@ -1,4 +1,6 @@ import { Server } from 'node-osc'; +import { OSCSettings } from 'ontime-types'; + import { PlaybackService } from '../services/PlaybackService.js'; import { messageManager } from '../classes/message-manager/MessageManager.js'; import { socketProvider } from '../classes/socket/SocketController.js'; @@ -14,11 +16,10 @@ export const shutdownOSCServer = () => { }; /** - * @description initialises OSC server - * @param {object} config + * Initialises OSC server */ -export const initiateOSC = (config) => { - oscServer = new Server(config.port, '0.0.0.0'); +export const initiateOSC = (config: OSCSettings) => { + oscServer = new Server(config.portIn, '0.0.0.0'); oscServer.on('error', console.error); @@ -34,7 +35,7 @@ export const initiateOSC = (config) => { // get first part before (ontime) if (address !== 'ontime') { - console.error('RX', `OSC IN: Message address ${address} not recognised`); + console.error('RX', `OSC IN: Message address ${address} not recognised`, msg); return; } @@ -123,10 +124,7 @@ export const initiateOSC = (config) => { try { const eventIndex = Number(args); if (isNaN(eventIndex) || eventIndex <= 0) { - socketProvider.error( - 'RX', - `OSC IN: event index not recognised or out of range ${eventIndex}` - ); + socketProvider.error('RX', `OSC IN: event index not recognised or out of range ${eventIndex}`); } else { PlaybackService.loadByIndex(eventIndex - 1); } diff --git a/apps/server/src/controllers/eventController.js b/apps/server/src/controllers/eventController.js index 6a7a81bb6..3e7518f73 100644 --- a/apps/server/src/controllers/eventController.js +++ b/apps/server/src/controllers/eventController.js @@ -1,6 +1,6 @@ import { removeUndefined } from '../utils/parserUtils.js'; import { failEmptyObjects } from '../utils/routerUtils.js'; -import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { DataProvider } from '../classes/data-provider/DataProvider.ts'; // Create controller for GET request to 'event' export const getEvent = async (req, res) => { diff --git a/apps/server/src/controllers/ontimeController.js b/apps/server/src/controllers/ontimeController.js index 3511459d9..393e48671 100644 --- a/apps/server/src/controllers/ontimeController.js +++ b/apps/server/src/controllers/ontimeController.js @@ -2,7 +2,7 @@ import fs from 'fs'; import { networkInterfaces } from 'os'; import { generateId } from 'ontime-utils'; import { fileHandler } from '../utils/parser.js'; -import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { DataProvider } from '../classes/data-provider/DataProvider.ts'; import { failEmptyObjects, failIsNotArray } from '../utils/routerUtils.js'; import { mergeObject } from '../utils/parserUtils.js'; import { PlaybackService } from '../services/PlaybackService.js'; diff --git a/apps/server/src/controllers/ontimeController.validate.js b/apps/server/src/controllers/ontimeController.validate.js index 717391b2b..256db8f24 100644 --- a/apps/server/src/controllers/ontimeController.validate.js +++ b/apps/server/src/controllers/ontimeController.validate.js @@ -65,10 +65,11 @@ export const validateSettings = [ * @description Validates object for POST /ontime/osc */ export const validateOSC = [ - body('port').exists().isInt({ min: 0, max: 65353 }), - body('portOut').exists().isInt({ min: 0, max: 65353 }), + body('portIn').exists().isInt({ min: 1024, max: 65535 }), + body('portOut').exists().isInt({ min: 1024, max: 65535 }), body('targetIP').exists().isIP(), - body('enabled').exists().isBoolean(), + body('enabledIn').exists().isBoolean(), + body('enabledOut').exists().isBoolean(), (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); diff --git a/apps/server/src/controllers/rundownController.js b/apps/server/src/controllers/rundownController.js index 1877bba6b..32e7bbcfa 100644 --- a/apps/server/src/controllers/rundownController.js +++ b/apps/server/src/controllers/rundownController.js @@ -1,4 +1,4 @@ -import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { DataProvider } from '../classes/data-provider/DataProvider.ts'; import { failEmptyObjects } from '../utils/routerUtils.js'; import { addEvent, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 079750058..6b270bd10 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,16 +1,14 @@ -// import { promise } from './modules/loadDb.js'; -import { startOSCServer, startServer } from './app.js'; +import { startDb, startIntegrations, startOSCServer, startServer } from './app.js'; async function startOntime() { try { - // await promise; + await startDb(); - // Start express server const loaded = await startServer(); console.log(loaded); - // Start OSC Server (API) await startOSCServer(); + await startIntegrations(); } catch (error) { console.log('Error starting Ontime'); console.log(error); diff --git a/apps/server/src/models/dataModel.js b/apps/server/src/models/dataModel.ts similarity index 64% rename from apps/server/src/models/dataModel.js rename to apps/server/src/models/dataModel.ts index 300d4aadf..014a7c954 100644 --- a/apps/server/src/models/dataModel.js +++ b/apps/server/src/models/dataModel.ts @@ -1,4 +1,6 @@ -export const dbModel = { +import { DatabaseModel } from 'ontime-types'; + +export const dbModel: DatabaseModel = { rundown: [], event: { title: '', @@ -33,10 +35,37 @@ export const dbModel = { user9: 'user9', }, osc: { - port: 8888, + portIn: 8888, portOut: 9999, targetIP: '127.0.0.1', - enabled: true, + 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, + }, + }, }, http: { user: null, diff --git a/apps/server/src/modules/loadDb.js b/apps/server/src/modules/loadDb.ts similarity index 87% rename from apps/server/src/modules/loadDb.js rename to apps/server/src/modules/loadDb.ts index bb66bb8ec..e7934ae36 100644 --- a/apps/server/src/modules/loadDb.js +++ b/apps/server/src/modules/loadDb.ts @@ -1,6 +1,8 @@ import { Low } from 'lowdb'; import { JSONFile } from 'lowdb/node'; import { copyFileSync, existsSync } from 'fs'; +import { DatabaseModel } from 'ontime-types'; + import { ensureDirectory } from '../utils/fileManagement.js'; import { validateFile } from '../utils/parserUtils.js'; import { dbModel } from '../models/dataModel.js'; @@ -51,7 +53,7 @@ const parseDb = async (fileToRead, adapterToUse) => { async function loadDb() { const dbInDisk = populateDb(); - const adapter = new JSONFile(dbInDisk); + const adapter = new JSONFile(dbInDisk); const db = new Low(adapter); const data = await parseDb(dbInDisk, db); @@ -63,11 +65,11 @@ async function loadDb() { } export let db = {}; -export let data = {}; -export const promise = loadDb(); +export let data = {} as DatabaseModel; +export const dbLoadingProcess = loadDb(); const init = async () => { - const dbProvider = await promise; + const dbProvider = await dbLoadingProcess; db = dbProvider.db; data = dbProvider.data; }; diff --git a/apps/server/src/services/PlaybackService.js b/apps/server/src/services/PlaybackService.js index faeb105cd..761aac230 100644 --- a/apps/server/src/services/PlaybackService.js +++ b/apps/server/src/services/PlaybackService.js @@ -3,7 +3,7 @@ */ import { socketProvider } from '../classes/socket/SocketController.js'; import { eventLoader, EventLoader } from '../classes/event-loader/EventLoader.js'; -import { eventTimer, TimerService } from './TimerService.js'; +import { eventTimer, TimerService } from './TimerService.ts'; /** * Service manages playback status of app diff --git a/apps/server/src/services/RundownService.js b/apps/server/src/services/RundownService.js index 030c6d5d9..e9fbd7185 100644 --- a/apps/server/src/services/RundownService.js +++ b/apps/server/src/services/RundownService.js @@ -1,5 +1,5 @@ import { generateId } from 'ontime-utils'; -import { DataProvider } from '../classes/data-provider/DataProvider.js'; +import { DataProvider } from '../classes/data-provider/DataProvider.ts'; import { block as blockDef, delay as delayDef, @@ -7,7 +7,7 @@ import { } from '../models/eventsDefinition.js'; import { MAX_EVENTS } from '../settings.js'; import { EventLoader, eventLoader } from '../classes/event-loader/EventLoader.js'; -import { eventTimer } from './TimerService.js'; +import { eventTimer } from './TimerService.ts'; import { socketProvider } from '../classes/socket/SocketController.js'; /** diff --git a/apps/server/src/services/TimerService.js b/apps/server/src/services/TimerService.ts similarity index 86% rename from apps/server/src/services/TimerService.js rename to apps/server/src/services/TimerService.ts index 2c452aca4..2c860d54a 100644 --- a/apps/server/src/services/TimerService.js +++ b/apps/server/src/services/TimerService.ts @@ -1,15 +1,40 @@ +import { TimerLifeCycle } from 'ontime-types'; + import { runtimeState } from '../stores/EventStore.js'; import { PlaybackService } from './PlaybackService.js'; import { updateRoll } from './rollUtils.js'; import { DAY_TO_MS } from '../utils/time.js'; +import { integrationService } from './integration-service/IntegrationService.js'; export class TimerService { + private readonly _interval: NodeJS.Timer; + + private playback: string; + + private loadedTimerId: null; + private _pausedInterval: number; + private _pausedAt: number | null; + private _secondaryTarget: number | null; + + timer: { + clock: number; + current: number | null; + elapsed: number | null; + expectedFinish: number | null; + addedTime: number; + startedAt: number | null; + finishedAt: number | null; + secondaryTimer: number | null; + selectedEventId: string | null; + duration: number | null; + }; + /** * @constructor * @param {object} [timerConfig] * @param {number} [timerConfig.refresh] */ - constructor(timerConfig) { + constructor(timerConfig?) { this._clear(); this._interval = setInterval(() => this.update(), timerConfig?.refresh || 1000); } @@ -45,7 +70,7 @@ export class TimerService { return Math.max( this.timer.startedAt + this.timer.duration + this._pausedInterval + this.timer.addedTime, - this.timer.startedAt + this.timer.startedAt, ); } @@ -64,6 +89,8 @@ export class TimerService { startedAt: null, finishedAt: null, secondaryTimer: null, + selectedEventId: null, + duration: null, }; this.loadedTimerId = null; this._pausedInterval = 0; @@ -138,6 +165,7 @@ export class TimerService { _onLoad() { runtimeState.set('playback', this.playback); runtimeState.set('ontime-timer', this.timer); + integrationService.dispatch(TimerLifeCycle.onLoad); } start() { @@ -172,6 +200,7 @@ export class TimerService { _onStart() { runtimeState.set('playback', this.playback); runtimeState.set('ontime-timer', this.timer); + integrationService.dispatch(TimerLifeCycle.onStart); } pause() { @@ -188,6 +217,7 @@ export class TimerService { _onPause() { runtimeState.set('playback', this.playback); runtimeState.set('ontime-timer', this.timer); + integrationService.dispatch(TimerLifeCycle.onPause); } stop() { @@ -202,6 +232,7 @@ export class TimerService { _onStop() { runtimeState.set('playback', this.playback); runtimeState.set('ontime-timer', this.timer); + integrationService.dispatch(TimerLifeCycle.onStop); } /** @@ -249,8 +280,7 @@ export class TimerService { secondaryTimer: this.timer.secondaryTimer, _secondaryTarget: this._secondaryTarget, }; - const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = - updateRoll(tempCurrentTimer); + const { updatedTimer, updatedSecondaryTimer, doRollLoad, isFinished } = updateRoll(tempCurrentTimer); this.timer.current = updatedTimer; this.timer.secondaryTimer = updatedSecondaryTimer; @@ -272,11 +302,7 @@ export class TimerService { } this.timer.current = - this.timer.startedAt + - this.timer.duration + - this.timer.addedTime + - this._pausedInterval - - this.timer.clock; + this.timer.startedAt + this.timer.duration + this.timer.addedTime + this._pausedInterval - this.timer.clock; this.timer.elapsed = this.timer.duration - this.timer.current; if (this.playback === 'play' && this.timer.current <= 0 && this.timer.finishedAt === null) { @@ -294,11 +320,13 @@ export class TimerService { _onUpdate() { runtimeState.set('playback', this.playback); runtimeState.set('ontime-timer', this.timer); + integrationService.dispatch(TimerLifeCycle.onUpdate); } _onFinish() { runtimeState.set('playback', this.playback); runtimeState.set('ontime-timer', this.timer); + integrationService.dispatch(TimerLifeCycle.onFinish); } roll(currentEvent, nextEvent, timers) { diff --git a/apps/server/src/services/integration-service/IIntegration.ts b/apps/server/src/services/integration-service/IIntegration.ts new file mode 100644 index 000000000..64f81bf75 --- /dev/null +++ b/apps/server/src/services/integration-service/IIntegration.ts @@ -0,0 +1,24 @@ +import { TimerLifeCycle, OscSubscription } from 'ontime-types'; + +export type TimerLifeCycleKey = keyof typeof TimerLifeCycle; + +export default interface IIntegration { + subscriptions: OscSubscription; + init: (config: unknown) => OperationReturn; + dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn; + emit: (...args: unknown[]) => unknown; + shutdown: () => void; +} + +// either went well, or explain what failed +type OperationReturn = ReturnOnSuccess | ReturnOnError; + +type ReturnOnSuccess = { + success: true; + message?: string; +}; + +type ReturnOnError = { + success: false; + message: string; +}; diff --git a/apps/server/src/services/integration-service/IntegrationService.ts b/apps/server/src/services/integration-service/IntegrationService.ts new file mode 100644 index 000000000..65c89ed9d --- /dev/null +++ b/apps/server/src/services/integration-service/IntegrationService.ts @@ -0,0 +1,34 @@ +import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; +import { runtimeState } from '../../stores/EventStore.js'; + +class IntegrationService { + private integrations: IIntegration[]; + + constructor() { + this.integrations = []; + } + + register(integrationService: IIntegration) { + this.integrations.push(integrationService); + } + + unregister(integrationService: IIntegration) { + this.integrations = this.integrations.filter((int) => int !== integrationService); + } + + dispatch(action: TimerLifeCycleKey) { + const state = runtimeState.poll(); + this.integrations.forEach((integration) => { + integration.dispatch(action, state); + }); + } + + shutdown() { + console.log('Shutdown integrations'); + this.integrations.forEach((integration) => { + integration.shutdown(); + }); + } +} + +export const integrationService = new IntegrationService(); diff --git a/apps/server/src/services/integration-service/OscIntegration.ts b/apps/server/src/services/integration-service/OscIntegration.ts new file mode 100644 index 000000000..bf51f8e02 --- /dev/null +++ b/apps/server/src/services/integration-service/OscIntegration.ts @@ -0,0 +1,118 @@ +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 { isObject } from '../../utils/varUtils.js'; + +type Action = TimerLifeCycleKey | string; + +/** + * @description Class contains logic towards outgoing OSC communications + * @class + */ +export class OscIntegration implements IIntegration { + protected oscClient: null | Client; + subscriptions: OscSubscription; + + constructor() { + this.oscClient = null; + this.subscriptions = {}; + } + + /** + * Initializes oscClient + */ + init(config: OSCSettings) { + const { targetIP, portOut, subscriptions } = config; + + this.initSubscriptions(subscriptions); + + // runtime validation + const validateType = typeof targetIP !== 'string' || typeof portOut !== 'number'; + const validateNull = !targetIP || !portOut; + + if (validateType || validateNull) { + return { + success: false, + message: `Config options incorrect`, + }; + } + try { + this.oscClient = new Client(targetIP, portOut); + return { + success: true, + message: `OSC integration client connected to ${targetIP}:${portOut}`, + }; + } catch (error) { + this.oscClient = null; + return { + success: false, + message: `Failed initialising OSC Client: ${error}`, + }; + } + } + + initSubscriptions(subscriptionOptions: OscSubscription) { + this.subscriptions = { ...this.subscriptions, ...subscriptionOptions }; + } + + dispatch(action: Action, state?: object) { + if (!this.oscClient) { + return { + success: false, + message: 'Client not initialised', + }; + } + + if (!action) { + return { + success: false, + message: 'OSC called with no action', + }; + } + + // check subscriptions for action + const { enabled, message } = this.subscriptions?.[action] || {}; + if (enabled) { + const parsedMessage = parseTemplate(message, state || {}); + this.emit('address/', parsedMessage); + } + } + + emit(path: string, payload?: ArgumentType) { + const message = new Message(path); + if (payload) { + try { + if (isObject(payload)) { + message.append(JSON.stringify(payload)); + } else { + message.append(payload); + } + } catch (error) { + console.log('OSC ERROR', error, payload); + } + } + + this.oscClient.send(message, (error) => { + if (error) { + return { + success: false, + message: `error is here ${JSON.stringify(error)}`, + }; + } + return { + success: true, + message: 'OSC Message sent', + }; + }); + } + + shutdown() { + console.log('Shutting down OSC integration'); + if (this.oscClient) { + this.oscClient?.close(); + this.oscClient = null; + } + } +} diff --git a/apps/server/src/services/integration-service/integrationUtils.test.ts b/apps/server/src/services/integration-service/integrationUtils.test.ts new file mode 100644 index 000000000..1f2ad4bc4 --- /dev/null +++ b/apps/server/src/services/integration-service/integrationUtils.test.ts @@ -0,0 +1,53 @@ +import { parseTemplate } from './integrationUtils.js'; + +describe('integrationUtils', () => { + it('correctly parses a given string', () => { + const mockState = { test: 'this' }; + const testString = 'That should replace {{test}}'; + const expected = `That should replace ${mockState.test}`; + + const result = parseTemplate(testString, mockState); + expect(result).toStrictEqual(expected); + }); + + 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 = parseTemplate(testString, mockState); + expect(result).toStrictEqual(expected); + }); + + it('correctly parses a string without templates', () => { + const testString = 'That should replace {test}'; + + const result = parseTemplate(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 = parseTemplate(testString, mockState); + expect(result).toStrictEqual(expected); + }); + + it('doesnt yet handle nested variables', () => { + const mockState = { + timer: { + time: '10', + }, + enabled: 'is', + }; + const testString = 'Timer {{enabled}} enabled with {{timer.time}}ms interval'; + const expected = 'Timer is enabled with {{timer.time}}ms interval'; + + const result = parseTemplate(testString, mockState); + expect(result).toStrictEqual(expected); + }); +}); diff --git a/apps/server/src/services/integration-service/integrationUtils.ts b/apps/server/src/services/integration-service/integrationUtils.ts new file mode 100644 index 000000000..2d4b16893 --- /dev/null +++ b/apps/server/src/services/integration-service/integrationUtils.ts @@ -0,0 +1,18 @@ +// any value inside double curly braces {{val}} +const placeholderRegex = /{{(.*?)}}/g; + +/** + * Parses a templated string + */ +export function parseTemplate(template: string, state: object): string { + let parsedTemplate = template; + let match; + while ((match = placeholderRegex.exec(template)) !== null) { + const variableName = match[1]; + if (Object.hasOwn(state, variableName)) { + parsedTemplate = parsedTemplate.replace(match[0], state[variableName]); + } + } + + return parsedTemplate; +} diff --git a/apps/server/src/utils/__tests__/varUtils.test.ts b/apps/server/src/utils/__tests__/varUtils.test.ts new file mode 100644 index 000000000..fd0f81eb2 --- /dev/null +++ b/apps/server/src/utils/__tests__/varUtils.test.ts @@ -0,0 +1,11 @@ +import { isObject } from '../varUtils.js'; + +describe('isObject', () => { + const testCases = [1, 0, false, undefined, 'test', null, () => undefined, []]; + testCases.forEach((test) => { + it(`recognises normal primitives ${test}`, () => { + const result = isObject(test); + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/server/src/utils/parserFunctions.js b/apps/server/src/utils/parserFunctions.ts similarity index 89% rename from apps/server/src/utils/parserFunctions.js rename to apps/server/src/utils/parserFunctions.ts index 6ed04eeae..330289788 100644 --- a/apps/server/src/utils/parserFunctions.js +++ b/apps/server/src/utils/parserFunctions.ts @@ -1,4 +1,6 @@ import { generateId } from 'ontime-utils'; +import { OSCSettings } from 'ontime-types'; + import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; import { dbModel } from '../models/dataModel.js'; import { validateEvent } from './parser.js'; @@ -148,31 +150,27 @@ export const parseViews = (data, enforce) => { /** * Parse osc portion of an entry - * @param {object} data - data object - * @param {boolean} enforce - whether to create a definition if one is missing - * @returns {object} - event object data */ -export const parseOsc = (data, enforce) => { - let newOsc = {}; +export const parseOsc = ( + data: { osc?: Partial }, + enforce: boolean, +): OSCSettings | Record => { if ('osc' in data) { console.log('Found OSC definition, importing...'); - const s = data.osc; - const osc = {}; - if (s.port) osc.port = s.port; - if (s.portOut) osc.portOut = s.portOut; - if (s.targetIP) osc.targetIP = s.targetIP; - if (typeof s.enabled !== 'undefined') osc.enabled = s.enabled; - // write to db - newOsc = { - ...dbModel.osc, - ...osc, + const loadedConfig = data?.osc || {}; + 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, }; } else if (enforce) { - newOsc = { ...dbModel.osc }; console.log(`Created OSC object in db`); - } - return newOsc; + return { ...dbModel.osc }; + } else return {}; }; /** @@ -185,18 +183,21 @@ export const parseHttp = (data, enforce) => { const newHttp = {}; if ('http' in data) { console.log('Found HTTP definition, importing...'); - const h = data.osc; + const h = data.http; const http = {}; + // @ts-expect-error -- not yet if (h.user) http.user = h.user; + // @ts-expect-error -- not yet if (h.pwd) http.pwd = h.pwd; - // write to db + // @ts-expect-error -- not yet newHttp.http = { ...dbModel.http, ...http, }; } else if (enforce) { + // @ts-expect-error -- not yet newHttp.http = { ...dbModel.http }; console.log(`Created http object in db`); } diff --git a/apps/server/src/utils/varUtils.ts b/apps/server/src/utils/varUtils.ts new file mode 100644 index 000000000..d0fb68a3d --- /dev/null +++ b/apps/server/src/utils/varUtils.ts @@ -0,0 +1,3 @@ +export function isObject(variable: unknown): boolean { + return typeof variable === 'object' && variable !== null && !Array.isArray(variable); +} diff --git a/packages/types/src/definitions/DataModel.type.ts b/packages/types/src/definitions/DataModel.type.ts new file mode 100644 index 000000000..65360a284 --- /dev/null +++ b/packages/types/src/definitions/DataModel.type.ts @@ -0,0 +1,12 @@ +import { OSCSettings } from './core/OscSettings.type.js'; + +export type DatabaseModel = { + rundown: any; + event: any; + settings: any; + views: any; + aliases: any; + userFields: any; + osc: OSCSettings; + http: any; +}; diff --git a/packages/types/src/definitions/TimerType.ts b/packages/types/src/definitions/TimerType.type.ts similarity index 59% rename from packages/types/src/definitions/TimerType.ts rename to packages/types/src/definitions/TimerType.type.ts index 0f2861236..3962446a8 100644 --- a/packages/types/src/definitions/TimerType.ts +++ b/packages/types/src/definitions/TimerType.type.ts @@ -1,7 +1,7 @@ -enum TimerType { +enum TimerTypeType { CountDown = 'count-down', CountUp = 'count-up', Clock = 'clock' } -export default TimerType +export default TimerTypeType diff --git a/packages/types/src/definitions/core/OscSettings.type.ts b/packages/types/src/definitions/core/OscSettings.type.ts new file mode 100644 index 000000000..ee5394533 --- /dev/null +++ b/packages/types/src/definitions/core/OscSettings.type.ts @@ -0,0 +1,13 @@ +import { TimerLifeCycle } from './TimerLifecycle.type'; + +export type TimerLifeCycleKey = keyof typeof TimerLifeCycle; +export type OscSubscription = { [key in TimerLifeCycleKey]?: { message: string; enabled: boolean } }; + +export interface OSCSettings { + portIn: number; + portOut: number; + targetIP: string; + enabledIn: boolean; + enabledOut: boolean; + subscriptions: OscSubscription; +} diff --git a/packages/types/src/definitions/core/TimerLifecycle.type.ts b/packages/types/src/definitions/core/TimerLifecycle.type.ts new file mode 100644 index 000000000..f803b4cdb --- /dev/null +++ b/packages/types/src/definitions/core/TimerLifecycle.type.ts @@ -0,0 +1,8 @@ +export enum TimerLifeCycle { + onLoad = 'onLoad', + onStart = 'onStart', + onPause = 'onPause', + onStop = 'onStop', + onUpdate = 'onUpdate', + onFinish = 'onFinish', +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 625b61a2c..6a5ae3513 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,3 +1,24 @@ -import TimerType from './definitions/TimerType.js'; +import TimerTypeType from './definitions/TimerType.type.js'; +import { DatabaseModel } from './definitions/DataModel.type.js'; +import { TimerLifeCycle } from './definitions/core/TimerLifecycle.type.js'; +import { OSCSettings, OscSubscription } from './definitions/core/OscSettings.type.js'; -export { TimerType }; +// DATA MODEL +export type { DatabaseModel }; + +// ---> Rundown +export type { TimerTypeType }; + +// ---> Event +// ---> Settings +// ---> Views +// ---> Aliases +// ---> User Fields +// ---> OSC +export type { OscSubscription, OSCSettings }; +// ---> HTTP + +// SERVER +export { TimerLifeCycle }; + +// CLIENT diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8e18956a..7b68c55e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,7 @@ importers: react-beautiful-dnd: ^13.1.1 react-dom: ^18.2.0 react-fast-compare: ^3.2.0 + react-hook-form: ^7.43.1 react-qr-code: ^2.0.11 react-router-dom: ^6.3.0 react-table: ^7.7.0 @@ -116,6 +117,7 @@ importers: react-beautiful-dnd: 13.1.1_biqbaboplfbrettd7655fr4n2y react-dom: 18.2.0_react@18.2.0 react-fast-compare: 3.2.0 + react-hook-form: 7.43.1_react@18.2.0 react-qr-code: 2.0.11_react@18.2.0 react-router-dom: 6.6.2_biqbaboplfbrettd7655fr4n2y react-table: 7.8.0_react@18.2.0 @@ -177,8 +179,9 @@ importers: specifiers: '@sentry/node': ^7.24.1 '@sentry/tracing': ^7.24.1 - '@types/express': ^4.17.15 + '@types/express': ^4.17.17 '@types/node': ^16.11.7 + '@types/node-osc': ^6.0.0 '@typescript-eslint/eslint-plugin': ^5.48.1 '@typescript-eslint/parser': ^5.48.1 body-parser: ^1.20.0 @@ -187,7 +190,7 @@ importers: esbuild: ^0.17.5 eslint: ^8.31.0 eslint-plugin-prettier: ^4.2.1 - express: ^4.18.1 + express: ^4.18.2 express-session: ^1.17.3 express-validator: ^6.14.2 lowdb: ^5.0.5 @@ -225,8 +228,9 @@ importers: passport-local: 1.0.0 socket.io: 4.5.4 devDependencies: - '@types/express': 4.17.15 + '@types/express': 4.17.17 '@types/node': 16.18.11 + '@types/node-osc': 6.0.0 '@typescript-eslint/eslint-plugin': 5.48.1_3jon24igvnqaqexgwtxk6nkpse '@typescript-eslint/parser': 5.48.1_iukboom6ndih5an6iafl45j2fe esbuild: 0.17.5 @@ -2922,19 +2926,19 @@ packages: resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==} dev: true - /@types/express-serve-static-core/4.17.32: - resolution: {integrity: sha512-aI5h/VOkxOF2Z1saPy0Zsxs5avets/iaiAJYznQFm5By/pamU31xWKL//epiF4OfUA2qTOc9PV6tCUjhO8wlZA==} + /@types/express-serve-static-core/4.17.33: + resolution: {integrity: sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA==} dependencies: '@types/node': 18.11.18 '@types/qs': 6.9.7 '@types/range-parser': 1.2.4 dev: true - /@types/express/4.17.15: - resolution: {integrity: sha512-Yv0k4bXGOH+8a+7bELd2PqHQsuiANB+A8a4gnQrkRWzrkKlb6KHaVvyXhqs04sVW/OWlbPyYxRgYlIXLfrufMQ==} + /@types/express/4.17.17: + resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==} dependencies: '@types/body-parser': 1.19.2 - '@types/express-serve-static-core': 4.17.32 + '@types/express-serve-static-core': 4.17.33 '@types/qs': 6.9.7 '@types/serve-static': 1.15.0 dev: true @@ -3021,6 +3025,10 @@ packages: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: true + /@types/node-osc/6.0.0: + resolution: {integrity: sha512-25DwJOFe1KueUZz2oIURT3qCMQ28Jdvy9JqGz8d0mKM1Mlx0agHD9N3S0hMKajVCjw7TGtf3gGjbl5gDCFfIWQ==} + dev: true + /@types/node/16.18.11: resolution: {integrity: sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==} dev: true @@ -7462,6 +7470,15 @@ packages: use-sidecar: 1.1.2_kzbn2opkn2327fwg5yzwzya5o4 dev: false + /react-hook-form/7.43.1_react@18.2.0: + resolution: {integrity: sha512-+s3+s8LLytRMriwwuSqeLStVjRXFGxgjjx2jED7Z+wz1J/88vpxieRQGvJVvzrzVxshZ0BRuocFERb779m2kNg==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-is/16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}