From 2cdf8348ab94fc43f5248984aeca69e710d90b34 Mon Sep 17 00:00:00 2001
From: AML - A Laycock
Date: Tue, 24 Mar 2026 19:22:40 +0000
Subject: [PATCH 1/2] feat: config for control point
---
app/lib/settings.server.ts | 4 ++-
app/locales/en.ts | 7 ++++
app/routes/actions.$action._index.tsx | 3 ++
app/routes/actions.$action.edit.tsx | 16 ++++++++-
app/routes/control-point.config.tsx | 35 +++++++++++++++++++
app/routes/settings.tsx | 29 +++++++++++++--
.../migration.sql | 18 ++++++++++
prisma/schema.prisma | 11 +++---
8 files changed, 113 insertions(+), 10 deletions(-)
create mode 100644 app/routes/control-point.config.tsx
create mode 100644 prisma/migrations/20260324124137_add_control_pin_to_action/migration.sql
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/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 {
+ result: 'error',
+ error: 'Control Point Key has not been set in the settings.'
+ }
+ }
+
+ const authHeader = request.headers.get('Auth')
+
+ if (!authHeader) {
+ return {result: 'error', error: 'No key provided.'}
+ }
+
+ if (authHeader !== controlPointKey) {
+ return {result: 'error', error: 'Invalid Ket provided.'}
+ }
+
+ const prisma = getPrisma()
+
+ const zones = await prisma.zone.findMany({orderBy: {name: 'asc'}})
+
+ return {
+ zones: zones.map(({id, name}) => {
+ return {id, name}
+ })
+ }
+}
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}
/>
+
+
+
Date: Tue, 24 Mar 2026 20:29:02 +0000
Subject: [PATCH 2/2] feat: trigger actions with control point
---
app/lib/trigger-action.server.ts | 21 +++++++++++
app/routes/control-point.config.tsx | 12 +++----
app/routes/control-point.trigger.tsx | 44 +++++++++++++++++++++++
app/routes/hook.$hook.tsx | 22 +++---------
app/routes/sounder-api.trigger-action.tsx | 23 +++---------
5 files changed, 81 insertions(+), 41 deletions(-)
create mode 100644 app/lib/trigger-action.server.ts
create mode 100644 app/routes/control-point.trigger.tsx
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/routes/control-point.config.tsx b/app/routes/control-point.config.tsx
index d62cace..95513f2 100644
--- a/app/routes/control-point.config.tsx
+++ b/app/routes/control-point.config.tsx
@@ -7,29 +7,29 @@ export const loader = async ({request}: LoaderFunctionArgs) => {
const controlPointKey = await getSetting('controlPointKey')
if (controlPointKey === '') {
- return {
+ 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 {result: 'error', error: 'No key provided.'}
+ return Response.json({result: 'error', error: 'No key provided.'})
}
if (authHeader !== controlPointKey) {
- return {result: 'error', error: 'Invalid Ket provided.'}
+ return Response.json({result: 'error', error: 'Invalid Ket provided.'})
}
const prisma = getPrisma()
const zones = await prisma.zone.findMany({orderBy: {name: 'asc'}})
- return {
+ 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/sounder-api.trigger-action.tsx b/app/routes/sounder-api.trigger-action.tsx
index e36f136..5dc2e9a 100644
--- a/app/routes/sounder-api.trigger-action.tsx
+++ b/app/routes/sounder-api.trigger-action.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, 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'})
}