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({ - )} >
- )} >