feat(devices): push General/Clock/Bells/Alerts config live over MQTT

Adopts useDeviceCommand across DeviceDetail and its tabs so config
changes (log levels, clock settings, bell outputs, alert thresholds,
backlight, attributes) are sent to the device immediately via
control commands and only persisted to Firestore once the device
acks success, instead of writing Firestore first and hoping the
device eventually picks it up.

- GeneralTab: log-level sliders now call log.set_serial/sd/mqtt
  directly and revert on failure; a background log.get_config +
  network.info pull reconciles Firestore against the device's actual
  state once per mount
- ClockTab, BellsTab, ControlTab, and the Edit* modals: same
  live-command-then-persist pattern
- EditLoggingModal is removed — its job (log level editing) moved
  inline into GeneralTab's sliders, so a modal round-trip is no
  longer needed
- DeviceDetail wires the shared useDeviceCommand connection through
  to each tab and adds a Health tab entry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:25:30 +03:00
co-authored by Claude Sonnet 5
parent 7c533b9245
commit cc138e3d82
11 changed files with 940 additions and 259 deletions
@@ -112,8 +112,23 @@ function SilenceBlock({ title, isOn, onToggle, from, onFromChange, to, onToChang
) )
} }
export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, id }) { // Console UI uses alertType 'disabled'|'single'|'multi'; firmware uses
const totalBells = attr?.totalBells || 8 // alert_type 'OFF'|'SINGLE'|'HOURS'. Bell fields: console uses 0 = disabled,
// firmware uses 255 = disabled.
const ALERT_TYPE_TO_FIRMWARE = { disabled: 'OFF', single: 'SINGLE', multi: 'HOURS' }
const toFirmwareBell = (n) => (n === 0 ? 255 : n)
export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, id, sendMqttCommand }) {
// Only bells that actually have a relay output assigned — same source of
// truth as BellsTab's bell cards. Ignores attr.totalBells, which can drift
// from the real configured count. 0 and 65535 both mean "no output assigned"
// on the wire (uint16 "unset" sentinel) — treat both as disconnected here too,
// in case older Firestore data still has 65535 values from before that was
// normalized on write.
const connectedBellIndexes = (attr?.bellOutputs || [])
.map((output, i) => ({ output, bellNumber: i + 1 }))
.filter(({ output }) => output > 0 && output !== 65535)
.map(({ bellNumber }) => bellNumber)
const [alertType, setAlertType] = useState(clock?.ringAlerts || 'disabled') const [alertType, setAlertType] = useState(clock?.ringAlerts || 'disabled')
const [ringIntervals, setRingIntervals] = useState(clock?.ringIntervals ?? 1000) const [ringIntervals, setRingIntervals] = useState(clock?.ringIntervals ?? 1000)
@@ -141,12 +156,28 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
// Push to the device first — clock.set_alerts_config batches alert type/bells
// and both silence windows into one round trip. Firestore is only written
// once the device confirms.
await sendMqttCommand('clock.set_alerts_config', {
alert_type: ALERT_TYPE_TO_FIRMWARE[alertType],
alert_interval: alertType === 'multi' ? ringIntervals : undefined,
hour_bell: toFirmwareBell(Number(hourBell)),
half_bell: toFirmwareBell(Number(halfBell)),
quarter_bell: toFirmwareBell(Number(quarterBell)),
daytime_silence: isDayOn,
daytime_on: isDayOn ? daySilenceFrom : undefined,
daytime_off: isDayOn ? daySilenceTo : undefined,
night_silence: isNightOn,
night_on: isNightOn ? nightFrom : undefined,
night_off: isNightOn ? nightTo : undefined,
})
await api.put(`/devices/${id}`, { await api.put(`/devices/${id}`, {
device_attributes: { device_attributes: {
clockSettings: { clockSettings: {
...clock, ...clock,
ringAlerts: alertType, ringAlerts: alertType,
ringAlertsMasterOn: alertType !== 'disabled',
ringIntervals: alertType === 'multi' ? ringIntervals : clock?.ringIntervals, ringIntervals: alertType === 'multi' ? ringIntervals : clock?.ringIntervals,
hourAlertsBell: Number(hourBell), hourAlertsBell: Number(hourBell),
halfhourAlertsBell: Number(halfBell), halfhourAlertsBell: Number(halfBell),
@@ -163,6 +194,7 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
await onSaved() await onSaved()
onClose() onClose()
} catch (err) { } catch (err) {
// sendMqttCommand already surfaced a failure toast if the device rejected it.
setError(err.message || 'Failed to save.') setError(err.message || 'Failed to save.')
} finally { } finally {
setSaving(false) setSaving(false)
@@ -171,7 +203,7 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
const bellOptions = [ const bellOptions = [
{ value: '0', label: 'Disabled' }, { value: '0', label: 'Disabled' },
...Array.from({ length: totalBells }, (_, i) => ({ value: String(i + 1), label: `Bell ${i + 1}` })), ...connectedBellIndexes.map((n) => ({ value: String(n), label: `Bell ${n}` })),
] ]
return ( return (
@@ -11,8 +11,6 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
const [bellGuardOn, setBellGuardOn] = useState(String(attr?.bellGuardOn ?? false)) const [bellGuardOn, setBellGuardOn] = useState(String(attr?.bellGuardOn ?? false))
const [bellGuardSafetyOn, setBellGuardSafetyOn] = useState(String(attr?.bellGuardSafetyOn ?? false)) const [bellGuardSafetyOn, setBellGuardSafetyOn] = useState(String(attr?.bellGuardSafetyOn ?? false))
const [warningsOn, setWarningsOn] = useState(String(attr?.warningsOn ?? false)) const [warningsOn, setWarningsOn] = useState(String(attr?.warningsOn ?? false))
const [hasBells, setHasBells] = useState(String(attr?.hasBells ?? false))
const [hasClock, setHasClock] = useState(String(attr?.hasClock ?? false))
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -20,13 +18,12 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
// App-only settings — no device-side equivalent, Firestore is the source of truth.
await api.put(`/devices/${id}`, { await api.put(`/devices/${id}`, {
device_attributes: { device_attributes: {
bellGuardOn: toBool(bellGuardOn), bellGuardOn: toBool(bellGuardOn),
warningsOn: toBool(warningsOn), warningsOn: toBool(warningsOn),
bellGuardSafetyOn: toBool(bellGuardSafetyOn), bellGuardSafetyOn: toBool(bellGuardSafetyOn),
hasBells: toBool(hasBells),
hasClock: toBool(hasClock),
}, },
}) })
await onSaved() await onSaved()
@@ -42,7 +39,7 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Edit Basic Attributes" title="Edit Safety Settings"
size="sm" size="sm"
footer={ footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}> <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
@@ -84,31 +81,6 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
<option value="true">Active</option> <option value="true">Active</option>
<option value="false">Disabled</option> <option value="false">Disabled</option>
</FormField> </FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField
label="Bells Mechanism"
name="hasBells"
type="select"
value={hasBells}
onChange={(e) => setHasBells(e.target.value)}
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</FormField>
<FormField
label="Clock Output"
name="hasClock"
type="select"
value={hasClock}
onChange={(e) => setHasClock(e.target.value)}
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</FormField>
</div>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Connected bells: {Number.isFinite(attr?.totalBells) ? attr.totalBells : '—'} — managed via Bell Mechanisms tab.
</p>
{error && ( {error && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p> <p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
)} )}
@@ -23,7 +23,7 @@ function timeToIso(hhmm) {
const toBool = (v) => v === 'true' || v === true const toBool = (v) => v === 'true' || v === true
export default function EditBacklightModal({ open, onClose, onSaved, attr, clock, sub, id }) { export default function EditBacklightModal({ open, onClose, onSaved, attr, clock, sub, id, sendMqttCommand }) {
const maxOutputs = sub?.maxOutputs || 8 const maxOutputs = sub?.maxOutputs || 8
const bellOutputsUsed = (attr?.bellOutputs || []).filter((o) => o > 0) const bellOutputsUsed = (attr?.bellOutputs || []).filter((o) => o > 0)
const clockOutputsUsed = (clock?.clockOutputs || []).filter((o) => o > 0) const clockOutputsUsed = (clock?.clockOutputs || []).filter((o) => o > 0)
@@ -47,20 +47,30 @@ export default function EditBacklightModal({ open, onClose, onSaved, attr, clock
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
const enabled = toBool(isOn)
// Push to the device first — only write Firestore once it confirms.
await sendMqttCommand('clock.set_backlight', {
backlight: enabled,
backlight_output: Number(output),
backlight_on: enabled ? onTime : undefined,
backlight_off: enabled ? offTime : undefined,
})
await api.put(`/devices/${id}`, { await api.put(`/devices/${id}`, {
device_attributes: { device_attributes: {
clockSettings: { clockSettings: {
...clock, ...clock,
isBacklightAutomationOn: toBool(isOn), isBacklightAutomationOn: enabled,
backlightOutput: Number(output), backlightOutput: Number(output),
backlightTurnOnTime: toBool(isOn) && onTime ? timeToIso(onTime) : '', backlightTurnOnTime: enabled && onTime ? timeToIso(onTime) : '',
backlightTurnOffTime: toBool(isOn) && offTime ? timeToIso(offTime) : '', backlightTurnOffTime: enabled && offTime ? timeToIso(offTime) : '',
}, },
}, },
}) })
await onSaved() await onSaved()
onClose() onClose()
} catch (err) { } catch (err) {
// sendMqttCommand already surfaced a failure toast if the device rejected it.
setError(err.message || 'Failed to save.') setError(err.message || 'Failed to save.')
} finally { } finally {
setSaving(false) setSaving(false)
@@ -17,7 +17,7 @@ const TIMING_LABELS = {
140: 'Max — 140ms', 140: 'Max — 140ms',
} }
export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub, id }) { export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub, id, sendMqttCommand }) {
const maxOutputs = sub?.maxOutputs || 8 const maxOutputs = sub?.maxOutputs || 8
const existingCount = attr?.totalBells || 0 const existingCount = attr?.totalBells || 0
const existingOutputs = attr?.bellOutputs || [] const existingOutputs = attr?.bellOutputs || []
@@ -25,10 +25,15 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
const [activeBells, setActiveBells] = useState(existingCount) const [activeBells, setActiveBells] = useState(existingCount)
const [rows, setRows] = useState(() => const [rows, setRows] = useState(() =>
Array.from({ length: maxOutputs }, (_, i) => ({ Array.from({ length: maxOutputs }, (_, i) => {
output: existingOutputs[i] ?? 0, // 65535 is the firmware's uint16 "unset" sentinel — treat it like 0 (Disabled)
// in case stale Firestore data still has it from before this was normalized.
const rawOutput = existingOutputs[i] ?? 0
return {
output: rawOutput === 65535 ? 0 : rawOutput,
timing: existingTimings[i] ?? 90, timing: existingTimings[i] ?? 90,
})) }
})
) )
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -43,6 +48,17 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
// Push to the device first — relay.set_config takes both maps in one call.
// Only write to Firestore once the device confirms it actually applied
// and persisted the change (sendMqttCommand's toast reports the outcome).
const durations = {}
const outputs = {}
activeRows.forEach((r, i) => {
durations[i] = r.timing
outputs[i] = r.output
})
await sendMqttCommand('relay.set_config', { durations, outputs })
await api.put(`/devices/${id}`, { await api.put(`/devices/${id}`, {
device_attributes: { device_attributes: {
totalBells: activeBells, totalBells: activeBells,
@@ -53,6 +69,7 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
await onSaved() await onSaved()
onClose() onClose()
} catch (err) { } catch (err) {
// sendMqttCommand already surfaced a failure toast if the device rejected it.
setError(err.message || 'Failed to save.') setError(err.message || 'Failed to save.')
} finally { } finally {
setSaving(false) setSaving(false)
@@ -55,7 +55,7 @@ function StepSlider({ steps, value, onChange, label }) {
) )
} }
export default function EditClockSettingsModal({ open, onClose, onSaved, attr, sub, id }) { export default function EditClockSettingsModal({ open, onClose, onSaved, attr, sub, id, sendMqttCommand }) {
const clock = attr?.clockSettings || {} const clock = attr?.clockSettings || {}
const maxOutputs = sub?.maxOutputs || 8 const maxOutputs = sub?.maxOutputs || 8
const bellOutputsUsed = (attr?.bellOutputs || []).filter(o => o > 0) const bellOutputsUsed = (attr?.bellOutputs || []).filter(o => o > 0)
@@ -85,6 +85,16 @@ export default function EditClockSettingsModal({ open, onClose, onSaved, attr, s
setSaving(true) setSaving(true)
setError('') setError('')
try { try {
// Push to the device first — clock.set_config batches enable/outputs/timings
// into one round trip. Firestore is only written once the device confirms.
await sendMqttCommand('clock.set_config', {
enabled: hasClock,
c1: out1,
c2: out2,
pulse_duration: runPulse,
pause_duration: pauseMs,
})
await api.put(`/devices/${id}`, { await api.put(`/devices/${id}`, {
device_attributes: { device_attributes: {
hasClock, hasClock,
@@ -98,6 +108,7 @@ export default function EditClockSettingsModal({ open, onClose, onSaved, attr, s
await onSaved() await onSaved()
onClose() onClose()
} catch (err) { } catch (err) {
// sendMqttCommand already surfaced a failure toast if the device rejected it.
setError(err.message || 'Failed to save.') setError(err.message || 'Failed to save.')
} finally { } finally {
setSaving(false) setSaving(false)
@@ -1,97 +0,0 @@
// frontend/src/modals/devices/EditLoggingModal.jsx
import { useState } from 'react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import FormField from '@/components/ui/FormField'
import api from '@/lib/api'
const LOG_LEVELS = [
{ value: '0', label: '(0) Disabled' },
{ value: '1', label: '(1) Error' },
{ value: '2', label: '(2) Warning' },
{ value: '3', label: '(3) Info' },
{ value: '4', label: '(4) Debug' },
{ value: '5', label: '(5) Verbose' },
]
export default function EditLoggingModal({ open, onClose, onSaved, attr, id }) {
const [serialLevel, setSerialLevel] = useState(String(attr?.serialLogLevel ?? 0))
const [sdLevel, setSdLevel] = useState(String(attr?.sdLogLevel ?? 0))
const [mqttLevel, setMqttLevel] = useState(String(attr?.mqttLogLevel ?? 0))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const handleSave = async () => {
setSaving(true)
setError('')
try {
await api.put(`/devices/${id}`, {
device_attributes: {
serialLogLevel: Number(serialLevel),
sdLogLevel: Number(sdLevel),
mqttLogLevel: Number(mqttLevel),
},
})
await onSaved()
onClose()
} catch (err) {
setError(err.message || 'Failed to save.')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Edit Log Levels"
size="sm"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" loading={saving} onClick={handleSave}>Save</Button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<FormField
label="Serial Log Level"
name="serialLevel"
type="select"
value={serialLevel}
onChange={(e) => setSerialLevel(e.target.value)}
>
{LOG_LEVELS.map((l) => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</FormField>
<FormField
label="SD Card Log Level"
name="sdLevel"
type="select"
value={sdLevel}
onChange={(e) => setSdLevel(e.target.value)}
>
{LOG_LEVELS.map((l) => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</FormField>
<FormField
label="MQTT Log Level"
name="mqttLevel"
type="select"
value={mqttLevel}
onChange={(e) => setMqttLevel(e.target.value)}
>
{LOG_LEVELS.map((l) => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</FormField>
{error && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
</Modal>
)
}
@@ -6,15 +6,17 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import api from '@/lib/api' import api from '@/lib/api'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
import { useToast } from '@/components/ui/Toast' import { useToast } from '@/components/ui/Toast'
import { useDeviceCommand } from '@/hooks/useDeviceCommand'
import PageHeader from '@/components/ui/PageHeader' import PageHeader from '@/components/ui/PageHeader'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import SignalIndicator from '@/components/ui/SignalIndicator'
import EditableText from '@/components/ui/EditableText'
import Tabs from '@/components/ui/Tabs' import Tabs from '@/components/ui/Tabs'
import Spinner from '@/components/ui/Spinner' import Spinner from '@/components/ui/Spinner'
import ConfirmDialog from '@/components/ui/ConfirmDialog' import ConfirmDialog from '@/components/ui/ConfirmDialog'
import EditLocationModal from '@/modals/bellcloud/devices/EditLocationModal' import EditLocationModal from '@/modals/bellcloud/devices/EditLocationModal'
import EditAttributesModal from '@/modals/bellcloud/devices/EditAttributesModal' import EditAttributesModal from '@/modals/bellcloud/devices/EditAttributesModal'
import EditLoggingModal from '@/modals/bellcloud/devices/EditLoggingModal'
import EditMiscModal from '@/modals/bellcloud/devices/EditMiscModal' import EditMiscModal from '@/modals/bellcloud/devices/EditMiscModal'
import EditBellOutputsModal from '@/modals/bellcloud/devices/EditBellOutputsModal' import EditBellOutputsModal from '@/modals/bellcloud/devices/EditBellOutputsModal'
import EditClockSettingsModal from '@/modals/bellcloud/devices/EditClockSettingsModal' import EditClockSettingsModal from '@/modals/bellcloud/devices/EditClockSettingsModal'
@@ -31,11 +33,13 @@ import ClockTab from '@/pages/bellcloud/devices/tabs/ClockTab'
import WarrantyTab from '@/pages/bellcloud/devices/tabs/WarrantyTab' import WarrantyTab from '@/pages/bellcloud/devices/tabs/WarrantyTab'
import ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab' import ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab'
import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab' import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab'
import HealthTab from '@/pages/bellcloud/devices/tabs/HealthTab'
// ─── Tab config ─────────────────────────────────────────────────────────────── // ─── Tab config ───────────────────────────────────────────────────────────────
const TABS = [ const TABS = [
{ key: 'overview', label: 'Overview' }, { key: 'overview', label: 'Overview' },
{ key: 'health', label: 'Health' },
{ key: 'general', label: 'General' }, { key: 'general', label: 'General' },
{ key: 'bells', label: 'Bell Mechanisms' }, { key: 'bells', label: 'Bell Mechanisms' },
{ key: 'clock', label: 'Clock & Alerts' }, { key: 'clock', label: 'Clock & Alerts' },
@@ -44,10 +48,15 @@ const TABS = [
{ key: 'control', label: 'Control' }, { key: 'control', label: 'Control' },
] ]
// 'logs' used to be its own top-level tab — now a sub-tab of Health.
// Old bookmarks/links with ?tab=logs still land somewhere sensible.
const LEGACY_TAB_REDIRECTS = { logs: 'health' }
function resolveInitialTab(searchParams) { function resolveInitialTab(searchParams) {
const raw = searchParams.get('tab') const raw = searchParams.get('tab')?.toLowerCase()
if (!raw) return 'overview' if (!raw) return 'overview'
const match = TABS.find(t => t.key === raw.toLowerCase()) if (LEGACY_TAB_REDIRECTS[raw]) return LEGACY_TAB_REDIRECTS[raw]
const match = TABS.find(t => t.key === raw)
return match ? match.key : 'overview' return match ? match.key : 'overview'
} }
@@ -81,12 +90,10 @@ export default function DeviceDetail() {
const [cmdHistory, setCmdHistory] = useState([]) const [cmdHistory, setCmdHistory] = useState([])
const [cmdHistoryLoading, setCmdHistoryLoading] = useState(false) const [cmdHistoryLoading, setCmdHistoryLoading] = useState(false)
const [sendingCmd, setSendingCmd] = useState('')
// Modal open flags // Modal open flags
const [editingLocation, setEditingLocation] = useState(false) const [editingLocation, setEditingLocation] = useState(false)
const [editingAttributes, setEditingAttributes] = useState(false) const [editingAttributes, setEditingAttributes] = useState(false)
const [editingLogging, setEditingLogging] = useState(false)
const [editingMisc, setEditingMisc] = useState(false) const [editingMisc, setEditingMisc] = useState(false)
const [editingBellOutputs, setEditingBellOutputs] = useState(false) const [editingBellOutputs, setEditingBellOutputs] = useState(false)
const [editingClockSettings, setEditingClockSettings] = useState(false) const [editingClockSettings, setEditingClockSettings] = useState(false)
@@ -139,6 +146,19 @@ export default function DeviceDetail() {
useEffect(() => { loadDevice() }, [loadDevice]) useEffect(() => { loadDevice() }, [loadDevice])
// Re-fetches the device without toggling the page-level loading spinner —
// used by background reconciliation (e.g. ClockTab/BellsTab silently
// correcting drift from a GET-config refresh) so it doesn't unmount/remount
// the active tab mid-sync.
const reloadDeviceSilent = useCallback(async () => {
try {
const d = await api.get(`/devices/${id}`)
setDevice(d)
} catch {
// Silent by design — a failed background refresh isn't worth surfacing.
}
}, [id])
const loadCmdHistory = useCallback(async () => { const loadCmdHistory = useCallback(async () => {
const sn = device?.serial_number || device?.device_id const sn = device?.serial_number || device?.device_id
if (!sn) return if (!sn) return
@@ -157,6 +177,53 @@ export default function DeviceDetail() {
if (activeTab === 'control' && device) loadCmdHistory() if (activeTab === 'control' && device) loadCmdHistory()
}, [activeTab, device, loadCmdHistory]) }, [activeTab, device, loadCmdHistory])
// ── Command ack (live reply over WebSocket) ───────────────────────────────
// Send/ack-queue machinery lives in useDeviceCommand (shared with the
// onboarding wizard). Heartbeats aren't command acks, so they're handled
// here via onNonAckMessage — the hook forwards anything that isn't a
// 'control/ack' reply for this device instead of opening a second WS connection.
const deviceSerial = device?.serial_number || device?.device_id
const handleNonAckMessage = useCallback((msg) => {
// Live heartbeat — updates the online dot/RSSI without polling. The device
// publishes one every ~30s; we just reflect whatever lands here instead of
// waiting for the next page load / GET /mqtt/status round trip.
// v2 heartbeat payload is FLAT (no nested .payload wrapper) — see
// vesper_mqtt_topic_spec_v2.md. field names: fw_version, uptime_human.
if (msg.type !== 'status/heartbeat') return
const hb = msg.payload || {}
setMqttStatus(prev => ({
device_serial: deviceSerial,
online: true,
seconds_since_heartbeat: 0,
last_alert_event: prev?.last_alert_event ?? null,
last_heartbeat: {
...(prev?.last_heartbeat || {}),
device_id: hb.device_id ?? prev?.last_heartbeat?.device_id,
firmware_version: hb.fw_version ?? prev?.last_heartbeat?.firmware_version,
ip_address: hb.ip_address ?? prev?.last_heartbeat?.ip_address,
gateway: hb.gateway ?? prev?.last_heartbeat?.gateway,
uptime_ms: hb.uptime_ms ?? prev?.last_heartbeat?.uptime_ms,
uptime_display: hb.uptime_human ?? prev?.last_heartbeat?.uptime_display,
rssi: hb.rssi ?? prev?.last_heartbeat?.rssi,
free_heap: hb.free_heap ?? prev?.last_heartbeat?.free_heap,
state: hb.state ?? prev?.last_heartbeat?.state,
ok: hb.ok ?? prev?.last_heartbeat?.ok,
received_at: new Date().toISOString(),
},
}))
}, [deviceSerial])
// DeviceDetail additionally refreshes the Control tab's command history as
// soon as a command is accepted for sending — the shared hook doesn't know
// about that concern, so it's wired in via onCommandSent.
const { sendMqttCommand, sendMqttCommandSilent, sendingCmd } = useDeviceCommand({
deviceSerial,
onNonAckMessage: handleNonAckMessage,
onCommandSent: loadCmdHistory,
})
// ── Tab sync ─────────────────────────────────────────────────────────────── // ── Tab sync ───────────────────────────────────────────────────────────────
const handleTabChange = key => { const handleTabChange = key => {
@@ -213,20 +280,19 @@ export default function DeviceDetail() {
} }
} }
const sendMqttCommand = async (cmd, contents = {}) => { // Console-only friendly name — separate from device_name (the name the end
const deviceId = device?.device_id || device?.serial_number // user gave the device in the app). Saves optimistically into local state
if (!deviceId) return // so the hero title updates immediately; a failed save reverts and toasts.
setSendingCmd(cmd) const handleSaveConsoleName = useCallback(async (newName) => {
const previous = device?.console_name || ''
setDevice(d => d ? { ...d, console_name: newName } : d)
try { try {
await api.post(`/mqtt/command/${deviceId}`, { cmd, contents }) await api.put(`/devices/${id}`, { console_name: newName })
toast.success('Sent', `Command "${cmd}" sent.`)
loadCmdHistory()
} catch (err) { } catch (err) {
toast.danger('Error', err.message || 'Failed to send command.') setDevice(d => d ? { ...d, console_name: previous } : d)
} finally { toast({ variant: 'danger', title: 'Failed to rename device', description: err.message || 'Please try again.' })
setSendingCmd('')
}
} }
}, [id, device, toast])
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
@@ -272,6 +338,7 @@ export default function DeviceDetail() {
stats, stats,
sn, sn,
isOnline, isOnline,
mqttStatus,
tags, tags,
setTags, setTags,
staffNotes, staffNotes,
@@ -284,14 +351,15 @@ export default function DeviceDetail() {
cmdHistoryLoading, cmdHistoryLoading,
sendingCmd, sendingCmd,
loadDevice, loadDevice,
reloadDeviceSilent,
loadCmdHistory, loadCmdHistory,
sendMqttCommand, sendMqttCommand,
sendMqttCommandSilent,
onShowDelete: () => setShowDelete(true), onShowDelete: () => setShowDelete(true),
onAssignCustomer: () => setShowAssignCustomer(true), onAssignCustomer: () => setShowAssignCustomer(true),
onUnassignCustomer: handleUnassignCustomer, onUnassignCustomer: handleUnassignCustomer,
onEditLocation: () => setEditingLocation(true), onEditLocation: () => setEditingLocation(true),
onEditAttributes: () => setEditingAttributes(true), onEditAttributes: () => setEditingAttributes(true),
onEditLogging: () => setEditingLogging(true),
onEditMisc: () => setEditingMisc(true), onEditMisc: () => setEditingMisc(true),
onEditBellOutputs: () => setEditingBellOutputs(true), onEditBellOutputs: () => setEditingBellOutputs(true),
onEditClockSettings: () => setEditingClockSettings(true), onEditClockSettings: () => setEditingClockSettings(true),
@@ -305,12 +373,40 @@ export default function DeviceDetail() {
// ── Render ───────────────────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────────────────
return ( return (
<div className="page-wrapper"> <div className="page-wrapper page-wrapper--capped">
<PageHeader <PageHeader
title={device.device_name || 'Unnamed Device'} title={
subtitle={sn} <span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-4)' }}>
<span style={{
width: 48, height: 48,
borderRadius: 'var(--radius-xl)',
background: 'rgba(28, 32, 38, 0.30)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<SignalIndicator rssi={mqttStatus?.last_heartbeat?.rssi} size="md" />
</span>
<span style={{ display: 'flex', flexDirection: 'column' }}>
<EditableText
value={device.console_name || ''}
placeholder={device.device_name || 'Unnamed Device'}
onSave={handleSaveConsoleName}
editable={canEdit}
inputAriaLabel="Edit console name"
/>
<span className="page-header-subtitle" style={{ margin: 0 }}>{sn}</span>
</span>
</span>
}
> >
<Button variant="ghost" onClick={() => sendMqttCommand('ping')}>
Ping Now
</Button>
{canEdit && ( {canEdit && (
<Button variant="secondary" onClick={() => navigate(`/devices/${id}/edit`)}> <Button variant="secondary" onClick={() => navigate(`/devices/${id}/edit`)}>
Edit Device Edit Device
@@ -326,6 +422,7 @@ export default function DeviceDetail() {
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{activeTab === 'overview' && <OverviewTab {...tabProps} />} {activeTab === 'overview' && <OverviewTab {...tabProps} />}
{activeTab === 'health' && <HealthTab {...tabProps} />}
{activeTab === 'general' && <GeneralTab {...tabProps} />} {activeTab === 'general' && <GeneralTab {...tabProps} />}
{activeTab === 'bells' && <BellsTab {...tabProps} />} {activeTab === 'bells' && <BellsTab {...tabProps} />}
{activeTab === 'clock' && <ClockTab {...tabProps} />} {activeTab === 'clock' && <ClockTab {...tabProps} />}
@@ -350,13 +447,6 @@ export default function DeviceDetail() {
attr={attr} attr={attr}
id={id} id={id}
/> />
<EditLoggingModal
open={editingLogging}
onClose={() => setEditingLogging(false)}
onSaved={loadDevice}
attr={attr}
id={id}
/>
<EditMiscModal <EditMiscModal
open={editingMisc} open={editingMisc}
onClose={() => setEditingMisc(false)} onClose={() => setEditingMisc(false)}
@@ -372,6 +462,7 @@ export default function DeviceDetail() {
attr={attr} attr={attr}
sub={sub} sub={sub}
id={id} id={id}
sendMqttCommand={sendMqttCommand}
/> />
<EditClockSettingsModal <EditClockSettingsModal
open={editingClockSettings} open={editingClockSettings}
@@ -380,6 +471,7 @@ export default function DeviceDetail() {
attr={attr} attr={attr}
sub={sub} sub={sub}
id={id} id={id}
sendMqttCommand={sendMqttCommand}
/> />
<EditAlertsModal <EditAlertsModal
open={editingAlerts} open={editingAlerts}
@@ -388,6 +480,7 @@ export default function DeviceDetail() {
clock={clock} clock={clock}
attr={attr} attr={attr}
id={id} id={id}
sendMqttCommand={sendMqttCommand}
/> />
<EditBacklightModal <EditBacklightModal
open={editingBacklight} open={editingBacklight}
@@ -397,6 +490,7 @@ export default function DeviceDetail() {
clock={clock} clock={clock}
sub={sub} sub={sub}
id={id} id={id}
sendMqttCommand={sendMqttCommand}
/> />
<EditSubscriptionModal <EditSubscriptionModal
open={editingSubscription} open={editingSubscription}
@@ -422,7 +516,7 @@ export default function DeviceDetail() {
open={showDelete} open={showDelete}
variant="danger" variant="danger"
title="Delete Device" title="Delete Device"
message={`Are you sure you want to delete "${device.device_name || sn}"? This action cannot be undone.`} message={`Are you sure you want to delete "${device.console_name || device.device_name || sn}"? This action cannot be undone.`}
confirmLabel="Delete" confirmLabel="Delete"
onConfirm={handleDelete} onConfirm={handleDelete}
onCancel={() => setShowDelete(false)} onCancel={() => setShowDelete(false)}
@@ -2,9 +2,8 @@
// Bell Mechanisms tab — displays Bell Commander/Guard status, connected bell count, // Bell Mechanisms tab — displays Bell Commander/Guard status, connected bell count,
// master switch, and per-bell glass cards with output, timing, size, and strike stats. // master switch, and per-bell glass cards with output, timing, size, and strike stats.
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import api from '@/lib/api' import api from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge' import StatusBadge from '@/components/ui/StatusBadge'
import Icon from '@/components/ui/Icon' import Icon from '@/components/ui/Icon'
@@ -91,7 +90,7 @@ const SPEC_TILE = {
function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) { function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) {
const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th` const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th`
const strikerSize = getStrikerSize(hammingMs) const strikerSize = getStrikerSize(hammingMs)
const isDisabled = Number.isFinite(output) && Number(output) === 0 const isDisabled = Number.isFinite(output) && (Number(output) === 0 || Number(output) === 65535)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null const strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null
@@ -272,23 +271,85 @@ export default function BellsTab({
attr, attr,
canEdit, canEdit,
loadDevice, loadDevice,
reloadDeviceSilent,
sendMqttCommand, sendMqttCommand,
sendMqttCommandSilent,
onEditBellOutputs, onEditBellOutputs,
}) { }) {
const { toast } = useToast()
const [togglingCommander, setTogglingCommander] = useState(false) const [togglingCommander, setTogglingCommander] = useState(false)
const [requestingCounters, setRequestingCounters] = useState(false) const [requestingCounters, setRequestingCounters] = useState(false)
const [liveCounters] = useState(null) const [liveCounters] = useState(null)
const [confirmDisable, setConfirmDisable] = useState(false) const [confirmDisable, setConfirmDisable] = useState(false)
// Derived data // Derived data — shown instantly from Firestore on load, then silently
// reconciled against the device's true on-device config (see effect below).
const bellOutputs = attr?.bellOutputs || [] const bellOutputs = attr?.bellOutputs || []
const hammerTimings = attr?.hammerTimings || [] const hammerTimings = attr?.hammerTimings || []
const totalBells = attr?.totalBells || 0 const totalBells = attr?.totalBells || 0
const hasBells = attr?.hasBells ?? false const hasBells = attr?.hasBells ?? false
const bellGuardOn = attr?.bellGuardOn ?? false const bellGuardOn = attr?.bellGuardOn ?? false
// ── Background refresh: pull true state from the device right after load ──
// Fires at most once per mount (hasSyncedRef guard — belt-and-suspenders
// against StrictMode's double-invoke or any parent re-render retriggering
// this effect). Reconciles bells_enabled/durations/outputs against Firestore
// and silently corrects any drift — no toast, this is a quiet sync.
const hasSyncedRef = useRef(false)
useEffect(() => {
if (!sendMqttCommandSilent || !device) return
if (hasSyncedRef.current) return
hasSyncedRef.current = true
const deviceId = device.id || device.device_id
sendMqttCommandSilent('bells.get_config').then((reply) => {
const data = reply?.data || {}
const durations = data.durations || {}
const outputs = data.outputs || {}
const channelCount = Math.max(
Object.keys(durations).length,
Object.keys(outputs).length,
totalBells,
)
if (channelCount === 0) return
// Firmware uses both 0 and 65535 (uint16 "unset") as "no output assigned" —
// treat both as disabled so unconfigured channels are never mistaken for
// connected bells.
const isDisabledOutput = (o) => !o || o === 65535
// The console's data model assumes bells are a contiguous block starting at
// index 0 (see EditBellOutputsModal / totalBells). The device, however, can
// report enabled channels at any index with gaps in between — so compact
// only the genuinely-connected channels down to a contiguous list, in
// channel order, rather than naively slicing the raw 16-slot arrays.
const connectedIndexes = Array.from({ length: channelCount }, (_, i) => i)
.filter((i) => !isDisabledOutput(Number(outputs[i] ?? 0)))
const trueOutputs = connectedIndexes.map((i) => Number(outputs[i]))
const trueTimings = connectedIndexes.map((i) => Number(durations[i] ?? 0))
const trueEnabled = !!data.bells_enabled
const trueActiveCount = trueOutputs.length
const outputsDiffer = JSON.stringify(trueOutputs) !== JSON.stringify(bellOutputs)
const timingsDiffer = JSON.stringify(trueTimings) !== JSON.stringify(hammerTimings)
const enabledDiffers = trueEnabled !== hasBells
if (!outputsDiffer && !timingsDiffer && !enabledDiffers) return
api.put(`/devices/${deviceId}`, {
device_attributes: {
hasBells: trueEnabled,
totalBells: trueActiveCount,
bellOutputs: trueOutputs,
hammerTimings: trueTimings,
},
}).then(() => reloadDeviceSilent?.())
}).catch(() => {
// Device offline or didn't reply — keep showing the last-known Firestore values.
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [device?.id, device?.device_id])
// Request strike counters via MQTT // Request strike counters via MQTT
const requestStrikeCounters = useCallback(async () => { const requestStrikeCounters = useCallback(async () => {
if (!sendMqttCommand) return if (!sendMqttCommand) return
@@ -301,20 +362,19 @@ export default function BellsTab({
} }
}, [sendMqttCommand]) }, [sendMqttCommand])
// Toggle Bell Commander (master switch) // Toggle Bell Commander (master switch) — sends bells.enable/bells.disable to
// the device first; Firestore (hasBells) is only updated once the device
// confirms SUCCESS. sendMqttCommand already shows the blue→green/red toast.
const handleToggleCommander = async (newVal) => { const handleToggleCommander = async (newVal) => {
setTogglingCommander(true) setTogglingCommander(true)
try { try {
await sendMqttCommand(newVal ? 'bells.enable' : 'bells.disable')
await api.put(`/devices/${device.id || device.device_id}`, { await api.put(`/devices/${device.id || device.device_id}`, {
device_attributes: { hasBells: newVal }, device_attributes: { hasBells: newVal },
}) })
await loadDevice() await loadDevice()
toast.success( } catch {
newVal ? 'Bell Commander Enabled' : 'Bell Commander Disabled', // sendMqttCommand's toast already reported the failure — nothing further to do.
newVal ? 'All bell mechanisms are now active.' : 'All bell mechanisms have been disabled.'
)
} catch (err) {
toast.danger('Error', err.message || 'Failed to update Bell Commander.')
} finally { } finally {
setTogglingCommander(false) setTogglingCommander(false)
} }
@@ -495,7 +555,7 @@ export default function BellsTab({
setConfirmDisable(false) setConfirmDisable(false)
handleToggleCommander(false) handleToggleCommander(false)
}} }}
onCancel={() => setConfirmDisable(false)} onClose={() => setConfirmDisable(false)}
loading={togglingCommander} loading={togglingCommander}
/> />
@@ -5,7 +5,6 @@ import api from '@/lib/api'
import Card from '@/components/ui/Card' import Card from '@/components/ui/Card'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge' import StatusBadge from '@/components/ui/StatusBadge'
import { useToast } from '@/components/ui/Toast'
import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal' import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -45,6 +44,16 @@ function silenceLabel(isOn, from, to) {
return 'On' return 'On'
} }
function formatUtcOffset(offsetSec) {
if (offsetSec == null) return null
const totalMinutes = offsetSec / 60
const sign = totalMinutes >= 0 ? '+' : '-'
const abs = Math.abs(totalMinutes)
const hours = Math.floor(abs / 60)
const minutes = abs % 60
return minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours}:${String(minutes).padStart(2, '0')}`
}
// ─── Analog Clock Face ──────────────────────────────────────────────────────── // ─── Analog Clock Face ────────────────────────────────────────────────────────
function AnalogClock({ time, label, variant = 'primary' }) { function AnalogClock({ time, label, variant = 'primary' }) {
@@ -261,19 +270,111 @@ export default function ClockTab({
onEditClockSettings, onEditClockSettings,
onEditAlerts, onEditAlerts,
onEditBacklight, onEditBacklight,
onDeviceUpdated, sendMqttCommand,
sendMqttCommandSilent,
loadDevice,
reloadDeviceSilent,
}) { }) {
const [now, setNow] = useState(new Date()) const [now, setNow] = useState(new Date())
const [showSync, setShowSync] = useState(false) const [showSync, setShowSync] = useState(false)
const [flashFields, setFlashFields] = useState(false) const [flashFields, setFlashFields] = useState(false)
const [togglingClock, setTogglingClock] = useState(false) const [togglingClock, setTogglingClock] = useState(false)
const { toast } = useToast()
useEffect(() => { useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000) const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id) return () => clearInterval(id)
}, []) }, [])
// ── Background refresh: pull true state from the device right after load ──
// Fires at most once per mount (hasSyncedRef guard — belt-and-suspenders
// against StrictMode's double-invoke or any parent re-render retriggering
// this effect). Reconciles enabled/outputs/timings/alerts/backlight/silence
// against Firestore using semantic (not raw-string) comparison, and silently
// corrects any real drift — no toast.
const hasSyncedRef = useRef(false)
useEffect(() => {
if (!sendMqttCommandSilent || !device) return
if (hasSyncedRef.current) return
hasSyncedRef.current = true
const deviceId = device.id || device.device_id
sendMqttCommandSilent('clock.get_config').then((reply) => {
const data = reply?.data || {}
if (!data || Object.keys(data).length === 0) return
const trueAlertType = { OFF: 'disabled', SINGLE: 'single', HOURS: 'multi' }[data.alert_type] ?? 'disabled'
const trueHourBell = data.hour_bell === 255 ? 0 : (data.hour_bell || 0)
const trueHalfBell = data.half_bell === 255 ? 0 : (data.half_bell || 0)
const trueQuarterBell = data.quarter_bell === 255 ? 0 : (data.quarter_bell || 0)
const trueC1 = data.c1output || 0
const trueC2 = data.c2output || 0
const truePulse = data.pulse_duration || 0
const truePause = data.pause_duration || 0
const trueBacklightOutput = data.backlight_output === 255 ? 0 : (data.backlight_output || 0)
// Semantic comparison — normalize times to HH:MM via extractTime so format
// differences (timezone suffix, seconds, etc.) never register as false drift.
const hasDrift =
!!data.enabled !== (attr?.hasClock ?? false) ||
trueC1 !== (clock?.clockOutputs?.[0] || 0) ||
trueC2 !== (clock?.clockOutputs?.[1] || 0) ||
truePulse !== (clock?.clockTimings?.[0] || 0) ||
truePause !== (clock?.clockTimings?.[1] || 0) ||
trueAlertType !== (clock?.ringAlerts || 'disabled') ||
(trueAlertType === 'multi' && data.alert_interval !== clock?.ringIntervals) ||
trueHourBell !== (clock?.hourAlertsBell || 0) ||
trueHalfBell !== (clock?.halfhourAlertsBell || 0) ||
trueQuarterBell !== (clock?.quarterAlertsBell || 0) ||
!!data.daytime_silence !== (clock?.isDaySilenceOn ?? false) ||
data.daytime_on !== extractTime(clock?.daySilenceFrom) ||
data.daytime_off !== extractTime(clock?.daySilenceTo) ||
!!data.night_silence !== (clock?.isNightSilenceOn ?? false) ||
data.night_on !== extractTime(clock?.nightSilenceFrom) ||
data.night_off !== extractTime(clock?.nightSilenceTo) ||
!!data.backlight !== (clock?.isBacklightAutomationOn ?? false) ||
trueBacklightOutput !== (clock?.backlightOutput || 0) ||
(!!data.backlight && data.backlight_on !== extractTime(clock?.backlightTurnOnTime)) ||
(!!data.backlight && data.backlight_off !== extractTime(clock?.backlightTurnOffTime)) ||
(data.gmt_offset_sec != null && data.gmt_offset_sec !== clock?.gmtOffsetSec) ||
(data.dst_offset_sec != null && data.dst_offset_sec !== clock?.dstOffsetSec) ||
(data.ntp_server != null && data.ntp_server !== clock?.ntpServer)
if (!hasDrift) return
api.put(`/devices/${deviceId}`, {
device_attributes: {
hasClock: !!data.enabled,
clockSettings: {
...clock,
clockOutputs: [trueC1, trueC2],
clockTimings: [truePulse, truePause],
ringAlerts: trueAlertType,
ringIntervals: data.alert_interval,
hourAlertsBell: trueHourBell,
halfhourAlertsBell: trueHalfBell,
quarterAlertsBell: trueQuarterBell,
isDaySilenceOn: !!data.daytime_silence,
daySilenceFrom: data.daytime_on ? `2000-01-01T${data.daytime_on}:00Z` : (clock?.daySilenceFrom || ''),
daySilenceTo: data.daytime_off ? `2000-01-01T${data.daytime_off}:00Z` : (clock?.daySilenceTo || ''),
isNightSilenceOn: !!data.night_silence,
nightSilenceFrom: data.night_on ? `2000-01-01T${data.night_on}:00Z` : (clock?.nightSilenceFrom || ''),
nightSilenceTo: data.night_off ? `2000-01-01T${data.night_off}:00Z` : (clock?.nightSilenceTo || ''),
isBacklightAutomationOn: !!data.backlight,
backlightOutput: trueBacklightOutput,
backlightTurnOnTime: data.backlight_on ? `2000-01-01T${data.backlight_on}:00Z` : '',
backlightTurnOffTime: data.backlight_off ? `2000-01-01T${data.backlight_off}:00Z` : '',
gmtOffsetSec: data.gmt_offset_sec ?? clock?.gmtOffsetSec,
dstOffsetSec: data.dst_offset_sec ?? clock?.dstOffsetSec,
ntpServer: data.ntp_server ?? clock?.ntpServer,
},
},
}).then(() => reloadDeviceSilent?.())
}).catch(() => {
// Device offline or didn't reply — keep showing the last-known Firestore values.
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [device?.id, device?.device_id])
// ── Derived values ──────────────────────────────────────────────────────── // ── Derived values ────────────────────────────────────────────────────────
const hasClock = attr?.hasClock ?? false const hasClock = attr?.hasClock ?? false
@@ -285,7 +386,7 @@ export default function ClockTab({
const pauseTime = clockTimings[1] const pauseTime = clockTimings[1]
const alertType = clock?.ringAlerts || 'disabled' const alertType = clock?.ringAlerts || 'disabled'
const alertMasterOn = clock?.ringAlertsMasterOn ?? false const alertMasterOn = alertType !== 'disabled' // "master on" = alert_type is not OFF
const ringIntervals = clock?.ringIntervals const ringIntervals = clock?.ringIntervals
const hourBell = clock?.hourAlertsBell || 0 const hourBell = clock?.hourAlertsBell || 0
const halfBell = clock?.halfhourAlertsBell || 0 const halfBell = clock?.halfhourAlertsBell || 0
@@ -298,9 +399,9 @@ export default function ClockTab({
const backlightOnTime = extractTime(clock?.backlightTurnOnTime) const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
const backlightOffTime = extractTime(clock?.backlightTurnOffTime) const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
const timezone = attr?.timezone || device?.timezone || null const timezone = formatUtcOffset(clock?.gmtOffsetSec)
const ntpServer = attr?.ntpServer || attr?.networkSettings?.ntpServer || null const ntpServer = clock?.ntpServer || null
const autoDst = attr?.autoDST ?? false const dstActive = !!clock?.dstOffsetSec
const alertTypeLabel = const alertTypeLabel =
alertType === 'single' ? 'Single Fire' : alertType === 'single' ? 'Single Fire' :
@@ -312,6 +413,9 @@ export default function ClockTab({
// All 4 hero fields must be set before ENABLING the master switch // All 4 hero fields must be set before ENABLING the master switch
const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0 const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0
// Sends clock.enable/clock.disable to the device first; Firestore (hasClock)
// is only updated once the device confirms SUCCESS. sendMqttCommand already
// shows the blue→green/red toast.
const handleMasterToggleClick = async () => { const handleMasterToggleClick = async () => {
if (!canEdit || togglingClock) return if (!canEdit || togglingClock) return
@@ -326,23 +430,13 @@ export default function ClockTab({
const newValue = !hasClock const newValue = !hasClock
setTogglingClock(true) setTogglingClock(true)
try { try {
const updated = await api.put(`/devices/${deviceId}`, { await sendMqttCommand(newValue ? 'clock.enable' : 'clock.disable')
device_attributes: { ...(attr || {}), hasClock: newValue }, await api.put(`/devices/${deviceId}`, {
device_attributes: { hasClock: newValue },
}) })
if (onDeviceUpdated) { await loadDevice?.()
// If API returns the full device object, use it; otherwise patch manually } catch {
if (updated?.device_attributes !== undefined) { // sendMqttCommand's toast already reported the failure — nothing further to do.
onDeviceUpdated(updated)
} else {
onDeviceUpdated(prev => ({
...prev,
device_attributes: { ...(prev?.device_attributes || {}), hasClock: newValue },
}))
}
}
toast.success(newValue ? 'Clock enabled' : 'Clock disabled', '')
} catch (err) {
toast.danger('Error', err.message || 'Failed to update clock status.')
} finally { } finally {
setTogglingClock(false) setTogglingClock(false)
} }
@@ -607,28 +701,14 @@ export default function ClockTab({
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} /> <SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} /> <SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
<SettingRow <SettingRow
label="Auto-DST" label="DST"
badge={ badge={<StatusBadge variant={dstActive ? 'success' : 'neutral'}>{dstActive ? 'Active' : 'Not Active'}</StatusBadge>}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<StatusBadge variant="neutral">{autoDst ? 'Enabled' : 'Disabled'}</StatusBadge>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(28, 32, 38, 0.40)',
padding: '2px var(--space-2)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
}}>
Coming Soon
</span>
</div>
}
/> />
</div> </div>
{canEdit && ( {canEdit && (
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}> <div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}> <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Timezone and NTP configuration are managed via device attributes. Auto-DST adjustment is pending implementation. Read-only — reported directly by the device. Editing timezone/NTP settings from the console is not yet supported.
</p> </p>
</div> </div>
)} )}
@@ -1,5 +1,457 @@
// frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx // frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
// Control tab — direct MQTT command execution: relay tests, clock control, system actions.
export default function ControlTab({ device, canEdit, onDeviceUpdated }) { import { useState } from 'react'
return null import Card from '@/components/ui/Card'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Icon from '@/components/ui/Icon'
import FormField from '@/components/ui/FormField'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import { useToast } from '@/components/ui/Toast'
import ResetStatsModal from '@/modals/bellcloud/devices/ResetStatsModal'
// ─── Glass surface constants (match OverviewTab / BellsTab aesthetic) ─────────
const GLASS = 'rgba(28, 32, 38, 0.30)'
const BLUR = 'blur(12px)'
function GlassCard({ children, style = {} }) {
return (
<div style={{
background: GLASS,
backdropFilter: BLUR,
WebkitBackdropFilter: BLUR,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
...style,
}}>
{children}
</div>
)
}
function SectionLabel({ children, icon }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
{icon && <Icon name={icon} size={15} color="var(--color-text-muted)" />}
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{children}
</span>
</div>
)
}
// ─── Action row — label/description on the left, control on the right ────────
function ActionRow({ title, description, children, last = false }) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 'var(--space-4)',
padding: 'var(--space-4) 0',
borderBottom: last ? 'none' : '1px solid var(--color-border)',
flexWrap: 'wrap',
}}>
<div style={{ minWidth: 200, flex: 1 }}>
<p style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{title}
</p>
{description && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
{description}
</p>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexShrink: 0 }}>
{children}
</div>
</div>
)
}
// ─── Stacked action row — title/description on top, control row beneath ──────
function StackedActionRow({ title, description, children, last = false }) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-3)',
padding: 'var(--space-4) 0',
borderBottom: last ? 'none' : '1px solid var(--color-border)',
}}>
<div>
<p style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{title}
</p>
{description && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
{description}
</p>
)}
</div>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
{children}
</div>
</div>
)
}
// ─── ControlTab ────────────────────────────────────────────────────────────────
export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCommand, sendingCmd }) {
const { toast } = useToast()
const totalBells = attr?.totalBells || 0
const bellOptions = Array.from({ length: totalBells }, (_, i) => i)
// relay.test_output
const [testOutput, setTestOutput] = useState('')
const [testDuration, setTestDuration] = useState('100')
// relay.test_bell
const [firingBell, setFiringBell] = useState(null)
// relay.test_batch
const [batchBells, setBatchBells] = useState([])
// system destructive confirms
const [confirmRestart, setConfirmRestart] = useState(false)
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false)
const [showResetStats, setShowResetStats] = useState(false)
const disabled = !canEdit || !isOnline
const run = async (cmd, contents) => {
await sendMqttCommand(cmd, contents)
}
const handleTestOutput = () => {
const output = parseInt(testOutput, 10)
const duration_ms = parseInt(testDuration, 10)
if (!Number.isFinite(output) || output < 0 || output > 31) {
toast.danger('Invalid Output', 'Output must be a number between 0 and 31.')
return
}
if (!Number.isFinite(duration_ms) || duration_ms < 1 || duration_ms > 5000) {
toast.danger('Invalid Duration', 'Duration must be between 1 and 5000 ms.')
return
}
run('relay.test_output', { output, duration_ms })
}
const handleTestBell = async (bell) => {
setFiringBell(bell)
try {
await run('relay.test_bell', { bell })
} finally {
setFiringBell(null)
}
}
const toggleBatchBell = (i) => {
setBatchBells(prev => prev.includes(i) ? prev.filter(b => b !== i) : [...prev, i].sort((a, b) => a - b))
}
const handleTestBatch = () => {
if (batchBells.length === 0) {
toast.danger('No Bells Selected', 'Select at least one bell channel to fire.')
return
}
run('relay.test_batch', { bells: batchBells })
}
const handleFactoryReset = () => {
setConfirmFactoryReset(false)
run('system.factory_reset', {})
}
const handleRestart = () => {
setConfirmRestart(false)
run('system.restart', {})
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 1400, margin: '0 auto' }}>
{/* ── Status bar ────────────────────────────────────────────────────── */}
<GlassCard style={{ padding: 'var(--space-4) var(--space-6)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<StatusBadge variant={isOnline ? 'success' : 'danger'}>{isOnline ? 'Online' : 'Offline'}</StatusBadge>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Commands are sent live over MQTT and require the device to be connected.
</span>
</div>
{!canEdit && (
<StatusBadge variant="neutral">Read-only</StatusBadge>
)}
</GlassCard>
{!isOnline && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)',
fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)',
}}>
<Icon name="warning" size={16} color="var(--color-warning)" />
<span>This device is currently offline. Commands sent below will not be delivered until it reconnects.</span>
</div>
)}
<div className="masonry-grid masonry-grid--2">
{/* ── Relay Testing ──────────────────────────────────────────────── */}
<Card title="Relay Testing" subtitle="Fire physical outputs and bell channels directly — bypasses melody playback.">
<div style={{ display: 'flex', flexDirection: 'column' }}>
{/* relay.test_output */}
<StackedActionRow
title="Test Raw Output"
description="Fire a specific relay output for a set duration (1–5000 ms)."
>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)' }}>
<div style={{ width: 96 }}>
<FormField
label="Output"
name="test-output"
type="number"
value={testOutput}
onChange={e => setTestOutput(e.target.value)}
placeholder="0–31"
disabled={disabled}
inputProps={{ min: 0, max: 31 }}
/>
</div>
<div style={{ width: 110 }}>
<FormField
label="Duration (ms)"
name="test-duration"
type="number"
value={testDuration}
onChange={e => setTestDuration(e.target.value)}
placeholder="1–5000"
disabled={disabled}
inputProps={{ min: 1, max: 5000 }}
/>
</div>
</div>
<Button
variant="secondary"
size="md"
disabled={disabled || testOutput === ''}
loading={sendingCmd === 'relay.test_output'}
onClick={handleTestOutput}
>
Fire
</Button>
</StackedActionRow>
{/* relay.test_bell */}
<StackedActionRow
title="Test Bell Channel"
description="Fire a configured bell channel using its assigned output and duration."
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
{(() => {
const bells = bellOptions.length > 0 ? bellOptions : Array.from({ length: 16 }, (_, i) => i)
const widestLabel = `Ring Bell ${bells.length}`
return bells.map(i => (
<Button
key={i}
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'relay.test_bell' && firingBell === i}
onClick={() => handleTestBell(i)}
style={{ minWidth: `${widestLabel.length}ch`, justifyContent: 'center' }}
>
Ring Bell {i + 1}
</Button>
))
})()}
</div>
</StackedActionRow>
{/* relay.test_batch */}
<StackedActionRow
title="Test Batch"
description="Fire multiple bell channels simultaneously, like a single melody step."
last
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
{(bellOptions.length > 0 ? bellOptions : Array.from({ length: 16 }, (_, i) => i)).map(i => {
const active = batchBells.includes(i)
return (
<button
key={i}
type="button"
disabled={disabled}
onClick={() => toggleBatchBell(i)}
style={{
width: 32, height: 32,
borderRadius: 'var(--radius-md)',
border: `1px solid ${active ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
backgroundColor: active ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
transition: 'all 0.15s ease',
}}
aria-pressed={active}
aria-label={`Bell ${i + 1}`}
>
{i + 1}
</button>
)
})}
</div>
<Button
variant="secondary"
size="md"
disabled={disabled || batchBells.length === 0}
loading={sendingCmd === 'relay.test_batch'}
onClick={handleTestBatch}
>
Fire Selected
</Button>
</StackedActionRow>
</div>
</Card>
{/* ── Clock Control ──────────────────────────────────────────────── */}
<Card title="Clock Control" subtitle="Pause or resume clock face and strike updates without changing configuration.">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<ActionRow
title="Pause Clock Updates"
description="Freezes the clock face and strike scheduling until resumed."
>
<Button
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'clock.pause'}
onClick={() => run('clock.pause', {})}
>
Pause
</Button>
</ActionRow>
<ActionRow
title="Resume Clock Updates"
description="Resumes normal clock face and strike scheduling."
last
>
<Button
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'clock.resume'}
onClick={() => run('clock.resume', {})}
>
Resume
</Button>
</ActionRow>
</div>
</Card>
{/* ── System ──────────────────────────────────────────────────────── */}
<Card
title="System"
subtitle="Device-wide controls. Factory reset and restart affect the whole unit."
icon={<Icon name="warning" size={15} />}
>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<ActionRow
title="Restart Device"
description="Reboots the device. Response is sent 2 seconds before reboot."
>
<Button
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'system.restart'}
onClick={() => setConfirmRestart(true)}
>
Restart
</Button>
</ActionRow>
<ActionRow
title="Factory Reset"
description="Wipes all saved configuration back to factory defaults. Non-reversible."
>
<Button
variant="danger"
size="md"
disabled={disabled}
loading={sendingCmd === 'system.factory_reset'}
onClick={() => setConfirmFactoryReset(true)}
>
Factory Reset
</Button>
</ActionRow>
<ActionRow
title="Reset Device Stats"
description="Clears QA/bench test data (bell fires, playback, reboots, logs) before shipping to a customer. Does not affect configuration."
last
>
<Button
variant="danger"
size="md"
disabled={!canEdit}
onClick={() => setShowResetStats(true)}
>
Reset Stats…
</Button>
</ActionRow>
</div>
</Card>
</div>
{/* ── Confirm dialogs ───────────────────────────────────────────────── */}
<ConfirmDialog
open={confirmRestart}
variant="primary"
title="Restart Device"
message="This will reboot the device immediately. Any active playback will be interrupted. Continue?"
confirmLabel="Restart"
onConfirm={handleRestart}
onCancel={() => setConfirmRestart(false)}
loading={sendingCmd === 'system.restart'}
/>
<ConfirmDialog
open={confirmFactoryReset}
variant="danger"
title="Factory Reset Device"
message="This will permanently wipe ALL saved configuration on this device — bell outputs, clock settings, network config, everything. This action cannot be undone. The device must be restarted afterwards to apply."
confirmLabel="Factory Reset"
onConfirm={handleFactoryReset}
onCancel={() => setConfirmFactoryReset(false)}
loading={sendingCmd === 'system.factory_reset'}
/>
<ResetStatsModal
open={showResetStats}
onClose={() => setShowResetStats(false)}
deviceId={device?.id}
isOnline={isOnline}
sendMqttCommand={sendMqttCommand}
/>
</div>
)
} }
@@ -28,6 +28,9 @@ const LOG_LEVEL_META = {
const GLASS_BG = 'rgba(28, 32, 38, 0.30)' const GLASS_BG = 'rgba(28, 32, 38, 0.30)'
const GLASS_BG_INNER = 'rgba(28, 32, 38, 0.40)' const GLASS_BG_INNER = 'rgba(28, 32, 38, 0.40)'
const LOG_CMD = { serial: 'log.set_serial', sd: 'log.set_sd', mqtt: 'log.set_mqtt' }
const LOG_ATTR = { serial: 'serialLogLevel', sd: 'sdLogLevel', mqtt: 'mqttLogLevel' }
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function parseCoordinates(coordStr) { function parseCoordinates(coordStr) {
@@ -851,7 +854,9 @@ export default function GeneralTab({
setTags, setTags,
onEditLocation, onEditLocation,
onEditAttributes, onEditAttributes,
onEditLogging, sendMqttCommand,
sendMqttCommandSilent,
reloadDeviceSilent,
}) { }) {
const { toast } = useToast() const { toast } = useToast()
const [locationName, setLocationName] = useState(null) const [locationName, setLocationName] = useState(null)
@@ -874,6 +879,56 @@ export default function GeneralTab({
if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(attr.mqttLogLevel) if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(attr.mqttLogLevel)
}, [attr?.serialLogLevel, attr?.sdLogLevel, attr?.mqttLogLevel]) }, [attr?.serialLogLevel, attr?.sdLogLevel, attr?.mqttLogLevel])
// ── Background refresh: pull true log levels + network info from the device ──
// Fires at most once per mount (hasSyncedRef guard). Log levels are reconciled
// against Firestore and silently corrected on drift. Network info is read-only
// and always reflects whatever the device just reported — no drift comparison
// needed since nothing else in the console ever writes it.
const hasSyncedRef = useRef(false)
const [networkInfo, setNetworkInfo] = useState(null)
useEffect(() => {
if (!sendMqttCommandSilent || !device) return
if (hasSyncedRef.current) return
hasSyncedRef.current = true
const deviceId = device.id || device.device_id
sendMqttCommandSilent('log.get_config').then((reply) => {
const data = reply?.data || {}
const trueSerial = data.serial_level
const trueSd = data.sd_level
const trueMqtt = data.mqtt_level
if (trueSerial == null && trueSd == null && trueMqtt == null) return
const hasDrift =
(trueSerial != null && trueSerial !== (attr?.serialLogLevel ?? 0)) ||
(trueSd != null && trueSd !== (attr?.sdLogLevel ?? 0)) ||
(trueMqtt != null && trueMqtt !== (attr?.mqttLogLevel ?? 0))
if (!hasDrift) return
if (trueSerial != null) setSerialLevel(trueSerial)
if (trueSd != null) setSdLevel(trueSd)
if (trueMqtt != null) setMqttLevel(trueMqtt)
api.put(`/devices/${deviceId}`, {
device_attributes: {
serialLogLevel: trueSerial ?? attr?.serialLogLevel ?? 0,
sdLogLevel: trueSd ?? attr?.sdLogLevel ?? 0,
mqttLogLevel: trueMqtt ?? attr?.mqttLogLevel ?? 0,
},
}).then(() => reloadDeviceSilent?.())
}).catch(() => {
// Device offline or didn't reply — keep showing the last-known Firestore values.
})
sendMqttCommandSilent('network.info').then((reply) => {
setNetworkInfo(reply?.data || null)
}).catch(() => {
// Device offline or didn't reply — Network Info card stays empty/dash.
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [device?.id, device?.device_id])
// Nearest place // Nearest place
const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates]) const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates])
@@ -886,11 +941,6 @@ export default function GeneralTab({
.catch(() => setLocationName(null)) .catch(() => setLocationName(null))
}, [coords]) }, [coords])
const net = attr?.networkSettings || {}
const staticIpAddress = Array.isArray(net.ipAddress)
? net.ipAddress.filter(Boolean).join('.')
: (net.ipAddress || '')
// ── Tag handlers ──────────────────────────────────────────────────────────── // ── Tag handlers ────────────────────────────────────────────────────────────
const handleAddTag = useCallback(async (tag) => { const handleAddTag = useCallback(async (tag) => {
@@ -921,34 +971,43 @@ export default function GeneralTab({
} }
}, [id, tags, setTags, toast]) }, [id, tags, setTags, toast])
// ── Log level save (on blur / edit) ───────────────────────────────────────── // ── Log level save ────────────────────────────────────────────────────────
// Each slider sends its own log.set_* command to the device first; Firestore
// is only updated once the device confirms SUCCESS. sendMqttCommand already
// shows the blue→green/red toast, so a slider that fails to apply snaps back
// to its last confirmed value instead of drifting from reality.
const logSetters = useMemo(
() => ({ serial: setSerialLevel, sd: setSdLevel, mqtt: setMqttLevel }),
[]
)
const handleSaveLogging = useCallback(async (field, value) => { const handleSaveLogging = useCallback(async (field, value) => {
if (!canEdit) return if (!canEdit || !sendMqttCommand) return
const previous = { serial: serialLevel, sd: sdLevel, mqtt: mqttLevel }[field]
logSetters[field](value)
try { try {
await sendMqttCommand(LOG_CMD[field], { level: value })
await api.put(`/devices/${id}`, { await api.put(`/devices/${id}`, {
device_attributes: { device_attributes: { [LOG_ATTR[field]]: value },
...(attr || {}),
serialLogLevel: field === 'serial' ? value : serialLevel,
sdLogLevel: field === 'sd' ? value : sdLevel,
mqttLogLevel: field === 'mqtt' ? value : mqttLevel,
}
}) })
toast.success('Saved', 'Log levels updated.') } catch {
} catch (err) { // sendMqttCommand's toast already reported the failure — revert the slider.
toast.danger('Error', err.message || 'Failed to save log levels.') logSetters[field](previous)
} }
}, [id, attr, serialLevel, sdLevel, mqttLevel, canEdit, toast]) }, [id, serialLevel, sdLevel, mqttLevel, canEdit, sendMqttCommand, logSetters])
// ── Network fields ─────────────────────────────────────────────────────────── // ── Network fields ─────────────────────────────────────────────────────────
// Populated live from network.info — read-only, reported directly by the device.
const netFields = [ const netFields = [
{ label: 'Hostname', value: net.hostname || '—' }, { label: 'Hostname', value: networkInfo?.hostname || '—' },
{ label: 'IP Address', value: staticIpAddress || '—' }, { label: 'IP Address', value: networkInfo?.ip || '—' },
{ label: 'Gateway', value: net.gateway || '—' }, { label: 'Gateway', value: networkInfo?.gateway || '—' },
{ label: 'DNS', value: net.dns || '—' }, { label: 'DNS', value: networkInfo?.dns || '—' },
{ label: 'Subnet', value: net.subnet || '—' }, { label: 'Subnet', value: networkInfo?.subnet || '—' },
{ label: 'MAC Address', value: net.mac || net.macAddress || '—' }, { label: 'MAC Address', value: networkInfo?.mac || '—' },
{ label: 'SSID', value: networkInfo?.ssid || '—' },
{ label: 'Connection', value: networkInfo?.connection_type || '—' },
] ]
// ── Render ────────────────────────────────────────────────────────────────── // ── Render ──────────────────────────────────────────────────────────────────
@@ -1013,9 +1072,6 @@ export default function GeneralTab({
<GlassCard <GlassCard
title="Mechanisms" title="Mechanisms"
fullHeight fullHeight
titleAction={canEdit && (
<EditActionButton onClick={onEditAttributes} />
)}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}>
@@ -1058,9 +1114,6 @@ export default function GeneralTab({
<GlassCard <GlassCard
title="Log Settings" title="Log Settings"
fullHeight fullHeight
titleAction={canEdit && (
<EditActionButton onClick={onEditLogging} />
)}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', flex: 1, justifyContent: 'center' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', flex: 1, justifyContent: 'center' }}>
<LogSlider <LogSlider
@@ -1091,9 +1144,6 @@ export default function GeneralTab({
<GlassCard <GlassCard
title="Network Info" title="Network Info"
fullHeight fullHeight
titleAction={canEdit && (
<EditActionButton onClick={onEditAttributes} />
)}
> >
<div style={{ <div style={{
display: 'grid', display: 'grid',