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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:25:30 +03:00
co-authored by Claude Sonnet 5
parent 7c533b9245
commit cc138e3d82
11 changed files with 940 additions and 259 deletions
@@ -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',