)
}
function AlertConfigTab({ values, onChange, connectedBellIndexes }) {
const { alertType, tempo, hourBell, halfBell, quarterBell, isDayOn, dayFrom, dayTo, isNightOn, nightFrom, nightTo } = values
const bellOptions = [{ value: '0', label: 'Disabled' }, ...connectedBellIndexes.map((n) => ({ value: String(n), label: `Bell ${n}` }))]
const validationMsg = (() => {
if (alertType === 'multi' && !tempo) return 'Alert Tempo must be set for Hour Indicating mode.'
if (isDayOn && (!dayFrom || !dayTo)) return 'Day Silence is on — set both From and Until times.'
if (isNightOn && (!nightFrom || !nightTo)) return 'Night Silence is on — set both From and Until times.'
return null
})()
return (
Alert Type onChange({ ...values, alertType: v })} />
)
}
// ─── Configure step ───────────────────────────────────────────────────────────
const CONFIGURE_TABS = [
{ key: 'log', label: 'Logging' },
{ key: 'bell', label: 'Bell' },
{ key: 'clock', label: 'Clock' },
{ key: 'alert', label: 'Alerts' },
{ key: 'backlight', label: 'Backlight' },
]
// Builds the five settings-group form states from a device_attributes object
// (either freshly fetched from the DB, or defaults when there's nothing saved yet).
function buildConfigFormState(attr, maxOutputs) {
const clock = attr.clockSettings || {}
const existingOutputs = attr.bellOutputs || []
const existingTimings = attr.hammerTimings || []
const existingClockOutputs = clock.clockOutputs || []
const existingClockTimings = clock.clockTimings || []
return {
log: {
serial: attr.serialLogLevel ?? 0,
sd: attr.sdLogLevel ?? 0,
mqtt: attr.mqttLogLevel ?? 0,
},
bell: {
masterOn: attr.hasBells ?? false,
activeBells: attr.totalBells || 0,
rows: Array.from({ length: maxOutputs }, (_, i) => ({
output: (existingOutputs[i] ?? 0) === 65535 ? 0 : (existingOutputs[i] ?? 0),
timing: existingTimings[i] ?? 90,
})),
},
clockForm: {
masterOn: attr.hasClock ?? false,
out1: existingClockOutputs[0] ?? 0,
out2: existingClockOutputs[1] ?? 0,
pulse: existingClockTimings[0] || PULSE_STEPS[3],
pause: existingClockTimings[1] || PAUSE_STEPS[1],
},
alert: {
alertType: clock.ringAlerts || 'disabled',
// clock.ringIntervals can be 0/unset when never configured — `||` (not
// `??`) so a falsy 0 also falls back to a real default, matching the
// slider's own visible position instead of silently desyncing from it.
tempo: clock.ringIntervals || 1000,
hourBell: String(clock.hourAlertsBell ?? 0),
halfBell: String(clock.halfhourAlertsBell ?? 0),
quarterBell: String(clock.quarterAlertsBell ?? 0),
isDayOn: clock.isDaySilenceOn ?? false,
dayFrom: extractTime(clock.daySilenceFrom),
dayTo: extractTime(clock.daySilenceTo),
isNightOn: clock.isNightSilenceOn ?? false,
nightFrom: extractTime(clock.nightSilenceFrom),
nightTo: extractTime(clock.nightSilenceTo),
},
backlight: {
enabled: clock.isBacklightAutomationOn ?? false,
output: clock.backlightOutput ?? 0,
onTime: extractTime(clock.backlightTurnOnTime),
offTime: extractTime(clock.backlightTurnOffTime),
},
}
}
function ConfigureStep({ device, onDeviceUpdated, onNext, onBack }) {
const { toast } = useToast()
const deviceSerial = device.serial_number
const maxOutputs = device?.device_subscription?.maxOutputs || 8
const [subTab, setSubTab] = useState('log')
const [loading, setLoading] = useState(true)
const [isOnline, setIsOnline] = useState(false)
const [mode, setMode] = useState(null) // null (choosing) | 'push' | 'skip' | 'save-only'
const [clock, setClock] = useState({})
// ── Form state for each settings group — seeded with defaults, then
// replaced with the device's actual current attributes once loaded below.
// These come from a plain DB read (GET /devices/{id}), not a live device
// poll — the wizard's cached device object (DeviceInventoryItem, from the
// /manufacturing/devices list) never carries device_attributes at all.
const [log, setLog] = useState({ serial: 0, sd: 0, mqtt: 0 })
const [bell, setBell] = useState({ masterOn: false, activeBells: 0, rows: [] })
const [clockForm, setClockForm] = useState({ masterOn: false, out1: 0, out2: 0, pulse: PULSE_STEPS[3], pause: PAUSE_STEPS[1] })
const [alert, setAlert] = useState({
alertType: 'disabled', tempo: 1000, hourBell: '0', halfBell: '0', quarterBell: '0',
isDayOn: false, dayFrom: '', dayTo: '', isNightOn: false, nightFrom: '', nightTo: '',
})
const [backlight, setBacklight] = useState({ enabled: false, output: 0, onTime: '', offTime: '' })
// Fetch the device's saved attributes and its live online status together
// (rather than serially) so the step doesn't sit on a spinner any longer
// than the slower of the two calls.
const loadConfig = useCallback(async () => {
setLoading(true)
try {
const [deviceDoc, mqttStatus] = await Promise.all([
api.get(`/devices/${device.id}`),
api.get('/mqtt/status').catch(() => null),
])
const attr = deviceDoc.device_attributes || {}
const state = buildConfigFormState(attr, maxOutputs)
setClock(attr.clockSettings || {})
setLog(state.log)
setBell(state.bell)
setClockForm(state.clockForm)
setAlert(state.alert)
setBacklight(state.backlight)
const match = (mqttStatus?.devices || []).find((d) => d.device_serial === deviceSerial)
setIsOnline(!!match?.online)
} finally {
setLoading(false)
}
}, [device.id, deviceSerial, maxOutputs])
useEffect(() => { loadConfig() }, [loadConfig])
// Always bind to the real deviceSerial (not gated on `mode`) — gating it
// meant the very first push after clicking "Push Settings to Device" used
// a stale closure from the render where mode was still null, so the first
// command always failed instantly with "No device id." before the
// useDeviceCommand hook had a chance to re-render with the real serial.
const { sendMqttCommandSilent: sendSilently } = useDeviceCommand({ deviceSerial })
// Outputs are exclusive across Bell / Clock / Backlight — each group can
// only see its own current picks plus whatever's still free; anything
// claimed by one of the other two groups is shown disabled everywhere else.
const bellOutputsUsed = bell.rows.slice(0, bell.activeBells).map((r) => r.output).filter((o) => o > 0)
const connectedBellIndexes = bell.rows.slice(0, bell.activeBells)
.map((r, i) => ({ output: r.output, bellNumber: i + 1 }))
.filter(({ output }) => output > 0)
.map(({ bellNumber }) => bellNumber)
const clockOutputsUsed = [clockForm.out1, clockForm.out2].filter((o) => o > 0)
const backlightOutputUsed = backlight.output > 0 ? [backlight.output] : []
// ── Build the command sequence + Firestore patch for each group ─────────
const buildGroups = useCallback(() => {
const activeBellRows = bell.rows.slice(0, bell.activeBells)
// relay.set_config is a partial update — any bell index NOT present in
// these maps is left untouched by the firmware. Send every row (not just
// the active ones) so anything beyond activeBells is explicitly zeroed —
// same fix as EditBellOutputsModal.
const durations = {}
const outputs = {}
bell.rows.forEach((r, i) => { durations[i] = r.timing; outputs[i] = i < bell.activeBells ? r.output : 0 })
return [
{
key: 'log', label: 'Logging Settings',
commands: [
{ cmd: LOG_CMD.serial, contents: { level: log.serial } },
{ cmd: LOG_CMD.sd, contents: { level: log.sd } },
{ cmd: LOG_CMD.mqtt, contents: { level: log.mqtt } },
],
firestorePatch: {
device_attributes: {
[LOG_ATTR.serial]: log.serial, [LOG_ATTR.sd]: log.sd, [LOG_ATTR.mqtt]: log.mqtt,
},
},
},
{
key: 'bell', label: 'Bell Settings',
commands: [
{ cmd: bell.masterOn ? 'bells.enable' : 'bells.disable', contents: {} },
...(bell.activeBells > 0 ? [{ cmd: 'relay.set_config', contents: { durations, outputs } }] : []),
],
firestorePatch: {
device_attributes: {
hasBells: bell.masterOn,
totalBells: bell.activeBells,
bellOutputs: activeBellRows.map((r) => r.output),
hammerTimings: activeBellRows.map((r) => r.timing),
},
},
},
{
key: 'clock', label: 'Clock Settings',
commands: [
{ cmd: 'clock.set_config', contents: { enabled: clockForm.masterOn, c1: clockForm.out1, c2: clockForm.out2, pulse_duration: clockForm.pulse, pause_duration: clockForm.pause } },
],
firestorePatch: {
device_attributes: {
hasClock: clockForm.masterOn,
clockSettings: {
clockOutputs: [clockForm.out1, clockForm.out2].filter((o) => o > 0),
clockTimings: [clockForm.pulse, clockForm.pause],
},
},
},
},
{
key: 'alert', label: 'Alert Settings',
commands: [
{ cmd: 'clock.set_alerts_config', contents: {
alert_type: ALERT_TYPE_TO_FIRMWARE[alert.alertType],
alert_interval: alert.alertType === 'multi' ? alert.tempo : undefined,
hour_bell: toFirmwareBell(Number(alert.hourBell)),
half_bell: toFirmwareBell(Number(alert.halfBell)),
quarter_bell: toFirmwareBell(Number(alert.quarterBell)),
daytime_silence: alert.isDayOn,
daytime_on: alert.isDayOn ? alert.dayFrom : undefined,
daytime_off: alert.isDayOn ? alert.dayTo : undefined,
night_silence: alert.isNightOn,
night_on: alert.isNightOn ? alert.nightFrom : undefined,
night_off: alert.isNightOn ? alert.nightTo : undefined,
} },
],
firestorePatch: {
device_attributes: {
clockSettings: {
ringAlerts: alert.alertType,
ringIntervals: alert.alertType === 'multi' ? alert.tempo : clock.ringIntervals,
hourAlertsBell: Number(alert.hourBell),
halfhourAlertsBell: Number(alert.halfBell),
quarterAlertsBell: Number(alert.quarterBell),
isDaySilenceOn: alert.isDayOn,
daySilenceFrom: alert.isDayOn && alert.dayFrom ? timeToIso(alert.dayFrom) : (clock.daySilenceFrom || ''),
daySilenceTo: alert.isDayOn && alert.dayTo ? timeToIso(alert.dayTo) : (clock.daySilenceTo || ''),
isNightSilenceOn: alert.isNightOn,
nightSilenceFrom: alert.isNightOn && alert.nightFrom ? timeToIso(alert.nightFrom) : (clock.nightSilenceFrom || ''),
nightSilenceTo: alert.isNightOn && alert.nightTo ? timeToIso(alert.nightTo) : (clock.nightSilenceTo || ''),
},
},
},
},
{
key: 'backlight', label: 'Backlight Settings',
commands: [
{ cmd: 'clock.set_backlight', contents: {
backlight: backlight.enabled,
backlight_output: Number(backlight.output),
backlight_on: backlight.enabled ? backlight.onTime : undefined,
backlight_off: backlight.enabled ? backlight.offTime : undefined,
} },
],
firestorePatch: {
device_attributes: {
clockSettings: {
isBacklightAutomationOn: backlight.enabled,
backlightOutput: Number(backlight.output),
backlightTurnOnTime: backlight.enabled && backlight.onTime ? timeToIso(backlight.onTime) : '',
backlightTurnOffTime: backlight.enabled && backlight.offTime ? timeToIso(backlight.offTime) : '',
},
},
},
},
]
}, [log, bell, clockForm, alert, backlight, clock])
// ── Sequencer ─────────────────────────────────────────────────────────
const [sequencing, setSequencing] = useState(false)
const [groupStatus, setGroupStatus] = useState({})
const [failedGroup, setFailedGroup] = useState(null) // { group, attempt }
const groupsRef = useRef(buildGroups())
const runGroup = useCallback(async (group) => {
setGroupStatus((prev) => ({ ...prev, [group.key]: 'running' }))
let lastError = null
for (let attempt = 1; attempt <= 3; attempt++) {
try {
for (const { cmd, contents } of group.commands) {
await sendSilently(cmd, contents)
}
// Device confirmed every command in this group — persist to Firestore.
await api.put(`/devices/${device.id}`, group.firestorePatch)
setGroupStatus((prev) => ({ ...prev, [group.key]: 'success' }))
return true
} catch (err) {
lastError = err
}
}
setGroupStatus((prev) => ({ ...prev, [group.key]: 'failed' }))
setFailedGroup({ group, error: lastError })
return false
}, [sendSilently, device.id])
const runSequence = useCallback(async (groups) => {
setSequencing(true)
setFailedGroup(null)
for (const group of groups) {
const ok = await runGroup(group)
if (!ok) { setSequencing(false); return }
}
setSequencing(false)
const updated = await api.get(`/manufacturing/devices/${device.serial_number}`)
onDeviceUpdated(updated)
toast.success('Configured', 'All device settings were applied successfully.')
onNext()
}, [runGroup, device.serial_number, onDeviceUpdated, onNext, toast])
const handlePush = () => {
const groups = buildGroups()
groupsRef.current = groups
setMode('push')
setGroupStatus({})
runSequence(groups)
}
const handleRetryFailed = () => {
if (!failedGroup) return
const remaining = groupsRef.current.slice(groupsRef.current.findIndex((g) => g.key === failedGroup.group.key))
setFailedGroup(null)
runSequence(remaining)
}
const handleForceProceed = () => {
toast.danger('Skipped', `${failedGroup.group.label} was not confirmed by the device — settings were not saved.`)
setFailedGroup(null)
setSequencing(false)
onNext()
}
const handleSaveOnly = async () => {
setMode('save-only')
try {
const groups = buildGroups()
const merged = groups.reduce(
(acc, g) => deepMerge(acc, g.firestorePatch),
{ device_attributes: {} }
)
await api.put(`/devices/${device.id}`, merged)
const updated = await api.get(`/manufacturing/devices/${device.serial_number}`)
onDeviceUpdated(updated)
toast.success('Saved', 'Settings saved to the console. They will need to be pushed to the device once it comes online.')
onNext()
} catch (err) {
toast.danger('Error', err.message || 'Failed to save settings.')
}
}
const handleSkipConfigure = () => {
onNext()
}
// ── Loading gate — waits on the device's saved attributes + online status ──
if (loading) {
return
}
if (!mode) {
return (
{isOnline ? 'Device Online' : 'Device Offline'}
{isOnline ? 'Commands can be sent and confirmed live.' : 'The device is not currently reachable over MQTT.'}
{isOnline ? (
Configure logging, bells, clock, alerts, and backlight below, then push all settings to the device.
) : (
This device is offline, so settings can't be pushed and confirmed right now. You can save your configuration to the console only (it will need to be pushed once the device comes online), or skip configuration entirely for now.