Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- **DeviceCommand — one seam for server→firmware MCP tool calls** (`custom-providers/xiaozhi-patches/device_command.py`, mounted at `core/utils/device_command.py`) — twelve call sites across the `/xiaozhi/admin/*` handlers and `receiveAudioHandle.py` each hand-rolled the same JSON-RPC envelope, so every shared defect was twelve defects (2026-06-06 audit): request ids were `int(time.time()*1000) % 0x7FFFFFFF` (same-millisecond calls collided, with zero reply correlation to notice), and every site fired `conn.websocket.send()` uncoordinated against the other senders on the same connection. The seam owns **monotonic per-connection request ids**, **the MCP envelope**, and **per-connection serialized sends** (an asyncio.Lock — play-asset's opus frames now route through it too, so a concurrent head-turn can't interleave mid-frame). Admin handlers also share one `_dotty_resolve_conn()` instead of eight copies of the device-lookup block (which fixes the audit's `inject-text` missing-`or {}` headers nit as a side effect). Reply correlation is deliberately still absent — `call_tool` returns the request id so it can be added behind this interface without touching the callers again. **Bench: needs a live-device smoke (LED pips, head-turn, take-photo, play-asset) before release sign-off.**
- **Bridge systemd unit loads API keys from `${BRIDGE_DIR}/.env`** (#15) — `zeroclaw-bridge.service.template` and `scripts/install-bridge.sh` now emit `EnvironmentFile=-${BRIDGE_DIR}/.env`. `install-bridge.sh` creates a mode-0600 stub `.env` containing `OPENROUTER_API_KEY=` (and commented `VISION_API_KEY` / `VLM_API_KEY` placeholders) when one isn't already present, so the missing-vision-key failure surfaces as the bridge's existing ERROR ("camera offline") instead of a silent confabulation. Existing `.env` files are preserved.

### Changed
Expand Down
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,17 @@ For hardware specs, protocol details, model internals, latent capabilities, and
- xiaozhi-esp32 firmware (upstream): https://github.com/78/xiaozhi-esp32
- StackChan (hardware + firmware patches): https://github.com/m5stack/StackChan
- Emotion protocol: https://xiaozhi.dev/en/docs/development/emotion/

## Agent skills

### Issue tracker

Issues live as GitHub issues on `BrettKinny/dotty-stackchan` (the `origin` remote), managed via the `gh` CLI. See `docs/agents/issue-tracker.md`.

### Triage labels

Five canonical triage roles use their default label strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`), orthogonal to the existing `status:*` / `area:*` labels. See `docs/agents/triage-labels.md`.

### Domain docs

Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
3 changes: 3 additions & 0 deletions compose.all-in-one.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ services:
- ./receiveAudioHandle.py:/opt/xiaozhi-esp32-server/core/handle/receiveAudioHandle.py:ro
- ./dances.py:/opt/xiaozhi-esp32-server/core/handle/dances.py:ro
- ./custom-providers/textUtils.py:/opt/xiaozhi-esp32-server/core/utils/textUtils.py:ro
# DOTTY DeviceCommand seam: MCP request ids + envelope + per-conn
# serialized sends, shared by http_server.py + receiveAudioHandle.py
- ./custom-providers/xiaozhi-patches/device_command.py:/opt/xiaozhi-esp32-server/core/utils/device_command.py:ro
- ./songs:/opt/xiaozhi-esp32-server/config/assets/songs:ro
# DOTTY portal + admin routes + active-connection registry
- ./custom-providers/xiaozhi-patches/portal_bridge.py:/opt/xiaozhi-esp32-server/core/portal_bridge.py:ro
Expand Down
94 changes: 94 additions & 0 deletions custom-providers/xiaozhi-patches/device_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""DeviceCommand — the single seam for server→firmware MCP tool calls.

Mounted into the xiaozhi container at `core/utils/device_command.py`
(importable as `core.utils.device_command`) so both patch surfaces that
talk MCP to the device — the `/xiaozhi/admin/*` handlers in
`http_server.py` and the chat-pipeline helpers in
`receiveAudioHandle.py` — build and send tool calls through one module.

Before this seam, twelve call sites each hand-rolled the same JSON-RPC
envelope, and every shared defect was twelve defects (2026-06-06
audit): request ids were `int(time.time()*1000) % 0x7FFFFFFF`, so two
calls in the same millisecond collided; and every site fired
`conn.websocket.send()` with no coordination, racing the other senders
on the same ServerConnection (the websockets library does not allow
interleaved sends).

What this module owns:

* **Monotonic per-connection request ids** — a plain counter stored
on the conn, so ids are unique for the life of the connection and
a future reply-correlation layer has something to correlate on.
* **The MCP envelope** — one place to change the wire shape.
* **Serialized device-bound sends** — a per-connection asyncio.Lock;
every send routed through here is mutually exclusive with every
other send routed through here. (Upstream xiaozhi's own chat-path
writer does not take this lock — full serialization would mean
patching upstream send sites; this seam is where that lands when
it does.)

Reply correlation is deliberately NOT implemented yet: device-side MCP
replies are still fire-and-forget. `call_tool` returns the request id
so a correlation layer can be added behind this interface without
touching the twelve callers again.

State is attached to the conn object (`_dotty_mcp_next_id`,
`_dotty_send_lock`) rather than a side table so it lives and dies with
the connection — no leak across reconnects, no weakref bookkeeping.
All attachment happens on the event-loop thread with no awaits between
check and set, so initialisation cannot race.
"""

import asyncio
import json

_ID_ATTR = "_dotty_mcp_next_id"
_LOCK_ATTR = "_dotty_send_lock"


def next_request_id(conn) -> int:
"""Monotonic JSON-RPC id, unique per connection lifetime."""
current = getattr(conn, _ID_ATTR, 1)
setattr(conn, _ID_ATTR, current + 1)
return current


def mcp_envelope(conn, tool: str, arguments: dict, request_id: int) -> str:
"""Serialize one MCP tools/call frame for `conn`."""
return json.dumps({
"session_id": getattr(conn, "session_id", ""),
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {"name": tool, "arguments": arguments},
"id": request_id,
},
})


def _send_lock(conn) -> asyncio.Lock:
lock = getattr(conn, _LOCK_ATTR, None)
if lock is None:
lock = asyncio.Lock()
setattr(conn, _LOCK_ATTR, lock)
return lock


async def send_serialized(conn, message) -> None:
"""Send one frame (str or bytes) on the device WebSocket, mutually
exclusive with every other send routed through this module."""
async with _send_lock(conn):
await conn.websocket.send(message)


async def call_tool(conn, tool: str, arguments: dict) -> int:
"""Build + send one MCP tools/call. Returns the request id.

Fire-and-forget at the protocol level (no reply wait — see module
docstring), but the send itself is serialized against other
device-bound sends from this module.
"""
request_id = next_request_id(conn)
await send_serialized(conn, mcp_envelope(conn, tool, arguments, request_id))
return request_id
Loading
Loading