{"asyncapi":"2.6.0","info":{"title":"hwclaw 设备 WebSocket 契约","version":"0.2.0","description":"两条 WebSocket 通道：\n- GET /v1/talk/asr/stream：设备 HMAC 鉴权后的实时 ASR（App / Linux 级设备）。\n- GET /v2/device：设备网关 v2，一条持久连接完成 challenge 鉴权、能力握手与半双工一轮对话；\n  完整规范见 docs/10-设备网关协议v2.md。\n"},"servers":{"local":{"url":"localhost:8080","protocol":"ws","description":"将 http(s) BaseURL 的协议转换为 ws(s)。"}},"channels":{"/v1/talk/asr/stream":{"description":"设备持续发送 PCM。每个推荐分片为 6400 bytes（200ms），总时长最多 60 秒。\n收到 ready 之前的 PCM 必须由客户端缓存并在 ready 后按原顺序发送。\n","bindings":{"ws":{"headers":{"type":"object","required":["X-Device-Id","X-Timestamp","X-Nonce","X-Request-Id","X-Signature"],"properties":{"X-Device-Id":{"type":"string"},"X-Timestamp":{"type":"string","description":"Unix seconds"},"X-Nonce":{"type":"string"},"X-Request-Id":{"type":"string"},"X-Signature":{"type":"string","description":"hex HMAC-SHA256"}}}}},"publish":{"operationId":"deviceToServer","summary":"设备上行 PCM 或控制消息","message":{"oneOf":[{"$ref":"#/components/messages/PCMChunk"},{"$ref":"#/components/messages/Commit"},{"$ref":"#/components/messages/Cancel"}]}},"subscribe":{"operationId":"serverToDevice","summary":"服务端 ready、转写、错误事件","message":{"oneOf":[{"$ref":"#/components/messages/Ready"},{"$ref":"#/components/messages/Transcript"},{"$ref":"#/components/messages/Error"}]}}},"/v2/device":{"description":"设备网关 v2。连接后服务端先发 challenge，设备必须在 5 秒内以唯一一条文本 hello 应答\n（signature = hex(HMAC-SHA256(device_secret, \"hwclaw-v2\\n\" + device_id + \"\\n\" + nonce + \"\\n\" + key_id))，\n不依赖设备时钟）。鉴权前单条消息上限约 20KB，鉴权后 64KB。\n一轮对话：listen{start} → state{listening} → 二进制 mic 帧 → listen{stop} → stt.final → state{thinking}\n→ reply.text → state{speaking} → 二进制 tts 帧（最后一帧 END）→ turn.end。\n二进制帧固定 12 字节头：version=0x02(1) kind(1: 0x01 mic/0x02 tts) codec(1: 0x01 pcm_s16le/0x02 opus/0x03 mp3)\nflags(1: bit0 END) seq(u16 BE) turn_seq(u16 BE) timestamp_ms(u32 BE) + 载荷。\n未知 type 一律忽略；服务端只发送 hello.features 列出的消息类型。\n","publish":{"operationId":"v2DeviceToServer","summary":"设备上行：hello、listen、ping、mic 音频帧","message":{"oneOf":[{"$ref":"#/components/messages/V2Hello"},{"$ref":"#/components/messages/V2Listen"},{"$ref":"#/components/messages/V2Ping"},{"$ref":"#/components/messages/V2MicFrame"},{"$ref":"#/components/messages/V2ToolsResult"},{"$ref":"#/components/messages/V2ContextUpdate"},{"$ref":"#/components/messages/V2Text"}]}},"subscribe":{"operationId":"v2ServerToDevice","summary":"服务端下行：challenge、hello、state、stt.*、reply.text、tts 音频帧、turn.end、error、pong","message":{"oneOf":[{"$ref":"#/components/messages/V2Challenge"},{"$ref":"#/components/messages/V2ServerHello"},{"$ref":"#/components/messages/V2State"},{"$ref":"#/components/messages/V2STTPartial"},{"$ref":"#/components/messages/V2STTFinal"},{"$ref":"#/components/messages/V2ReplyText"},{"$ref":"#/components/messages/V2TTSFrame"},{"$ref":"#/components/messages/V2TurnEnd"},{"$ref":"#/components/messages/V2Error"},{"$ref":"#/components/messages/V2Pong"},{"$ref":"#/components/messages/V2ToolsCall"}]}}}},"components":{"messages":{"V2Challenge":{"name":"challenge","payload":{"type":"object","required":["type","nonce","server_time_ms","protocol_versions"],"properties":{"type":{"const":"challenge"},"nonce":{"type":"string","description":"64 hex，一连接一用，服务端 5 秒有效"},"server_time_ms":{"type":"integer","description":"仅供校时与日志"},"protocol_versions":{"type":"array","items":{"type":"integer"},"description":"当前只有 [2]"}}}},"V2Hello":{"name":"hello","payload":{"type":"object","required":["type","protocol_version","auth"],"properties":{"type":{"const":"hello"},"protocol_version":{"type":"integer","const":2},"auth":{"type":"object","required":["device_id","key_id","signature"],"properties":{"device_id":{"type":"string"},"key_id":{"type":"string","description":"轮换前为 k1"},"signature":{"type":"string","description":"hex HMAC-SHA256"}}},"product_code":{"type":"string"},"firmware":{"type":"string"},"hardware":{"type":"string"},"client_id":{"type":"string"},"locale":{"type":"string"},"audio_in":{"type":"array","items":{"type":"string","enum":["opus_16k","pcm_s16le_16k"]},"description":"按设备优先级；缺省 pcm_s16le_16k"},"audio_out":{"type":"array","items":{"type":"string","enum":["opus_16k","opus_24k","pcm_s16le_16k","mp3_24k"]},"description":"按设备优先级；缺省 pcm_s16le_16k"},"features":{"type":"object","description":"缺省即 false；无 speaker 则不发音频，有 screen 才发 stt.*/reply.text","properties":{"ptt":{"type":"boolean"},"wake_word":{"type":"boolean"},"vad":{"type":"boolean"},"aec":{"type":"boolean"},"speaker":{"type":"boolean"},"screen":{"type":"object","properties":{"w":{"type":"integer"},"h":{"type":"integer"},"text":{"type":"boolean"},"glyph":{"type":"boolean"}}},"led":{"type":"boolean"},"battery":{"type":"boolean"},"ota":{"type":"boolean"},"device_tools":{"type":"array","items":{"type":"object"}},"text_input":{"type":"boolean","description":"设备能发送 text 消息开一轮文字轮次（有键盘/触屏的设备、App）；服务端在 interaction.text_input 回显"}}},"limits":{"type":"object","properties":{"max_utterance_ms":{"type":"integer"},"rx_buffer_bytes":{"type":"integer"}}}}}},"V2ServerHello":{"name":"hello","payload":{"type":"object","required":["type","session_id","server_time_ms","tier","interaction","audio_in","features","agent","keepalive","reconnect","limits"],"properties":{"type":{"const":"hello"},"session_id":{"type":"string"},"server_time_ms":{"type":"integer"},"tier":{"type":"string","enum":["L0","L1","L2"]},"interaction":{"type":"object","properties":{"mode":{"type":"string","enum":["ptt"]},"text_input":{"type":"boolean","description":"回显设备声明的 features.text_input；为真时本会话接受 text 消息"}}},"audio_in":{"$ref":"#/components/schemas/V2AudioSpec"},"audio_out":{"$ref":"#/components/schemas/V2AudioSpec"},"features":{"type":"array","items":{"type":"string"},"description":"本会话服务端会发送的消息类型（audio 表示二进制 tts 帧，tools.call 表示会下发设备工具调用）"},"agent":{"type":"object","properties":{"name":{"type":"string"},"welcome":{"type":"string"}}},"tools":{"type":"object","properties":{"server_skills":{"type":"array","items":{"type":"string"}},"device_tools_enabled":{"type":"boolean"}}},"keepalive":{"type":"object","properties":{"ping_interval_ms":{"type":"integer"},"timeout_ms":{"type":"integer"}}},"reconnect":{"type":"object","properties":{"min_backoff_ms":{"type":"integer"},"max_backoff_ms":{"type":"integer"},"jitter":{"type":"number"}}},"limits":{"type":"object","properties":{"max_utterance_ms":{"type":"integer"},"max_turn_ms":{"type":"integer"},"pre_ack_buffer_ms":{"type":"integer","description":"设备在收到 state{listening} 前必须缓冲的采音时长"},"listen_ack_timeout_ms":{"type":"integer","description":"超时未收到 ack 时设备中止本轮"},"daily_turn_quota":{"type":"integer","description":"0 表示不限（策略未配置每设备额度）"},"turns_used_today":{"type":"integer","description":"当日已用轮数，始终回报"}}}}}},"V2Listen":{"name":"listen","payload":{"type":"object","required":["type","state","turn_id"],"properties":{"type":{"const":"listen"},"state":{"type":"string","enum":["start","stop","abort"]},"turn_id":{"type":"string","maxLength":128,"description":"设备生成；重试同一轮必须复用"},"mode":{"type":"string","enum":["ptt"]},"reason":{"type":"string","description":"abort 原因，如 wake_word"}}}},"V2Text":{"name":"text","payload":{"type":"object","required":["type","turn_id","text"],"description":"文字轮次（需 hello.features.text_input 且服务端 interaction.text_input 回显）；跳过 ASR，服务端直接 state{thinking}，其余与语音轮次一致；未声明即发送 → error{protocol_error, close:false}","properties":{"type":{"const":"text"},"turn_id":{"type":"string","maxLength":128,"description":"设备生成；与 listen 共用同一套幂等"},"text":{"type":"string","minLength":1,"maxLength":2000}}}},"V2Ping":{"name":"ping","payload":{"type":"object","required":["type"],"properties":{"type":{"const":"ping"},"ts":{"type":"integer"}}}},"V2Pong":{"name":"pong","payload":{"type":"object","required":["type"],"properties":{"type":{"const":"pong"},"ts":{"type":"integer"},"server_time_ms":{"type":"integer"}}}},"V2ToolsCall":{"name":"tools.call","payload":{"type":"object","required":["type","id","name","arguments","timeout_ms"],"properties":{"type":{"const":"tools.call"},"id":{"type":"string"},"turn_id":{"type":"string"},"name":{"type":"string","description":"设备在 hello 中声明的 device.* 工具名"},"arguments":{"type":"object","additionalProperties":true},"timeout_ms":{"type":"integer"}}}},"V2ToolsResult":{"name":"tools.result","payload":{"type":"object","required":["type","id"],"properties":{"type":{"const":"tools.result"},"id":{"type":"string"},"result":{"type":"object","additionalProperties":true},"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}}}}}},"V2ContextUpdate":{"name":"context.update","payload":{"type":"object","required":["type"],"description":"与 PUT /v1/device/context 复用同一 tenant_id + device_id 设备上下文 patch：省略字段=保留，出现字段=替换；location / battery / network 显式 null=清除。每字段独立默认保鲜 30 分钟，网络心跳不会延长旧位置。上下文只保存原始坐标，平台不会主动逆地理编码；只有用户问到位置且部署配置了 AMAP_WEB_KEY 时，current_location skill 才按需解析一次，否则降级返回城市或经纬度。","properties":{"type":{"const":"context.update"},"battery":{"type":["object","null"],"properties":{"level":{"type":"integer"},"charging":{"type":"boolean"},"status":{"type":"string"}}},"network":{"type":["object","null"],"properties":{"type":{"type":"string"},"signal":{"type":"string"},"online":{"type":"boolean"},"carrier":{"type":"string"}}},"location":{"type":["object","null"],"properties":{"lat":{"type":"number"},"lng":{"type":"number"},"accuracy_m":{"type":"number"},"source":{"type":"string"},"city":{"type":"string"},"captured_at":{"type":"string","format":"date-time"}}},"firmware_version":{"type":["string","null"]},"app_version":{"type":["string","null"]}}}},"V2MicFrame":{"name":"mic_frame","title":"上行音频二进制帧","payload":{"type":"string","format":"binary","description":"kind=0x01；codec 必须等于握手协商的 audio_in；seq 每轮从 0 递增；turn_seq 回填 state{listening} 给出的值；仅 listening 期间被接受。"}},"V2TTSFrame":{"name":"tts_frame","title":"下行音频二进制帧","payload":{"type":"string","format":"binary","description":"kind=0x02；PCM 每帧 20ms/640B，Opus 每帧一个包，MP3 原样分块；最后一帧为空载荷且 flags 含 END；pacing=realtime 时先突发 200ms 再按节拍发送。"}},"V2State":{"name":"state","payload":{"type":"object","required":["type","state","turn_id","turn_seq"],"properties":{"type":{"const":"state"},"state":{"type":"string","enum":["listening","thinking","speaking"]},"turn_id":{"type":"string"},"turn_seq":{"type":"integer"}}}},"V2STTPartial":{"name":"stt.partial","payload":{"type":"object","required":["type","turn_id","revision","text"],"properties":{"type":{"const":"stt.partial"},"turn_id":{"type":"string"},"revision":{"type":"integer"},"text":{"type":"string"}}}},"V2STTFinal":{"name":"stt.final","payload":{"type":"object","required":["type","turn_id","revision","text","audio_duration_ms"],"properties":{"type":{"const":"stt.final"},"turn_id":{"type":"string"},"revision":{"type":"integer"},"text":{"type":"string"},"audio_duration_ms":{"type":"integer"}}}},"V2ReplyText":{"name":"reply.text","payload":{"type":"object","required":["type","turn_id","text"],"properties":{"type":{"const":"reply.text"},"turn_id":{"type":"string"},"text":{"type":"string"}}}},"V2TurnEnd":{"name":"turn.end","payload":{"type":"object","required":["type","turn_id","disposition","stats"],"properties":{"type":{"const":"turn.end"},"turn_id":{"type":"string"},"server_turn_id":{"type":"integer"},"disposition":{"type":"string","enum":["ok","fallback","busy","empty","aborted","duplicate","quota_exceeded","rate_limited"]},"finish_reason":{"type":"string"},"retry_after_ms":{"type":"integer"},"stats":{"type":"object","properties":{"asr_ms":{"type":"integer"},"ark_ms":{"type":"integer"},"tts_ms":{"type":"integer"},"total_ms":{"type":"integer","description":"listen start → turn.end"},"speech_ms":{"type":"integer","description":"listen start → listen stop"},"first_audio_ms":{"type":"integer","description":"listen stop → 首个下行音频帧（松手→首包）"},"audio_frames":{"type":"integer"},"audio_bytes":{"type":"integer"},"skill_calls":{"type":"integer"},"model":{"type":"string"},"replay":{"type":"boolean"}}}}}},"V2Error":{"name":"error","payload":{"type":"object","required":["type","code","message","retryable","close"],"properties":{"type":{"const":"error"},"code":{"type":"string","enum":["auth_failed","device_disabled","not_bound","protocol_error","unsupported_audio","unsupported_protocol_version","protocol_too_old","product_required","product_mismatch","superseded","server_draining","quota_exceeded","audio_too_long","busy","asr_unavailable","rate_limited","internal"]},"message":{"type":"string"},"retryable":{"type":"boolean"},"close":{"type":"boolean","description":"true 表示随后关闭连接"},"turn_id":{"type":"string"}}}},"PCMChunk":{"name":"pcm_chunk","title":"PCM binary","summary":"s16le PCM 二进制帧","payload":{"type":"string","format":"binary","description":"16kHz/16bit/mono；推荐 6400 bytes，服务端限制单帧 64KiB。"}},"Commit":{"name":"commit","payload":{"type":"object","required":["type"],"properties":{"type":{"const":"commit"}}}},"Cancel":{"name":"cancel","payload":{"type":"object","required":["type"],"properties":{"type":{"const":"cancel"}}}},"Ready":{"name":"ready","payload":{"type":"object","required":["type"],"properties":{"type":{"const":"ready"}}}},"Transcript":{"name":"transcript","payload":{"type":"object","required":["type","revision","text","final","audio_duration_ms"],"properties":{"type":{"const":"transcript"},"revision":{"type":"integer","minimum":1,"description":"单调递增；客户端丢弃旧 revision。"},"text":{"type":"string"},"final":{"type":"boolean","description":"commit 后的最终事件为 true；一个连接最多一个 final。"},"audio_duration_ms":{"type":"integer","minimum":0}}}},"Error":{"name":"error","payload":{"type":"object","required":["type","code","message"],"properties":{"type":{"const":"error"},"code":{"type":"string","enum":["asr_unavailable","asr_send_failed","asr_commit_failed","asr_upstream_error","asr_final_timeout","audio_too_large","invalid_control"]},"message":{"type":"string"}}}}},"schemas":{"V2AudioSpec":{"type":"object","properties":{"codec":{"type":"string","enum":["pcm_s16le","opus","mp3"]},"sample_rate":{"type":"integer"},"frame_ms":{"type":"integer"},"pacing":{"type":"string","enum":["realtime","burst"]}}}}},"x-hwclaw-rules":{"ready_timeout":"2s","final_timeout_after_commit":"2s","max_duration":"60s","max_pcm_bytes":1920000,"duplicate_commit":"ignored","duplicate_cancel":"ignored"},"x-hwclaw-v2-rules":{"hello_timeout":"5s","pre_auth_message_limit":"20KB","post_auth_message_limit":"64KB","keepalive":"server ping 20s / pong timeout 10s","final_timeout_after_stop":"2s","max_utterance":"60s","duplicate_listen_start_same_turn_id":"re-ack listening","listen_start_new_turn_id_during_turn":"previous turn ends with aborted","empty_transcript":"turn.end{empty} without turn/quota","same_device_second_connection":"first receives error{superseded, close:true}"},"x-hwclaw-binary-frame":{"description":"二进制帧 = 固定 12 字节头 + 载荷。多字节整数一律大端。","header":[{"offset":0,"size":1,"name":"version","description":"固定 0x02"},{"offset":1,"size":1,"name":"kind","description":"0x01 mic（上行）、0x02 tts（下行）"},{"offset":2,"size":1,"name":"codec","description":"0x01 pcm_s16le、0x02 opus、0x03 mp3；上行必须等于协商出的 audio_in"},{"offset":3,"size":1,"name":"flags","description":"bit0 = END"},{"offset":4,"size":2,"name":"seq","description":"uint16 BE，每方向每轮从 0 递增；上行序号不连续会以 protocol_error 关连接"},{"offset":6,"size":2,"name":"turn_seq","description":"uint16 BE，由 state{listening} 给出、设备回填；不匹配的帧按旧轮迟到帧丢弃"},{"offset":8,"size":4,"name":"timestamp_ms","description":"uint32 BE。上行是自 listen start 起的偏移（L0 可填 0），下行是该帧在回复音频里的起始偏移"}],"notes":["上行 PCM 为 16k/mono/s16le，建议 20ms/640B；Opus 每帧一个包（推荐 60ms/16kbps voip），服务端按 TOC 校验后直通，不解码。","上行的空载荷 END 帧只做一致性校验，不能代替 listen stop。","下行 PCM 定长 20ms/640B（末帧可短）；Opus 每帧一个包；MP3 原样分块。","pacing: realtime 表示先突发 200ms，之后按帧时长节拍发送，用来保护小接收缓冲。"]},"x-hwclaw-error-catalog":{"auth_failed":{"retryable":false,"close":true,"description":"握手或身份问题"},"device_disabled":{"retryable":false,"close":true,"description":"握手或身份问题"},"not_bound":{"retryable":false,"close":true,"description":"握手或身份问题"},"unsupported_audio":{"retryable":false,"close":true,"description":"握手或身份问题"},"unsupported_protocol_version":{"retryable":false,"close":true,"description":"握手或身份问题"},"protocol_too_old":{"retryable":false,"close":true,"description":"设备协议版本低于产品要求的 protocol_min_version"},"product_required":{"retryable":false,"close":true,"description":"设备没有绑定产品，无法解析能力"},"product_mismatch":{"retryable":false,"close":true,"description":"设备声明的产品与激活时绑定的不一致"},"superseded":{"retryable":false,"close":true,"description":"同一设备建立了第二条连接，先前那条被顶下线"},"protocol_error":{"retryable":false,"close":true,"description":"JSON 非法、二进制帧先于 hello、帧头或序号错误、二次 hello。listen 缺 turn_id 或 state 非法时 close 为 false"},"server_draining":{"retryable":true,"close":true,"description":"滚动发布，按 reconnect 退避后重连"},"internal":{"retryable":true,"close":false,"description":"本轮编排失败，随后会跟一条 turn.end{fallback}"},"quota_exceeded":{"retryable":false,"close":false,"description":"设备或租户当日轮次额度用尽"},"rate_limited":{"retryable":true,"close":false,"description":"触发设备分钟级限频"},"audio_too_long":{"retryable":false,"close":false,"description":"单轮语音超过 60 秒上限"},"busy":{"retryable":true,"close":false,"description":"上一轮尚未结束"},"asr_unavailable":{"retryable":true,"close":false,"description":"语音识别暂时不可用"},"gateway_busy":{"description":"只出现在升级前的 HTTP 503 响应里，不是 WebSocket 消息"}}}