diff --git a/app/lib/settings.server.ts b/app/lib/settings.server.ts index 725f69b..f8e3807 100644 --- a/app/lib/settings.server.ts +++ b/app/lib/settings.server.ts @@ -12,6 +12,7 @@ type SettingKey = | 'password' | 'ttsSpeed' | 'enrollUrl' + | 'controlPointKey' export const DEFAULT_SETTINGS: {[setting in SettingKey]: string} = { lockdownEntrySound: '', @@ -23,7 +24,8 @@ export const DEFAULT_SETTINGS: {[setting in SettingKey]: string} = { lockdownRepetitions: '4', password: 'bell', ttsSpeed: '1', - enrollUrl: 'http://controller:3000' + enrollUrl: 'http://controller:3000', + controlPointKey: '' } export const getSetting = async (setting: SettingKey) => { diff --git a/app/lib/trigger-action.server.ts b/app/lib/trigger-action.server.ts new file mode 100644 index 0000000..c4e6720 --- /dev/null +++ b/app/lib/trigger-action.server.ts @@ -0,0 +1,21 @@ +import {type Action} from '@prisma/client' + +import {broadcast} from './broadcast.server' +import {toggleLockdown} from './lockdown.server' + +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}) + } + + await broadcast(zone, action.data) + break + case 'lockdown': + await toggleLockdown() + break + default: + break + } +} diff --git a/app/locales/en.ts b/app/locales/en.ts index ba606d3..b3e2532 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -58,6 +58,9 @@ export const en = { 'actions.form.sequence.helper': 'Build your broadcast sequence.', 'actions.form.sound.label': 'Sound', 'actions.form.sound.helper': 'Which sound should be used when broadcasting?', + 'actions.form.pin.label': 'Control Point Pin', + 'actions.form.pin.helper': + 'The PIN used to trigger this action from the control point interface.', 'button.cancel': 'Cancel', 'button.add': 'Add', 'button.save': 'Save', @@ -66,6 +69,7 @@ export const en = { 'actions.detail.icon': 'Icon', 'actions.detail.type': 'Type', 'actions.detail.sound': 'Sound:', + 'actions.detail.pin': 'Control Pin', 'actions.edit.metaTitle': 'Edit {{name}}', 'actions.edit.pageTitle': 'Edit {{name}}', 'actions.detail.sequence.label': 'Sequence', @@ -206,6 +210,9 @@ export const en = { 'settings.ttsSpeed.label': 'Text-to-speech speed', 'settings.ttsSpeed.helper': 'Speed factor for text-to-speech generation. Default is 1; lower is faster.', + 'settings.controlPointKey.label': 'Control Point Key', + 'settings.controlPointKey.helper': + 'The key used by the Control Point App to communicate with the controller.', 'settings.password.label': 'Change password', 'settings.password.helper': 'Leave fields empty to keep the current password.', diff --git a/app/routes/actions.$action._index.tsx b/app/routes/actions.$action._index.tsx index c267f76..0613420 100644 --- a/app/routes/actions.$action._index.tsx +++ b/app/routes/actions.$action._index.tsx @@ -56,6 +56,9 @@ const Action = () => { {t('actions.detail.type')}:{' '} {typeLabels[action.action] ?? action.action}

+

+ {t('actions.detail.pin')}: {action.controlPin} +

{ const icon = formData.get('icon') as string | undefined const action = formData.get('action') as string | undefined const data = formData.get('data') as string | undefined + const controlPin = (formData.get('controlPin') as string | undefined) + ? (formData.get('controlPin') as string | undefined) + : '' invariant(name) invariant(icon) invariant(action) invariant(data) + invariant(controlPin) await prisma.action.update({ where: {id: params.action}, - data: {name, icon, action, data} + data: {name, icon, action, data, controlPin} }) return redirect(`/actions/${params.action}`) @@ -105,6 +109,16 @@ const AddAction = () => { defaultValue={action.icon} /> + + + { + const controlPointKey = await getSetting('controlPointKey') + + if (controlPointKey === '') { + return Response.json({ + result: 'error', + error: 'Control Point Key has not been set in the settings.' + }) + } + + const authHeader = request.headers.get('Auth') + + if (!authHeader) { + return Response.json({result: 'error', error: 'No key provided.'}) + } + + if (authHeader !== controlPointKey) { + return Response.json({result: 'error', error: 'Invalid Ket provided.'}) + } + + const prisma = getPrisma() + + const zones = await prisma.zone.findMany({orderBy: {name: 'asc'}}) + + return Response.json({ + zones: zones.map(({id, name}) => { + return {id, name} + }) + }) +} diff --git a/app/routes/control-point.trigger.tsx b/app/routes/control-point.trigger.tsx new file mode 100644 index 0000000..617b443 --- /dev/null +++ b/app/routes/control-point.trigger.tsx @@ -0,0 +1,44 @@ +import {type ActionFunctionArgs} from '@remix-run/node' + +import {getSetting} from '~/lib/settings.server' +import {getPrisma} from '~/lib/prisma.server' + +import {triggerAction} from '~/lib/trigger-action.server' + +export const action = async ({request}: ActionFunctionArgs) => { + const controlPointKey = await getSetting('controlPointKey') + + if (controlPointKey === '') { + return Response.json({ + result: 'error', + error: 'Control Point Key has not been set in the settings.' + }) + } + + const authHeader = request.headers.get('Auth') + + if (!authHeader) { + return Response.json({result: 'error', error: 'No key provided.'}) + } + + if (authHeader !== controlPointKey) { + return Response.json({result: 'error', error: 'Invalid Ket provided.'}) + } + + const data = (await request.json()) as {pin: string; zone: string} + + const prisma = getPrisma() + + const action = await prisma.action.findFirst({where: {controlPin: data.pin}}) + + if (!action) { + return Response.json({result: 'error', error: 'No action for this pin'}) + } + + await triggerAction(action, data.zone) + + return Response.json({ + result: 'success', + message: `${action.icon} ${action.name}` + }) +} diff --git a/app/routes/hook.$hook.tsx b/app/routes/hook.$hook.tsx index 2fae3bd..4669094 100644 --- a/app/routes/hook.$hook.tsx +++ b/app/routes/hook.$hook.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, params}: ActionFunctionArgs) => { const prisma = getPrisma() @@ -22,22 +21,11 @@ export const action = async ({request, params}: ActionFunctionArgs) => { return {error: 'Bad Key Provided'} } - switch (webhook.action.action) { - case 'broadcast': - if (!zone) { - return {error: 'No zone provided.'} - } - - if (webhook.action.audioId) { - await broadcast(zone, webhook.action.data) - } - break - case 'lockdown': - await toggleLockdown() - break - default: - break + if (!zone) { + return {error: 'No zone provided.'} } + await triggerAction(webhook.action, zone) + return {status: 'ok'} } diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 15d2aa8..cd9b7d7 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -27,11 +27,16 @@ export const loader = async ({request}: LoaderFunctionArgs) => { return redirect('/login') } - const {ttsSpeed, enrollUrl} = await getSettings(['ttsSpeed', 'enrollUrl']) + const {ttsSpeed, enrollUrl, controlPointKey} = await getSettings([ + 'ttsSpeed', + 'enrollUrl', + 'controlPointKey' + ]) return { ttsSpeed, - enrollUrl + enrollUrl, + controlPointKey } } @@ -42,12 +47,19 @@ export const action = async ({request}: ActionFunctionArgs) => { const ttsSpeed = formData.get('ttsSpeed') as string | undefined const password = formData.get('password') as string | undefined const checkPassword = formData.get('confirmPassword') as string | undefined + const controlPointKey = (formData.get('controlPointKey') as + | string + | undefined) + ? (formData.get('controlPointKey') as string | undefined) + : '' invariant(enrollUrl) invariant(ttsSpeed) + invariant(controlPointKey) await setSetting('enrollUrl', enrollUrl) await setSetting('ttsSpeed', ttsSpeed) + await setSetting('controlPointKey', controlPointKey) if (password && checkPassword && password === checkPassword) { await setSetting('password', password) @@ -57,7 +69,7 @@ export const action = async ({request}: ActionFunctionArgs) => { } const Settings = () => { - const {ttsSpeed, enrollUrl} = useLoaderData() + const {ttsSpeed, enrollUrl, controlPointKey} = useLoaderData() const {t} = useTranslation() return ( @@ -85,6 +97,17 @@ const Settings = () => { defaultValue={ttsSpeed} /> + + + { const {key, action, zone} = (await request.json()) as { @@ -37,23 +36,11 @@ export const action = async ({request}: ActionFunctionArgs) => { return Response.json({error: 'action not found'}, {status: 404}) } - switch (dbAction.action) { - case 'broadcast': - if (!zone || typeof zone !== 'string' || zone.trim() === '') { - return Response.json({error: 'missing zone'}, {status: 400}) - } - - if (dbAction.audioId) { - const zoneId = zone.trim() - await broadcast(zoneId, JSON.stringify([dbAction.audioId])) - } - break - case 'lockdown': - await toggleLockdown() - break - default: - break + if (!zone || typeof zone !== 'string' || zone.trim() === '') { + return Response.json({error: 'missing zone'}, {status: 400}) } + await triggerAction(dbAction, zone) + return Response.json({ping: 'pong'}) } diff --git a/prisma/migrations/20260324124137_add_control_pin_to_action/migration.sql b/prisma/migrations/20260324124137_add_control_pin_to_action/migration.sql new file mode 100644 index 0000000..c45a455 --- /dev/null +++ b/prisma/migrations/20260324124137_add_control_pin_to_action/migration.sql @@ -0,0 +1,18 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Action" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "icon" TEXT NOT NULL, + "action" TEXT NOT NULL, + "data" TEXT NOT NULL DEFAULT '', + "controlPin" TEXT NOT NULL DEFAULT '', + "audioId" TEXT, + CONSTRAINT "Action_audioId_fkey" FOREIGN KEY ("audioId") REFERENCES "Audio" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Action" ("action", "audioId", "data", "icon", "id", "name") SELECT "action", "audioId", "data", "icon", "id", "name" FROM "Action"; +DROP TABLE "Action"; +ALTER TABLE "new_Action" RENAME TO "Action"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14b37e0..2f634db 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -102,11 +102,12 @@ model Audio { } model Action { - id String @id @default(uuid()) - name String - icon String - action String - data String @default("") + id String @id @default(uuid()) + name String + icon String + action String + data String @default("") + controlPin String @default("") // BREAKING CHANGE When we make the jump to 2.x this can be removed. Not Worth a full V2 bump just to take it out. audio Audio? @relation(fields: [audioId], references: [id])