diff --git a/frontend/src/hooks/useDeviceCommand.js b/frontend/src/hooks/useDeviceCommand.js new file mode 100644 index 0000000..cb59da2 --- /dev/null +++ b/frontend/src/hooks/useDeviceCommand.js @@ -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