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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user