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