diff --git a/app/lib/constants.ts b/app/lib/constants.ts index 0d5682d..e5a5c56 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -1,11 +1,12 @@ -export const VERSION = '1.7.0' +export const VERSION = '1.7.1' export const RequiredVersions = { controller: VERSION, - tts: '2.0.0', - piper: '1.3.0', + tts: '2.0.2', + piper: '1.6.0', sounder: '2.4.0', - button: '1.0.0' + button: '1.0.0', + controlPoint: '1.0.0' } export const DOCS_URL = `https://openschoolbell.co.uk` diff --git a/app/lib/log.server.ts b/app/lib/log.server.ts new file mode 100644 index 0000000..1b10d06 --- /dev/null +++ b/app/lib/log.server.ts @@ -0,0 +1,7 @@ +import {getPrisma} from './prisma.server' + +export const log = async (message: string) => { + const prisma = getPrisma() + + await prisma.log.create({data: {message}}) +} diff --git a/app/lib/settings.server.ts b/app/lib/settings.server.ts index d8ecdb4..2e301ba 100644 --- a/app/lib/settings.server.ts +++ b/app/lib/settings.server.ts @@ -9,10 +9,12 @@ type SettingKey = | 'ttsSpeed' | 'enrollUrl' | 'controlPointKey' + | 'controlPointDefaultZone' | 'ttsLastSeen' | 'workerLastSeen' | 'lockdownEntrySequence' | 'lockdownExitSequence' + | 'siteName' export const DEFAULT_SETTINGS: {[setting in SettingKey]: string} = { lockdownRepeat: '5', @@ -22,10 +24,12 @@ export const DEFAULT_SETTINGS: {[setting in SettingKey]: string} = { ttsSpeed: '1', enrollUrl: 'http://controller:3000', controlPointKey: '', + controlPointDefaultZone: '', ttsLastSeen: '"1970-01-01T23:00:00.000Z"', workerLastSeen: '"1970-01-01T23:00:00.000Z"', lockdownEntrySequence: '[]', - lockdownExitSequence: '[]' + lockdownExitSequence: '[]', + siteName: 'Open School Bell' } export const getSetting = async (setting: SettingKey) => { diff --git a/app/lib/trigger-action.server.ts b/app/lib/trigger-action.server.ts index c4e6720..1bd19d7 100644 --- a/app/lib/trigger-action.server.ts +++ b/app/lib/trigger-action.server.ts @@ -2,12 +2,21 @@ import {type Action} from '@prisma/client' import {broadcast} from './broadcast.server' import {toggleLockdown} from './lockdown.server' +import {log} from './log.server' + +export const triggerAction = async ( + action: Action, + zone: string | null | undefined, + source: string, + callbacks: {onMissingZone: (suppliedZone: string | null | undefined) => void} +) => { + await log(`🎬 Triggering action ${action.name} from ${source}`) -export const triggerAction = async (action: Action, zone: string) => { switch (action.action) { case 'broadcast': if (!zone || typeof zone !== 'string' || zone.trim() === '') { - return Response.json({error: 'missing zone'}, {status: 400}) + callbacks.onMissingZone(zone) + return } await broadcast(zone, action.data) diff --git a/app/locales/en.ts b/app/locales/en.ts index 7e1b01f..527911f 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -222,11 +222,17 @@ export const en = { 'settings.controlPointKey.label': 'Control Point Key', 'settings.controlPointKey.helper': 'The key used by the Control Point App to communicate with the controller.', + 'settings.controlPointDefaultZone.label': 'Control Point Zone', + 'settings.controlPointDefaultZone.helper': + 'Which zone should the control point user interface default to?', 'settings.password.label': 'Change password', 'settings.password.helper': 'Leave fields empty to keep the current password.', 'settings.password.placeholderNew': 'New password', 'settings.password.placeholderConfirm': 'Repeat new password', + 'settings.siteName.label': 'Site Name', + 'settings.siteName.helper': + 'The site names to display at the top of the application.', 'schedule.metaTitle': 'Schedule', 'schedule.pageTitle': 'Schedule', 'schedule.defaultOption': 'Default', diff --git a/app/root.tsx b/app/root.tsx index 95b40cc..4c11875 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -38,6 +38,7 @@ import {locales, type SupportedLocale} from '~/locales' import {SidebarLink, NavSep} from './lib/ui' import {I18nProvider, useTranslation} from './lib/i18n' import {initTranslations, type InitTranslationsReturn} from './lib/i18n.server' +import {getSetting} from './lib/settings.server' const FALLBACK_LOCALE: SupportedLocale = 'en' const FALLBACK_TRANSLATIONS: InitTranslationsReturn = { @@ -49,7 +50,10 @@ export const links: LinksFunction = () => [] export const loader = async ({request}: LoaderFunctionArgs) => { const {locale, messages} = initTranslations(request) - return json({locale, messages}) + + const siteName = await getSetting('siteName') + + return {locale, messages, siteName} } export function Layout({children}: {children: React.ReactNode}) { @@ -74,13 +78,14 @@ export function Layout({children}: {children: React.ReactNode}) { } const AppContent = () => { + const {siteName} = useLoaderData() const {t} = useTranslation() return (
- {t('app.title')} + {siteName}
diff --git a/app/routes/_index.tsx b/app/routes/_index.tsx index a27197f..c3dc51f 100644 --- a/app/routes/_index.tsx +++ b/app/routes/_index.tsx @@ -31,13 +31,23 @@ export const loader = async ({request}: LoaderFunctionArgs) => { const buttons = await prisma.actionButton.findMany({orderBy: {name: 'asc'}}) const logs = await prisma.log.findMany({orderBy: {time: 'desc'}, take: 10}) - const {lockdownMode, workerLastSeen, ttsLastSeen} = await getSettings([ - 'lockdownMode', - 'workerLastSeen', - 'ttsLastSeen' - ]) + const {lockdownMode, workerLastSeen, ttsLastSeen, siteName} = + await getSettings([ + 'lockdownMode', + 'workerLastSeen', + 'ttsLastSeen', + 'siteName' + ]) - return {sounders, lockdownMode, buttons, logs, workerLastSeen, ttsLastSeen} + return { + sounders, + lockdownMode, + buttons, + logs, + workerLastSeen, + ttsLastSeen, + siteName + } } export const meta: MetaFunction = ({matches}) => { @@ -46,14 +56,21 @@ export const meta: MetaFunction = ({matches}) => { } export default function Index() { - const {sounders, lockdownMode, buttons, logs, workerLastSeen, ttsLastSeen} = - useLoaderData() + const { + sounders, + lockdownMode, + buttons, + logs, + workerLastSeen, + ttsLastSeen, + siteName + } = useLoaderData() const {t, locale} = useTranslation() const dateLocale = locale === 'pl' ? pl : enUS useLivePageData() return ( - +

{t('dashboard.devices')}

@@ -92,44 +109,46 @@ export default function Index() {
-

- {t('dashboard.lockdown.message', { - status: t( - lockdownMode === '0' - ? 'dashboard.lockdown.status.disabled' - : 'dashboard.lockdown.status.enabled' - ) - })} -

-
{ - if ( - !confirm( - t( - lockdownMode === '0' - ? 'dashboard.lockdown.confirmEnable' - : 'dashboard.lockdown.confirmDisable' - ) +
+

+ {t('dashboard.lockdown.message', { + status: t( + lockdownMode === '0' + ? 'dashboard.lockdown.status.disabled' + : 'dashboard.lockdown.status.enabled' ) - ) { - e.preventDefault() - } - }} - > - -

+ + +

{t('dashboard.buttons')}

diff --git a/app/routes/about.tsx b/app/routes/about.tsx index e0a4632..7066156 100644 --- a/app/routes/about.tsx +++ b/app/routes/about.tsx @@ -131,6 +131,25 @@ export const loader = async ({request}: LoaderFunctionArgs) => { .catch(() => resolve('error')) }) + const controlPointLatest = await new Promise(resolve => { + fetch( + 'https://api.github.com/repos/Open-School-Bell/control-point/releases?per_page=1', + { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28' + } + } + ) + .then(response => { + response + .json() + .then(data => resolve(data[0].tag_name)) + .catch(() => resolve('error')) + }) + .catch(() => resolve('error')) + }) + const prisma = getPrisma() const redis = getRedis() @@ -160,6 +179,8 @@ export const loader = async ({request}: LoaderFunctionArgs) => { buttonVersions[id] = version ? version : '0.0.0' }) + const controlPointVersion = await redis.get('osb-control-point-version') + const license = ( await readFile(path.join(process.cwd(), 'LICENSE')) ).toString() @@ -174,7 +195,9 @@ export const loader = async ({request}: LoaderFunctionArgs) => { buttonLatest, ttsLatest, controllerLatest, - license + license, + controlPointVersion, + controlPointLatest } } @@ -194,7 +217,9 @@ const About = () => { buttonLatest, ttsLatest, controllerLatest, - license + license, + controlPointVersion, + controlPointLatest } = useLoaderData() const {t} = useTranslation() @@ -284,6 +309,24 @@ const About = () => { ) })} + {controlPointVersion ? ( + + Control Point + {controlPointVersion} + + {controlPointLatest.replace('v', '')} + + + {RequiredVersions.controlPoint} + + + ) : ( + '' + )} diff --git a/app/routes/button-api.trigger.tsx b/app/routes/button-api.trigger.tsx index 745fea7..0ac1c7c 100644 --- a/app/routes/button-api.trigger.tsx +++ b/app/routes/button-api.trigger.tsx @@ -1,8 +1,7 @@ import {type ActionFunctionArgs} from '@remix-run/node' import {getPrisma} from '~/lib/prisma.server' -import {broadcast} from '~/lib/broadcast.server' -import {toggleLockdown} from '~/lib/lockdown.server' +import {triggerAction} from '~/lib/trigger-action.server' export const action = async ({request}: ActionFunctionArgs) => { const {key} = (await request.json()) as { @@ -26,23 +25,17 @@ export const action = async ({request}: ActionFunctionArgs) => { const zone = button.zoneId - switch (button.action.action) { - case 'broadcast': - if (!zone || typeof zone !== 'string' || zone.trim() === '') { - return Response.json({error: 'missing zone'}, {status: 400}) - } - - if (button.action.audioId) { - const zoneId = zone.trim() - await broadcast(zoneId, JSON.stringify([button.action.audioId])) - } - break - case 'lockdown': - await toggleLockdown() - break - default: - break - } + let response: {result: 'ok' | 'error'; error?: string} = {result: 'ok'} + let status = 200 + + await triggerAction(button.action, zone, `Button ${button.name}`, { + onMissingZone: () => { + response = {result: 'error', error: 'missing zone'} + status = 400 + + return + } + }) - return Response.json({ping: 'pong'}) + return Response.json(response, {status}) } diff --git a/app/routes/control-point.config.tsx b/app/routes/control-point.config.tsx index 95513f2..71e914a 100644 --- a/app/routes/control-point.config.tsx +++ b/app/routes/control-point.config.tsx @@ -1,10 +1,18 @@ import {type LoaderFunctionArgs} from '@remix-run/node' -import {getSetting} from '~/lib/settings.server' +import {getSettings} from '~/lib/settings.server' import {getPrisma} from '~/lib/prisma.server' +import {getRedis} from '~/lib/redis.server.mjs' export const loader = async ({request}: LoaderFunctionArgs) => { - const controlPointKey = await getSetting('controlPointKey') + const {controlPointKey, controlPointDefaultZone, siteName} = + await getSettings([ + 'controlPointKey', + 'controlPointDefaultZone', + 'siteName' + ]) + + const redis = getRedis() if (controlPointKey === '') { return Response.json({ @@ -14,13 +22,18 @@ export const loader = async ({request}: LoaderFunctionArgs) => { } const authHeader = request.headers.get('Auth') + const versionHeader = request.headers.get('Version') if (!authHeader) { return Response.json({result: 'error', error: 'No key provided.'}) } if (authHeader !== controlPointKey) { - return Response.json({result: 'error', error: 'Invalid Ket provided.'}) + return Response.json({result: 'error', error: 'Invalid Key provided.'}) + } + + if (versionHeader) { + void redis.set(`osb-control-point-version`, versionHeader) } const prisma = getPrisma() @@ -30,6 +43,9 @@ export const loader = async ({request}: LoaderFunctionArgs) => { return Response.json({ zones: zones.map(({id, name}) => { return {id, name} - }) + }), + defaultZone: + controlPointDefaultZone !== '' ? controlPointDefaultZone : zones[0].id, + siteName }) } diff --git a/app/routes/control-point.trigger.tsx b/app/routes/control-point.trigger.tsx index 617b443..7751269 100644 --- a/app/routes/control-point.trigger.tsx +++ b/app/routes/control-point.trigger.tsx @@ -22,7 +22,7 @@ export const action = async ({request}: ActionFunctionArgs) => { } if (authHeader !== controlPointKey) { - return Response.json({result: 'error', error: 'Invalid Ket provided.'}) + return Response.json({result: 'error', error: 'Invalid Key provided.'}) } const data = (await request.json()) as {pin: string; zone: string} @@ -35,10 +35,16 @@ export const action = async ({request}: ActionFunctionArgs) => { return Response.json({result: 'error', error: 'No action for this pin'}) } - await triggerAction(action, data.zone) - - return Response.json({ + let response = { result: 'success', message: `${action.icon} ${action.name}` + } + + await triggerAction(action, data.zone, 'Control Point', { + onMissingZone: zone => { + response = {result: 'error', message: 'Zone does not exist'} + } }) + + return Response.json(response) } diff --git a/app/routes/hook.$hook.tsx b/app/routes/hook.$hook.tsx index 4669094..6d78127 100644 --- a/app/routes/hook.$hook.tsx +++ b/app/routes/hook.$hook.tsx @@ -25,7 +25,13 @@ export const action = async ({request, params}: ActionFunctionArgs) => { return {error: 'No zone provided.'} } - await triggerAction(webhook.action, zone) + let response = {status: 'ok'} - return {status: 'ok'} + await triggerAction(webhook.action, zone, `Webhook: ${params.hook}`, { + onMissingZone: zone => { + response = {status: 'zone not found'} + } + }) + + return Response.json(response) } diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index cd9b7d7..79202f7 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -4,7 +4,7 @@ import { type MetaFunction, redirect } from '@remix-run/node' -import {useLoaderData} from '@remix-run/react' +import {Form, useLoaderData} from '@remix-run/react' import {invariant} from '@arcath/utils' import {getSettings, setSetting} from '~/lib/settings.server' @@ -14,6 +14,7 @@ import {Page, FormElement} from '~/lib/ui' import {useTranslation} from '~/lib/i18n' import {translate} from '~/lib/i18n.shared' import {getRootI18n} from '~/lib/i18n.meta' +import {getPrisma} from '~/lib/prisma.server' export const meta: MetaFunction = ({matches}) => { const {messages} = getRootI18n(matches) @@ -27,16 +28,34 @@ export const loader = async ({request}: LoaderFunctionArgs) => { return redirect('/login') } - const {ttsSpeed, enrollUrl, controlPointKey} = await getSettings([ + const prisma = getPrisma() + + const { + ttsSpeed, + enrollUrl, + controlPointKey, + controlPointDefaultZone, + siteName + } = await getSettings([ 'ttsSpeed', 'enrollUrl', - 'controlPointKey' + 'controlPointKey', + 'controlPointDefaultZone', + 'siteName' ]) + const zones = await prisma.zone.findMany({ + select: {id: true, name: true}, + orderBy: {name: 'asc'} + }) + return { ttsSpeed, enrollUrl, - controlPointKey + controlPointKey, + controlPointDefaultZone, + zones, + siteName } } @@ -52,14 +71,22 @@ export const action = async ({request}: ActionFunctionArgs) => { | undefined) ? (formData.get('controlPointKey') as string | undefined) : '' + const controlPointDefaultZone = formData.get('controlPointDefaultZone') as + | string + | undefined + const siteName = formData.get('siteName') as string | undefined invariant(enrollUrl) invariant(ttsSpeed) invariant(controlPointKey) + invariant(controlPointDefaultZone) + invariant(siteName) await setSetting('enrollUrl', enrollUrl) await setSetting('ttsSpeed', ttsSpeed) await setSetting('controlPointKey', controlPointKey) + await setSetting('controlPointDefaultZone', controlPointDefaultZone) + await setSetting('siteName', siteName) if (password && checkPassword && password === checkPassword) { await setSetting('password', password) @@ -69,12 +96,30 @@ export const action = async ({request}: ActionFunctionArgs) => { } const Settings = () => { - const {ttsSpeed, enrollUrl, controlPointKey} = useLoaderData() + const { + ttsSpeed, + enrollUrl, + controlPointKey, + controlPointDefaultZone, + zones, + siteName + } = useLoaderData() const {t} = useTranslation() return (
+ + + { defaultValue={controlPointKey} /> + + + { }) if (!dbAction) { - return Response.json({error: 'action not found'}, {status: 404}) + return Response.json( + {result: 'error', error: 'action not found'}, + {status: 404} + ) } - if (!zone || typeof zone !== 'string' || zone.trim() === '') { - return Response.json({error: 'missing zone'}, {status: 400}) + let response: {result: 'error' | 'ok'; error?: string} = { + result: 'ok' } + let status = 200 - await triggerAction(dbAction, zone) + await triggerAction(dbAction, zone, `Sounder: ${sounder.name}`, { + onMissingZone: zone => { + response = {result: 'error', error: 'Zone not found.'} + status = 400 + } + }) - return Response.json({ping: 'pong'}) + return Response.json(response, {status}) } diff --git a/app/routes/sounds.add-tts.tsx b/app/routes/sounds.add-tts.tsx index ad54a97..e3c0981 100644 --- a/app/routes/sounds.add-tts.tsx +++ b/app/routes/sounds.add-tts.tsx @@ -72,14 +72,17 @@ export const action = async ({request}: ActionFunctionArgs) => { } }) - const downloadResponse = await fetch(`${process.env.TTS_API}/piper`, { - body: JSON.stringify({ - text: tts, - length_scale: speed - }), - headers: {'Content-Type': 'application/json'}, - method: 'post' - }).catch(() => {}) + const downloadResponse = await fetch( + `${process.env.TTS_API}/piper/synthesize`, + { + body: JSON.stringify({ + text: tts, + length_scale: speed + }), + headers: {'Content-Type': 'application/json'}, + method: 'post' + } + ).catch(() => {}) const downloadStream = fs.createWriteStream( path.join(process.cwd(), 'public', 'sounds', `${sound.id}.wav`)