Files
hermes-esp32-voice-gateway/scripts/restore_after_update.py
T
Adolfo Reyna 6d593cd9ef 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
2026-07-14 23:42:34 -04:00

188 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()