Skip to content

Commit 91803e9

Browse files
authored
feat: release 1.6.0 (#22)
* chore: update deps * feat: re-implement action to broadcast using sequence builder (#23) * feat: re-implement action to broadcast using sequence builder * feat: use db seed to take the single audio id and convert it to a sequence * docs: note that relation can be removed in future version * Feat/contact point configuration (#24) * feat: config for control point * feat: trigger actions with control point * fix: don't send sounders sounds that don't have files * feat: allow sounders to confirm enrollment * feat: bump to node 24 * feat: bump version number * docs: update license to 2026 * feat: allow buttons to enroll the same as sounders, ready for docker button * feat: display tts and worker status on the dashboard * fix: use translated strings
1 parent 2547f85 commit 91803e9

26 files changed

Lines changed: 509 additions & 290 deletions

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Start with the node debian image
2-
FROM node:22-bullseye-slim AS base
2+
FROM node:24-bullseye-slim AS base
33

44
# Install openssl for Prisma
55
RUN apt-get update && apt-get install openssl -y

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2025 Open School Bell
3+
Copyright (c) 2025 - 2026 Open School Bell
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy of
66
this software and associated documentation files (the "Software"), to deal in

app/lib/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const VERSION = '1.5.0'
1+
export const VERSION = '1.6.0'
22

33
export const RequiredVersions = {
44
controller: VERSION,

app/lib/sequence-builder.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,49 @@ export const SequenceBuilder = ({
130130
</div>
131131
)
132132
}
133+
134+
export const SequenceViewer = ({
135+
queue,
136+
sounds,
137+
label
138+
}: {
139+
queue: string[]
140+
sounds: Audio[]
141+
label: string
142+
}) => {
143+
const {t} = useTranslation()
144+
145+
let duration = 0
146+
147+
return (
148+
<div className="grid grid-cols-4 gap-4">
149+
<span className="font-semibold col-span-4">{label}</span>
150+
<div className="col-span-3 row-span-2">
151+
{queue.map((queuedId, i) => {
152+
const sound = sounds.filter(({id}) => {
153+
return id === queuedId
154+
})[0]
155+
156+
duration += sound.duration
157+
158+
return (
159+
<div
160+
key={`${sound.id}-${i}`}
161+
className="border-b border-b-stone-100 mb-2 pb-2 grid grid-cols-5"
162+
>
163+
<p className="col-span-4">{sound.name}</p>
164+
<p className="col-span-4 text-sm text-gray-400">
165+
{getSecondsAsTime(sound.duration)}
166+
</p>
167+
</div>
168+
)
169+
})}
170+
<div>
171+
{t('broadcast.builder.totalDuration', {
172+
duration: getSecondsAsTime(duration)
173+
})}
174+
</div>
175+
</div>
176+
</div>
177+
)
178+
}

app/lib/settings.server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ type SettingKey =
1212
| 'password'
1313
| 'ttsSpeed'
1414
| 'enrollUrl'
15+
| 'controlPointKey'
16+
| 'ttsLastSeen'
17+
| 'workerLastSeen'
1518

1619
export const DEFAULT_SETTINGS: {[setting in SettingKey]: string} = {
1720
lockdownEntrySound: '',
@@ -23,7 +26,10 @@ export const DEFAULT_SETTINGS: {[setting in SettingKey]: string} = {
2326
lockdownRepetitions: '4',
2427
password: 'bell',
2528
ttsSpeed: '1',
26-
enrollUrl: 'http://controller:3000'
29+
enrollUrl: 'http://controller:3000',
30+
controlPointKey: '',
31+
ttsLastSeen: '"1970-01-01T23:00:00.000Z"',
32+
workerLastSeen: '"1970-01-01T23:00:00.000Z"'
2733
}
2834

2935
export const getSetting = async (setting: SettingKey) => {

app/lib/trigger-action.server.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {type Action} from '@prisma/client'
2+
3+
import {broadcast} from './broadcast.server'
4+
import {toggleLockdown} from './lockdown.server'
5+
6+
export const triggerAction = async (action: Action, zone: string) => {
7+
switch (action.action) {
8+
case 'broadcast':
9+
if (!zone || typeof zone !== 'string' || zone.trim() === '') {
10+
return Response.json({error: 'missing zone'}, {status: 400})
11+
}
12+
13+
await broadcast(zone, action.data)
14+
break
15+
case 'lockdown':
16+
await toggleLockdown()
17+
break
18+
default:
19+
break
20+
}
21+
}

app/locales/en.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export const en = {
1919
'nav.logout': 'Logout',
2020
'dashboard.pageTitle': 'Open School Bell',
2121
'dashboard.devices': 'Sounders',
22+
'dashboard.services': 'Services',
23+
'dashboard.services.background': 'Background Worker',
24+
'dashboard.services.tts': 'Text-To-Speech Engine',
2225
'dashboard.table.name': 'Name',
2326
'dashboard.table.status': 'Status',
2427
'dashboard.table.lastSeen': 'Last Seen',
@@ -53,9 +56,14 @@ export const en = {
5356
'An emoji to use as the action icon. Note that emoji render differently on the RPi screen.',
5457
'actions.form.type.label': 'Type',
5558
'actions.form.type.helper':
56-
'Broadcast runs a broadcast to the supplied zone. Lockdown toggles a system wide lockdown.',
59+
'Broadcast runs a broadcast to the supplied zone, you can build the broadcaast once the action has been created. Lockdown toggles a system wide lockdown.',
60+
'actions.form.sequence.label': 'Sequence',
61+
'actions.form.sequence.helper': 'Build your broadcast sequence.',
5762
'actions.form.sound.label': 'Sound',
5863
'actions.form.sound.helper': 'Which sound should be used when broadcasting?',
64+
'actions.form.pin.label': 'Control Point Pin',
65+
'actions.form.pin.helper':
66+
'The PIN used to trigger this action from the control point interface.',
5967
'button.cancel': 'Cancel',
6068
'button.add': 'Add',
6169
'button.save': 'Save',
@@ -64,8 +72,10 @@ export const en = {
6472
'actions.detail.icon': 'Icon',
6573
'actions.detail.type': 'Type',
6674
'actions.detail.sound': 'Sound:',
75+
'actions.detail.pin': 'Control Pin',
6776
'actions.edit.metaTitle': 'Edit {{name}}',
6877
'actions.edit.pageTitle': 'Edit {{name}}',
78+
'actions.detail.sequence.label': 'Sequence',
6979
'backup.pageTitle': 'Backups',
7080
'backup.create': 'Create Backup',
7181
'broadcast.pageTitle': 'Broadcast',
@@ -203,6 +213,9 @@ export const en = {
203213
'settings.ttsSpeed.label': 'Text-to-speech speed',
204214
'settings.ttsSpeed.helper':
205215
'Speed factor for text-to-speech generation. Default is 1; lower is faster.',
216+
'settings.controlPointKey.label': 'Control Point Key',
217+
'settings.controlPointKey.helper':
218+
'The key used by the Control Point App to communicate with the controller.',
206219
'settings.password.label': 'Change password',
207220
'settings.password.helper':
208221
'Leave fields empty to keep the current password.',

app/routes/_index.tsx

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {enUS, pl} from 'date-fns/locale'
1010
import {getPrisma} from '~/lib/prisma.server'
1111
import {checkSession} from '~/lib/session'
1212
import {pageTitle} from '~/lib/utils'
13-
import {getSetting} from '~/lib/settings.server'
13+
import {getSettings} from '~/lib/settings.server'
1414
import {Page} from '~/lib/ui'
1515
import {useTranslation} from '~/lib/i18n'
1616
import {translate} from '~/lib/i18n.shared'
@@ -31,9 +31,13 @@ export const loader = async ({request}: LoaderFunctionArgs) => {
3131
const buttons = await prisma.actionButton.findMany({orderBy: {name: 'asc'}})
3232
const logs = await prisma.log.findMany({orderBy: {time: 'desc'}, take: 10})
3333

34-
const lockdownMode = await getSetting('lockdownMode')
34+
const {lockdownMode, workerLastSeen, ttsLastSeen} = await getSettings([
35+
'lockdownMode',
36+
'workerLastSeen',
37+
'ttsLastSeen'
38+
])
3539

36-
return {sounders, lockdownMode, buttons, logs}
40+
return {sounders, lockdownMode, buttons, logs, workerLastSeen, ttsLastSeen}
3741
}
3842

3943
export const meta: MetaFunction = ({matches}) => {
@@ -42,7 +46,8 @@ export const meta: MetaFunction = ({matches}) => {
4246
}
4347

4448
export default function Index() {
45-
const {sounders, lockdownMode, buttons, logs} = useLoaderData<typeof loader>()
49+
const {sounders, lockdownMode, buttons, logs, workerLastSeen, ttsLastSeen} =
50+
useLoaderData<typeof loader>()
4651
const {t, locale} = useTranslation()
4752
const dateLocale = locale === 'pl' ? pl : enUS
4853
useLivePageData()
@@ -185,6 +190,52 @@ export default function Index() {
185190
</tbody>
186191
</table>
187192
</div>
193+
<div className="box">
194+
<h2>{t('dashboard.services')}</h2>
195+
<table className="box-table">
196+
<thead>
197+
<tr>
198+
<th className="p-2">{t('dashboard.table.name')}</th>
199+
<th className="p-2">{t('dashboard.table.status')}</th>
200+
<th className="p-2">{t('dashboard.table.lastSeen')}</th>
201+
</tr>
202+
</thead>
203+
<tbody>
204+
<tr>
205+
<td>{t('dashboard.services.background')}</td>
206+
<td className="text-center">
207+
{new Date().getTime() / 1000 -
208+
new Date(JSON.parse(workerLastSeen)).getTime() / 1000 <
209+
65
210+
? '🟢'
211+
: '🔴'}
212+
</td>
213+
<td>
214+
{formatDistance(JSON.parse(workerLastSeen), new Date(), {
215+
addSuffix: true,
216+
locale: dateLocale
217+
})}
218+
</td>
219+
</tr>
220+
<tr>
221+
<td>{t('dashboard.services.tts')}</td>
222+
<td className="text-center">
223+
{new Date().getTime() / 1000 -
224+
new Date(JSON.parse(ttsLastSeen)).getTime() / 1000 <
225+
65
226+
? '🟢'
227+
: '🔴'}
228+
</td>
229+
<td>
230+
{formatDistance(JSON.parse(ttsLastSeen), new Date(), {
231+
addSuffix: true,
232+
locale: dateLocale
233+
})}
234+
</td>
235+
</tr>
236+
</tbody>
237+
</table>
238+
</div>
188239
</div>
189240
</Page>
190241
)

app/routes/actions.$action._index.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {Page, Actions} from '~/lib/ui'
1212
import {useTranslation} from '~/lib/i18n'
1313
import {translate} from '~/lib/i18n.shared'
1414
import {getRootI18n} from '~/lib/i18n.meta'
15+
import {SequenceViewer} from '~/lib/sequence-builder'
1516

1617
export const meta: MetaFunction = ({matches}) => {
1718
const {messages} = getRootI18n(matches)
@@ -28,15 +29,16 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => {
2829
const prisma = getPrisma()
2930

3031
const action = await prisma.action.findFirstOrThrow({
31-
where: {id: params.action},
32-
include: {audio: true}
32+
where: {id: params.action}
3333
})
3434

35-
return {action}
35+
const sounds = await prisma.audio.findMany({orderBy: {name: 'asc'}})
36+
37+
return {action, sounds}
3638
}
3739

3840
const Action = () => {
39-
const {action} = useLoaderData<typeof loader>()
41+
const {action, sounds} = useLoaderData<typeof loader>()
4042
const navigate = useNavigate()
4143
const {t} = useTranslation()
4244
const typeLabels: Record<string, string> = {
@@ -55,10 +57,14 @@ const Action = () => {
5557
{typeLabels[action.action] ?? action.action}
5658
</p>
5759
<p>
58-
{t('actions.detail.sound')}{' '}
59-
<Link to={`/sounds/${action.audioId}`}>{action.audio!.name}</Link>
60+
{t('actions.detail.pin')}: {action.controlPin}
6061
</p>
6162
</div>
63+
<SequenceViewer
64+
sounds={sounds}
65+
label={t('actions.detail.sequence.label')}
66+
queue={action.data === '' ? [] : JSON.parse(action.data)}
67+
/>
6268
<Actions
6369
actions={[
6470
{

app/routes/actions.$action.edit.tsx

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {Page, FormElement, Actions} from '~/lib/ui'
1414
import {useTranslation} from '~/lib/i18n'
1515
import {translate} from '~/lib/i18n.shared'
1616
import {getRootI18n} from '~/lib/i18n.meta'
17+
import {SequenceBuilder} from '~/lib/sequence-builder'
1718

1819
export const meta: MetaFunction<typeof loader> = ({matches, data}) => {
1920
const {messages} = getRootI18n(matches)
@@ -61,16 +62,20 @@ export const action = async ({params, request}: ActionFunctionArgs) => {
6162
const name = formData.get('name') as string | undefined
6263
const icon = formData.get('icon') as string | undefined
6364
const action = formData.get('action') as string | undefined
64-
const sound = formData.get('sound') as string | undefined
65+
const data = formData.get('data') as string | undefined
66+
const controlPin = (formData.get('controlPin') as string | undefined)
67+
? (formData.get('controlPin') as string | undefined)
68+
: ''
6569

6670
invariant(name)
6771
invariant(icon)
6872
invariant(action)
69-
invariant(sound)
73+
invariant(data)
74+
invariant(controlPin)
7075

7176
await prisma.action.update({
7277
where: {id: params.action},
73-
data: {name, icon, action, audioId: sound}
78+
data: {name, icon, action, data, controlPin}
7479
})
7580

7681
return redirect(`/actions/${params.action}`)
@@ -104,6 +109,16 @@ const AddAction = () => {
104109
defaultValue={action.icon}
105110
/>
106111
</FormElement>
112+
<FormElement
113+
label={t('actions.form.pin.label')}
114+
helperText={t('actions.form.pin.helper')}
115+
>
116+
<input
117+
name="controlPin"
118+
className={INPUT_CLASSES}
119+
defaultValue={action.controlPin}
120+
/>
121+
</FormElement>
107122
<FormElement
108123
label={t('actions.form.type.label')}
109124
helperText={t('actions.form.type.helper')}
@@ -117,24 +132,13 @@ const AddAction = () => {
117132
<option value="lockdown">{t('actions.types.lockdown')}</option>
118133
</select>
119134
</FormElement>
120-
<FormElement
121-
label={t('actions.form.sound.label')}
122-
helperText={t('actions.form.sound.helper')}
123-
>
124-
<select
125-
name="sound"
126-
className={INPUT_CLASSES}
127-
defaultValue={action.audioId!}
128-
>
129-
{sounds.map(({id, name}) => {
130-
return (
131-
<option key={id} value={id}>
132-
{name}
133-
</option>
134-
)
135-
})}
136-
</select>
137-
</FormElement>
135+
<SequenceBuilder
136+
sounds={sounds}
137+
initialQueue={action.data === '' ? [] : JSON.parse(action.data)}
138+
name="data"
139+
label={t('actions.form.sequence.label')}
140+
helperText={t('actions.form.sequence.helper')}
141+
/>
138142
<Actions
139143
actions={[
140144
{

0 commit comments

Comments
 (0)