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:
@@ -112,8 +112,23 @@ function SilenceBlock({ title, isOn, onToggle, from, onFromChange, to, onToChang
|
||||
)
|
||||
}
|
||||
|
||||
export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, id }) {
|
||||
const totalBells = attr?.totalBells || 8
|
||||
// Console UI uses alertType 'disabled'|'single'|'multi'; firmware uses
|
||||
// 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 [ringIntervals, setRingIntervals] = useState(clock?.ringIntervals ?? 1000)
|
||||
@@ -141,12 +156,28 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
|
||||
setSaving(true)
|
||||
setError('')
|
||||
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}`, {
|
||||
device_attributes: {
|
||||
clockSettings: {
|
||||
...clock,
|
||||
ringAlerts: alertType,
|
||||
ringAlertsMasterOn: alertType !== 'disabled',
|
||||
ringIntervals: alertType === 'multi' ? ringIntervals : clock?.ringIntervals,
|
||||
hourAlertsBell: Number(hourBell),
|
||||
halfhourAlertsBell: Number(halfBell),
|
||||
@@ -163,6 +194,7 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
|
||||
await onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
// sendMqttCommand already surfaced a failure toast if the device rejected it.
|
||||
setError(err.message || 'Failed to save.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -171,7 +203,7 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
|
||||
|
||||
const bellOptions = [
|
||||
{ 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 (
|
||||
|
||||
@@ -11,8 +11,6 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
|
||||
const [bellGuardOn, setBellGuardOn] = useState(String(attr?.bellGuardOn ?? false))
|
||||
const [bellGuardSafetyOn, setBellGuardSafetyOn] = useState(String(attr?.bellGuardSafetyOn ?? 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 [error, setError] = useState('')
|
||||
|
||||
@@ -20,13 +18,12 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
// App-only settings — no device-side equivalent, Firestore is the source of truth.
|
||||
await api.put(`/devices/${id}`, {
|
||||
device_attributes: {
|
||||
bellGuardOn: toBool(bellGuardOn),
|
||||
warningsOn: toBool(warningsOn),
|
||||
bellGuardSafetyOn: toBool(bellGuardSafetyOn),
|
||||
hasBells: toBool(hasBells),
|
||||
hasClock: toBool(hasClock),
|
||||
},
|
||||
})
|
||||
await onSaved()
|
||||
@@ -42,7 +39,7 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Edit Basic Attributes"
|
||||
title="Edit Safety Settings"
|
||||
size="sm"
|
||||
footer={
|
||||
<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="false">Disabled</option>
|
||||
</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 && (
|
||||
<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
|
||||
|
||||
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 bellOutputsUsed = (attr?.bellOutputs || []).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)
|
||||
setError('')
|
||||
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}`, {
|
||||
device_attributes: {
|
||||
clockSettings: {
|
||||
...clock,
|
||||
isBacklightAutomationOn: toBool(isOn),
|
||||
isBacklightAutomationOn: enabled,
|
||||
backlightOutput: Number(output),
|
||||
backlightTurnOnTime: toBool(isOn) && onTime ? timeToIso(onTime) : '',
|
||||
backlightTurnOffTime: toBool(isOn) && offTime ? timeToIso(offTime) : '',
|
||||
backlightTurnOnTime: enabled && onTime ? timeToIso(onTime) : '',
|
||||
backlightTurnOffTime: enabled && offTime ? timeToIso(offTime) : '',
|
||||
},
|
||||
},
|
||||
})
|
||||
await onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
// sendMqttCommand already surfaced a failure toast if the device rejected it.
|
||||
setError(err.message || 'Failed to save.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
|
||||
@@ -17,7 +17,7 @@ const TIMING_LABELS = {
|
||||
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 existingCount = attr?.totalBells || 0
|
||||
const existingOutputs = attr?.bellOutputs || []
|
||||
@@ -25,10 +25,15 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
|
||||
|
||||
const [activeBells, setActiveBells] = useState(existingCount)
|
||||
const [rows, setRows] = useState(() =>
|
||||
Array.from({ length: maxOutputs }, (_, i) => ({
|
||||
output: existingOutputs[i] ?? 0,
|
||||
timing: existingTimings[i] ?? 90,
|
||||
}))
|
||||
Array.from({ length: maxOutputs }, (_, i) => {
|
||||
// 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,
|
||||
}
|
||||
})
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -43,6 +48,17 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
|
||||
setSaving(true)
|
||||
setError('')
|
||||
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}`, {
|
||||
device_attributes: {
|
||||
totalBells: activeBells,
|
||||
@@ -53,6 +69,7 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
|
||||
await onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
// sendMqttCommand already surfaced a failure toast if the device rejected it.
|
||||
setError(err.message || 'Failed to save.')
|
||||
} finally {
|
||||
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 maxOutputs = sub?.maxOutputs || 8
|
||||
const bellOutputsUsed = (attr?.bellOutputs || []).filter(o => o > 0)
|
||||
@@ -85,6 +85,16 @@ export default function EditClockSettingsModal({ open, onClose, onSaved, attr, s
|
||||
setSaving(true)
|
||||
setError('')
|
||||
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}`, {
|
||||
device_attributes: {
|
||||
hasClock,
|
||||
@@ -98,6 +108,7 @@ export default function EditClockSettingsModal({ open, onClose, onSaved, attr, s
|
||||
await onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
// sendMqttCommand already surfaced a failure toast if the device rejected it.
|
||||
setError(err.message || 'Failed to save.')
|
||||
} finally {
|
||||
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 { useAuth } from '@/hooks/useAuth'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import { useDeviceCommand } from '@/hooks/useDeviceCommand'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
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 Spinner from '@/components/ui/Spinner'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
|
||||
import EditLocationModal from '@/modals/bellcloud/devices/EditLocationModal'
|
||||
import EditAttributesModal from '@/modals/bellcloud/devices/EditAttributesModal'
|
||||
import EditLoggingModal from '@/modals/bellcloud/devices/EditLoggingModal'
|
||||
import EditMiscModal from '@/modals/bellcloud/devices/EditMiscModal'
|
||||
import EditBellOutputsModal from '@/modals/bellcloud/devices/EditBellOutputsModal'
|
||||
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 ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab'
|
||||
import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab'
|
||||
import HealthTab from '@/pages/bellcloud/devices/tabs/HealthTab'
|
||||
|
||||
// ─── Tab config ───────────────────────────────────────────────────────────────
|
||||
|
||||
const TABS = [
|
||||
{ key: 'overview', label: 'Overview' },
|
||||
{ key: 'health', label: 'Health' },
|
||||
{ key: 'general', label: 'General' },
|
||||
{ key: 'bells', label: 'Bell Mechanisms' },
|
||||
{ key: 'clock', label: 'Clock & Alerts' },
|
||||
@@ -44,10 +48,15 @@ const TABS = [
|
||||
{ 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) {
|
||||
const raw = searchParams.get('tab')
|
||||
const raw = searchParams.get('tab')?.toLowerCase()
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -81,12 +90,10 @@ export default function DeviceDetail() {
|
||||
|
||||
const [cmdHistory, setCmdHistory] = useState([])
|
||||
const [cmdHistoryLoading, setCmdHistoryLoading] = useState(false)
|
||||
const [sendingCmd, setSendingCmd] = useState('')
|
||||
|
||||
// Modal open flags
|
||||
const [editingLocation, setEditingLocation] = useState(false)
|
||||
const [editingAttributes, setEditingAttributes] = useState(false)
|
||||
const [editingLogging, setEditingLogging] = useState(false)
|
||||
const [editingMisc, setEditingMisc] = useState(false)
|
||||
const [editingBellOutputs, setEditingBellOutputs] = useState(false)
|
||||
const [editingClockSettings, setEditingClockSettings] = useState(false)
|
||||
@@ -139,6 +146,19 @@ export default function DeviceDetail() {
|
||||
|
||||
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 sn = device?.serial_number || device?.device_id
|
||||
if (!sn) return
|
||||
@@ -157,6 +177,53 @@ export default function DeviceDetail() {
|
||||
if (activeTab === 'control' && 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleTabChange = key => {
|
||||
@@ -213,20 +280,19 @@ export default function DeviceDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
const sendMqttCommand = async (cmd, contents = {}) => {
|
||||
const deviceId = device?.device_id || device?.serial_number
|
||||
if (!deviceId) return
|
||||
setSendingCmd(cmd)
|
||||
// Console-only friendly name — separate from device_name (the name the end
|
||||
// user gave the device in the app). Saves optimistically into local state
|
||||
// so the hero title updates immediately; a failed save reverts and toasts.
|
||||
const handleSaveConsoleName = useCallback(async (newName) => {
|
||||
const previous = device?.console_name || ''
|
||||
setDevice(d => d ? { ...d, console_name: newName } : d)
|
||||
try {
|
||||
await api.post(`/mqtt/command/${deviceId}`, { cmd, contents })
|
||||
toast.success('Sent', `Command "${cmd}" sent.`)
|
||||
loadCmdHistory()
|
||||
await api.put(`/devices/${id}`, { console_name: newName })
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to send command.')
|
||||
} finally {
|
||||
setSendingCmd('')
|
||||
setDevice(d => d ? { ...d, console_name: previous } : d)
|
||||
toast({ variant: 'danger', title: 'Failed to rename device', description: err.message || 'Please try again.' })
|
||||
}
|
||||
}
|
||||
}, [id, device, toast])
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -272,6 +338,7 @@ export default function DeviceDetail() {
|
||||
stats,
|
||||
sn,
|
||||
isOnline,
|
||||
mqttStatus,
|
||||
tags,
|
||||
setTags,
|
||||
staffNotes,
|
||||
@@ -284,14 +351,15 @@ export default function DeviceDetail() {
|
||||
cmdHistoryLoading,
|
||||
sendingCmd,
|
||||
loadDevice,
|
||||
reloadDeviceSilent,
|
||||
loadCmdHistory,
|
||||
sendMqttCommand,
|
||||
sendMqttCommandSilent,
|
||||
onShowDelete: () => setShowDelete(true),
|
||||
onAssignCustomer: () => setShowAssignCustomer(true),
|
||||
onUnassignCustomer: handleUnassignCustomer,
|
||||
onEditLocation: () => setEditingLocation(true),
|
||||
onEditAttributes: () => setEditingAttributes(true),
|
||||
onEditLogging: () => setEditingLogging(true),
|
||||
onEditMisc: () => setEditingMisc(true),
|
||||
onEditBellOutputs: () => setEditingBellOutputs(true),
|
||||
onEditClockSettings: () => setEditingClockSettings(true),
|
||||
@@ -305,12 +373,40 @@ export default function DeviceDetail() {
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div className="page-wrapper page-wrapper--capped">
|
||||
|
||||
<PageHeader
|
||||
title={device.device_name || 'Unnamed Device'}
|
||||
subtitle={sn}
|
||||
title={
|
||||
<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 && (
|
||||
<Button variant="secondary" onClick={() => navigate(`/devices/${id}/edit`)}>
|
||||
Edit Device
|
||||
@@ -326,6 +422,7 @@ export default function DeviceDetail() {
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{activeTab === 'overview' && <OverviewTab {...tabProps} />}
|
||||
{activeTab === 'health' && <HealthTab {...tabProps} />}
|
||||
{activeTab === 'general' && <GeneralTab {...tabProps} />}
|
||||
{activeTab === 'bells' && <BellsTab {...tabProps} />}
|
||||
{activeTab === 'clock' && <ClockTab {...tabProps} />}
|
||||
@@ -350,13 +447,6 @@ export default function DeviceDetail() {
|
||||
attr={attr}
|
||||
id={id}
|
||||
/>
|
||||
<EditLoggingModal
|
||||
open={editingLogging}
|
||||
onClose={() => setEditingLogging(false)}
|
||||
onSaved={loadDevice}
|
||||
attr={attr}
|
||||
id={id}
|
||||
/>
|
||||
<EditMiscModal
|
||||
open={editingMisc}
|
||||
onClose={() => setEditingMisc(false)}
|
||||
@@ -372,6 +462,7 @@ export default function DeviceDetail() {
|
||||
attr={attr}
|
||||
sub={sub}
|
||||
id={id}
|
||||
sendMqttCommand={sendMqttCommand}
|
||||
/>
|
||||
<EditClockSettingsModal
|
||||
open={editingClockSettings}
|
||||
@@ -380,6 +471,7 @@ export default function DeviceDetail() {
|
||||
attr={attr}
|
||||
sub={sub}
|
||||
id={id}
|
||||
sendMqttCommand={sendMqttCommand}
|
||||
/>
|
||||
<EditAlertsModal
|
||||
open={editingAlerts}
|
||||
@@ -388,6 +480,7 @@ export default function DeviceDetail() {
|
||||
clock={clock}
|
||||
attr={attr}
|
||||
id={id}
|
||||
sendMqttCommand={sendMqttCommand}
|
||||
/>
|
||||
<EditBacklightModal
|
||||
open={editingBacklight}
|
||||
@@ -397,6 +490,7 @@ export default function DeviceDetail() {
|
||||
clock={clock}
|
||||
sub={sub}
|
||||
id={id}
|
||||
sendMqttCommand={sendMqttCommand}
|
||||
/>
|
||||
<EditSubscriptionModal
|
||||
open={editingSubscription}
|
||||
@@ -422,7 +516,7 @@ export default function DeviceDetail() {
|
||||
open={showDelete}
|
||||
variant="danger"
|
||||
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"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDelete(false)}
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
// 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.
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import api from '@/lib/api'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
@@ -91,7 +90,7 @@ const SPEC_TILE = {
|
||||
function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) {
|
||||
const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th`
|
||||
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 strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null
|
||||
@@ -272,23 +271,85 @@ export default function BellsTab({
|
||||
attr,
|
||||
canEdit,
|
||||
loadDevice,
|
||||
reloadDeviceSilent,
|
||||
sendMqttCommand,
|
||||
sendMqttCommandSilent,
|
||||
onEditBellOutputs,
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
|
||||
const [togglingCommander, setTogglingCommander] = useState(false)
|
||||
const [requestingCounters, setRequestingCounters] = useState(false)
|
||||
const [liveCounters] = useState(null)
|
||||
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 hammerTimings = attr?.hammerTimings || []
|
||||
const totalBells = attr?.totalBells || 0
|
||||
const hasBells = attr?.hasBells ?? 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
|
||||
const requestStrikeCounters = useCallback(async () => {
|
||||
if (!sendMqttCommand) return
|
||||
@@ -301,20 +362,19 @@ export default function BellsTab({
|
||||
}
|
||||
}, [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) => {
|
||||
setTogglingCommander(true)
|
||||
try {
|
||||
await sendMqttCommand(newVal ? 'bells.enable' : 'bells.disable')
|
||||
await api.put(`/devices/${device.id || device.device_id}`, {
|
||||
device_attributes: { hasBells: newVal },
|
||||
})
|
||||
await loadDevice()
|
||||
toast.success(
|
||||
newVal ? 'Bell Commander Enabled' : 'Bell Commander Disabled',
|
||||
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.')
|
||||
} catch {
|
||||
// sendMqttCommand's toast already reported the failure — nothing further to do.
|
||||
} finally {
|
||||
setTogglingCommander(false)
|
||||
}
|
||||
@@ -495,7 +555,7 @@ export default function BellsTab({
|
||||
setConfirmDisable(false)
|
||||
handleToggleCommander(false)
|
||||
}}
|
||||
onCancel={() => setConfirmDisable(false)}
|
||||
onClose={() => setConfirmDisable(false)}
|
||||
loading={togglingCommander}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import api from '@/lib/api'
|
||||
import Card from '@/components/ui/Card'
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import { useToast } from '@/components/ui/Toast'
|
||||
import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -45,6 +44,16 @@ function silenceLabel(isOn, from, to) {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
function AnalogClock({ time, label, variant = 'primary' }) {
|
||||
@@ -261,19 +270,111 @@ export default function ClockTab({
|
||||
onEditClockSettings,
|
||||
onEditAlerts,
|
||||
onEditBacklight,
|
||||
onDeviceUpdated,
|
||||
sendMqttCommand,
|
||||
sendMqttCommandSilent,
|
||||
loadDevice,
|
||||
reloadDeviceSilent,
|
||||
}) {
|
||||
const [now, setNow] = useState(new Date())
|
||||
const [showSync, setShowSync] = useState(false)
|
||||
const [flashFields, setFlashFields] = useState(false)
|
||||
const [togglingClock, setTogglingClock] = useState(false)
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 1000)
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
const hasClock = attr?.hasClock ?? false
|
||||
@@ -285,7 +386,7 @@ export default function ClockTab({
|
||||
const pauseTime = clockTimings[1]
|
||||
|
||||
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 hourBell = clock?.hourAlertsBell || 0
|
||||
const halfBell = clock?.halfhourAlertsBell || 0
|
||||
@@ -298,9 +399,9 @@ export default function ClockTab({
|
||||
const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
|
||||
const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
|
||||
|
||||
const timezone = attr?.timezone || device?.timezone || null
|
||||
const ntpServer = attr?.ntpServer || attr?.networkSettings?.ntpServer || null
|
||||
const autoDst = attr?.autoDST ?? false
|
||||
const timezone = formatUtcOffset(clock?.gmtOffsetSec)
|
||||
const ntpServer = clock?.ntpServer || null
|
||||
const dstActive = !!clock?.dstOffsetSec
|
||||
|
||||
const alertTypeLabel =
|
||||
alertType === 'single' ? 'Single Fire' :
|
||||
@@ -312,6 +413,9 @@ export default function ClockTab({
|
||||
// All 4 hero fields must be set before ENABLING the master switch
|
||||
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 () => {
|
||||
if (!canEdit || togglingClock) return
|
||||
|
||||
@@ -326,23 +430,13 @@ export default function ClockTab({
|
||||
const newValue = !hasClock
|
||||
setTogglingClock(true)
|
||||
try {
|
||||
const updated = await api.put(`/devices/${deviceId}`, {
|
||||
device_attributes: { ...(attr || {}), hasClock: newValue },
|
||||
await sendMqttCommand(newValue ? 'clock.enable' : 'clock.disable')
|
||||
await api.put(`/devices/${deviceId}`, {
|
||||
device_attributes: { hasClock: newValue },
|
||||
})
|
||||
if (onDeviceUpdated) {
|
||||
// If API returns the full device object, use it; otherwise patch manually
|
||||
if (updated?.device_attributes !== undefined) {
|
||||
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.')
|
||||
await loadDevice?.()
|
||||
} catch {
|
||||
// sendMqttCommand's toast already reported the failure — nothing further to do.
|
||||
} finally {
|
||||
setTogglingClock(false)
|
||||
}
|
||||
@@ -607,28 +701,14 @@ export default function ClockTab({
|
||||
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
|
||||
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
|
||||
<SettingRow
|
||||
label="Auto-DST"
|
||||
badge={
|
||||
<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>
|
||||
}
|
||||
label="DST"
|
||||
badge={<StatusBadge variant={dstActive ? 'success' : 'neutral'}>{dstActive ? 'Active' : 'Not Active'}</StatusBadge>}
|
||||
/>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<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)' }}>
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,457 @@
|
||||
// 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 }) {
|
||||
return null
|
||||
import { useState } from 'react'
|
||||
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_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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseCoordinates(coordStr) {
|
||||
@@ -851,7 +854,9 @@ export default function GeneralTab({
|
||||
setTags,
|
||||
onEditLocation,
|
||||
onEditAttributes,
|
||||
onEditLogging,
|
||||
sendMqttCommand,
|
||||
sendMqttCommandSilent,
|
||||
reloadDeviceSilent,
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [locationName, setLocationName] = useState(null)
|
||||
@@ -874,6 +879,56 @@ export default function GeneralTab({
|
||||
if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(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
|
||||
const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates])
|
||||
|
||||
@@ -886,11 +941,6 @@ export default function GeneralTab({
|
||||
.catch(() => setLocationName(null))
|
||||
}, [coords])
|
||||
|
||||
const net = attr?.networkSettings || {}
|
||||
const staticIpAddress = Array.isArray(net.ipAddress)
|
||||
? net.ipAddress.filter(Boolean).join('.')
|
||||
: (net.ipAddress || '')
|
||||
|
||||
// ── Tag handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
const handleAddTag = useCallback(async (tag) => {
|
||||
@@ -921,34 +971,43 @@ export default function GeneralTab({
|
||||
}
|
||||
}, [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) => {
|
||||
if (!canEdit) return
|
||||
if (!canEdit || !sendMqttCommand) return
|
||||
const previous = { serial: serialLevel, sd: sdLevel, mqtt: mqttLevel }[field]
|
||||
logSetters[field](value)
|
||||
try {
|
||||
await sendMqttCommand(LOG_CMD[field], { level: value })
|
||||
await api.put(`/devices/${id}`, {
|
||||
device_attributes: {
|
||||
...(attr || {}),
|
||||
serialLogLevel: field === 'serial' ? value : serialLevel,
|
||||
sdLogLevel: field === 'sd' ? value : sdLevel,
|
||||
mqttLogLevel: field === 'mqtt' ? value : mqttLevel,
|
||||
}
|
||||
device_attributes: { [LOG_ATTR[field]]: value },
|
||||
})
|
||||
toast.success('Saved', 'Log levels updated.')
|
||||
} catch (err) {
|
||||
toast.danger('Error', err.message || 'Failed to save log levels.')
|
||||
} catch {
|
||||
// sendMqttCommand's toast already reported the failure — revert the slider.
|
||||
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 = [
|
||||
{ label: 'Hostname', value: net.hostname || '—' },
|
||||
{ label: 'IP Address', value: staticIpAddress || '—' },
|
||||
{ label: 'Gateway', value: net.gateway || '—' },
|
||||
{ label: 'DNS', value: net.dns || '—' },
|
||||
{ label: 'Subnet', value: net.subnet || '—' },
|
||||
{ label: 'MAC Address', value: net.mac || net.macAddress || '—' },
|
||||
{ label: 'Hostname', value: networkInfo?.hostname || '—' },
|
||||
{ label: 'IP Address', value: networkInfo?.ip || '—' },
|
||||
{ label: 'Gateway', value: networkInfo?.gateway || '—' },
|
||||
{ label: 'DNS', value: networkInfo?.dns || '—' },
|
||||
{ label: 'Subnet', value: networkInfo?.subnet || '—' },
|
||||
{ label: 'MAC Address', value: networkInfo?.mac || '—' },
|
||||
{ label: 'SSID', value: networkInfo?.ssid || '—' },
|
||||
{ label: 'Connection', value: networkInfo?.connection_type || '—' },
|
||||
]
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
@@ -1013,9 +1072,6 @@ export default function GeneralTab({
|
||||
<GlassCard
|
||||
title="Mechanisms"
|
||||
fullHeight
|
||||
titleAction={canEdit && (
|
||||
<EditActionButton onClick={onEditAttributes} />
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}>
|
||||
|
||||
@@ -1058,9 +1114,6 @@ export default function GeneralTab({
|
||||
<GlassCard
|
||||
title="Log Settings"
|
||||
fullHeight
|
||||
titleAction={canEdit && (
|
||||
<EditActionButton onClick={onEditLogging} />
|
||||
)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', flex: 1, justifyContent: 'center' }}>
|
||||
<LogSlider
|
||||
@@ -1091,9 +1144,6 @@ export default function GeneralTab({
|
||||
<GlassCard
|
||||
title="Network Info"
|
||||
fullHeight
|
||||
titleAction={canEdit && (
|
||||
<EditActionButton onClick={onEditAttributes} />
|
||||
)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
|
||||
Reference in New Issue
Block a user