From cc138e3d82a5f120ae31610c117e8dd39fd982ff Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 21 Sep 2026 18:25:30 +0300 Subject: [PATCH] feat(devices): push General/Clock/Bells/Alerts config live over MQTT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../bellcloud/devices/EditAlertsModal.jsx | 40 +- .../bellcloud/devices/EditAttributesModal.jsx | 32 +- .../bellcloud/devices/EditBacklightModal.jsx | 18 +- .../devices/EditBellOutputsModal.jsx | 27 +- .../devices/EditClockSettingsModal.jsx | 13 +- .../bellcloud/devices/EditLoggingModal.jsx | 97 ---- .../pages/bellcloud/devices/DeviceDetail.jsx | 150 ++++-- .../pages/bellcloud/devices/tabs/BellsTab.jsx | 88 +++- .../pages/bellcloud/devices/tabs/ClockTab.jsx | 160 ++++-- .../bellcloud/devices/tabs/ControlTab.jsx | 456 +++++++++++++++++- .../bellcloud/devices/tabs/GeneralTab.jsx | 118 +++-- 11 files changed, 940 insertions(+), 259 deletions(-) delete mode 100644 frontend/src/modals/bellcloud/devices/EditLoggingModal.jsx diff --git a/frontend/src/modals/bellcloud/devices/EditAlertsModal.jsx b/frontend/src/modals/bellcloud/devices/EditAlertsModal.jsx index 22112a0..3b40583 100644 --- a/frontend/src/modals/bellcloud/devices/EditAlertsModal.jsx +++ b/frontend/src/modals/bellcloud/devices/EditAlertsModal.jsx @@ -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 ( diff --git a/frontend/src/modals/bellcloud/devices/EditAttributesModal.jsx b/frontend/src/modals/bellcloud/devices/EditAttributesModal.jsx index 2a5def8..6e84114 100644 --- a/frontend/src/modals/bellcloud/devices/EditAttributesModal.jsx +++ b/frontend/src/modals/bellcloud/devices/EditAttributesModal.jsx @@ -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 } @@ -84,31 +81,6 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id } -
- setHasBells(e.target.value)} - > - - - - setHasClock(e.target.value)} - > - - - -
-

- Connected bells: {Number.isFinite(attr?.totalBells) ? attr.totalBells : '—'} — managed via Bell Mechanisms tab. -

{error && (

{error}

)} diff --git a/frontend/src/modals/bellcloud/devices/EditBacklightModal.jsx b/frontend/src/modals/bellcloud/devices/EditBacklightModal.jsx index 2554c20..22f6f9c 100644 --- a/frontend/src/modals/bellcloud/devices/EditBacklightModal.jsx +++ b/frontend/src/modals/bellcloud/devices/EditBacklightModal.jsx @@ -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) diff --git a/frontend/src/modals/bellcloud/devices/EditBellOutputsModal.jsx b/frontend/src/modals/bellcloud/devices/EditBellOutputsModal.jsx index c61e580..6a73038 100644 --- a/frontend/src/modals/bellcloud/devices/EditBellOutputsModal.jsx +++ b/frontend/src/modals/bellcloud/devices/EditBellOutputsModal.jsx @@ -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) diff --git a/frontend/src/modals/bellcloud/devices/EditClockSettingsModal.jsx b/frontend/src/modals/bellcloud/devices/EditClockSettingsModal.jsx index f3fae11..3f1c869 100644 --- a/frontend/src/modals/bellcloud/devices/EditClockSettingsModal.jsx +++ b/frontend/src/modals/bellcloud/devices/EditClockSettingsModal.jsx @@ -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) diff --git a/frontend/src/modals/bellcloud/devices/EditLoggingModal.jsx b/frontend/src/modals/bellcloud/devices/EditLoggingModal.jsx deleted file mode 100644 index cac6c71..0000000 --- a/frontend/src/modals/bellcloud/devices/EditLoggingModal.jsx +++ /dev/null @@ -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 ( - - - - - } - > -
- setSerialLevel(e.target.value)} - > - {LOG_LEVELS.map((l) => ( - - ))} - - setSdLevel(e.target.value)} - > - {LOG_LEVELS.map((l) => ( - - ))} - - setMqttLevel(e.target.value)} - > - {LOG_LEVELS.map((l) => ( - - ))} - - {error && ( -

{error}

- )} -
-
- ) -} diff --git a/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx b/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx index b1abf5c..2e7ac6a 100644 --- a/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx +++ b/frontend/src/pages/bellcloud/devices/DeviceDetail.jsx @@ -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 ( -
+
+ + + + + + {sn} + + + } > + {canEdit && (
{canEdit && (

- 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.

)} diff --git a/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx index 0704c8a..2db9d6c 100644 --- a/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx +++ b/frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx @@ -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 ( +
+ {children} +
+ ) +} + +function SectionLabel({ children, icon }) { + return ( +
+ {icon && } + + {children} + +
+ ) +} + +// ─── Action row — label/description on the left, control on the right ──────── + +function ActionRow({ title, description, children, last = false }) { + return ( +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {children} +
+
+ ) +} + +// ─── Stacked action row — title/description on top, control row beneath ────── + +function StackedActionRow({ title, description, children, last = false }) { + return ( +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {children} +
+
+ ) +} + +// ─── 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 ( +
+ + {/* ── Status bar ────────────────────────────────────────────────────── */} + +
+ {isOnline ? 'Online' : 'Offline'} + + Commands are sent live over MQTT and require the device to be connected. + +
+ {!canEdit && ( + Read-only + )} +
+ + {!isOnline && ( +
+ + This device is currently offline. Commands sent below will not be delivered until it reconnects. +
+ )} + +
+ + {/* ── Relay Testing ──────────────────────────────────────────────── */} + +
+ + {/* relay.test_output */} + +
+
+ setTestOutput(e.target.value)} + placeholder="0–31" + disabled={disabled} + inputProps={{ min: 0, max: 31 }} + /> +
+
+ setTestDuration(e.target.value)} + placeholder="1–5000" + disabled={disabled} + inputProps={{ min: 1, max: 5000 }} + /> +
+
+ +
+ + {/* relay.test_bell */} + +
+ {(() => { + const bells = bellOptions.length > 0 ? bellOptions : Array.from({ length: 16 }, (_, i) => i) + const widestLabel = `Ring Bell ${bells.length}` + return bells.map(i => ( + + )) + })()} +
+
+ + {/* relay.test_batch */} + +
+ {(bellOptions.length > 0 ? bellOptions : Array.from({ length: 16 }, (_, i) => i)).map(i => { + const active = batchBells.includes(i) + return ( + + ) + })} +
+ +
+ +
+
+ + {/* ── Clock Control ──────────────────────────────────────────────── */} + +
+ + + + + + +
+
+ + {/* ── System ──────────────────────────────────────────────────────── */} + } + > +
+ + + + + + + + + +
+
+ +
+ + {/* ── Confirm dialogs ───────────────────────────────────────────────── */} + setConfirmRestart(false)} + loading={sendingCmd === 'system.restart'} + /> + + setConfirmFactoryReset(false)} + loading={sendingCmd === 'system.factory_reset'} + /> + + setShowResetStats(false)} + deviceId={device?.id} + isOnline={isOnline} + sendMqttCommand={sendMqttCommand} + /> + +
+ ) } diff --git a/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx b/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx index e606837..3cd4452 100644 --- a/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx +++ b/frontend/src/pages/bellcloud/devices/tabs/GeneralTab.jsx @@ -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({ - )} >
@@ -1058,9 +1114,6 @@ export default function GeneralTab({ - )} >
- )} >