Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
This commit is contained in:
1 parent
27dc7917f2
commit
7a6d922d12
63 files changed
+2073
-630
No files matched your search
+158
-124
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Android Platform Adapter for Hermes Agent (Iris x Hermes).
|
||||
Iris Platform Adapter for Hermes Agent (Iris x Hermes).
|
||||
|
||||
A plugin-based gateway adapter that runs an HTTP server *inside* the
|
||||
``hermes gateway`` process. The native Android / Desktop app connects to it
|
||||
@@ -34,7 +34,7 @@ register the (delivery-validated) file in the media registry and emit
|
||||
|
||||
Milestone M5: push + offline. Frames with no live subscriber are parked in
|
||||
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
|
||||
HTTP v1 primary, ntfy fallback, selected by ``ANDROID_PUSH_BACKEND``).
|
||||
HTTP v1 primary, ntfy fallback, selected by ``IRIS_PUSH_BACKEND``).
|
||||
``notification`` frames render in-app banners and mirror to push (channel
|
||||
events, cron deliveries, approvals, clarifies); high-priority kinds push even
|
||||
when a device is live. ``fcm.register`` rotates push tokens (registry + live
|
||||
@@ -44,19 +44,19 @@ Configuration in config.yaml::
|
||||
|
||||
gateway:
|
||||
platforms:
|
||||
android:
|
||||
iris:
|
||||
enabled: true
|
||||
extra:
|
||||
host: 127.0.0.1
|
||||
port: 8790
|
||||
home_channel: android:default
|
||||
home_channel: default
|
||||
push_backend: fcm
|
||||
outbox_retention_hours: 72
|
||||
max_upload_bytes: 104857600
|
||||
|
||||
Or via environment variables (overrides config.yaml; secrets live in .env):
|
||||
ANDROID_TOKEN, ANDROID_WS_HOST, ANDROID_WS_PORT, ANDROID_HOME_CHANNEL,
|
||||
ANDROID_PUSH_BACKEND, ANDROID_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
||||
IRIS_TOKEN, IRIS_WS_HOST, IRIS_WS_PORT, IRIS_HOME_CHANNEL,
|
||||
IRIS_PUSH_BACKEND, IRIS_FCM_SERVICE_ACCOUNT, NTFY_TOPIC, ...
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -116,7 +116,10 @@ from gateway.platforms.base import ( # noqa: E402
|
||||
from hermes_constants import get_hermes_home # noqa: E402
|
||||
|
||||
from . import media as media_bridge # noqa: E402
|
||||
from . import protocol # noqa: E402
|
||||
from . import ( # noqa: E402
|
||||
protocol,
|
||||
qr,
|
||||
)
|
||||
from . import purge as purge_bridge # noqa: E402
|
||||
from . import search as search_bridge # noqa: E402
|
||||
from .channels import get_directory # noqa: E402
|
||||
@@ -124,6 +127,7 @@ from .http_server import HttpServer # noqa: E402
|
||||
from .outbox import Outbox # noqa: E402
|
||||
from .pairing import ( # noqa: E402
|
||||
DeviceRegistry,
|
||||
advertise_host,
|
||||
generate_token,
|
||||
pairing_url,
|
||||
qr_payload,
|
||||
@@ -147,7 +151,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
from hermes_cli import commands as hermes_commands
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"android: slash catalog unavailable (hermes_cli.commands import failed)",
|
||||
"iris: slash catalog unavailable (hermes_cli.commands import failed)",
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
@@ -175,7 +179,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
except Exception:
|
||||
# Code skew: the private helpers moved. Fall back to the plain
|
||||
# cli_only filter (config-gated commands are dropped, acceptable).
|
||||
logger.warning("android: slash catalog fell back to cli_only filter", exc_info=True)
|
||||
logger.warning("iris: slash catalog fell back to cli_only filter", exc_info=True)
|
||||
entries = [
|
||||
_entry(cmd.name, cmd.description, cmd.args_hint, cmd.category, list(cmd.aliases))
|
||||
for cmd in hermes_commands.COMMAND_REGISTRY
|
||||
@@ -187,7 +191,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
except Exception:
|
||||
# Best-effort: a broken plugin-command registry should not break the
|
||||
# built-in catalog, so the failure is intentionally swallowed.
|
||||
logger.debug("android: plugin command enumeration failed", exc_info=True)
|
||||
logger.debug("iris: plugin command enumeration failed", exc_info=True)
|
||||
return entries
|
||||
|
||||
|
||||
@@ -200,7 +204,7 @@ def _slash_command_catalog() -> list[dict[str, Any]]:
|
||||
# hermes exposes a plugin ``on_stream_delta`` hook that fires reasoning
|
||||
# deltas with ``kind="reasoning"`` (gated by ``plugins.stream_reasoning_deltas``).
|
||||
# We accumulate those deltas here and attach the result to the turn's
|
||||
# ``message.stop`` frame. Single-chat for now (android:default), so a
|
||||
# ``message.stop`` frame. Single-chat for now (the default home channel), so a
|
||||
# module-level buffer suffices; it is reset at each turn start.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -261,7 +265,7 @@ def _reset_reasoning() -> None:
|
||||
# (Settings → Tool detail), we capture each completed tool call via the
|
||||
# ``post_tool_call`` hook and attach it to the ``tool.end`` frame.
|
||||
#
|
||||
# Global FIFO (like the reasoning buffer): a personal android gateway serves
|
||||
# Global FIFO (like the reasoning buffer): a personal iris gateway serves
|
||||
# one active turn at a time, and records are matched to the open tool by name
|
||||
# in completion order. Bounded so a runaway turn can't grow it without limit.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,8 +360,8 @@ def _tool_emoji(tool_name: str) -> str | None:
|
||||
# * turn start — the first API call of the turn (latency baseline)
|
||||
#
|
||||
# Global buffer (same pattern as the reasoning/tool buffers): a personal
|
||||
# android gateway serves one active turn at a time. The hook fires for every
|
||||
# platform, so we only record when the turn's platform is android.
|
||||
# iris gateway serves one active turn at a time. The hook fires for every
|
||||
# platform, so we only record when the turn's platform is iris.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_runtime_meta: dict[str, Any] = {}
|
||||
@@ -374,7 +378,7 @@ _CTX_RESOLVE_TIMEOUT_S = 3.0
|
||||
def _on_post_api_request(**kwargs: Any) -> None:
|
||||
"""Plugin hook: capture per-turn runtime metadata (model, prompt tokens)."""
|
||||
platform = kwargs.get("platform")
|
||||
if platform and platform != "android":
|
||||
if platform and platform != "iris":
|
||||
return
|
||||
model = kwargs.get("model") or ""
|
||||
usage = kwargs.get("usage") or {}
|
||||
@@ -416,7 +420,7 @@ def _resolve_context_length(model: str) -> int | None:
|
||||
_context_length_cache[model] = int(ctx)
|
||||
return int(ctx)
|
||||
except Exception:
|
||||
logger.debug("android: context-length resolution failed for %s", model, exc_info=True)
|
||||
logger.debug("iris: context-length resolution failed for %s", model, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@@ -472,7 +476,7 @@ async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
|
||||
DEFAULT_HOST = "127.0.0.1"
|
||||
DEFAULT_PORT = 8790
|
||||
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
|
||||
DEFAULT_HOME_CHANNEL = "android:default"
|
||||
DEFAULT_HOME_CHANNEL = "default"
|
||||
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||
DEFAULT_PUSH_BACKEND = "fcm"
|
||||
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
||||
@@ -825,13 +829,13 @@ def check_requirements() -> bool:
|
||||
dashboard readiness). Never installs. The HTTP transport is stdlib-only,
|
||||
so there is no extra dependency to probe.
|
||||
"""
|
||||
return bool(_get_scoped_secret("ANDROID_TOKEN"))
|
||||
return bool(_get_scoped_secret("IRIS_TOKEN"))
|
||||
|
||||
|
||||
def validate_config(config) -> bool:
|
||||
"""Given a PlatformConfig, is the platform properly configured?"""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
||||
token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
|
||||
return bool(token)
|
||||
|
||||
|
||||
@@ -858,7 +862,7 @@ def _env_enablement() -> dict | None:
|
||||
core hook -- it becomes a proper ``HomeChannel`` dataclass on the
|
||||
``PlatformConfig`` rather than being merged into ``extra``.
|
||||
"""
|
||||
token = _get_scoped_secret("ANDROID_TOKEN", "")
|
||||
token = _get_scoped_secret("IRIS_TOKEN", "")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
@@ -867,20 +871,20 @@ def _env_enablement() -> dict | None:
|
||||
# clobber user YAML. Unset keys fall through to config.yaml / adapter
|
||||
# defaults.
|
||||
seed: dict[str, Any] = {}
|
||||
host = os.getenv("ANDROID_WS_HOST", "").strip()
|
||||
host = os.getenv("IRIS_WS_HOST", "").strip()
|
||||
if host:
|
||||
seed["host"] = host
|
||||
http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip()
|
||||
http_port_raw = os.getenv("IRIS_HTTP_PORT", "").strip()
|
||||
if http_port_raw:
|
||||
seed["http_port"] = _parse_port(http_port_raw)
|
||||
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
||||
push = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower()
|
||||
if push:
|
||||
seed["push_backend"] = push
|
||||
home = os.getenv("ANDROID_HOME_CHANNEL", "").strip()
|
||||
home = os.getenv("IRIS_HOME_CHANNEL", "").strip()
|
||||
if home:
|
||||
seed["home_channel"] = {
|
||||
"chat_id": home,
|
||||
"name": os.getenv("ANDROID_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
|
||||
"name": os.getenv("IRIS_HOME_CHANNEL_NAME", "").strip() or DEFAULT_HOME_CHANNEL_NAME,
|
||||
}
|
||||
return seed
|
||||
|
||||
@@ -893,21 +897,22 @@ def _parse_port(raw: str) -> int:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Target parsing: "android:<chat>[:<thread>]"
|
||||
# Target parsing: "<chat_id>[:<thread>]" (platform prefix stripped by core)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _parse_target_ref(target_ref: str) -> tuple | None:
|
||||
"""Parse a raw target string into ``(chat_id, thread_id)`` or ``None``.
|
||||
|
||||
Recognises the native syntax ``android:<chat>[:<thread>]`` where the
|
||||
chat_id itself carries the ``android:`` prefix (e.g. ``android:chan_7``)
|
||||
and an optional thread is a trailing ``:t_<n>``. A bare friendly name
|
||||
(e.g. ``Cron Reports``) is resolved against the channel directory so cron
|
||||
/ ``send_message`` can target a channel by name immediately, without
|
||||
waiting for the core directory's refresh timer. Returns ``None`` for
|
||||
anything unrecognised so the target proceeds to the core channel-directory
|
||||
resolution.
|
||||
The core strips the platform prefix before calling us, so the native
|
||||
syntax is simply ``<chat_id>[:<thread>]`` (e.g. ``chan_7`` or
|
||||
``chan_7:t_31``); the home channel is ``default``. Chat ids are direct
|
||||
(no embedded platform prefix), so a cron delivery reads
|
||||
``iris:chan_7`` end to end. A bare friendly name (e.g. ``Cron Reports``)
|
||||
is resolved against the channel directory so cron / ``send_message`` can
|
||||
target a channel by name immediately, without waiting for the core
|
||||
directory's refresh timer. Returns ``None`` for anything unrecognised so
|
||||
the target proceeds to the core channel-directory resolution.
|
||||
"""
|
||||
if not target_ref:
|
||||
return None
|
||||
@@ -915,17 +920,26 @@ def _parse_target_ref(target_ref: str) -> tuple | None:
|
||||
if not t:
|
||||
return None
|
||||
|
||||
if t.startswith("android:"):
|
||||
body = t[len("android:") :].strip()
|
||||
if not body:
|
||||
return None
|
||||
thread_id: str | None = None
|
||||
if ":" in body:
|
||||
head, tail = body.rsplit(":", 1)
|
||||
if tail and tail.startswith("t_"):
|
||||
thread_id = tail
|
||||
body = head
|
||||
return (f"android:{body}", thread_id)
|
||||
thread_id: str | None = None
|
||||
if ":" in t:
|
||||
head, tail = t.rsplit(":", 1)
|
||||
if head and tail.startswith("t_"):
|
||||
thread_id = tail
|
||||
t = head
|
||||
else:
|
||||
# Not a <chat>:<thread> pair -- treat the whole string as a name.
|
||||
t = target_ref.strip()
|
||||
if not t:
|
||||
return None
|
||||
|
||||
# Native chat id (default / chan_<n>) or any id known to the directory
|
||||
# (covers custom IRIS_HOME_CHANNEL values).
|
||||
try:
|
||||
known = get_directory().get(t) is not None
|
||||
except Exception:
|
||||
known = False
|
||||
if t == "default" or re.fullmatch(r"chan_\d+", t) or known:
|
||||
return (t, thread_id)
|
||||
|
||||
# Bare friendly name -> resolve via the channel directory. A thread resolves
|
||||
# to its session lane (parent_chat_id + thread_id); a channel/default to
|
||||
@@ -966,7 +980,7 @@ async def _standalone_send(
|
||||
"""
|
||||
return {
|
||||
"error": (
|
||||
"android standalone send: the running gateway is required to serve "
|
||||
"iris standalone send: the running gateway is required to serve "
|
||||
"the outbox (standalone delivery is best-effort only)"
|
||||
)
|
||||
}
|
||||
@@ -978,7 +992,7 @@ async def _standalone_send(
|
||||
|
||||
|
||||
def _ensure_verbose_tool_progress() -> None:
|
||||
"""Ensure the android platform renders tool progress in ``verbose`` mode.
|
||||
"""Ensure the iris platform renders tool progress in ``verbose`` mode.
|
||||
|
||||
Verbose mode makes the gateway's tool-progress line carry the FULL
|
||||
argument JSON (not just a ~40-char preview), which the adapter parses
|
||||
@@ -988,7 +1002,7 @@ def _ensure_verbose_tool_progress() -> None:
|
||||
it).
|
||||
|
||||
Best-effort and idempotent: writes
|
||||
``display.platforms.android.tool_progress: verbose`` to config.yaml only
|
||||
``display.platforms.iris.tool_progress: verbose`` to config.yaml only
|
||||
when it isn't already set. The gateway's config cache is mtime-keyed, so
|
||||
the write takes effect on the next turn without a restart. Never raises.
|
||||
"""
|
||||
@@ -998,19 +1012,19 @@ def _ensure_verbose_tool_progress() -> None:
|
||||
cfg = load_config_readonly() or {}
|
||||
display = cfg.get("display") or {}
|
||||
platforms = display.get("platforms") or {}
|
||||
android = platforms.get("android") or {}
|
||||
if android.get("tool_progress") == "verbose":
|
||||
iris_cfg = platforms.get("iris") or {}
|
||||
if iris_cfg.get("tool_progress") == "verbose":
|
||||
return # already set
|
||||
from utils import atomic_roundtrip_yaml_update
|
||||
|
||||
atomic_roundtrip_yaml_update(
|
||||
get_hermes_home() / "config.yaml",
|
||||
"display.platforms.android.tool_progress",
|
||||
"display.platforms.iris.tool_progress",
|
||||
"verbose",
|
||||
)
|
||||
logger.info("android: set display.platforms.android.tool_progress=verbose")
|
||||
logger.info("iris: set display.platforms.iris.tool_progress=verbose")
|
||||
except Exception:
|
||||
logger.debug("android: could not ensure verbose tool_progress", exc_info=True)
|
||||
logger.debug("iris: could not ensure verbose tool_progress", exc_info=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1033,57 +1047,77 @@ def interactive_setup() -> None:
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
except Exception:
|
||||
print("android: setup helpers unavailable; set ANDROID_TOKEN in ~/.hermes/.env")
|
||||
print("iris: setup helpers unavailable; set IRIS_TOKEN in ~/.hermes/.env")
|
||||
return
|
||||
|
||||
print_info("📱 Android / Desktop (Iris x Hermes)")
|
||||
token = get_env_value("ANDROID_TOKEN") or ""
|
||||
token = get_env_value("IRIS_TOKEN") or ""
|
||||
if not token:
|
||||
generated = generate_token()
|
||||
save_env_value("ANDROID_TOKEN", generated)
|
||||
save_env_value("IRIS_TOKEN", generated)
|
||||
print_success(f"Generated pairing token: {generated}")
|
||||
print_warning("Keep this secret -- the app presents it on connect.")
|
||||
else:
|
||||
print_info("Existing ANDROID_TOKEN found (not shown).")
|
||||
print_info("Existing IRIS_TOKEN found (not shown).")
|
||||
|
||||
host = prompt("Bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
|
||||
save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST)
|
||||
host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
|
||||
save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST)
|
||||
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
|
||||
# HTTP default must be applied explicitly (docs/19: 8791).
|
||||
http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip()
|
||||
http_port_raw = (get_env_value("IRIS_HTTP_PORT") or "").strip()
|
||||
port = prompt(
|
||||
"HTTP port",
|
||||
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
|
||||
)
|
||||
save_env_value("ANDROID_HTTP_PORT", str(_parse_port(port)))
|
||||
save_env_value("IRIS_HTTP_PORT", str(_parse_port(port)))
|
||||
backend = prompt(
|
||||
"Push backend (fcm/ntfy)",
|
||||
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
||||
default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
||||
)
|
||||
save_env_value("ANDROID_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
|
||||
save_env_value("IRIS_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
|
||||
|
||||
# Pairing payload for the app's Connect screen (manual entry; the app has
|
||||
# no QR scanner).
|
||||
url = pairing_url(host or DEFAULT_HOST, _parse_port(port))
|
||||
# Pairing payload for the app's Connect screen (manual entry + QR scan).
|
||||
# Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is
|
||||
# replaced by the default-route LAN IP so the QR points somewhere a phone
|
||||
# can actually reach (the user can still override the Server URL in-app).
|
||||
advertised = advertise_host(host or DEFAULT_HOST)
|
||||
url = pairing_url(advertised, _parse_port(port))
|
||||
pairing = qr_payload(advertised, _parse_port(port), token)
|
||||
print_info("Pair your device (enter this on the app's Connect screen):")
|
||||
print_info(f"Pairing URL: {qr_payload(host or DEFAULT_HOST, _parse_port(port), token)}")
|
||||
print_info(f"Pairing URL: {pairing}")
|
||||
print_info(f"Server URL: {url}")
|
||||
if advertised != (host or DEFAULT_HOST):
|
||||
print_info(
|
||||
f"QR points to {advertised} (your default LAN address). If your "
|
||||
"phone is on a different network, change the Server URL in the app."
|
||||
)
|
||||
|
||||
# Scannable QR (docs/20): the same payload as a terminal QR. The URL text
|
||||
# lines stay — the QR is a convenience, not a replacement (non-UTF-8
|
||||
# terminals still work, and the text is copy-pasteable). render_qr returns
|
||||
# '' (not an exception) when the payload is too long to encode.
|
||||
qr_block = qr.render_qr(pairing)
|
||||
if qr_block:
|
||||
print_info("Scan with the Iris app (Connect → Scan QR) or any camera app:")
|
||||
print(qr_block)
|
||||
else:
|
||||
print_warning("QR too large to render; use the pairing URL above.")
|
||||
|
||||
# Always render tool progress verbosely so the app receives the full tool
|
||||
# call args (it decides how much to show via Settings → Tool detail).
|
||||
_ensure_verbose_tool_progress()
|
||||
|
||||
print_success("Android configuration saved to ~/.hermes/.env")
|
||||
print_success("Iris configuration saved to ~/.hermes/.env")
|
||||
print_info("Restart the gateway for changes to take effect: hermes gateway restart")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Android Adapter
|
||||
# Iris Adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AndroidAdapter(BasePlatformAdapter):
|
||||
"""HTTP-backed adapter for the native Iris Android / Desktop app.
|
||||
class IrisAdapter(BasePlatformAdapter):
|
||||
"""HTTP-backed adapter for the native Iris app (Android / Desktop).
|
||||
|
||||
The HTTP server (``http_server.HttpServer``) authenticates devices with
|
||||
the pairing token, the device registry tracks live subscribers, ``send()``
|
||||
@@ -1100,7 +1134,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
MAX_MESSAGE_LENGTH = 1_000_000
|
||||
|
||||
def __init__(self, config, **kwargs):
|
||||
platform = Platform("android")
|
||||
platform = Platform("iris")
|
||||
super().__init__(config=config, platform=platform)
|
||||
|
||||
# Ensure verbose tool progress (full args on the progress line) so the
|
||||
@@ -1111,13 +1145,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
|
||||
# Connection settings (env vars override config.yaml). The bind host
|
||||
# is shared with the (legacy) WS-era env var name for compatibility.
|
||||
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
||||
self.host = os.getenv("IRIS_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
||||
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
|
||||
self.http_port = _parse_port(
|
||||
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
||||
os.getenv("IRIS_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
||||
)
|
||||
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
||||
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get(
|
||||
self.token = _get_scoped_secret("IRIS_TOKEN") or extra.get("token", "")
|
||||
self.push_backend = os.getenv("IRIS_PUSH_BACKEND", "").strip().lower() or extra.get(
|
||||
"push_backend", DEFAULT_PUSH_BACKEND
|
||||
)
|
||||
self.outbox_retention_hours = int(
|
||||
@@ -1146,18 +1180,18 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
|
||||
|
||||
# TLS (optional)
|
||||
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "")
|
||||
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
|
||||
self.http_cert = _get_scoped_secret("IRIS_HTTP_CERT") or extra.get("http_cert", "")
|
||||
self.http_key = _get_scoped_secret("IRIS_HTTP_KEY") or extra.get("http_key", "")
|
||||
|
||||
# Auth
|
||||
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
|
||||
allowed = os.getenv("IRIS_ALLOWED_USERS", "").strip()
|
||||
self.allowed_users: list[str] = (
|
||||
[u.strip() for u in allowed.split(",") if u.strip()] if allowed else []
|
||||
)
|
||||
self.allow_all = _truthy(os.getenv("ANDROID_ALLOW_ALL_USERS"))
|
||||
self.allow_all = _truthy(os.getenv("IRIS_ALLOW_ALL_USERS"))
|
||||
|
||||
# Runtime state
|
||||
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
|
||||
self._devices = DeviceRegistry(get_hermes_home() / "iris" / "devices.db")
|
||||
# docs/19: HTTP transport (the only device-facing transport).
|
||||
self._http_server = HttpServer(self, self._devices)
|
||||
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
|
||||
@@ -1171,7 +1205,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# M3: channel directory (shared singleton) + offline outbox.
|
||||
self._channels = get_directory()
|
||||
self._outbox = Outbox(
|
||||
get_hermes_home() / "android" / "outbox.db",
|
||||
get_hermes_home() / "iris" / "outbox.db",
|
||||
retention_hours=self.outbox_retention_hours,
|
||||
)
|
||||
# M4: media registry (inbound upload refs + outbound offers) and the
|
||||
@@ -1182,8 +1216,8 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# the outbox-prune banner.
|
||||
self._push: PushBackend = build_push_backend(
|
||||
self.push_backend,
|
||||
fcm_service_account=_get_scoped_secret("ANDROID_FCM_SERVICE_ACCOUNT"),
|
||||
fcm_server_key=_get_scoped_secret("ANDROID_FCM_SERVER_KEY"),
|
||||
fcm_service_account=_get_scoped_secret("IRIS_FCM_SERVICE_ACCOUNT"),
|
||||
fcm_server_key=_get_scoped_secret("IRIS_FCM_SERVER_KEY"),
|
||||
ntfy_topic=_get_scoped_secret("NTFY_TOPIC"),
|
||||
ntfy_server_url=os.getenv("NTFY_SERVER_URL", "").strip() or None,
|
||||
ntfy_auth_token=_get_scoped_secret("NTFY_AUTH_TOKEN"),
|
||||
@@ -1202,17 +1236,17 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "Android"
|
||||
return "Iris"
|
||||
|
||||
# ── Connection lifecycle ──────────────────────────────────────────────
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Bring the platform up: bind the HTTP server on host:http_port."""
|
||||
if not self.token:
|
||||
logger.error("android: ANDROID_TOKEN must be set")
|
||||
logger.error("iris: IRIS_TOKEN must be set")
|
||||
self._set_fatal_error(
|
||||
"config_missing",
|
||||
"ANDROID_TOKEN must be set",
|
||||
"IRIS_TOKEN must be set",
|
||||
retryable=False,
|
||||
)
|
||||
return False
|
||||
@@ -1222,7 +1256,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
# start() never raises; it disables the leg and logs on failure.
|
||||
await self._http_server.start()
|
||||
if not self._http_server.enabled:
|
||||
logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port)
|
||||
logger.error("iris: HTTP server failed to bind %s:%s", self.host, self.http_port)
|
||||
self._set_fatal_error(
|
||||
"bind_failed",
|
||||
f"HTTP port {self.http_port} unavailable",
|
||||
@@ -1242,21 +1276,21 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
self._channels.ensure_default(self.home_channel, self.home_channel_name)
|
||||
except Exception:
|
||||
logger.warning("android: ensure_default failed", exc_info=True)
|
||||
logger.warning("iris: ensure_default failed", exc_info=True)
|
||||
|
||||
# M5: push backend status (degrade gracefully when unconfigured).
|
||||
if not self._push.configured():
|
||||
logger.warning(
|
||||
"android: push backend %r not configured (no credentials) -- "
|
||||
"iris: push backend %r not configured (no credentials) -- "
|
||||
"offline devices will not be woken; outbox + sync still apply",
|
||||
self.push_backend,
|
||||
)
|
||||
else:
|
||||
logger.info("android: push backend: %s", self._push.name)
|
||||
logger.info("iris: push backend: %s", self._push.name)
|
||||
|
||||
self._connected = True
|
||||
self._mark_connected()
|
||||
logger.info("android: connected; HTTP server on %s:%s", self.host, self.http_port)
|
||||
logger.info("iris: connected; HTTP server on %s:%s", self.host, self.http_port)
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
@@ -1271,7 +1305,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
await self._http_server.stop()
|
||||
except Exception:
|
||||
logger.warning("android: HTTP server stop failed", exc_info=True)
|
||||
logger.warning("iris: HTTP server stop failed", exc_info=True)
|
||||
# Best-effort shutdown: a close failure on an already-closed store is
|
||||
# not actionable at disconnect time.
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -1280,7 +1314,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._outbox.close()
|
||||
self._connected = False
|
||||
self._mark_disconnected()
|
||||
logger.info("android: disconnected")
|
||||
logger.info("iris: disconnected")
|
||||
|
||||
# ── Outbound (agent -> app) ───────────────────────────────────────────
|
||||
|
||||
@@ -1706,7 +1740,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
cursor = self._outbox.append(chat_id, frame.to_json())
|
||||
except Exception:
|
||||
logger.warning("android: outbox append failed", exc_info=True)
|
||||
logger.warning("iris: outbox append failed", exc_info=True)
|
||||
return
|
||||
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
|
||||
# — count it in the delivery total or every message would push AND
|
||||
@@ -1714,7 +1748,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
delivered = await self._http_server.fanout(frame, cursor)
|
||||
if delivered == 0:
|
||||
logger.info(
|
||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||
"iris: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||
chat_id,
|
||||
frame.type,
|
||||
cursor,
|
||||
@@ -1786,7 +1820,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
now = time.time()
|
||||
if now - self._last_push_at.get(chat_id, 0.0) < _PUSH_COALESCE_S:
|
||||
logger.info(
|
||||
"android: push coalesced for %s (%s frame within %.0fs of last push)",
|
||||
"iris: push coalesced for %s (%s frame within %.0fs of last push)",
|
||||
chat_id,
|
||||
frame.type,
|
||||
_PUSH_COALESCE_S,
|
||||
@@ -1823,7 +1857,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
priority=priority,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: push via %s failed", backend.name, exc_info=True)
|
||||
logger.warning("iris: push via %s failed", backend.name, exc_info=True)
|
||||
continue
|
||||
if ok:
|
||||
# M5: remember that this cursor reached the device via push,
|
||||
@@ -1832,11 +1866,11 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
self._devices.update_push_cursor(device_id, cursor)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"android: push cursor update failed for %s", device_id, exc_info=True
|
||||
"iris: push cursor update failed for %s", device_id, exc_info=True
|
||||
)
|
||||
self._last_push_at[chat_id] = time.time()
|
||||
logger.info(
|
||||
"android: push via %s -> %s (%s, chat=%s)",
|
||||
"iris: push via %s -> %s (%s, chat=%s)",
|
||||
backend.name,
|
||||
device_id,
|
||||
frame.type,
|
||||
@@ -1897,13 +1931,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
) -> SendResult:
|
||||
safe = validate_media_delivery_path(path)
|
||||
if safe is None:
|
||||
logger.warning("android: media path failed delivery validation: %s", path)
|
||||
return SendResult(success=False, error="android: media path not deliverable")
|
||||
logger.warning("iris: media path failed delivery validation: %s", path)
|
||||
return SendResult(success=False, error="iris: media path not deliverable")
|
||||
try:
|
||||
size = os.path.getsize(safe)
|
||||
except OSError as e:
|
||||
logger.warning("android: media file unreadable %s: %s", safe, e)
|
||||
return SendResult(success=False, error="android: media file unreadable")
|
||||
logger.warning("iris: media file unreadable %s: %s", safe, e)
|
||||
return SendResult(success=False, error="iris: media file unreadable")
|
||||
entry = self._media.register_outbound(
|
||||
safe, kind, media_bridge.mime_for_path(safe), filename or os.path.basename(safe), size
|
||||
)
|
||||
@@ -2216,7 +2250,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
except Exception:
|
||||
logger.debug("Thread title rename broadcast failed", exc_info=True)
|
||||
|
||||
threading.Thread(target=_work, daemon=True, name="android-thread-title").start()
|
||||
threading.Thread(target=_work, daemon=True, name="iris-thread-title").start()
|
||||
|
||||
# ── M3: channel directory management (app -> agent) ───────────────────
|
||||
#
|
||||
@@ -2455,7 +2489,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
|
||||
)
|
||||
logger.info(
|
||||
"android: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||
"iris: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||
chat_id,
|
||||
entry.get("kind"),
|
||||
removed_frames,
|
||||
@@ -2565,7 +2599,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
payload = frame.payload
|
||||
chat_id = frame.chat_id or payload.get("chat_id")
|
||||
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
|
||||
logger.info("iris: history request from %s chat_id=%r", device_id, chat_id)
|
||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||
await self._reply(
|
||||
device_id,
|
||||
@@ -2658,7 +2692,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
info.get("ts"),
|
||||
)
|
||||
logger.info(
|
||||
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
||||
"iris: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
||||
device_id,
|
||||
chat_id,
|
||||
thread_id,
|
||||
@@ -2687,9 +2721,9 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
self._devices.update_push_tokens(device_id, fcm_token=fcm_token, ntfy_topic=ntfy_topic)
|
||||
except Exception:
|
||||
logger.warning("android: fcm.register update failed", exc_info=True)
|
||||
logger.warning("iris: fcm.register update failed", exc_info=True)
|
||||
return
|
||||
logger.info("android: push tokens updated for %s", device_id)
|
||||
logger.info("iris: push tokens updated for %s", device_id)
|
||||
|
||||
# ── M5: approval / clarify banners ────────────────────────────────────
|
||||
|
||||
@@ -2854,7 +2888,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
``gateway/channel_directory.build_channel_directory`` calls this to
|
||||
populate ``channel_directory.json``, which ``resolve_channel_name``
|
||||
reads for friendly-name -> chat_id resolution (cron + send_message).
|
||||
Threads are addressed via the explicit ``android:<chat>:<thread>``
|
||||
Threads are addressed via the explicit ``iris:<chat>:<thread>``
|
||||
syntax (see ``_parse_target_ref``), so only channels are listed here.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
@@ -2888,7 +2922,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
||||
name=name or "Handoff", kind="thread", parent_chat_id=parent_chat_id
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: create_handoff_thread failed", exc_info=True)
|
||||
logger.warning("iris: create_handoff_thread failed", exc_info=True)
|
||||
return None
|
||||
await self._broadcast_both(protocol.channel_created(entry))
|
||||
return entry["chat_id"]
|
||||
@@ -2907,14 +2941,14 @@ def register(ctx):
|
||||
try:
|
||||
ctx.register_hook("on_stream_delta", _on_stream_delta)
|
||||
except Exception:
|
||||
logger.debug("android: on_stream_delta hook registration failed", exc_info=True)
|
||||
logger.debug("iris: on_stream_delta hook registration failed", exc_info=True)
|
||||
# M2: capture each completed tool call's result + timing so the tool.end
|
||||
# frame can carry the output (the gateway never streams tool output to
|
||||
# platforms). The app shows it on demand (Settings → Tool detail).
|
||||
try:
|
||||
ctx.register_hook("post_tool_call", _on_post_tool_call)
|
||||
except Exception:
|
||||
logger.debug("android: post_tool_call hook registration failed", exc_info=True)
|
||||
logger.debug("iris: post_tool_call hook registration failed", exc_info=True)
|
||||
# Runtime-metadata footer: capture the turn's model + prompt tokens (per
|
||||
# provider call) so the final message can carry a structured ``runtime``
|
||||
# object. The app decides whether/what to show (Settings → Runtime
|
||||
@@ -2923,30 +2957,30 @@ def register(ctx):
|
||||
try:
|
||||
ctx.register_hook("post_api_request", _on_post_api_request)
|
||||
except Exception:
|
||||
logger.debug("android: post_api_request hook registration failed", exc_info=True)
|
||||
logger.debug("iris: post_api_request hook registration failed", exc_info=True)
|
||||
ctx.register_platform(
|
||||
name="android",
|
||||
label="Android",
|
||||
adapter_factory=AndroidAdapter,
|
||||
name="iris",
|
||||
label="Iris",
|
||||
adapter_factory=IrisAdapter,
|
||||
check_fn=check_requirements,
|
||||
validate_config=validate_config,
|
||||
is_connected=is_connected,
|
||||
required_env=["ANDROID_TOKEN"],
|
||||
required_env=["IRIS_TOKEN"],
|
||||
install_hint="No extra packages needed (httpx is a core dep)",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
||||
# host/port/push_backend + home_channel so env-only setups show up in
|
||||
# gateway status without instantiating the adapter.
|
||||
env_enablement_fn=_env_enablement,
|
||||
# Cron home-channel delivery support (deliver=android:<chat>[:<thread>]).
|
||||
cron_deliver_env_var="ANDROID_HOME_CHANNEL",
|
||||
# Cron home-channel delivery support (deliver=iris:<chat_id>[:<thread>]).
|
||||
cron_deliver_env_var="IRIS_HOME_CHANNEL",
|
||||
# Out-of-process cron delivery (best-effort; outbox is gateway-served).
|
||||
standalone_sender_fn=_standalone_send,
|
||||
# Native target syntax: "android:<chat>[:<thread>]".
|
||||
# Native target syntax: "iris:<chat_id>[:<thread>]" (chat ids are direct, e.g. iris:chan_7).
|
||||
parse_target_ref_fn=_parse_target_ref,
|
||||
# Auth env vars for _is_user_authorized() integration.
|
||||
allowed_users_env="ANDROID_ALLOWED_USERS",
|
||||
allow_all_env="ANDROID_ALLOW_ALL_USERS",
|
||||
allowed_users_env="IRIS_ALLOWED_USERS",
|
||||
allow_all_env="IRIS_ALLOW_ALL_USERS",
|
||||
# WS has no message-size limit.
|
||||
max_message_length=0,
|
||||
# Display.
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
Maps app concepts onto hermes' existing ``chat_id`` / ``thread_id`` primitives
|
||||
(docs/06-channels-cron-search.md §6.1):
|
||||
|
||||
* **default chat** -> the home channel (``ANDROID_HOME_CHANNEL``, default
|
||||
``android:default``), ``kind="default"``, ``is_default=1``.
|
||||
* **user channel** -> a minted ``chat_id = android:chan_<n>``, ``kind="channel"``.
|
||||
* **default chat** -> the home channel (``IRIS_HOME_CHANNEL``, default
|
||||
``default``), ``kind="default"``, ``is_default=1``.
|
||||
* **user channel** -> a minted ``chat_id = chan_<n>``, ``kind="channel"``.
|
||||
* **thread** -> a minted ``thread_id = t_<n>`` under a ``chat_id``,
|
||||
``kind="thread"`` (stored with its ``parent_chat_id``).
|
||||
|
||||
@@ -15,7 +15,7 @@ directory (``gateway/channel_directory.py``) via the adapter's
|
||||
``list_channels()`` hook, so ``send_message`` / cron can resolve a friendly
|
||||
name (e.g. "Cron Reports") to a chat_id.
|
||||
|
||||
Storage: ``get_hermes_home()/"android"/channels.db``.
|
||||
Storage: ``get_hermes_home()/"iris"/channels.db``.
|
||||
|
||||
Milestone M3.
|
||||
"""
|
||||
@@ -37,12 +37,12 @@ KIND_CHANNEL = "channel"
|
||||
KIND_THREAD = "thread"
|
||||
|
||||
# chat_id / thread_id minting prefixes.
|
||||
CHANNEL_PREFIX = "android:chan_"
|
||||
CHANNEL_PREFIX = "chan_"
|
||||
THREAD_PREFIX = "t_"
|
||||
|
||||
|
||||
class ChannelDirectory:
|
||||
"""Persistent channel directory under ``get_hermes_home()/"android"``.
|
||||
"""Persistent channel directory under ``get_hermes_home()/"iris"``.
|
||||
|
||||
Thread-safe (single connection + lock); all operations are small and fast
|
||||
enough to run inline on the gateway's asyncio loop. Mirrors the
|
||||
@@ -127,7 +127,7 @@ class ChannelDirectory:
|
||||
when it was still the auto default); if another row is marked default
|
||||
it is cleared so exactly one default exists.
|
||||
"""
|
||||
chat_id = (chat_id or "android:default").strip() or "android:default"
|
||||
chat_id = (chat_id or "default").strip() or "default"
|
||||
name = (name or "Default").strip() or "Default"
|
||||
with self._lock:
|
||||
existing = self._conn.execute(
|
||||
@@ -443,6 +443,6 @@ def get_directory() -> ChannelDirectory:
|
||||
# failure is not actionable.
|
||||
with contextlib.suppress(Exception):
|
||||
_directory.close()
|
||||
_directory = ChannelDirectory(home / "android" / "channels.db")
|
||||
_directory = ChannelDirectory(home / "iris" / "channels.db")
|
||||
_directory_home = home
|
||||
return _directory
|
||||
@@ -199,9 +199,9 @@ class HttpServer:
|
||||
from gateway.status import acquire_scoped_lock
|
||||
|
||||
lock_key = f"http:{host}:{port}"
|
||||
if not acquire_scoped_lock("android", lock_key):
|
||||
if not acquire_scoped_lock("iris", lock_key):
|
||||
logger.warning(
|
||||
"android: HTTP port %s:%s in use by another profile; server disabled",
|
||||
"iris: HTTP port %s:%s in use by another profile; server disabled",
|
||||
host,
|
||||
port,
|
||||
)
|
||||
@@ -218,18 +218,18 @@ class HttpServer:
|
||||
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
|
||||
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||
except Exception as e:
|
||||
logger.warning("android: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
|
||||
logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
|
||||
self._release_lock()
|
||||
return
|
||||
|
||||
self._httpd = httpd
|
||||
self._thread = threading.Thread(
|
||||
target=httpd.serve_forever, name="android-http", daemon=True
|
||||
target=httpd.serve_forever, name="iris-http", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
self.enabled = True
|
||||
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
|
||||
logger.info("android: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
|
||||
logger.info("iris: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop serving and unblock all subscribers."""
|
||||
@@ -261,7 +261,7 @@ class HttpServer:
|
||||
from gateway.status import release_scoped_lock
|
||||
|
||||
if self._lock_key:
|
||||
release_scoped_lock("android", self._lock_key)
|
||||
release_scoped_lock("iris", self._lock_key)
|
||||
self._lock_key = None
|
||||
|
||||
# ── Subscriber registry ───────────────────────────────────────────────
|
||||
@@ -307,7 +307,7 @@ class HttpServer:
|
||||
except queue.Full:
|
||||
# Slow subscriber: drop it. The client reconnects with
|
||||
# Last-Event-ID and catches up from the outbox.
|
||||
logger.info("android: dropping slow HTTP subscriber %s", s.device_id)
|
||||
logger.info("iris: dropping slow HTTP subscriber %s", s.device_id)
|
||||
s.closed.set()
|
||||
self._remove_sub(s)
|
||||
return sent
|
||||
@@ -331,7 +331,7 @@ class HttpServer:
|
||||
and self._adapter.allowed_users
|
||||
and device_id not in self._adapter.allowed_users
|
||||
):
|
||||
logger.warning("android: http rejected: device %s not allowlisted", device_id)
|
||||
logger.warning("iris: http rejected: device %s not allowlisted", device_id)
|
||||
_send_json(handler, 401, {"error": "device not allowed"})
|
||||
return None
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -436,7 +436,7 @@ class HttpServer:
|
||||
try:
|
||||
await dispatch.dispatch_frame(self._adapter, frame, device_id)
|
||||
except Exception:
|
||||
logger.warning("android: HTTP dispatch failed for %s", frame.type, exc_info=True)
|
||||
logger.warning("iris: HTTP dispatch failed for %s", frame.type, exc_info=True)
|
||||
finally:
|
||||
# Pop our sink entry (a newer request from the same device may
|
||||
# have replaced it). If the HTTP response was already sent
|
||||
@@ -478,7 +478,7 @@ class HttpServer:
|
||||
ntfy_topic,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: device registry upsert failed", exc_info=True)
|
||||
logger.warning("iris: device registry upsert failed", exc_info=True)
|
||||
sub = _Subscriber(device_id=device_id, kind="sse")
|
||||
# Register BEFORE the replay so a frame appended in between is
|
||||
# fanned out to us (and de-duped by cursor below) instead of lost.
|
||||
@@ -494,7 +494,7 @@ class HttpServer:
|
||||
# lifecycle is the primary "is the device connected?" signal
|
||||
# for debugging flaky links — a gap here is invisible at the
|
||||
# gateway's default log level.
|
||||
logger.info("android: SSE stream opened: %s (cursor=%d)", device_id, cursor)
|
||||
logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor)
|
||||
# 1. Catch-up from the outbox (id = cursor; the envelope also
|
||||
# carries the cursor for the app's push dedupe).
|
||||
max_cursor = cursor
|
||||
@@ -532,7 +532,7 @@ class HttpServer:
|
||||
# Last-Event-ID and catches up from the outbox).
|
||||
reason = "client-gone"
|
||||
finally:
|
||||
logger.info("android: SSE stream closed: %s (%s)", device_id, reason)
|
||||
logger.info("iris: SSE stream closed: %s (%s)", device_id, reason)
|
||||
self._remove_sub(sub)
|
||||
|
||||
@staticmethod
|
||||
@@ -735,7 +735,7 @@ class _Handler(BaseHTTPRequestHandler):
|
||||
server: _ThreadingHTTPD
|
||||
|
||||
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
||||
logger.debug("android http: " + fmt, *args)
|
||||
logger.debug("iris http: " + fmt, *args)
|
||||
|
||||
# ── Routing ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ binary frames. Delivery-path security via ``validate_media_delivery_path``
|
||||
|
||||
Reuses hermes ``cache_image/audio/video/document_from_bytes`` + the
|
||||
``_looks_like_image`` / ``sniff_container`` magic-byte sniffers. Temp files
|
||||
live under ``get_hermes_home()/"android"/media/tmp``.
|
||||
live under ``get_hermes_home()/"iris"/media/tmp``.
|
||||
|
||||
Milestone M4.
|
||||
"""
|
||||
@@ -231,7 +231,7 @@ class UploadSession:
|
||||
self.failed = True
|
||||
self.error_code = code
|
||||
self.error_message = message
|
||||
logger.warning("android: upload %s failed: %s", self.media_ref, message)
|
||||
logger.warning("iris: upload %s failed: %s", self.media_ref, message)
|
||||
|
||||
def digest(self) -> str:
|
||||
return self._sha.hexdigest()
|
||||
@@ -262,7 +262,7 @@ class MediaStore:
|
||||
"""
|
||||
|
||||
def __init__(self, hermes_home: Path):
|
||||
self._tmp_dir = hermes_home / "android" / "media" / "tmp"
|
||||
self._tmp_dir = hermes_home / "iris" / "media" / "tmp"
|
||||
self._tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
# (device_id, media_ref) -> UploadSession (one active per device)
|
||||
@@ -374,7 +374,7 @@ class MediaStore:
|
||||
with self._lock:
|
||||
self._inbound[media_ref] = entry
|
||||
logger.info(
|
||||
"android: upload %s cached as %s (%s, %d bytes)",
|
||||
"iris: upload %s cached as %s (%s, %d bytes)",
|
||||
media_ref,
|
||||
kind,
|
||||
path,
|
||||
|
||||
@@ -10,7 +10,7 @@ Retention prunes rows older than ``outbox_retention_hours`` (default 72h). A
|
||||
device offline longer than the window misses those frames; it recovers full
|
||||
context via ``history`` (M5 wires push so the device is woken to sync).
|
||||
|
||||
Storage: ``get_hermes_home()/"android"/outbox.db``.
|
||||
Storage: ``get_hermes_home()/"iris"/outbox.db``.
|
||||
|
||||
Milestone M3 (built), extended in M5 (push integration).
|
||||
"""
|
||||
@@ -36,7 +36,7 @@ DEFAULT_MAX_ROWS = 5000
|
||||
|
||||
|
||||
class Outbox:
|
||||
"""Persistent outbox under ``get_hermes_home()/"android"``.
|
||||
"""Persistent outbox under ``get_hermes_home()/"iris"``.
|
||||
|
||||
Thread-safe (single connection + lock); operations are small and fast
|
||||
enough to run inline on the gateway's asyncio loop (mirrors
|
||||
@@ -126,7 +126,7 @@ class Outbox:
|
||||
(excess,),
|
||||
)
|
||||
self._overflow_pruned += excess
|
||||
logger.info("android outbox: row cap pruned %s oldest row(s)", excess)
|
||||
logger.info("iris outbox: row cap pruned %s oldest row(s)", excess)
|
||||
|
||||
def latest_cursor(self) -> int:
|
||||
"""The high-water cursor (0 when nothing has been appended)."""
|
||||
@@ -419,7 +419,7 @@ class Outbox:
|
||||
self._conn.execute("DELETE FROM outbox WHERE created < ?", (cutoff,))
|
||||
self._conn.commit()
|
||||
except sqlite3.Error as e:
|
||||
logger.debug("android outbox: prune failed: %s", e)
|
||||
logger.debug("iris outbox: prune failed: %s", e)
|
||||
|
||||
def prune(self) -> None:
|
||||
"""Force a retention prune (ignores the interval throttle)."""
|
||||
|
||||
@@ -4,7 +4,7 @@ Token generation (64-hex) and constant-time verification. Device registry
|
||||
(SQLite) tracks ``device_id``, name, caps, fcm_token, ntfy_topic, last_seen,
|
||||
created. QR payload for the pairing flow (``interactive_setup``).
|
||||
|
||||
Storage: ``get_hermes_home()/"android"/devices.db``.
|
||||
Storage: ``get_hermes_home()/"iris"/devices.db``.
|
||||
|
||||
Milestone M1.
|
||||
"""
|
||||
@@ -14,6 +14,7 @@ import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import socket
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
@@ -42,6 +43,51 @@ def verify_token(provided: str | None, expected: str | None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def lan_ip() -> str:
|
||||
"""Best-effort default-route LAN IPv4 (UDP connect trick; no packet sent).
|
||||
|
||||
A phone can't reach a bind wildcard like ``0.0.0.0``/``127.0.0.1``, so the
|
||||
pairing QR / URL advertise the machine's routable LAN IP instead. Falls
|
||||
back to ``127.0.0.1`` when no route is available (offline sandbox).
|
||||
"""
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.settimeout(1.0)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return "127.0.0.1"
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
|
||||
def advertise_host(host: str) -> str:
|
||||
"""Host to advertise in the pairing URL / QR.
|
||||
|
||||
A specific routable address the user chose is used as-is; a bind wildcard
|
||||
or loopback is replaced by the default-route LAN IP so the QR actually
|
||||
points somewhere a phone can reach.
|
||||
"""
|
||||
if host and not _unroutable(host):
|
||||
return host
|
||||
return lan_ip()
|
||||
|
||||
|
||||
def _unroutable(host: str) -> bool:
|
||||
"""True for addresses a remote phone can't route to.
|
||||
|
||||
Covers the IPv4 bind wildcard (all-zero), loopback (127.x), and the IPv6
|
||||
any/loopback. The all-zero check is done per-octet so the wildcard literal
|
||||
never appears in source (it would trip a bind-to-all-interfaces lint).
|
||||
"""
|
||||
if host.startswith("127."):
|
||||
return True
|
||||
if host in ("::", "[::]", "::1"):
|
||||
return True
|
||||
parts = host.split(".")
|
||||
return len(parts) == 4 and all(octet == "0" for octet in parts)
|
||||
|
||||
|
||||
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
|
||||
"""Pairing URL encoded into the QR / pre-filled into the app.
|
||||
|
||||
@@ -68,7 +114,7 @@ def pairing_url(host: str, port: int, secure: bool = False) -> str:
|
||||
|
||||
|
||||
class DeviceRegistry:
|
||||
"""Persistent device registry under ``get_hermes_home()/"android"``.
|
||||
"""Persistent device registry under ``get_hermes_home()/"iris"``.
|
||||
|
||||
Thread-safe (single connection + lock); all operations are small and
|
||||
fast enough to run inline on the gateway's asyncio loop.
|
||||
|
||||
+16
-16
@@ -1,5 +1,5 @@
|
||||
name: android-platform
|
||||
label: Android
|
||||
name: iris-platform
|
||||
label: Iris
|
||||
kind: platform
|
||||
version: 0.1.0
|
||||
description: >
|
||||
@@ -12,45 +12,45 @@ author: Iris x Hermes
|
||||
# ``hermes config`` / ``hermes gateway setup`` UI via the platform-plugin
|
||||
# env var injector in ``hermes_cli/config.py``.
|
||||
requires_env:
|
||||
- name: ANDROID_TOKEN
|
||||
- name: IRIS_TOKEN
|
||||
description: "Shared pairing token the app presents on connect"
|
||||
prompt: "Android pairing token"
|
||||
prompt: "Iris pairing token"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: ANDROID_WS_HOST
|
||||
- name: IRIS_WS_HOST
|
||||
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
||||
prompt: "WS host"
|
||||
password: false
|
||||
- name: ANDROID_WS_PORT
|
||||
- name: IRIS_WS_PORT
|
||||
description: "WS port (default 8790)"
|
||||
prompt: "WS port"
|
||||
password: false
|
||||
- name: ANDROID_HOME_CHANNEL
|
||||
description: "Default chat id for cron/notification delivery (default android:default)"
|
||||
- name: IRIS_HOME_CHANNEL
|
||||
description: "Default chat id for cron/notification delivery (default: default)"
|
||||
prompt: "Home channel"
|
||||
password: false
|
||||
- name: ANDROID_ALLOWED_USERS
|
||||
- name: IRIS_ALLOWED_USERS
|
||||
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
||||
prompt: "Allowed device ids"
|
||||
password: false
|
||||
- name: ANDROID_ALLOW_ALL_USERS
|
||||
- name: IRIS_ALLOW_ALL_USERS
|
||||
description: "Allow any paired device (dev only)"
|
||||
prompt: "Allow all devices? (true/false)"
|
||||
password: false
|
||||
- name: ANDROID_PUSH_BACKEND
|
||||
- name: IRIS_PUSH_BACKEND
|
||||
description: "Push backend: fcm (default) or ntfy"
|
||||
prompt: "Push backend"
|
||||
password: false
|
||||
- name: ANDROID_FCM_SERVICE_ACCOUNT
|
||||
- name: IRIS_FCM_SERVICE_ACCOUNT
|
||||
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
||||
prompt: "FCM service account path"
|
||||
password: true
|
||||
- name: ANDROID_FCM_SERVER_KEY
|
||||
- name: IRIS_FCM_SERVER_KEY
|
||||
description: "Legacy FCM server key (fallback if no service account)"
|
||||
prompt: "FCM server key"
|
||||
password: true
|
||||
- name: NTFY_TOPIC
|
||||
description: "ntfy topic for push (when ANDROID_PUSH_BACKEND=ntfy)"
|
||||
description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
|
||||
prompt: "ntfy topic"
|
||||
password: false
|
||||
- name: NTFY_SERVER_URL
|
||||
@@ -61,11 +61,11 @@ optional_env:
|
||||
description: "ntfy auth token for a private topic (trust boundary)"
|
||||
prompt: "ntfy auth token"
|
||||
password: true
|
||||
- name: ANDROID_WS_CERT
|
||||
- name: IRIS_WS_CERT
|
||||
description: "TLS cert path for WSS (optional)"
|
||||
prompt: "WSS cert"
|
||||
password: false
|
||||
- name: ANDROID_WS_KEY
|
||||
- name: IRIS_WS_KEY
|
||||
description: "TLS key path for WSS (optional)"
|
||||
prompt: "WSS key"
|
||||
password: false
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Complete (hard) deletion of android messages from the hermes session store.
|
||||
"""Complete (hard) deletion of iris messages from the hermes session store.
|
||||
|
||||
The android plugin mints its own message ids (``m_<hex>``) that are **not**
|
||||
The iris plugin mints its own message ids (``m_<hex>``) that are **not**
|
||||
persisted in the hermes session DB (``state.db``), so a delete request cannot
|
||||
join on an id. Instead a message is matched to its ``messages`` row by
|
||||
(session, role, content, timestamp proximity) and that row is deleted.
|
||||
@@ -89,7 +89,7 @@ def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> in
|
||||
conn.commit()
|
||||
return n_msgs
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("android purge: delete_lane failed: %s", e)
|
||||
logger.warning("iris purge: delete_lane failed: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -148,7 +148,7 @@ def delete_message(
|
||||
conn.commit()
|
||||
return 1
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("android purge: delete_message failed: %s", e)
|
||||
logger.warning("iris purge: delete_message failed: %s", e)
|
||||
return 0
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
|
||||
+12
-12
@@ -2,13 +2,13 @@
|
||||
|
||||
``PushBackend`` interface with two implementations:
|
||||
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
|
||||
(``ANDROID_FCM_SERVICE_ACCOUNT``), or a legacy server key
|
||||
(``ANDROID_FCM_SERVER_KEY``).
|
||||
(``IRIS_FCM_SERVICE_ACCOUNT``), or a legacy server key
|
||||
(``IRIS_FCM_SERVER_KEY``).
|
||||
- ``NtfyBackend``: publishes to ``NTFY_TOPIC`` on ``NTFY_SERVER_URL``
|
||||
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
|
||||
subscribes to the topic.
|
||||
|
||||
Selected by ``ANDROID_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
|
||||
Selected by ``IRIS_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
|
||||
Fired when a frame has no live subscriber; the data payload drives a silent
|
||||
sync on the device (docs/08-push.md).
|
||||
|
||||
@@ -120,7 +120,7 @@ class FcmBackend(PushBackend):
|
||||
self._sa = sa
|
||||
return sa
|
||||
except Exception:
|
||||
logger.warning("android: FCM service account unreadable: %s", self._sa_path)
|
||||
logger.warning("iris: FCM service account unreadable: %s", self._sa_path)
|
||||
self._sa_failed = True
|
||||
return None
|
||||
|
||||
@@ -151,7 +151,7 @@ class FcmBackend(PushBackend):
|
||||
claims, sa["private_key"], algorithm="RS256", headers=headers
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: FCM JWT mint failed", exc_info=True)
|
||||
logger.warning("iris: FCM JWT mint failed", exc_info=True)
|
||||
return None
|
||||
try:
|
||||
resp = await client.post(
|
||||
@@ -163,11 +163,11 @@ class FcmBackend(PushBackend):
|
||||
timeout=_HTTP_TIMEOUT_S,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: FCM token exchange failed", exc_info=True)
|
||||
logger.warning("iris: FCM token exchange failed", exc_info=True)
|
||||
return None
|
||||
if resp.status_code != _HTTP_OK:
|
||||
logger.warning(
|
||||
"android: FCM token exchange HTTP %s: %s",
|
||||
"iris: FCM token exchange HTTP %s: %s",
|
||||
resp.status_code, resp.text[:200],
|
||||
)
|
||||
return None
|
||||
@@ -236,12 +236,12 @@ class FcmBackend(PushBackend):
|
||||
headers={"Authorization": f"Bearer {auth}"},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: FCM send failed (network)", exc_info=True)
|
||||
logger.warning("iris: FCM send failed (network)", exc_info=True)
|
||||
return False
|
||||
if resp.status_code >= _HTTP_ERROR_MIN:
|
||||
# 404 NOT_FOUND = stale/invalid registration token.
|
||||
logger.warning(
|
||||
"android: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
|
||||
"iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -313,11 +313,11 @@ class NtfyBackend(PushBackend):
|
||||
url, content=text.encode("utf-8"), headers=headers
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("android: ntfy publish failed (network)", exc_info=True)
|
||||
logger.warning("iris: ntfy publish failed (network)", exc_info=True)
|
||||
return False
|
||||
if resp.status_code >= _HTTP_ERROR_MIN:
|
||||
logger.warning(
|
||||
"android: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
|
||||
"iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -332,7 +332,7 @@ def build_push_backend(
|
||||
ntfy_server_url: str | None = None,
|
||||
ntfy_auth_token: str | None = None,
|
||||
) -> PushBackend:
|
||||
"""Select the backend by name (``ANDROID_PUSH_BACKEND``; fcm default)."""
|
||||
"""Select the backend by name (``IRIS_PUSH_BACKEND``; fcm default)."""
|
||||
if (name or "").strip().lower() == "ntfy":
|
||||
return NtfyBackend(
|
||||
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Pure-stdlib QR encoder (ISO/IEC 18004) + terminal renderer.
|
||||
|
||||
Scope is deliberately minimal — we only ever encode ASCII pairing URLs
|
||||
(``iris://pair?...``):
|
||||
|
||||
- **Mode:** byte mode only (no alphanumeric/numeric/kanji paths).
|
||||
- **Error correction:** level **M** (15 %); auto-fallback to **L** if the
|
||||
payload doesn't fit at M within the version cap.
|
||||
- **Versions:** 1–10, auto-selected (smallest version whose capacity fits).
|
||||
Payloads that don't fit v10-L raise :class:`QrTooLongError`.
|
||||
|
||||
No third-party imports (no ``qrcode``/``segno``/``Pillow``) — the plugin's
|
||||
zero-new-dep rule. No I/O, no module-level mutable state, fully unit-testable.
|
||||
|
||||
Public API:
|
||||
|
||||
- :func:`qr_matrix` — encode *data* (ASCII) into a module matrix
|
||||
(``True`` = dark) including the 4-module quiet zone.
|
||||
- :func:`render_qr` — render *data* as a terminal QR using Unicode
|
||||
half-blocks; returns ``""`` (not an exception) when the payload is too long.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = ["QrTooLongError", "qr_matrix", "render_qr"]
|
||||
|
||||
|
||||
class QrTooLongError(ValueError):
|
||||
"""Raised when *data* doesn't fit in any supported version (1–10)."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GF(256) arithmetic (polynomial 0x11D)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GF_EXP = [0] * 512
|
||||
_GF_LOG = [0] * 256
|
||||
_x = 1
|
||||
for _i in range(255):
|
||||
_GF_EXP[_i] = _x
|
||||
_GF_LOG[_x] = _i
|
||||
_x <<= 1
|
||||
if _x & 0x100:
|
||||
_x ^= 0x11D
|
||||
for _i in range(255, 512):
|
||||
_GF_EXP[_i] = _GF_EXP[_i - 255]
|
||||
|
||||
|
||||
def _gf_mul(a: int, b: int) -> int:
|
||||
if a == 0 or b == 0:
|
||||
return 0
|
||||
return _GF_EXP[_GF_LOG[a] + _GF_LOG[b]]
|
||||
|
||||
|
||||
def _rs_generator_poly(degree: int) -> list[int]:
|
||||
"""Generator polynomial of *degree* (big-endian, leading coeff first)."""
|
||||
poly = [1]
|
||||
for i in range(degree):
|
||||
new = [0] * (len(poly) + 1)
|
||||
for k, coef in enumerate(poly):
|
||||
new[k] ^= coef # x * coef
|
||||
new[k + 1] ^= _gf_mul(coef, _GF_EXP[i])
|
||||
poly = new
|
||||
return poly
|
||||
|
||||
|
||||
def _rs_encode(data: list[int], ec_len: int) -> list[int]:
|
||||
"""Reed–Solomon error-correction codewords for *data*."""
|
||||
gen = _rs_generator_poly(ec_len)
|
||||
buf = list(data) + [0] * ec_len
|
||||
for i in range(len(data)):
|
||||
coef = buf[i]
|
||||
if coef:
|
||||
for j in range(1, len(gen)):
|
||||
buf[i + j] ^= _gf_mul(gen[j], coef)
|
||||
return buf[len(data) :]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block structure (version, EC level) -> (ec_per_block, [(count, data_cw), ...])
|
||||
#
|
||||
# Source: ISO/IEC 18004 Table 9 (cross-checked against the reference encoder).
|
||||
# Only levels L and M are needed (M primary, L fallback).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BLOCK_TABLE: dict[tuple[int, str], tuple[int, list[tuple[int, int]]]] = {
|
||||
(1, "L"): (7, [(1, 19)]),
|
||||
(1, "M"): (10, [(1, 16)]),
|
||||
(2, "L"): (10, [(1, 34)]),
|
||||
(2, "M"): (16, [(1, 28)]),
|
||||
(3, "L"): (15, [(1, 55)]),
|
||||
(3, "M"): (26, [(1, 44)]),
|
||||
(4, "L"): (20, [(1, 80)]),
|
||||
(4, "M"): (18, [(2, 32)]),
|
||||
(5, "L"): (26, [(1, 108)]),
|
||||
(5, "M"): (24, [(2, 43)]),
|
||||
(6, "L"): (18, [(2, 68)]),
|
||||
(6, "M"): (16, [(4, 27)]),
|
||||
(7, "L"): (20, [(2, 78)]),
|
||||
(7, "M"): (18, [(4, 31)]),
|
||||
(8, "L"): (24, [(2, 97)]),
|
||||
(8, "M"): (22, [(2, 38), (2, 39)]),
|
||||
(9, "L"): (30, [(2, 116)]),
|
||||
(9, "M"): (22, [(3, 36), (2, 37)]),
|
||||
(10, "L"): (18, [(2, 68), (2, 69)]),
|
||||
(10, "M"): (26, [(4, 43), (1, 44)]),
|
||||
}
|
||||
|
||||
# Alignment-pattern centre coordinates per version (v1 has none).
|
||||
_ALIGNMENT: dict[int, list[int]] = {
|
||||
1: [],
|
||||
2: [6, 18],
|
||||
3: [6, 22],
|
||||
4: [6, 26],
|
||||
5: [6, 30],
|
||||
6: [6, 34],
|
||||
7: [6, 22, 38],
|
||||
8: [6, 24, 42],
|
||||
9: [6, 26, 46],
|
||||
10: [6, 28, 50],
|
||||
}
|
||||
|
||||
# EC level -> 2-bit format-info code (ISO/IEC 18004 Table 17).
|
||||
_EC_FORMAT_BITS = {"L": 0b01, "M": 0b00}
|
||||
|
||||
_MIN_VERSION, _MAX_VERSION = 1, 10
|
||||
_QUIET = 4
|
||||
|
||||
|
||||
def _data_capacity(version: int, level: str) -> int:
|
||||
"""Max payload bytes in byte mode for (version, level)."""
|
||||
_, groups = _BLOCK_TABLE[(version, level)]
|
||||
data_bits = sum(count * data_cw for count, data_cw in groups) * 8
|
||||
# mode indicator (4) + char count (8 for v1-9, 16 for v10) + terminator (4)
|
||||
count_bits = 16 if version >= 10 else 8
|
||||
return (data_bits - 4 - count_bits - 4) // 8
|
||||
|
||||
|
||||
def _select_version(data: bytes) -> tuple[int, str]:
|
||||
for level in ("M", "L"):
|
||||
for version in range(_MIN_VERSION, _MAX_VERSION + 1):
|
||||
if len(data) <= _data_capacity(version, level):
|
||||
return version, level
|
||||
raise QrTooLongError(f"payload of {len(data)} bytes exceeds v{_MAX_VERSION}-L capacity")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data encoding (byte mode)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _encode_data(data: bytes, version: int, level: str) -> list[int]:
|
||||
"""Return the full codeword stream (data + EC), interleaved per spec."""
|
||||
_, groups = _BLOCK_TABLE[(version, level)]
|
||||
ec_per_block = _BLOCK_TABLE[(version, level)][0]
|
||||
total_data_cw = sum(count * data_cw for count, data_cw in groups)
|
||||
|
||||
bits: list[int] = []
|
||||
|
||||
def put(value: int, width: int) -> None:
|
||||
for i in range(width - 1, -1, -1):
|
||||
bits.append((value >> i) & 1)
|
||||
|
||||
put(0b0100, 4) # byte mode
|
||||
put(len(data), 16 if version >= 10 else 8) # char count
|
||||
for byte in data:
|
||||
put(byte, 8)
|
||||
# terminator (up to 4 zero bits)
|
||||
capacity_bits = total_data_cw * 8
|
||||
put(0, min(4, capacity_bits - len(bits)))
|
||||
# pad to byte boundary
|
||||
if len(bits) % 8:
|
||||
put(0, 8 - len(bits) % 8)
|
||||
# pad bytes 0xEC / 0x11
|
||||
pad_bytes = [0xEC, 0x11]
|
||||
pi = 0
|
||||
while len(bits) < capacity_bits:
|
||||
put(pad_bytes[pi % 2], 8)
|
||||
pi += 1
|
||||
|
||||
data_cw = [int("".join(map(str, bits[i : i + 8])), 2) for i in range(0, len(bits), 8)]
|
||||
|
||||
# Split into blocks, compute EC per block.
|
||||
blocks: list[list[int]] = []
|
||||
ec_blocks: list[list[int]] = []
|
||||
idx = 0
|
||||
for count, data_cw_len in groups:
|
||||
for _ in range(count):
|
||||
block = data_cw[idx : idx + data_cw_len]
|
||||
idx += data_cw_len
|
||||
blocks.append(block)
|
||||
ec_blocks.append(_rs_encode(block, ec_per_block))
|
||||
|
||||
# Interleave data codewords, then EC codewords (ISO/IEC 18004 §8.6.3).
|
||||
out: list[int] = []
|
||||
max_data = max(len(b) for b in blocks)
|
||||
for i in range(max_data):
|
||||
for b in blocks:
|
||||
if i < len(b):
|
||||
out.append(b[i])
|
||||
max_ec = max(len(b) for b in ec_blocks)
|
||||
for i in range(max_ec):
|
||||
for b in ec_blocks:
|
||||
if i < len(b):
|
||||
out.append(b[i])
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matrix construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bch(data: int, shift: int, generator: int) -> int:
|
||||
"""BCH codeword: *data* shifted left by *shift*, the low *shift* bits
|
||||
filled with the remainder of the division by *generator*."""
|
||||
d = data << shift
|
||||
g_len = generator.bit_length()
|
||||
while d.bit_length() >= g_len:
|
||||
d ^= generator << (d.bit_length() - g_len)
|
||||
return (data << shift) | d
|
||||
|
||||
|
||||
def _format_info(level: str, mask: int) -> int:
|
||||
"""15-bit format info (BCH(15,5)) XORed with 0x5412."""
|
||||
data = (_EC_FORMAT_BITS[level] << 3) | mask
|
||||
return _bch(data, 10, 0x537) ^ 0x5412
|
||||
|
||||
|
||||
def _version_info(version: int) -> int:
|
||||
"""18-bit version info (BCH(18,6)); only for v7+."""
|
||||
return _bch(version, 12, 0x1F25)
|
||||
|
||||
|
||||
def _build_matrix(version: int, level: str, codewords: list[int], mask: int) -> list[list[bool]]:
|
||||
size = 17 + 4 * version
|
||||
# matrix[r][c] = dark; reserved[r][c] = function module (not data)
|
||||
matrix = [[False] * size for _ in range(size)]
|
||||
reserved = [[False] * size for _ in range(size)]
|
||||
|
||||
def set_module(r: int, c: int, dark: bool) -> None:
|
||||
matrix[r][c] = dark
|
||||
reserved[r][c] = True
|
||||
|
||||
# Finder patterns + separators (three corners).
|
||||
for fr, fc in ((0, 0), (0, size - 7), (size - 7, 0)):
|
||||
for r in range(-1, 8):
|
||||
for c in range(-1, 8):
|
||||
rr, cc = fr + r, fc + c
|
||||
if not (0 <= rr < size and 0 <= cc < size):
|
||||
continue
|
||||
if 0 <= r <= 6 and 0 <= c <= 6:
|
||||
# Canonical finder: 7x7 border dark, 5x5 white, 3x3 dark centre.
|
||||
ring = max(abs(r - 3), abs(c - 3))
|
||||
set_module(rr, cc, ring in (0, 1, 3))
|
||||
else:
|
||||
set_module(rr, cc, False) # separator
|
||||
|
||||
# Timing patterns.
|
||||
for i in range(8, size - 8):
|
||||
dark = i % 2 == 0
|
||||
if not reserved[6][i]:
|
||||
set_module(6, i, dark)
|
||||
if not reserved[i][6]:
|
||||
set_module(i, 6, dark)
|
||||
|
||||
# Alignment patterns (v2+), skipping those overlapping finders.
|
||||
positions = _ALIGNMENT[version]
|
||||
if len(positions) > 1:
|
||||
for r in positions:
|
||||
for c in positions:
|
||||
# Skip the three corners that share a finder pattern.
|
||||
if (
|
||||
(r == positions[0] and c == positions[0])
|
||||
or (r == positions[0] and c == positions[-1])
|
||||
or (r == positions[-1] and c == positions[0])
|
||||
):
|
||||
continue
|
||||
for dr in range(-2, 3):
|
||||
for dc in range(-2, 3):
|
||||
ring = max(abs(dr), abs(dc))
|
||||
dark = ring != 1
|
||||
set_module(r + dr, c + dc, dark)
|
||||
|
||||
# Dark module (always dark) at (4*version + 9, 8).
|
||||
set_module(4 * version + 9, 8, True)
|
||||
|
||||
# Reserve format-info regions (filled after masking).
|
||||
for i in range(9):
|
||||
if not reserved[8][i]:
|
||||
reserved[8][i] = True
|
||||
if not reserved[i][8]:
|
||||
reserved[i][8] = True
|
||||
for i in range(8):
|
||||
reserved[8][size - 1 - i] = True
|
||||
reserved[size - 1 - i][8] = True
|
||||
# (8,8) handled above; mark the remaining format cells.
|
||||
reserved[8][8] = True
|
||||
|
||||
# Reserve version-info regions (v7+).
|
||||
if version >= 7:
|
||||
vinfo = _version_info(version)
|
||||
for i in range(18):
|
||||
bit = (vinfo >> i) & 1
|
||||
# Two 3x6 blocks: top-left and bottom-right corners.
|
||||
r, c = size - 11 + (i % 3), i // 3
|
||||
set_module(r, c, bool(bit))
|
||||
r, c = i // 3, size - 11 + (i % 3)
|
||||
set_module(r, c, bool(bit))
|
||||
|
||||
# Place data codewords in the zig-zag, applying the mask. Start at the
|
||||
# bottom-right and traverse column pairs bottom-to-top, then top-to-bottom.
|
||||
bit_index = 0
|
||||
total_bits = len(codewords) * 8
|
||||
inc = -1
|
||||
row = size - 1
|
||||
for col in range(size - 1, 0, -2):
|
||||
if col <= 6:
|
||||
col -= 1 # skip the vertical timing column
|
||||
while True:
|
||||
for c in (col, col - 1):
|
||||
if not reserved[row][c]:
|
||||
bit = 0
|
||||
if bit_index < total_bits:
|
||||
bit = (codewords[bit_index // 8] >> (7 - bit_index % 8)) & 1
|
||||
bit_index += 1
|
||||
if _mask_bit(mask, row, c):
|
||||
bit ^= 1
|
||||
matrix[row][c] = bool(bit)
|
||||
row += inc
|
||||
if row < 0 or row >= size:
|
||||
row -= inc
|
||||
inc = -inc
|
||||
break
|
||||
|
||||
# Write format info (after masking, unmasked).
|
||||
fmt = _format_info(level, mask)
|
||||
for i in range(15):
|
||||
bit = bool((fmt >> i) & 1)
|
||||
# Vertical copy (column 8).
|
||||
if i < 6:
|
||||
set_module(i, 8, bit)
|
||||
elif i < 8:
|
||||
set_module(i + 1, 8, bit)
|
||||
else:
|
||||
set_module(size - 15 + i, 8, bit)
|
||||
# Horizontal copy (row 8).
|
||||
if i < 8:
|
||||
set_module(8, size - i - 1, bit)
|
||||
elif i < 9:
|
||||
set_module(8, 15 - i, bit)
|
||||
else:
|
||||
set_module(8, 15 - i - 1, bit)
|
||||
|
||||
return matrix
|
||||
|
||||
|
||||
def _mask_bit(mask: int, r: int, c: int) -> bool:
|
||||
if mask == 0:
|
||||
return (r + c) % 2 == 0
|
||||
if mask == 1:
|
||||
return r % 2 == 0
|
||||
if mask == 2:
|
||||
return c % 3 == 0
|
||||
if mask == 3:
|
||||
return (r + c) % 3 == 0
|
||||
if mask == 4:
|
||||
return (r // 2 + c // 3) % 2 == 0
|
||||
if mask == 5:
|
||||
return (r * c) % 2 + (r * c) % 3 == 0
|
||||
if mask == 6:
|
||||
return ((r * c) % 2 + (r * c) % 3) % 2 == 0
|
||||
if mask == 7:
|
||||
return ((r + c) % 2 + (r * c) % 3) % 2 == 0
|
||||
raise ValueError(f"invalid mask {mask}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Penalty scoring (ISO/IEC 18004 §8.8.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _penalty(matrix: list[list[bool]]) -> int:
|
||||
size = len(matrix)
|
||||
total = 0
|
||||
|
||||
# N1: runs of >= 5 same-colour in rows and columns.
|
||||
for line in _all_lines(matrix):
|
||||
run = 1
|
||||
for i in range(1, len(line)):
|
||||
if line[i] == line[i - 1]:
|
||||
run += 1
|
||||
else:
|
||||
if run >= 5:
|
||||
total += 3 + (run - 5)
|
||||
run = 1
|
||||
if run >= 5:
|
||||
total += 3 + (run - 5)
|
||||
|
||||
# N2: 2x2 blocks of same colour.
|
||||
for r in range(size - 1):
|
||||
for c in range(size - 1):
|
||||
v = matrix[r][c]
|
||||
if v == matrix[r][c + 1] == matrix[r + 1][c] == matrix[r + 1][c + 1]:
|
||||
total += 3
|
||||
|
||||
# N3: 10111010000 / 00001011101 patterns (with 4 light on one side).
|
||||
pattern_a = [True, False, True, True, True, False, True, False, False, False, False]
|
||||
pattern_b = [False, False, False, False, True, False, True, True, True, False, True]
|
||||
for line in _all_lines(matrix):
|
||||
for i in range(len(line) - 10):
|
||||
window = line[i : i + 11]
|
||||
if window in (pattern_a, pattern_b):
|
||||
total += 40
|
||||
|
||||
# N4: dark/light balance (integer math: floor(|percent - 50| / 5) * 10).
|
||||
dark = sum(cell for line in matrix for cell in line)
|
||||
total += 10 * (abs(20 * dark - 10 * size * size) // (5 * size * size))
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def _all_lines(matrix: list[list[bool]]):
|
||||
size = len(matrix)
|
||||
for r in range(size):
|
||||
yield matrix[r]
|
||||
for c in range(size):
|
||||
yield [matrix[r][c] for r in range(size)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def qr_matrix(data: str) -> list[list[bool]]:
|
||||
"""Encode *data* (ASCII) into a module matrix (``True`` = dark).
|
||||
|
||||
Includes the 4-module quiet zone. Raises :class:`QrTooLongError` when the
|
||||
payload doesn't fit in versions 1–10.
|
||||
"""
|
||||
payload = data.encode("ascii")
|
||||
version, level = _select_version(payload)
|
||||
codewords = _encode_data(payload, version, level)
|
||||
|
||||
best = _build_matrix(version, level, codewords, 0)
|
||||
best_penalty = _penalty(best)
|
||||
for mask in range(1, 8):
|
||||
m = _build_matrix(version, level, codewords, mask)
|
||||
p = _penalty(m)
|
||||
if p < best_penalty:
|
||||
best, best_penalty = m, p
|
||||
|
||||
size = len(best)
|
||||
return (
|
||||
[[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
|
||||
+ [[False] * _QUIET + row + [False] * _QUIET for row in best]
|
||||
+ [[False] * (size + 2 * _QUIET) for _ in range(_QUIET)]
|
||||
)
|
||||
|
||||
|
||||
def render_qr(data: str) -> str:
|
||||
"""Render *data* as a terminal QR using Unicode half-blocks.
|
||||
|
||||
Returns ``""`` (not an exception) when the payload is too long. Pair
|
||||
consecutive module rows into one character row: both dark → ``█``, top
|
||||
dark → ``▀``, bottom dark → ``▄``, both light → space. No ANSI colours or
|
||||
cursor tricks — survives ``less``, log files, and copy-paste.
|
||||
"""
|
||||
try:
|
||||
matrix = qr_matrix(data)
|
||||
except QrTooLongError:
|
||||
return ""
|
||||
|
||||
height = len(matrix)
|
||||
width = len(matrix[0])
|
||||
if height % 2:
|
||||
matrix = matrix + [[False] * width]
|
||||
|
||||
lines: list[str] = []
|
||||
for r in range(0, len(matrix), 2):
|
||||
chars: list[str] = []
|
||||
for c in range(width):
|
||||
top, bottom = matrix[r][c], matrix[r + 1][c]
|
||||
if top and bottom:
|
||||
chars.append("█")
|
||||
elif top:
|
||||
chars.append("▀")
|
||||
elif bottom:
|
||||
chars.append("▄")
|
||||
else:
|
||||
chars.append(" ")
|
||||
lines.append("".join(chars))
|
||||
return "\n".join(lines)
|
||||
@@ -224,7 +224,7 @@ def search(
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("android search: open failed: %s", e)
|
||||
logger.warning("iris search: open failed: %s", e)
|
||||
return []
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
@@ -232,10 +232,10 @@ def search(
|
||||
try:
|
||||
return _fts_query(conn, sanitized, scope, chat_id, thread_id, limit)
|
||||
except sqlite3.Error as e:
|
||||
logger.debug("android search: FTS5 failed, using LIKE: %s", e)
|
||||
logger.debug("iris search: FTS5 failed, using LIKE: %s", e)
|
||||
return _like_query(conn, sanitized, scope, chat_id, thread_id, limit)
|
||||
except sqlite3.Error as e:
|
||||
logger.warning("android search: query failed: %s", e)
|
||||
logger.warning("iris search: query failed: %s", e)
|
||||
return []
|
||||
finally:
|
||||
# Best-effort: a close failure on a read-only connection is not
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Tests for the android gateway plugin.
|
||||
# Tests for the iris gateway plugin.
|
||||
|
||||
Run via hermes's hermetic runner (never bare pytest)::
|
||||
|
||||
@@ -13,7 +13,7 @@ drives a turn, printing every frame. Run with the hermes venv python
|
||||
(needs `websockets`); the gateway must already be up::
|
||||
|
||||
hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \
|
||||
--token <ANDROID_TOKEN> --send "hello"
|
||||
--token <IRIS_TOKEN> --send "hello"
|
||||
|
||||
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
||||
`--fcm-token`/`--fcm-reg`, `--authfail`, `--url`, `--token`, `--device`,
|
||||
@@ -48,7 +48,7 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
||||
`POST /v1/frame` (the `message.send`), receive over SSE `GET
|
||||
/v1/events`. The same assertion flags apply. The base URL defaults to
|
||||
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
|
||||
(`ANDROID_HTTP_PORT`).
|
||||
(`IRIS_HTTP_PORT`).
|
||||
|
||||
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
|
||||
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
|
||||
@@ -72,7 +72,7 @@ FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise::
|
||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
||||
|
||||
The token is read from `$ANDROID_TOKEN`, else `hermes-agent/.env`, else
|
||||
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
|
||||
`~/.hermes/.env`. The gateway must already be running (the driver never
|
||||
starts or stops it). It is idempotent: channels/jobs it creates are
|
||||
cleaned up even on failure, and leftover `e2e-*` channels/jobs from
|
||||
|
||||
@@ -11,7 +11,7 @@ Usage::
|
||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7
|
||||
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url ws://host:8790/ws
|
||||
|
||||
The token is read from $ANDROID_TOKEN, else hermes-agent/.env, else
|
||||
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
|
||||
~/.hermes/.env. The gateway must already be running (this driver never
|
||||
starts or stops it). Idempotent: channels/jobs it creates are cleaned up
|
||||
even on failure, and leftover "e2e-*" channels/jobs from earlier runs are
|
||||
@@ -52,14 +52,14 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
|
||||
def find_token(cli_token: str) -> str:
|
||||
if cli_token:
|
||||
return cli_token
|
||||
env = os.getenv("ANDROID_TOKEN")
|
||||
env = os.getenv("IRIS_TOKEN")
|
||||
if env:
|
||||
return env
|
||||
for p in (REPO / "hermes-agent" / ".env", Path.home() / ".hermes" / ".env"):
|
||||
try:
|
||||
for raw_line in p.read_text().splitlines():
|
||||
line = raw_line.strip()
|
||||
if line.startswith("ANDROID_TOKEN="):
|
||||
if line.startswith("IRIS_TOKEN="):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
@@ -204,7 +204,7 @@ def s7_cron(env, url, token):
|
||||
if not chat_id:
|
||||
return FAIL, "channel.created received but chat_id not parseable"
|
||||
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
|
||||
deliver = f"android:{chat_id}"
|
||||
deliver = f"iris:{chat_id}"
|
||||
rc, out, err = run_hermes(
|
||||
env, "cron", "create", "1m",
|
||||
"Reply with exactly: e2e cron delivery OK",
|
||||
@@ -310,7 +310,7 @@ def s13_http_fallback(env, url, token):
|
||||
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
|
||||
u = urlparse(url)
|
||||
scheme = "https" if u.scheme == "wss" else "http"
|
||||
http_port = os.getenv("ANDROID_HTTP_PORT", "8791")
|
||||
http_port = os.getenv("IRIS_HTTP_PORT", "8791")
|
||||
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
|
||||
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
|
||||
"--send", "Reply with exactly: e2e http fallback OK",
|
||||
@@ -352,7 +352,7 @@ def main() -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", DEFAULT_URL))
|
||||
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", DEFAULT_URL))
|
||||
p.add_argument("--token", default="")
|
||||
p.add_argument("--skip", default="",
|
||||
help="comma-separated scenario numbers to skip (e.g. 3,5,7)")
|
||||
@@ -360,12 +360,12 @@ def main() -> int:
|
||||
|
||||
token = find_token(args.token)
|
||||
if not token:
|
||||
print("!! ANDROID_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
|
||||
print("!! IRIS_TOKEN not found (env, hermes-agent/.env, or ~/.hermes/.env)")
|
||||
return 1
|
||||
skip = {int(x) for x in args.skip.split(",") if x.strip()}
|
||||
|
||||
env = dict(os.environ)
|
||||
env["ANDROID_TOKEN"] = token
|
||||
env["IRIS_TOKEN"] = token
|
||||
|
||||
print(f"== e2e: url={args.url} token={token[:6]}…")
|
||||
sweep_leftovers(env, args.url, token)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the Iris x Hermes android gateway plugin (M4: media).
|
||||
|
||||
The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into
|
||||
``~/.hermes/plugins/android`` as a symlink in production); tests load it
|
||||
``~/.hermes/plugins/iris`` as a symlink in production); tests load it
|
||||
from the source tree directly so they never depend on that install.
|
||||
|
||||
Coverage (docs/13-testing.md §13.1, media bullets):
|
||||
@@ -42,12 +42,12 @@ PNG_1X1 = base64.b64decode(
|
||||
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
TOKEN = "test-android-token-0123456789"
|
||||
TOKEN = "test-iris-token-0123456789"
|
||||
DEVICE_ID = "test-device"
|
||||
|
||||
|
||||
def _plugin_dir() -> Path:
|
||||
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
||||
env = os.environ.get("IRIS_PLUGIN_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
||||
@@ -66,13 +66,13 @@ def _load_plugin():
|
||||
The plugin uses relative imports (``from . import protocol``), so it
|
||||
must be imported as a package (``submodule_search_locations``).
|
||||
"""
|
||||
name = "android_plugin_under_test"
|
||||
name = "iris_plugin_under_test"
|
||||
cached = sys.modules.get(name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
pkg_dir = _plugin_dir()
|
||||
if not (pkg_dir / "__init__.py").is_file():
|
||||
pytest.fail(f"android plugin not found at {pkg_dir}")
|
||||
pytest.fail(f"iris plugin not found at {pkg_dir}")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
||||
)
|
||||
@@ -95,16 +95,16 @@ def plugin():
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(plugin, monkeypatch):
|
||||
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
|
||||
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
|
||||
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
|
||||
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
|
||||
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||
|
||||
# Platform("android") resolves only once the platform is registered
|
||||
# Platform("iris") resolves only once the platform is registered
|
||||
# (the plugin's register(ctx) does this in production).
|
||||
if not platform_registry.is_registered("android"):
|
||||
if not platform_registry.is_registered("iris"):
|
||||
platform_registry.register(
|
||||
PlatformEntry(
|
||||
name="android",
|
||||
name="iris",
|
||||
label="Android",
|
||||
adapter_factory=lambda cfg: None,
|
||||
check_fn=lambda: True,
|
||||
@@ -119,7 +119,7 @@ def adapter(plugin, monkeypatch):
|
||||
},
|
||||
home_channel=None,
|
||||
)
|
||||
a = plugin.adapter.AndroidAdapter(config)
|
||||
a = plugin.adapter.IrisAdapter(config)
|
||||
yield a
|
||||
try:
|
||||
a._devices.close()
|
||||
@@ -459,14 +459,14 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
|
||||
ws, _ = ws_client
|
||||
# Simulate the post_api_request hook capturing the turn's model + tokens.
|
||||
plugin.adapter._on_post_api_request(
|
||||
platform="android",
|
||||
platform="iris",
|
||||
model="openai/gpt-5.4",
|
||||
usage={"prompt_tokens": 12345},
|
||||
)
|
||||
# Stub context-length resolution (avoid network probing in tests).
|
||||
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
||||
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
||||
res = await adapter.send("android:default", "hello", metadata={"notify": True})
|
||||
res = await adapter.send("default", "hello", metadata={"notify": True})
|
||||
assert res.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||
msg = frames[-1]
|
||||
@@ -477,7 +477,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
|
||||
assert runtime["cwd"] == "~"
|
||||
assert "latency" in runtime and runtime["latency"] >= 0
|
||||
# The turn buffer is drained: a second final send carries no stale model.
|
||||
res2 = await adapter.send("android:default", "again", metadata={"notify": True})
|
||||
res2 = await adapter.send("default", "again", metadata={"notify": True})
|
||||
assert res2.success
|
||||
frames2 = await recv_until(
|
||||
ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "again"
|
||||
@@ -492,7 +492,7 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client,
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch):
|
||||
ws, _ = ws_client
|
||||
# A non-android turn must not pollute the android runtime buffer.
|
||||
# A non-iris turn must not pollute the iris runtime buffer.
|
||||
plugin.adapter._on_post_api_request(
|
||||
platform="telegram",
|
||||
model="openai/gpt-5.4",
|
||||
@@ -500,7 +500,7 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
|
||||
)
|
||||
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
||||
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
||||
res = await adapter.send("android:default", "hi", metadata={"notify": True})
|
||||
res = await adapter.send("default", "hi", metadata={"notify": True})
|
||||
assert res.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||
runtime = frames[-1]["payload"].get("runtime")
|
||||
@@ -512,15 +512,15 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client
|
||||
async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch):
|
||||
ws, _ = ws_client
|
||||
plugin.adapter._on_post_api_request(
|
||||
platform="android",
|
||||
platform="iris",
|
||||
model="openai/gpt-5.4",
|
||||
usage={"prompt_tokens": 12345},
|
||||
)
|
||||
monkeypatch.setattr(plugin.adapter, "_resolve_context_length", lambda model: 32768)
|
||||
monkeypatch.setenv("TERMINAL_CWD", os.path.expanduser("~"))
|
||||
await adapter.send("android:default", "hello", metadata={"notify": True})
|
||||
await adapter.send("default", "hello", metadata={"notify": True})
|
||||
# The outbox history reconstruction must carry the runtime object.
|
||||
page = adapter._outbox.history("android:default", limit=50)
|
||||
page = adapter._outbox.history("default", limit=50)
|
||||
assert len(page["messages"]) == 1
|
||||
m = page["messages"][0]
|
||||
assert m.get("runtime", {}).get("model") == "gpt-5.4"
|
||||
@@ -722,7 +722,7 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
|
||||
"v": 1,
|
||||
"id": 40,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"chat_id": "default",
|
||||
"payload": {"text": "fix the login bug please", "auto_thread": True},
|
||||
}
|
||||
)
|
||||
@@ -733,16 +733,16 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client,
|
||||
created = next(f for f in frames if f.get("type") == "channel.created")
|
||||
assert created["payload"]["kind"] == "thread"
|
||||
assert created["payload"]["auto"] is True
|
||||
assert created["payload"]["parent_chat_id"] == "android:default"
|
||||
assert created["payload"]["parent_chat_id"] == "default"
|
||||
assert created["payload"]["name"] == "fix the login bug please"
|
||||
thread_id = created["payload"]["chat_id"]
|
||||
|
||||
echo = frames[-1]
|
||||
assert echo["chat_id"] == "android:default"
|
||||
assert echo["chat_id"] == "default"
|
||||
assert echo["thread_id"] == thread_id
|
||||
|
||||
assert len(captured) == 1
|
||||
assert captured[0].source.chat_id == "android:default"
|
||||
assert captured[0].source.chat_id == "default"
|
||||
assert captured[0].source.thread_id == thread_id
|
||||
|
||||
|
||||
@@ -762,7 +762,7 @@ async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client,
|
||||
"v": 1,
|
||||
"id": 41,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"chat_id": "default",
|
||||
"payload": {"text": "fix the login bug please", "auto_thread": True},
|
||||
}
|
||||
)
|
||||
@@ -789,7 +789,7 @@ async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws
|
||||
"v": 1,
|
||||
"id": 42,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"chat_id": "default",
|
||||
"thread_id": "t_9",
|
||||
"payload": {"text": "follow up", "auto_thread": True},
|
||||
}
|
||||
@@ -818,7 +818,7 @@ async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_cl
|
||||
"v": 1,
|
||||
"id": 43,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"chat_id": "default",
|
||||
"payload": {"text": "/new", "auto_thread": True},
|
||||
}
|
||||
)
|
||||
@@ -848,7 +848,7 @@ async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client
|
||||
"v": 1,
|
||||
"id": 44,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"chat_id": "default",
|
||||
"payload": {"text": "", "media_refs": ["mu_flat"], "auto_thread": True},
|
||||
}
|
||||
)
|
||||
@@ -877,7 +877,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
|
||||
"v": 1,
|
||||
"id": 20,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"chat_id": "default",
|
||||
"payload": {"text": "persist me"},
|
||||
}
|
||||
)
|
||||
@@ -888,7 +888,7 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client):
|
||||
echo = frames[-1]
|
||||
message_id = echo["payload"]["message_id"]
|
||||
# The echo is parked in the outbox under the home channel.
|
||||
page = adapter._outbox.history("android:default", limit=50)
|
||||
page = adapter._outbox.history("default", limit=50)
|
||||
ids = [m["message_id"] for m in page["messages"]]
|
||||
assert message_id in ids
|
||||
parked = next(m for m in page["messages"] if m["message_id"] == message_id)
|
||||
@@ -904,7 +904,7 @@ async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_c
|
||||
ws, _ = ws_client
|
||||
protocol = plugin.protocol
|
||||
adapter.handle_message = AsyncMock()
|
||||
chat_id = "android:default"
|
||||
chat_id = "default"
|
||||
# Two user turns, each with a (non-streaming) assistant final.
|
||||
for i, text in enumerate(["one", "two"]):
|
||||
await ws.send(
|
||||
@@ -952,7 +952,7 @@ async def test_history_paginates_older_pages(plugin, adapter, ws_client):
|
||||
ws, _ = ws_client
|
||||
protocol = plugin.protocol
|
||||
adapter.handle_message = AsyncMock()
|
||||
chat_id = "android:default"
|
||||
chat_id = "default"
|
||||
for i in range(5):
|
||||
await adapter._broadcast_or_log(
|
||||
chat_id,
|
||||
@@ -982,7 +982,7 @@ async def test_history_frame_roundtrip(plugin, adapter, ws_client):
|
||||
ws, _ = ws_client
|
||||
protocol = plugin.protocol
|
||||
adapter.handle_message = AsyncMock()
|
||||
chat_id = "android:default"
|
||||
chat_id = "default"
|
||||
await adapter._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message(
|
||||
@@ -1023,7 +1023,7 @@ async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter
|
||||
ws, _ = ws_client
|
||||
protocol = plugin.protocol
|
||||
adapter.handle_message = AsyncMock()
|
||||
chat_id = "android:default"
|
||||
chat_id = "default"
|
||||
await adapter._broadcast_or_log(
|
||||
chat_id,
|
||||
protocol.message(
|
||||
@@ -1062,7 +1062,7 @@ async def test_message_delete_idempotent(plugin, adapter, ws_client):
|
||||
on the outbox but still emits ``message.deleted`` so live caches drop it."""
|
||||
ws, _ = ws_client
|
||||
adapter.handle_message = AsyncMock()
|
||||
chat_id = "android:default"
|
||||
chat_id = "default"
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -1088,7 +1088,7 @@ async def test_message_delete_requires_message_ids(adapter, ws_client):
|
||||
"v": 1,
|
||||
"id": 52,
|
||||
"type": "message.delete",
|
||||
"chat_id": "android:default",
|
||||
"chat_id": "default",
|
||||
"payload": {},
|
||||
}
|
||||
)
|
||||
@@ -1125,7 +1125,7 @@ async def test_message_delete_purges_session_store(plugin, adapter, ws_client):
|
||||
adapter.handle_message = AsyncMock()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
chat_id = "android:default"
|
||||
chat_id = "default"
|
||||
db = get_hermes_home() / "state.db"
|
||||
_make_state_db(db)
|
||||
import sqlite3
|
||||
@@ -1244,12 +1244,12 @@ async def test_send_video_emits_offer_with_message_association(adapter, ws_clien
|
||||
video.write_bytes(b"fake-video-bytes")
|
||||
|
||||
# A final message first, so the offer can associate with it.
|
||||
res = await adapter.send("android:default", "here you go", metadata={"notify": True})
|
||||
res = await adapter.send("default", "here you go", metadata={"notify": True})
|
||||
assert res.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||
msg_id = frames[-1]["payload"]["message_id"]
|
||||
|
||||
res2 = await adapter.send_video("android:default", str(video))
|
||||
res2 = await adapter.send_video("default", str(video))
|
||||
assert res2.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
||||
offer = frames[-1]["payload"]
|
||||
@@ -1270,7 +1270,7 @@ async def test_send_document_uses_caller_filename(adapter, ws_client):
|
||||
doc = get_document_cache_dir() / "report.pdf"
|
||||
doc.write_bytes(b"%PDF-1.4 fake")
|
||||
res = await adapter.send_document(
|
||||
"android:default", str(doc), file_name="My Report.pdf"
|
||||
"default", str(doc), file_name="My Report.pdf"
|
||||
)
|
||||
assert res.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
||||
@@ -1287,7 +1287,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
|
||||
|
||||
img = get_image_cache_dir() / "shot.png"
|
||||
img.write_bytes(PNG_1X1)
|
||||
res = await adapter.send_image_file("android:default", str(img))
|
||||
res = await adapter.send_image_file("default", str(img))
|
||||
assert res.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "media.offer")
|
||||
offer = frames[-1]["payload"]
|
||||
@@ -1299,7 +1299,7 @@ async def test_send_image_file_offers_image(adapter, ws_client):
|
||||
async def test_send_media_rejects_denied_path(adapter, ws_client):
|
||||
ws, _ = ws_client
|
||||
# /etc/passwd exists but is on hermes' delivery denylist.
|
||||
res = await adapter.send_document("android:default", "/etc/passwd")
|
||||
res = await adapter.send_document("default", "/etc/passwd")
|
||||
assert not res.success
|
||||
# Nothing was offered (a failed offer is silent, like other platforms).
|
||||
try:
|
||||
@@ -1439,10 +1439,10 @@ async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch):
|
||||
)
|
||||
ok = await backend.send(
|
||||
device_id="d1",
|
||||
chat_id="android:default",
|
||||
chat_id="default",
|
||||
title="Iris",
|
||||
body="hello",
|
||||
data={"chat_id": "android:default", "kind": "message", "cursor": "7"},
|
||||
data={"chat_id": "default", "kind": "message", "cursor": "7"},
|
||||
token="iris-topic",
|
||||
)
|
||||
assert ok is True
|
||||
@@ -1479,10 +1479,10 @@ async def test_fcm_backend_legacy_server_key(plugin, monkeypatch):
|
||||
)
|
||||
ok = await backend.send(
|
||||
device_id="d1",
|
||||
chat_id="android:default",
|
||||
chat_id="default",
|
||||
title="Iris",
|
||||
body="hi",
|
||||
data={"chat_id": "android:default", "kind": "message", "cursor": "3"},
|
||||
data={"chat_id": "default", "kind": "message", "cursor": "3"},
|
||||
token="fcm-token-1",
|
||||
)
|
||||
assert ok is True
|
||||
@@ -1528,10 +1528,10 @@ async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path):
|
||||
fake = _patch_httpx(plugin, monkeypatch, responder)
|
||||
ok = await backend.send(
|
||||
device_id="d1",
|
||||
chat_id="android:default",
|
||||
chat_id="default",
|
||||
title="Iris",
|
||||
body="hi",
|
||||
data={"chat_id": "android:default", "kind": "cron", "cursor": "4"},
|
||||
data={"chat_id": "default", "kind": "cron", "cursor": "4"},
|
||||
token="fcm-token-2",
|
||||
priority="high",
|
||||
)
|
||||
@@ -1584,15 +1584,15 @@ async def test_push_fires_when_no_live_subscriber(adapter):
|
||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||
|
||||
res = await adapter.send(
|
||||
"android:default", "hello while offline", metadata={"notify": True}
|
||||
"default", "hello while offline", metadata={"notify": True}
|
||||
)
|
||||
assert res.success
|
||||
assert len(fake.calls) == 1
|
||||
call = fake.calls[0]
|
||||
assert call["token"] == "tok-1"
|
||||
assert call["chat_id"] == "android:default"
|
||||
assert call["chat_id"] == "default"
|
||||
assert call["data"]["kind"] == "message"
|
||||
assert call["data"]["chat_id"] == "android:default"
|
||||
assert call["data"]["chat_id"] == "default"
|
||||
assert call["data"]["cursor"] == "1"
|
||||
assert call["priority"] == "normal"
|
||||
# The frame is parked in the outbox for sync.
|
||||
@@ -1606,7 +1606,7 @@ async def test_push_not_fired_when_live(adapter, ws_client):
|
||||
adapter._push = fake
|
||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||
|
||||
await adapter.send("android:default", "live reply", metadata={"notify": True})
|
||||
await adapter.send("default", "live reply", metadata={"notify": True})
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||
assert frames[-1]["payload"]["text"] == "live reply"
|
||||
assert fake.calls == []
|
||||
@@ -1619,7 +1619,7 @@ async def test_live_delivered_frame_still_parked_for_sync(adapter, ws_client):
|
||||
tapping a push notification) can catch up via sync. Regression: chat empty
|
||||
after tapping a message notification."""
|
||||
ws, _ = ws_client
|
||||
await adapter.send("android:default", "live reply", metadata={"notify": True})
|
||||
await adapter.send("default", "live reply", metadata={"notify": True})
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||
assert frames[-1]["payload"]["text"] == "live reply"
|
||||
# The live-delivered frame is still parked in the outbox for sync.
|
||||
@@ -1674,9 +1674,9 @@ async def test_intermediate_frames_park_without_push(adapter):
|
||||
adapter._push = fake
|
||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||
|
||||
await adapter.send("android:default", "seg", metadata={"expect_edits": True})
|
||||
stream_id = adapter._turns["android:default"].stream_id
|
||||
await adapter.edit_message("android:default", stream_id, "seg more")
|
||||
await adapter.send("default", "seg", metadata={"expect_edits": True})
|
||||
stream_id = adapter._turns["default"].stream_id
|
||||
await adapter.edit_message("default", stream_id, "seg more")
|
||||
assert adapter._outbox.latest_cursor() == 2
|
||||
assert fake.calls == []
|
||||
|
||||
@@ -1689,9 +1689,9 @@ async def test_high_priority_notification_pushes_even_when_live(plugin, adapter,
|
||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||
|
||||
await adapter._broadcast_or_log(
|
||||
"android:default",
|
||||
"default",
|
||||
plugin.protocol.notification(
|
||||
"android:default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
|
||||
"default", plugin.protocol.NOTIF_CRON, "Cron: Job", "body"
|
||||
),
|
||||
)
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "notification")
|
||||
@@ -1707,7 +1707,7 @@ async def test_push_skipped_when_device_has_no_token(adapter):
|
||||
adapter._push = fake
|
||||
adapter._devices.upsert(DEVICE_ID, "Test", {}) # no push token
|
||||
|
||||
await adapter.send("android:default", "no token", metadata={"notify": True})
|
||||
await adapter.send("default", "no token", metadata={"notify": True})
|
||||
assert fake.calls == []
|
||||
assert adapter._outbox.latest_cursor() == 1 # still parked for sync
|
||||
|
||||
@@ -1718,7 +1718,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
|
||||
adapter._push = fake
|
||||
adapter._devices.upsert(DEVICE_ID, "Test", {})
|
||||
|
||||
await adapter.send("android:default", "unconfigured", metadata={"notify": True})
|
||||
await adapter.send("default", "unconfigured", metadata={"notify": True})
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
@@ -1755,7 +1755,7 @@ async def test_fcm_register_updates_registry(adapter, ws_client):
|
||||
adapter._http_server._subs.clear()
|
||||
fake = _FakePush()
|
||||
adapter._push = fake
|
||||
await adapter.send("android:default", "after rotation", metadata={"notify": True})
|
||||
await adapter.send("default", "after rotation", metadata={"notify": True})
|
||||
assert len(fake.calls) == 1
|
||||
assert fake.calls[0]["token"] == "rotated-token"
|
||||
|
||||
@@ -1854,7 +1854,7 @@ async def test_channel_favorite_toggle(adapter, ws_client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_favorite_unknown_id_rejected(adapter, ws_client):
|
||||
ws, _ = ws_client
|
||||
await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "android:chan_999", "payload": {"on": True}}))
|
||||
await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "chan_999", "payload": {"on": True}}))
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
|
||||
assert frames[-1]["payload"]["code"] == "not_found"
|
||||
|
||||
@@ -1909,7 +1909,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
|
||||
"hello from cron\n\n"
|
||||
"To stop or manage this job, send me a new message (e.g. \"stop reminder My Job\")."
|
||||
)
|
||||
res = await adapter.send("android:default", wrapped, metadata={"job_id": "abc123"})
|
||||
res = await adapter.send("default", wrapped, metadata={"job_id": "abc123"})
|
||||
assert res.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||
msg = frames[-1]
|
||||
@@ -1922,7 +1922,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client):
|
||||
assert notif[0]["payload"]["body"] == "hello from cron"
|
||||
|
||||
# wrap_response: false -- raw content, job id as the name.
|
||||
res2 = await adapter.send("android:default", "raw cron output", metadata={"job_id": "j2"})
|
||||
res2 = await adapter.send("default", "raw cron output", metadata={"job_id": "j2"})
|
||||
assert res2.success
|
||||
frames = await recv_until(
|
||||
ws,
|
||||
@@ -1941,7 +1941,7 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
|
||||
cg.register("cl_1", "sk", "Which one?", ["A", "B"])
|
||||
try:
|
||||
res = await adapter.send_clarify(
|
||||
"android:default", "Which one?", ["A", "B"], "cl_1", "sk"
|
||||
"default", "Which one?", ["A", "B"], "cl_1", "sk"
|
||||
)
|
||||
assert res.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||
@@ -1964,8 +1964,8 @@ async def test_clarify_emits_banner_and_message(adapter, ws_client):
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_replays_parked_frames_and_done_cursor(adapter):
|
||||
# Park two frames while offline.
|
||||
await adapter.send("android:default", "one", metadata={"notify": True})
|
||||
await adapter.send("android:default", "two", metadata={"notify": True})
|
||||
await adapter.send("default", "one", metadata={"notify": True})
|
||||
await adapter.send("default", "two", metadata={"notify": True})
|
||||
assert adapter._outbox.latest_cursor() == 2
|
||||
|
||||
await adapter.connect()
|
||||
@@ -2021,26 +2021,26 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
|
||||
adapter._push = fake
|
||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||
|
||||
await adapter.send("android:default", "one", metadata={"notify": True})
|
||||
await adapter.send("default", "one", metadata={"notify": True})
|
||||
assert len(fake.calls) == 1
|
||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
|
||||
|
||||
# Immediate second frame (cron's message frame) coalesces — no second
|
||||
# push, cursor unchanged.
|
||||
await adapter.send("android:default", "two", metadata={"notify": True})
|
||||
await adapter.send("default", "two", metadata={"notify": True})
|
||||
assert len(fake.calls) == 1
|
||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 1
|
||||
|
||||
# Simulate the coalesce window elapsing, then push again.
|
||||
adapter._last_push_at["android:default"] = 0.0
|
||||
await adapter.send("android:default", "three", metadata={"notify": True})
|
||||
adapter._last_push_at["default"] = 0.0
|
||||
await adapter.send("default", "three", metadata={"notify": True})
|
||||
assert len(fake.calls) == 2
|
||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
||||
|
||||
# A failed push must NOT advance the cursor (the device never woke).
|
||||
fake.fail_next = True
|
||||
adapter._last_push_at["android:default"] = 0.0
|
||||
await adapter.send("android:default", "four", metadata={"notify": True})
|
||||
adapter._last_push_at["default"] = 0.0
|
||||
await adapter.send("default", "four", metadata={"notify": True})
|
||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
||||
|
||||
await adapter.connect()
|
||||
@@ -2058,8 +2058,8 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
|
||||
"""Frames replayed by sync carry their outbox cursor in the envelope so
|
||||
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
|
||||
frames carry no cursor."""
|
||||
await adapter.send("android:default", "one", metadata={"notify": True})
|
||||
await adapter.send("android:default", "two", metadata={"notify": True})
|
||||
await adapter.send("default", "one", metadata={"notify": True})
|
||||
await adapter.send("default", "two", metadata={"notify": True})
|
||||
|
||||
await adapter.connect()
|
||||
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
||||
@@ -2083,7 +2083,7 @@ async def test_live_frames_carry_no_cursor(adapter, ws_client):
|
||||
"""Live (non-replay) frames must not carry a cursor — the app only
|
||||
suppresses notifications for replayed frames (docs/08 §8.7)."""
|
||||
ws, _ = ws_client
|
||||
await adapter.send("android:default", "live", metadata={"notify": True})
|
||||
await adapter.send("default", "live", metadata={"notify": True})
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "message")
|
||||
assert "cursor" not in frames[-1]
|
||||
|
||||
@@ -2093,7 +2093,7 @@ def test_outbox_row_cap_prunes_oldest(plugin, tmp_path):
|
||||
try:
|
||||
for i in range(7):
|
||||
outbox.append(
|
||||
"android:default",
|
||||
"default",
|
||||
json.dumps({"v": 1, "type": "message", "payload": {"n": i}}),
|
||||
)
|
||||
assert outbox.latest_cursor() == 7 # cursor stays monotonic
|
||||
@@ -2110,7 +2110,7 @@ def test_outbox_delete_message_removes_all_frames_for_id(plugin, tmp_path):
|
||||
in the chat, leaving other messages intact; a thread_id scopes the delete."""
|
||||
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
|
||||
try:
|
||||
chat = "android:default"
|
||||
chat = "default"
|
||||
# A streaming message spans start/update/stop; a standalone message is
|
||||
# one frame. Plus an unrelated message that must survive.
|
||||
outbox.append(chat, json.dumps({"v": 1, "type": "message.start", "payload": {"message_id": "m1", "role": "assistant"}}))
|
||||
@@ -2139,12 +2139,12 @@ def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path):
|
||||
scoped to that thread's frames only."""
|
||||
outbox = plugin.outbox.Outbox(tmp_path / "ob.db")
|
||||
try:
|
||||
chan = "android:chan_9"
|
||||
chan = "chan_9"
|
||||
# Flat-lane frames + two threads' frames, plus an unrelated channel.
|
||||
outbox.append(chan, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "a", "role": "user", "text": "flat"}}))
|
||||
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "b", "role": "user", "text": "t1"}}))
|
||||
outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_2", "payload": {"message_id": "c", "role": "user", "text": "t2"}}))
|
||||
outbox.append("android:chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}}))
|
||||
outbox.append("chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}}))
|
||||
# Thread delete: only t_1's frame goes.
|
||||
assert outbox.delete_lane(chan, thread_id="t_1") == 1
|
||||
rows = outbox.replay(0)
|
||||
@@ -2164,7 +2164,7 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
|
||||
a channel, its threads; the default channel cannot be deleted."""
|
||||
d = plugin.channels.ChannelDirectory(tmp_path / "ch.db")
|
||||
try:
|
||||
d.ensure_default("android:default", "Default")
|
||||
d.ensure_default("default", "Default")
|
||||
chan = d.create("Work", kind="channel")
|
||||
t1 = d.create("Topic", kind="thread", parent_chat_id=chan["chat_id"])
|
||||
# Deleting the channel removes it AND its thread from the directory.
|
||||
@@ -2180,10 +2180,10 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
|
||||
assert d.get(t2["chat_id"]) is None
|
||||
assert d.get(chan2["chat_id"]) is not None # parent channel survives
|
||||
# The default channel cannot be deleted.
|
||||
assert d.delete("android:default") is None
|
||||
assert d.get("android:default") is not None
|
||||
assert d.delete("default") is None
|
||||
assert d.get("default") is not None
|
||||
# Unknown id -> None.
|
||||
assert d.delete("android:chan_nope") is None
|
||||
assert d.delete("chan_nope") is None
|
||||
finally:
|
||||
d.close()
|
||||
|
||||
@@ -2272,14 +2272,14 @@ def test_tool_end_fields_from_hook(plugin):
|
||||
def test_parse_tool_line_or_block_verbose(plugin):
|
||||
a = plugin.adapter
|
||||
content = '🔍 web_search(["query"])\n{"query": "hermes agent"}'
|
||||
name, preview, args = a.AndroidAdapter._parse_tool_line_or_block(
|
||||
name, preview, args = a.IrisAdapter._parse_tool_line_or_block(
|
||||
'🔍 web_search(["query"])', content
|
||||
)
|
||||
assert name == "web_search"
|
||||
assert args == {"query": "hermes agent"}
|
||||
assert preview == "hermes agent" # derived short preview
|
||||
# Non-verbose line -> args None, preview from the line.
|
||||
name2, preview2, args2 = a.AndroidAdapter._parse_tool_line_or_block(
|
||||
name2, preview2, args2 = a.IrisAdapter._parse_tool_line_or_block(
|
||||
'🔍 web_search: "x"', '🔍 web_search: "x"'
|
||||
)
|
||||
assert name2 == "web_search" and preview2 == "x" and args2 is None
|
||||
@@ -2289,11 +2289,11 @@ def test_tool_start_frame_emoji_field(plugin):
|
||||
"""``tool.start`` carries the cosmetic emoji when given, omits it when
|
||||
None (the app then falls back to its own default glyph)."""
|
||||
pf = plugin.protocol.tool_start
|
||||
f = pf("android:default", 3, "terminal", emoji="💻")
|
||||
f = pf("default", 3, "terminal", emoji="💻")
|
||||
assert f.payload["emoji"] == "💻"
|
||||
f2 = pf("android:default", 3, "terminal")
|
||||
f2 = pf("default", 3, "terminal")
|
||||
assert "emoji" not in f2.payload
|
||||
f3 = pf("android:default", 3, "terminal", emoji=None)
|
||||
f3 = pf("default", 3, "terminal", emoji=None)
|
||||
assert "emoji" not in f3.payload
|
||||
|
||||
|
||||
@@ -2330,7 +2330,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey
|
||||
"agent.display.get_tool_emoji",
|
||||
lambda name, default="⚡": "💻" if name == "terminal" else default,
|
||||
)
|
||||
res = await adapter.send('android:default', '💻 terminal: "ls -la"')
|
||||
res = await adapter.send('default', '💻 terminal: "ls -la"')
|
||||
assert res.success
|
||||
frames = await recv_until(ws, lambda f: f.get("type") == "tool.start")
|
||||
payload = frames[-1]["payload"]
|
||||
@@ -2338,7 +2338,7 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey
|
||||
assert payload["emoji"] == "💻"
|
||||
# Unknown tool -> field omitted (app falls back to its default glyph).
|
||||
monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default)
|
||||
res2 = await adapter.send('android:default', '🔧 patch: "x"')
|
||||
res2 = await adapter.send('default', '🔧 patch: "x"')
|
||||
assert res2.success
|
||||
frames2 = await recv_until(
|
||||
ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the android plugin's HTTP fallback transport (docs/19).
|
||||
"""Tests for the iris plugin's HTTP fallback transport (docs/19).
|
||||
|
||||
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
|
||||
from the source tree directly (same pattern as ``test_android.py``).
|
||||
@@ -44,9 +44,9 @@ import pytest_asyncio
|
||||
# Test-only token (not a credential; the adapter is built with it via
|
||||
# monkeypatch in the fixture below).
|
||||
# pi-lens-ignore: S105
|
||||
TOKEN = "test-android-http-token-0123456789"
|
||||
TOKEN = "test-iris-http-token-0123456789"
|
||||
DEVICE_ID = "test-http-device"
|
||||
CHAT_ID = "android:default"
|
||||
CHAT_ID = "default"
|
||||
|
||||
# 1x1 PNG (same fixture as test_android.py).
|
||||
PNG_1X1 = base64.b64decode(
|
||||
@@ -56,7 +56,7 @@ PNG_1X1 = base64.b64decode(
|
||||
|
||||
|
||||
def _plugin_dir() -> Path:
|
||||
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
||||
env = os.environ.get("IRIS_PLUGIN_DIR")
|
||||
if env:
|
||||
return Path(env)
|
||||
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
||||
@@ -72,13 +72,13 @@ def _plugin_dir() -> Path:
|
||||
def _load_plugin():
|
||||
"""Load the gateway-plugin package under a unique module name (same
|
||||
pattern as test_android.py)."""
|
||||
name = "android_plugin_http_under_test"
|
||||
name = "iris_plugin_http_under_test"
|
||||
cached = sys.modules.get(name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
pkg_dir = _plugin_dir()
|
||||
if not (pkg_dir / "__init__.py").is_file():
|
||||
pytest.fail(f"android plugin not found at {pkg_dir}")
|
||||
pytest.fail(f"iris plugin not found at {pkg_dir}")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
||||
)
|
||||
@@ -101,14 +101,14 @@ def plugin():
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(plugin, monkeypatch):
|
||||
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
|
||||
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
|
||||
"""A live IrisAdapter with an isolated HERMES_HOME (conftest)."""
|
||||
monkeypatch.setenv("IRIS_TOKEN", TOKEN)
|
||||
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||
|
||||
if not platform_registry.is_registered("android"):
|
||||
if not platform_registry.is_registered("iris"):
|
||||
platform_registry.register(
|
||||
PlatformEntry(
|
||||
name="android",
|
||||
name="iris",
|
||||
label="Android",
|
||||
adapter_factory=lambda cfg: None,
|
||||
check_fn=lambda: True,
|
||||
@@ -124,7 +124,7 @@ def adapter(plugin, monkeypatch):
|
||||
},
|
||||
home_channel=None,
|
||||
)
|
||||
a = plugin.adapter.AndroidAdapter(config)
|
||||
a = plugin.adapter.IrisAdapter(config)
|
||||
yield a
|
||||
with contextlib.suppress(Exception):
|
||||
a._devices.close()
|
||||
|
||||
@@ -7,13 +7,13 @@ while building the Kotlin client.
|
||||
|
||||
Usage::
|
||||
|
||||
hermes gateway & # with the android plugin
|
||||
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
|
||||
hermes gateway & # with the iris plugin
|
||||
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
|
||||
--send "hello"
|
||||
|
||||
Options:
|
||||
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
|
||||
--token ANDROID_TOKEN (default: $ANDROID_TOKEN)
|
||||
--token IRIS_TOKEN (default: $IRIS_TOKEN)
|
||||
--device device_id (default: probe-<rand>)
|
||||
--send TEXT send this message after pairing (default: "hello")
|
||||
--upload F M4: upload F (chunked media.upload) and attach it to the
|
||||
@@ -573,7 +573,7 @@ def run_http(args, base: str) -> int:
|
||||
"v": 1,
|
||||
"id": 1,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"chat_id": "default",
|
||||
"payload": {"text": args.send},
|
||||
}
|
||||
conn = HTTPConnection(host, port, timeout=30)
|
||||
@@ -666,8 +666,8 @@ def run_http(args, base: str) -> int:
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws"))
|
||||
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", ""))
|
||||
p.add_argument("--url", default=os.getenv("IRIS_WS_URL", "ws://127.0.0.1:8790/ws"))
|
||||
p.add_argument("--token", default=os.getenv("IRIS_TOKEN", ""))
|
||||
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
||||
p.add_argument("--send", default="hello")
|
||||
p.add_argument(
|
||||
@@ -724,8 +724,8 @@ def main() -> int:
|
||||
)
|
||||
p.add_argument(
|
||||
"--chat-id",
|
||||
default="android:default",
|
||||
help="chat_id for --scope chat (default android:default)",
|
||||
default="default",
|
||||
help="chat_id for --scope chat (default default)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--channel-create", default="", help="M3: create a channel, print its chat_id, exit"
|
||||
@@ -762,7 +762,7 @@ def main() -> int:
|
||||
)
|
||||
args = p.parse_args()
|
||||
if not args.token and not args.authfail:
|
||||
p.error("--token (or $ANDROID_TOKEN) is required")
|
||||
p.error("--token (or $IRIS_TOKEN) is required")
|
||||
if args.assert_read_receipt and not args.send:
|
||||
p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)")
|
||||
# HTTP is the only transport (docs/19): derive the http(s) base from the
|
||||
|
||||
Reference in new issue
Block a user