feat: initial durable ESP32 voice gateway plugin + patches

- Migrates custom voice gateway from in-tree api_server.py patch to durable plugin
- Auto-restores routes after hermes update (POST /api/esp32/voice + WS /api/esp32/voice/ws)
- Includes live draft ingestion via MacMiniMCP speech_live_transcribe (Apple pipe volatile)
- Includes instant smart reply via apple_llm_quick_reply (3B ANE ~300ms, contextual not generic)
- Guard for missing ipaddress/urlparse imports that caused 500 loop (2026-07-14)
- Tools: esp32_voice_gateway_status, esp32_voice_gateway_restore
- Scripts: restore_after_update.py with 400-not-404 verification
- Installed as ~/.hermes/plugins/esp32-voice-gateway (enabled, survives hermes update)

Verified: routes present, plugin enabled, has_routes=True
This commit is contained in:
Adolfo Reyna
2026-07-14 23:42:34 -04:00
commit 6d593cd9ef
13 changed files with 13183 additions and 0 deletions
+304
View File
@@ -0,0 +1,304 @@
"""
Reyna family ESP32 Voice Gateway - durable Hermes plugin
Survives `hermes update` which wipes in-tree patches to gateway/platforms/api_server.py.
What it does:
- On Hermes import (plugin load), checks if api_server.py has ESP32 routes.
- If missing (after update), auto-restores from bundled patch + logs actionable warning.
- Exposes tools for manual verify/restore from inside Hermes chat (via slash or tool).
The actual voice logic stays in api_server.py (patched file) because it's 1600+ lines
deeply integrated with APIServerAdapter internals (ResponseStore, _run_agent, TTS, transcription,
screen MCP). Extracting as runtime monkeypatch would be brittle. The durable strategy is:
1. Patch file lives in this repo + bundled in plugin as `patch/*.patch`
2. Plugin's register() does self-heal check (fast ~10ms)
3. Gateway auto-restarts handled by operator (can't restart self from inside gateway - see skill pitfalls)
Future: true runtime injection plugin that patches APIServerAdapter.connect to add routes
without touching file — feasible but needs tracking _handle_esp32_* methods. Kept separate as v2.
"""
import logging
import subprocess
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
PLUGIN_DIR = Path(__file__).parent
PATCH_DIR = PLUGIN_DIR / "patch"
# repo root for dev checkout (symlinked plugin)
REPO_PATCH = PLUGIN_DIR.parent / "patch" / "api_server_esp32_live_draft_with_llm.patch"
def _find_hermes_agent_dir() -> Optional[Path]:
# Try standard locations
candidates = [
Path.home() / ".hermes" / "hermes-agent",
Path(__file__).resolve().parents[3], # if running from within hermes-agent/plugins/esp32-...
]
# Also check HERMES_AGENT_DIR env
import os
env_dir = os.getenv("HERMES_AGENT_DIR")
if env_dir:
candidates.insert(0, Path(env_dir))
# pip install location
try:
import hermes_cli
candidates.append(Path(hermes_cli.__file__).parents[1])
except Exception:
pass
for c in candidates:
if (c / "gateway" / "platforms" / "api_server.py").exists():
return c
return None
def _api_server_has_esp32() -> bool:
agent_dir = _find_hermes_agent_dir()
if not agent_dir:
return False
api_file = agent_dir / "gateway" / "platforms" / "api_server.py"
try:
txt = api_file.read_text()[:5000] # top matter + first routes
# quick check
if "ESP32_AUDIO_MAX_BYTES" not in api_file.read_text()[:20000]:
return False
# full route registration present?
content = api_file.read_text()
return "/api/esp32/voice" in content and "_handle_esp32_voice_ws" in content
except Exception:
return False
def _find_patch_file() -> Optional[Path]:
# Search order: bundled patch in plugin, repo patch, hermes-home fallback
for p in [
PATCH_DIR / "api_server_esp32_live_draft_with_llm.patch",
REPO_PATCH,
Path.home() / ".hermes" / "esp32_live_draft_with_llm_patch.patch",
Path.home() / ".hermes" / "esp32_voice_patch.patch",
PATCH_DIR / "esp32_live_draft_with_llm_patch.patch",
]:
if p.exists():
return p
# Also search inside plugin dir itself (dev checkout)
for pattern in ["*.patch"]:
for f in PATCH_DIR.glob(pattern):
if f.stat().st_size > 1000:
return f
return None
def _try_apply_patch(patch_path: Path, agent_dir: Path) -> bool:
"""Apply patch via git apply. Returns True if applied or already applied."""
api_file = agent_dir / "gateway" / "platforms" / "api_server.py"
if not api_file.exists():
logger.warning("[esp32-voice-gateway] api_server.py not found at %s", api_file)
return False
# Check if git
git_dir = agent_dir / ".git"
if not git_dir.exists():
logger.warning("[esp32-voice-gateway] not a git repo: %s", agent_dir)
return False
# Try git apply --check first
try:
# if already patched, check will fail with "already applied" type we detect via has_esp32 already
res = subprocess.run(
["git", "apply", "--check", str(patch_path)],
cwd=str(agent_dir),
capture_output=True,
text=True,
timeout=15,
)
if res.returncode != 0:
# Could be already applied or conflict. Log but try to see if content already has marker
if "ESP32_AUDIO_MAX_BYTES" not in api_file.read_text()[:20000]:
logger.warning("[esp32-voice-gateway] patch check failed: %s %s", res.stdout[:500], res.stderr[:800])
# Try --3way if possible
res2 = subprocess.run(
["git", "apply", "--3way", str(patch_path)],
cwd=str(agent_dir),
capture_output=True,
text=True,
timeout=30,
)
if res2.returncode != 0:
logger.error("[esp32-voice-gateway] git apply --3way failed: %s %s", res2.stdout[-500:], res2.stderr[-1000:])
return False
else:
# already has marker, consider applied
logger.info("[esp32-voice-gateway] patch already present (has ESP32 marker)")
return True
else:
res_apply = subprocess.run(
["git", "apply", str(patch_path)],
cwd=str(agent_dir),
capture_output=True,
text=True,
timeout=30,
)
if res_apply.returncode != 0:
logger.error("[esp32-voice-gateway] git apply failed: %s %s", res_apply.stdout[-500:], res_apply.stderr[-1000:])
return False
# Verify
if _api_server_has_esp32():
logger.info("[esp32-voice-gateway] Successfully restored ESP32 routes from %s -> %s", patch_path, api_file)
return True
else:
logger.warning("[esp32-voice-gateway] Patch applied but route marker still missing need manual check")
return False
except Exception as e:
logger.exception("[esp32-voice-gateway] Exception applying patch %s: %s", patch_path, e)
return False
def restore_if_needed() -> dict:
"""Check and auto-restore if needed. Safe to call multiple times."""
result = {"had_routes": False, "restored": False, "patch": None, "agent_dir": None, "error": None}
try:
agent_dir = _find_hermes_agent_dir()
result["agent_dir"] = str(agent_dir) if agent_dir else None
if not agent_dir:
result["error"] = "hermes-agent dir not found"
return result
if _api_server_has_esp32():
result["had_routes"] = True
return result
# missing -> try restore
patch_file = _find_patch_file()
result["patch"] = str(patch_file) if patch_file else None
if not patch_file:
result["error"] = "no patch file found (checked patch/*.patch + ~/.hermes/esp32*.patch)"
logger.error("[esp32-voice-gateway] Routes missing after update and no patch file found!")
return result
logger.warning("[esp32-voice-gateway] ESP32 routes missing (likely after `hermes update`) auto-restoring from %s", patch_file)
ok = _try_apply_patch(patch_file, agent_dir)
result["restored"] = ok
if ok:
# also copy patch to ~/.hermes for next fallback
try:
dest = Path.home() / ".hermes" / "esp32_live_draft_with_llm_patch.patch"
if patch_file.resolve() != dest.resolve():
import shutil
shutil.copy2(patch_file, dest)
except Exception:
pass
return result
except Exception as e:
result["error"] = str(e)
logger.exception("[esp32-voice-gateway] restore_if_needed failed")
return result
# --- Plugin registration ---
def register(ctx):
"""Hermes plugin entry point."""
# On import, do fast self-heal check (10ms if routes present)
info = restore_if_needed()
# Register a tool so user can ask inside Hermes: "check ESP32 voice gateway"
import json
def _tool_check_esp32_gateway(args, **kwargs) -> str:
res = restore_if_needed()
# also run live curl checks if gateway running
checks = {}
try:
import socket
# try to find api_server port from config
port = 8642
try:
from hermes_cli.config import load_config
cfg = load_config()
g_port = ((cfg.get("gateway") or {}).get("api_server") or {}).get("port")
p_port = ((cfg.get("platforms") or {}).get("api_server") or {}).get("port")
raw_port = g_port or p_port or port
port = int(raw_port)
except Exception:
pass
# quick TCP check
s = socket.socket()
s.settimeout(1.0)
try:
s.connect(("127.0.0.1", port))
checks["tcp"] = f"127.0.0.1:{port} open"
except Exception as e:
checks["tcp"] = f"closed: {e}"
finally:
s.close()
except Exception as e:
checks["tcp_error"] = str(e)
return json.dumps({
"had_routes_before": info.get("had_routes"),
"restored": info.get("restored"),
"patch_used": info.get("patch"),
"agent_dir": info.get("agent_dir"),
"error": info.get("error"),
"has_routes_now": _api_server_has_esp32(),
"checks": checks,
"verify_curl": f'curl -H "Authorization: Bearer $API_SERVER_KEY" http://127.0.0.1:8642/api/esp32/voice/ws -v 2>&1 | grep "< HTTP" -> want 400 not 404'
}, indent=2)
def _tool_restore_esp32_gateway(args, **kwargs) -> str:
res = restore_if_needed()
# force reapply even if has routes if requested
force = bool(args.get("force"))
if force:
patch = _find_patch_file()
agent_dir = _find_hermes_agent_dir()
if patch and agent_dir:
# reset file first then reapply? just apply
ok = _try_apply_patch(patch, agent_dir)
res["force_restored"] = ok
return json.dumps(res, indent=2)
ctx.register_tool(
name="esp32_voice_gateway_status",
toolset="esp32_voice",
schema={
"name": "esp32_voice_gateway_status",
"description": "Check if ESP32 voice gateway routes (POST /api/esp32/voice + WS /api/esp32/voice/ws) are present after hermes update. Auto-restores from bundled patch if missing.",
"parameters": {"type": "object", "properties": {}, "required": []},
},
handler=_tool_check_esp32_gateway,
description="Check ESP32 voice gateway route status and auto-restore after update",
)
ctx.register_tool(
name="esp32_voice_gateway_restore",
toolset="esp32_voice",
schema={
"name": "esp32_voice_gateway_restore",
"description": "Force restore ESP32 voice gateway routes from bundled patch after hermes update wipes api_server.py",
"parameters": {
"type": "object",
"properties": {
"force": {"type": "boolean", "description": "Force re-apply even if routes seem present"},
},
"required": [],
},
},
handler=_tool_restore_esp32_gateway,
description="Force restore ESP32 routes from patch",
)
def _on_startup(session_id=None, **kw):
# Gateway hook log status on startup for visibility
try:
if _api_server_has_esp32():
logger.info("[esp32-voice-gateway] Routes OK: /api/esp32/voice + /api/esp32/voice/ws present")
else:
logger.warning("[esp32-voice-gateway] Routes MISSING at startup will try restore on next tool call; if you just ran `hermes update`, run: ~/Projects/hermes-esp32-voice-gateway/scripts/restore_after_update.py && hermes gateway restart")
except Exception:
pass
try:
ctx.register_hook("on_session_start", _on_startup)
except Exception:
pass
logger.info("[esp32-voice-gateway] Plugin loaded (had_routes=%s restored=%s has_now=%s)", info.get("had_routes"), info.get("restored"), _api_server_has_esp32())
+8
View File
@@ -0,0 +1,8 @@
name: esp32-voice-gateway
version: "1.0.0"
description: "Reyna family ESP32 + iPhone Watch voice gateway - auto-restores custom api_server.py routes after hermes update (POST /api/esp32/voice + WS /api/esp32/voice/ws + live draft + Apple 3B instant reply)"
author: "Adolfo Reyna"
kind: standalone
provides_tools: []
provides_hooks:
- on_session_start