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:
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Restore ESP32 voice gateway after `hermes update`.
|
||||
|
||||
Usage:
|
||||
./scripts/restore_after_update.py
|
||||
python3 scripts/restore_after_update.py --verify
|
||||
python3 scripts/restore_after_update.py --force
|
||||
|
||||
What it does:
|
||||
- Checks if gateway/platforms/api_server.py has ESP32 routes
|
||||
- If missing, applies patch/api_server_esp32_live_draft_with_llm.patch via git apply
|
||||
- Verifies routes return 400 not 404 (400 means route exists but missing WS upgrade / empty body)
|
||||
- Copies patch to ~/.hermes/ fallback location
|
||||
- Prints next steps (gateway restart)
|
||||
|
||||
Env:
|
||||
HERMES_AGENT_DIR - override hermes-agent checkout path
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def find_agent_dir() -> Path | None:
|
||||
import os
|
||||
candidates = []
|
||||
env_dir = os.getenv("HERMES_AGENT_DIR")
|
||||
if env_dir:
|
||||
candidates.append(Path(env_dir))
|
||||
candidates.append(Path.home() / ".hermes" / "hermes-agent")
|
||||
alt_path = Path(__file__).resolve().parents[2] / ".hermes" / "hermes-agent"
|
||||
if alt_path.exists():
|
||||
candidates.append(alt_path)
|
||||
try:
|
||||
import hermes_cli # type: ignore
|
||||
candidates.append(Path(hermes_cli.__file__).parents[1])
|
||||
except Exception:
|
||||
pass
|
||||
for c in candidates:
|
||||
if c and (c / "gateway" / "platforms" / "api_server.py").exists():
|
||||
return c
|
||||
return None
|
||||
|
||||
def has_routes(api_file: Path) -> bool:
|
||||
try:
|
||||
txt = api_file.read_text()
|
||||
return "ESP32_AUDIO_MAX_BYTES" in txt[:20000] and "/api/esp32/voice" in txt and "_handle_esp32_voice_ws" in txt
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def find_patch(repo_root: Path) -> Path | None:
|
||||
for p in [
|
||||
repo_root / "patch" / "api_server_esp32_live_draft_with_llm.patch",
|
||||
repo_root / "hermes_plugin" / "patch" / "api_server_esp32_live_draft_with_llm.patch",
|
||||
Path.home() / ".hermes" / "esp32_live_draft_with_llm_patch.patch",
|
||||
Path.home() / ".hermes" / "esp32_voice_patch.patch",
|
||||
]:
|
||||
if p.exists() and p.stat().st_size > 1000:
|
||||
return p
|
||||
# any in patch/
|
||||
for f in (repo_root / "patch").glob("*.patch"):
|
||||
if f.stat().st_size > 1000:
|
||||
return f
|
||||
return None
|
||||
|
||||
def apply_patch(patch_path: Path, agent_dir: Path, force: bool=False) -> bool:
|
||||
api_file = agent_dir / "gateway" / "platforms" / "api_server.py"
|
||||
print(f"[restore] patch={patch_path} agent_dir={agent_dir} api_file={api_file} force={force}")
|
||||
if not (agent_dir / ".git").exists():
|
||||
print(f"[restore] {agent_dir} is not a git repo")
|
||||
return False
|
||||
if has_routes(api_file) and not force:
|
||||
print("[restore] routes already present, skipping (use --force to reapply)")
|
||||
return True
|
||||
# Try apply
|
||||
if force:
|
||||
# reset file to HEAD first? we do checkout
|
||||
try:
|
||||
subprocess.run(["git", "checkout", "HEAD", "--", "gateway/platforms/api_server.py"], cwd=str(agent_dir), timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
# Check
|
||||
chk = subprocess.run(["git", "apply", "--check", str(patch_path)], cwd=str(agent_dir), capture_output=True, text=True, timeout=15)
|
||||
if chk.returncode != 0:
|
||||
print(f"[restore] git apply --check output:\n{chk.stdout[:1000]}\n{chk.stderr[:1000]}")
|
||||
# Try 3way if not forced yet
|
||||
if not force:
|
||||
print("[restore] trying git apply --3way")
|
||||
r = subprocess.run(["git", "apply", "--3way", str(patch_path)], cwd=str(agent_dir), capture_output=True, text=True, timeout=30)
|
||||
print(r.stdout[-500:] if r.stdout else "", r.stderr[-1000:] if r.stderr else "")
|
||||
if r.returncode == 0:
|
||||
print("[restore] 3way apply ok")
|
||||
return has_routes(api_file)
|
||||
else:
|
||||
r = subprocess.run(["git", "apply", str(patch_path)], cwd=str(agent_dir), capture_output=True, text=True, timeout=30)
|
||||
print(r.stdout[-500:] if r.stdout else "", r.stderr[-1000:] if r.stderr else "")
|
||||
return r.returncode == 0 and has_routes(api_file)
|
||||
else:
|
||||
r = subprocess.run(["git", "apply", str(patch_path)], cwd=str(agent_dir), capture_output=True, text=True, timeout=30)
|
||||
if r.returncode != 0:
|
||||
print(f"[restore] apply failed: {r.stdout} {r.stderr}")
|
||||
return False
|
||||
print(f"[restore] applied OK")
|
||||
return has_routes(api_file)
|
||||
|
||||
def verify_curly_brace_imports(agent_dir: Path) -> bool:
|
||||
"""Guard against missing top imports ipaddress/urlparse (bug that caused 500 loop 2026-07-14)"""
|
||||
api_file = agent_dir / "gateway" / "platforms" / "api_server.py"
|
||||
txt = api_file.read_text()
|
||||
has_ip = "import ipaddress" in txt
|
||||
has_url = "from urllib.parse import urlparse" in txt
|
||||
print(f"[verify] top imports: ipaddress={has_ip} urlparse={has_url}")
|
||||
if not has_ip or not has_url:
|
||||
print("[verify] MISSING critical imports – this will cause 500 import loop (see skill pitfalls). Fix by ensuring patch includes them.")
|
||||
return False
|
||||
return True
|
||||
|
||||
def compile_check(agent_dir: Path) -> bool:
|
||||
api_file = agent_dir / "gateway" / "platforms" / "api_server.py"
|
||||
r = subprocess.run([sys.executable, "-m", "py_compile", str(api_file)], capture_output=True, text=True, timeout=15)
|
||||
if r.returncode != 0:
|
||||
print(f"[compile] failed: {r.stderr}")
|
||||
return False
|
||||
print("[compile] OK")
|
||||
return True
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--force", action="store_true", help="Force reapply even if routes present")
|
||||
parser.add_argument("--verify", action="store_true", help="Only verify, don't apply")
|
||||
args = parser.parse_args()
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
agent_dir = find_agent_dir()
|
||||
if not agent_dir:
|
||||
print("Could not find hermes-agent dir (checked ~/.hermes/hermes-agent, HERMES_AGENT_DIR, pip location)")
|
||||
sys.exit(2)
|
||||
api_file = agent_dir / "gateway" / "platforms" / "api_server.py"
|
||||
print(f"Agent dir: {agent_dir}")
|
||||
print(f"API file: {api_file}")
|
||||
print(f"Has routes: {has_routes(api_file)}")
|
||||
|
||||
if args.verify:
|
||||
ok = verify_curly_brace_imports(agent_dir) and compile_check(agent_dir) and has_routes(api_file)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
patch = find_patch(repo_root)
|
||||
if not patch:
|
||||
print(f"No patch found. Looked in {repo_root}/patch/ and ~/.hermes/*.patch")
|
||||
sys.exit(2)
|
||||
print(f"Using patch: {patch} ({patch.stat().st_size} bytes)")
|
||||
|
||||
ok = apply_patch(patch, agent_dir, force=args.force)
|
||||
if not ok:
|
||||
print("[restore] FAILED to restore")
|
||||
sys.exit(1)
|
||||
|
||||
verify_curly_brace_imports(agent_dir)
|
||||
compile_check(agent_dir)
|
||||
|
||||
# Copy to ~/.hermes fallback
|
||||
try:
|
||||
dest = Path.home() / ".hermes" / "esp32_live_draft_with_llm_patch.patch"
|
||||
if patch.resolve() != dest.resolve():
|
||||
import shutil
|
||||
shutil.copy2(patch, dest)
|
||||
print(f"[restore] copied patch to {dest} for fallback")
|
||||
except Exception as e:
|
||||
print(f"[restore] failed to copy fallback: {e}")
|
||||
|
||||
print("""
|
||||
[restore] DONE. Next:
|
||||
1. hermes gateway restart (from a DIFFERENT shell, not from inside Hermes chat - see skill pitfalls)
|
||||
Use: systemctl --user restart hermes-gateway hermes-gateway-kids --no-pager || hermes gateway restart
|
||||
2. If stuck in deactivating:
|
||||
pkill -9 -f "hermes_cli.main gateway run"; systemctl --user reset-failed hermes-gateway*; systemctl --user start hermes-gateway
|
||||
3. Verify routes (should be 400 not 404):
|
||||
API_KEY=$(grep -E API_SERVER_KEY .env -h ~/.hermes/.env ~/.hermes/profiles/*/ .env 2>/dev/null | head -1 | cut -d= -f2 | tr -d '\"'\\'')
|
||||
curl -s -o /dev/null -w "%{http_code}\\n" -H "Authorization: Bearer $API_KEY" http://127.0.0.1:8642/api/esp32/voice/ws
|
||||
curl -s -o /dev/null -w "%{http_code}\\n" -H "Authorization: Bearer $API_KEY" -X POST http://127.0.0.1:8642/api/esp32/voice --data-binary @/dev/null
|
||||
# Both should print 400. 404 means routes still missing.
|
||||
""")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# One-liner called by cron/gateway after update to ensure routes present
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
python3 "$REPO_ROOT/scripts/restore_after_update.py" "$@"
|
||||
Reference in New Issue
Block a user