feat(devices): extract useDeviceCommand hook for shared MQTT command/ack flow

Pulls the send-command-and-await-ack machinery out of DeviceDetail.jsx
into a reusable hook, so other pages (the onboarding wizard, etc.) can
send a device a command and await its control/ack reply the same way,
with a live-updating toast for non-silent commands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:22:48 +03:00
co-authored by Claude Sonnet 5
parent 38750ba951
commit e5d556fee1
+129
View File
@@ -0,0 +1,129 @@
// frontend/src/hooks/useDeviceCommand.js
// Shared MQTT command-send + ack-tracking machinery, extracted from
// DeviceDetail.jsx so any page (DeviceDetail, the onboarding wizard, …) can
// send a command to a device and await its reply the same way.
//
// Device replies arrive on control/ack. They can optionally echo a req_id
// (see CommandEnvelope.hpp / vesper_mqtt_topic_spec_v2.md), but this hook
// doesn't send one yet, so in-flight commands are matched FIFO against
// whichever command is oldest for this device — see the API Reference's wire
// envelope: { type: 'control/ack', device_serial, payload: { status, type, message, data } }.
//
// Usage:
// const { sendMqttCommand, sendMqttCommandSilent, sendingCmd, connected } =
// useDeviceCommand({ deviceSerial, onNonAckMessage, onCommandSent })
//
// sendMqttCommand(cmd, contents) — shows a live-updating toast (blue → success/danger/warning)
// sendMqttCommandSilent(cmd, contents, timeoutMs) — no toast, used for background GET-config refreshes
// onNonAckMessage(msg) — optional: receives every WS message that ISN'T a
// 'control/ack' reply for this device (e.g. status/heartbeat),
// so callers don't need a second WebSocket connection.
// onCommandSent(cmd) — optional: fires the instant a (non-silent) command is
// accepted for sending (POST resolved), before the device
// has replied — e.g. to refresh a command-history list.
import { useRef, useCallback, useState } from 'react'
import api from '@/lib/api'
import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
import { useToast } from '@/components/ui/Toast'
export function useDeviceCommand({ deviceSerial, onNonAckMessage, onCommandSent } = {}) {
const { toast } = useToast()
const [sendingCmd, setSendingCmd] = useState('')
// [{ cmd, toastId?, resolve, reject, timeoutId }]
const pendingAcksRef = useRef([])
const { connected } = useMqttWebSocket({
enabled: !!deviceSerial,
onMessage: (msg) => {
if (msg?.device_serial !== deviceSerial) return
if (msg.type !== 'control/ack') {
onNonAckMessage?.(msg)
return
}
const ack = pendingAcksRef.current.shift() // oldest in-flight command
if (!ack) return
const payload = msg.payload || {}
clearTimeout(ack.timeoutId)
const isSuccess = payload.status === 'SUCCESS'
if (ack.toastId != null) {
toast.update(ack.toastId, {
variant: isSuccess ? 'success' : 'danger',
title: isSuccess ? 'Success' : 'Failed',
message: payload.message || `Command "${payload.type || ack.cmd}" ${isSuccess ? 'succeeded' : 'failed'}.`,
pending: false,
duration: isSuccess ? 1000 : 6000,
})
}
if (isSuccess) ack.resolve?.(payload)
else ack.reject?.(new Error(payload.message || `Command "${ack.cmd}" failed.`))
},
})
// Sends a command and shows a live-updating toast. Returns a promise
// resolving with the reply payload on SUCCESS and rejecting on
// ERROR/timeout/send-failure.
const sendMqttCommand = useCallback((cmd, contents = {}) => {
if (!deviceSerial) return Promise.reject(new Error('No device id.'))
setSendingCmd(cmd)
const toastId = toast.pending(cmd, 'Sent — waiting for reply…')
return new Promise((resolve, reject) => {
api.post(`/mqtt/command/${deviceSerial}`, { cmd, contents })
.then(() => {
onCommandSent?.(cmd)
const ackEntry = { cmd, toastId, resolve, reject, timeoutId: null }
ackEntry.timeoutId = setTimeout(() => {
pendingAcksRef.current = pendingAcksRef.current.filter((a) => a !== ackEntry)
toast.update(toastId, {
variant: 'warning',
title: 'No reply received',
message: `"${cmd}" was sent but the device did not reply in time.`,
pending: false,
duration: 4000,
})
reject(new Error(`"${cmd}" timed out waiting for a reply.`))
}, 5000)
pendingAcksRef.current.push(ackEntry)
})
.catch((err) => {
toast.update(toastId, {
variant: 'danger',
title: 'Failed to send',
message: err.message || 'Failed to send command.',
pending: false,
duration: 6000,
})
reject(err)
})
.finally(() => setSendingCmd(''))
})
}, [deviceSerial, toast, onCommandSent])
// Silent variant — no toast, no sendingCmd flicker. Used for background
// GET-config refreshes and other non-user-initiated commands.
const sendMqttCommandSilent = useCallback((cmd, contents = {}, timeoutMs = 5000) => {
return new Promise((resolve, reject) => {
if (!deviceSerial) { reject(new Error('No device id.')); return }
api.post(`/mqtt/command/${deviceSerial}`, { cmd, contents })
.then(() => {
const ackEntry = { cmd, resolve, reject, timeoutId: null }
ackEntry.timeoutId = setTimeout(() => {
pendingAcksRef.current = pendingAcksRef.current.filter((a) => a !== ackEntry)
reject(new Error(`"${cmd}" timed out waiting for a reply.`))
}, timeoutMs)
pendingAcksRef.current.push(ackEntry)
})
.catch(reject)
})
}, [deviceSerial])
return { sendMqttCommand, sendMqttCommandSilent, sendingCmd, connected }
}
export default useDeviceCommand