From 310c0244dfd38d53b2ead39bae4bdb83ecce623f Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Mon, 7 Sep 2026 20:50:21 -0700 Subject: [PATCH] fix(plugin): bind adapter to real Hermes gateway API; add contract-test gate - Resolve the Hermes gateway API lazily via get_adapter_class() so the adapter always inherits the real gateway.platforms.base.BasePlatformAdapter instead of the ImportError fallback stub (root cause of the AttributeError: 'set_message_handler' crash at gateway startup). - Pass Platform('meshtastic') enum to super().__init__ and align connect(*, is_reconnect=False) -> bool with the gateway contract. - Split pure formatting utils into formatting.py; remove the stub fallback. - Add tests/contract (L0-L3): real-base binding, ABC instantiation, gateway run.py wiring replay, signature and event-shape conformance, run against hermes-agent pinned to the deployed image source (v2026.8.31). - Add scripts/contract-test.sh and a contract-test CI job; release publish now requires contract tests to pass. - Untrack stray __pycache__ artifacts. --- .gitea/workflows/release.yaml | 45 +- .gitignore | 4 + plugin/.gitignore | 6 + plugin/hermes_meshtastic/__init__.py | 15 +- .../__pycache__/__init__.cpython-314.pyc | Bin 1164 -> 0 bytes .../__pycache__/adapter.cpython-314.pyc | Bin 16279 -> 0 bytes plugin/hermes_meshtastic/adapter.py | 539 ++++++++++-------- plugin/hermes_meshtastic/formatting.py | 61 ++ plugin/pyproject.toml | 7 + .../test_adapter.cpython-314-pytest-9.0.3.pyc | Bin 7292 -> 0 bytes .../tests/contract/test_gateway_contract.py | 230 ++++++++ scripts/contract-test.sh | 61 ++ 12 files changed, 712 insertions(+), 256 deletions(-) create mode 100644 plugin/.gitignore delete mode 100644 plugin/hermes_meshtastic/__pycache__/__init__.cpython-314.pyc delete mode 100644 plugin/hermes_meshtastic/__pycache__/adapter.cpython-314.pyc create mode 100644 plugin/hermes_meshtastic/formatting.py delete mode 100644 plugin/tests/__pycache__/test_adapter.cpython-314-pytest-9.0.3.pyc create mode 100644 plugin/tests/contract/test_gateway_contract.py create mode 100755 scripts/contract-test.sh diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 71e8bc4..9f57c7a 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -8,8 +8,49 @@ on: - 'v*' jobs: + # Gateway contract gate: the plugin must bind and wire against the REAL + # Hermes gateway API (pinned to the deployed image source) before any + # release can be built or published. Without this, adapter/API drift used + # to surface only as a gateway crash after deploy (the set_message_handler + # AttributeError). HERMES_CONTRACT_REQUIRED=1 turns a missing hermes-agent + # into a hard failure so this gate can never silently skip. + contract-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python 3.13 (matches Hermes image) + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install uv + run: pip install uv + + - name: Clone pinned Hermes gateway source (deployed image tag) + run: | + git clone --depth 1 --branch v2026.8.31 \ + https://github.com/NousResearch/hermes-agent.git plugin/.hermes-src + test "$(git -C plugin/.hermes-src rev-parse HEAD)" = \ + "29112bef099274229cadff79cdff7bf7b99c4b77" + + - name: Editable install hermes-agent + pytest + run: | + uv venv --python 3.13 plugin/.venv-contract + uv pip install --python plugin/.venv-contract/bin/python -e plugin/.hermes-src + uv pip install --python plugin/.venv-contract/bin/python pytest + + - name: Run gateway contract tests + env: + HERMES_CONTRACT_REQUIRED: '1' + run: | + cd plugin + .venv-contract/bin/python -m pytest tests/contract -q + test-and-release: runs-on: ubuntu-latest + needs: contract-test steps: - name: Checkout code uses: actions/checkout@v4 @@ -40,13 +81,13 @@ jobs: echo "Publishing release for $tag_name" wheel_file=$(ls plugin/dist/*.whl | head -n 1) tar_file=$(ls plugin/dist/*.tar.gz | head -n 1) - + # Create release response=$(curl -s -k -X POST "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \ -H "Authorization: token $GITEA_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"tag_name\": \"$tag_name\", \"name\": \"$tag_name\", \"body\": \"Release $tag_name for hermes-meshtastic plugin\"}") - + release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -n 1 | cut -d: -f2) if [ -n "$release_id" ]; then curl -s -k -X POST "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$release_id/assets?name=$(basename $wheel_file)" \ diff --git a/.gitignore b/.gitignore index b240a97..5c53fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ .pio/ *.log plugin/dist/ +plugin/.venv-contract/ +plugin/.hermes-src/ +__pycache__/ +*.pyc diff --git a/plugin/.gitignore b/plugin/.gitignore new file mode 100644 index 0000000..6617f8d --- /dev/null +++ b/plugin/.gitignore @@ -0,0 +1,6 @@ +dist/ +__pycache__/ +*.pyc +.venv-contract/ +.hermes-src/ +.pytest_cache/ diff --git a/plugin/hermes_meshtastic/__init__.py b/plugin/hermes_meshtastic/__init__.py index 3492fab..395e396 100644 --- a/plugin/hermes_meshtastic/__init__.py +++ b/plugin/hermes_meshtastic/__init__.py @@ -1,16 +1,23 @@ -"""Meshtastic Platform Adapter plugin for Hermes Agent.""" +"""Meshtastic Platform Adapter plugin for Hermes Agent. -from .adapter import MeshtasticAdapter, check_requirements, MAX_LORA_MESSAGE_LENGTH +The ``register(ctx)`` entry point resolves the adapter class lazily so the real +Hermes gateway API (``gateway.platforms.base``) is imported only at registration +time — never at plugin-discovery import time (see ``adapter.get_adapter_class``). +""" + +from .formatting import MAX_LORA_MESSAGE_LENGTH def register(ctx): """Hermes plugin entrypoint.""" - from gateway.platform_registry import PlatformEntry + from .adapter import check_requirements, get_adapter_class + + adapter_cls = get_adapter_class() ctx.register_platform( name="meshtastic", label="Meshtastic LoRa", - adapter_factory=lambda config: MeshtasticAdapter(config), + adapter_factory=lambda config: adapter_cls(config), check_fn=check_requirements, max_message_length=MAX_LORA_MESSAGE_LENGTH, emoji="📻", diff --git a/plugin/hermes_meshtastic/__pycache__/__init__.cpython-314.pyc b/plugin/hermes_meshtastic/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 87d45159ff254468dca56229c2b0b88f1517c674..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1164 zcmZ8gU279T6rD{rABw5AiZv>9K(PeNrbGjK+r;omX>F7EN;0q#u%m{HT|I8*2;S}XijnT3AN&-?Bq z+9-~48|6{t7yP1Mx>FkNfo&Ohp4zKs1TsnhPWC&3TQZKRjF>Ok*XA@Z%{19Nd%_bh z3!nZ296+P<@H@T)%RbupL+UA-%VKEmtkxW9X9Jyzfk?M_TiNUl)2u&HOsYDQJlKtS zx4tIDHVd?>pCG6Y5!a)^0LWhnLU_tGAzr6%%JVTzS|MF9mnCa+1=I89V-e6;EkH2L zRiLS|V(+7m(|6xq{5ZXM3_H|yVL3W@^=|L|bJ4|AG8{xyYo4~TcnLoEPXjDz$b~o1 z1Gi`z_IRix%oT>>K8{7t!#-0Tk%k!T1C#@<7`TbTPP1K&dt67Nt8qf*PAGcm3~q@y zoWYv5Vy0#=7%m19%EJ#C>tLFy9_WOX2BCD^$K5mpShwb8El^1VpsR2%Vkxt5J3=WA z7kRakaZ{QwQ;2Dc#pWy>8k=mv5do7f4=uZunu&z&T5%O^GZM43ts_%liP+|*0>B5~ z1&P2ftF?luwy9=4+V}oshN8>9w2n06qZyD;n;;EH&b{p@-K9z zP?UDctF@vj5fX}k5L3!n4?K7RWYFdf8q{Y9<`WTiW40i#!X4J4%DVd~?>Nq9bngdR O|A5wi7SB1uCcgo)`buR0 diff --git a/plugin/hermes_meshtastic/__pycache__/adapter.cpython-314.pyc b/plugin/hermes_meshtastic/__pycache__/adapter.cpython-314.pyc deleted file mode 100644 index baeb1e1cc167d2f734b27fa3b5c13ccb55c4213b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16279 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 Xk~!cn?VTR(El>L)m-_8G9{&FWN2>1t diff --git a/plugin/hermes_meshtastic/adapter.py b/plugin/hermes_meshtastic/adapter.py index 5f8fefa..362276d 100644 --- a/plugin/hermes_meshtastic/adapter.py +++ b/plugin/hermes_meshtastic/adapter.py @@ -3,306 +3,345 @@ Connects to a Meshtastic node via TCP (e.g. meshtastic.local:4403), listening for LoRa packets, stripping formatting, and dispatching to Hermes agent sessions. Outbound replies are chunked into concise LoRa-friendly packets. + +Contract note +------------- +The Hermes gateway API (``gateway.platforms.base.BasePlatformAdapter`` and friends) +is resolved LAZILY, inside ``get_adapter_class()``, never at module import time. +Hermes' own guidance (``hermes_cli/plugins.py``) puts gateway imports inside factory +bodies because third-party plugin modules are imported during plugin discovery, which +runs before/independently of the gateway runtime being fully importable. Importing +``gateway.platforms.base`` at module scope silently failed in the deployed runtime +(an ``ImportError`` swallowed by an earlier fallback bound the adapter to a local stub +class lacking ``set_message_handler`` — see git history). Deferred resolution keeps +this module importable in isolation (unit tests / CI without Hermes) while always +binding the adapter to the REAL gateway base at registration/creation time. + +The contract tests in ``tests/contract/`` assert this invariant against the real +``hermes-agent`` package (pinned to the deployed image tag). """ from __future__ import annotations import asyncio import logging -import re import time from typing import Any, Dict, List, Optional -try: - from gateway.platforms.base import ( - BasePlatformAdapter, - MessageEvent, - MessageType, - SendResult, - ) -except ImportError: - # Allow importing and testing utility functions without gateway in environment - class BasePlatformAdapter: # type: ignore[no-redef] - def __init__(self, config: Any, platform: Any = "meshtastic"): - self.config = config - self.platform = platform - - def build_source(self, **kwargs): - return kwargs - - async def handle_message(self, event: Any) -> None: - pass - - class SendResult: # type: ignore[no-redef] - def __init__(self, success: bool, message_id: Optional[str] = None, error: Optional[str] = None): - self.success = success - self.message_id = message_id - self.error = error - - class MessageType: # type: ignore[no-redef] - TEXT = "text" - - class MessageEvent: # type: ignore[no-redef] - def __init__(self, **kwargs): - for k, v in kwargs.items(): - setattr(self, k, v) +from .formatting import MAX_LORA_MESSAGE_LENGTH, chunk_text, strip_markdown logger = logging.getLogger(__name__) -MAX_LORA_MESSAGE_LENGTH = 180 # Characters per packet to prevent LoRa airtime congestion - -_MARKDOWN_RULES = ( - (r"\*\*(.+?)\*\*", r"\1"), # bold - (r"__(.+?)__", r"\1"), - (r"\*(.+?)\*", r"\1"), # italic - (r"(? str: - """Convert markdown formatting into plain readable text suitable for radio.""" - if not text: - return "" - result = text - for pattern, replacement in _MARKDOWN_RULES: - result = re.sub(pattern, replacement, result, flags=re.MULTILINE) - # Collapse multiple blank lines - result = re.sub(r"\n{3,}", "\n\n", result) - return result.strip() +# --------------------------------------------------------------------------- +# Hermes gateway API — resolved lazily and cached (see module docstring). +# --------------------------------------------------------------------------- + +_gateway_api: Optional[Dict[str, Any]] = None -def chunk_text(text: str, limit: int = MAX_LORA_MESSAGE_LENGTH) -> List[str]: - """Split text into smaller chunks for LoRa transmission, breaking at whitespace/newlines.""" - if not text: - return [] - if len(text) <= limit: - return [text] +def _load_gateway_api() -> Dict[str, Any]: + """Import the real Hermes gateway types and bind them as module globals. - chunks: List[str] = [] - remaining = text - while remaining: - if len(remaining) <= limit: - chunks.append(remaining.strip()) - break - split_at = remaining.rfind(" ", 0, limit) - if split_at <= 0 or split_at < (limit // 3): - newline_at = remaining.rfind("\n", 0, limit) - if newline_at > 0: - split_at = newline_at - else: - split_at = limit - chunk = remaining[:split_at].strip() - if chunk: - chunks.append(chunk) - remaining = remaining[split_at:].lstrip() - return chunks + Called from ``get_adapter_class()`` at adapter-creation time (the gateway + runtime is fully loaded by then). Method bodies reference these names as + module globals, so they are injected into this module's namespace. + """ + global _gateway_api + if _gateway_api is None: + from gateway.config import Platform + from gateway.platforms.base import ( # type: ignore[import-not-found] + BasePlatformAdapter, + MessageEvent, + MessageType, + SendResult, + ) + + _gateway_api = { + "Platform": Platform, + "BasePlatformAdapter": BasePlatformAdapter, + "MessageEvent": MessageEvent, + "MessageType": MessageType, + "SendResult": SendResult, + } + globals().update(_gateway_api) + return _gateway_api + + +# --------------------------------------------------------------------------- +# Dependency probe (passive — never installs anything). +# --------------------------------------------------------------------------- def check_requirements() -> bool: """Check if meshtastic is installed.""" try: - import meshtastic - import meshtastic.tcp_interface + import meshtastic # noqa: F401 + import meshtastic.tcp_interface # noqa: F401 return True except ImportError: return False -class MeshtasticAdapter(BasePlatformAdapter): - """Hermes gateway adapter for Meshtastic LoRa radios over TCP.""" +# --------------------------------------------------------------------------- +# Adapter — class body defined once the real gateway API is available. +# --------------------------------------------------------------------------- - def __init__(self, config: Any): - super().__init__(config, platform="meshtastic") - self.extra = getattr(config, "extra", {}) or {} - self.host = self.extra.get("host", "meshtastic.local") - self.port = int(self.extra.get("port", 4403)) - self.channel_index = int(self.extra.get("channel_index", 1)) - self.allowed_nodes: List[str] = [ - n.strip() for n in self.extra.get("allowed_nodes", []) if n.strip() - ] - self._iface = None - self._loop: Optional[asyncio.AbstractEventLoop] = None - self._connected = False - self._running = False - self._task: Optional[asyncio.Task] = None +_adapter_class = None - async def connect(self) -> None: - """Connect to Meshtastic radio via TCP and start subscriber.""" - self._loop = asyncio.get_running_loop() - self._running = True - self._task = asyncio.create_task(self._connection_supervisor()) - async def _connection_supervisor(self) -> None: - backoff = 2.0 - while self._running: - try: - import meshtastic.tcp_interface - from pubsub import pub +def get_adapter_class(): + """Return the ``MeshtasticAdapter`` class bound to the real gateway base. - logger.info("[Meshtastic] Connecting to radio at %s:%s...", self.host, self.port) - # TCPInterface handles initial protobuf handshake synchronously - self._iface = await asyncio.to_thread( - meshtastic.tcp_interface.TCPInterface, - hostname=self.host, - portNumber=self.port, - noProto=False, - ) - self._connected = True - backoff = 2.0 - my_id = getattr(self._iface.myInfo, "my_node_num", "unknown") - logger.info("[Meshtastic] Connected to radio (my_node_num=%s, listening on ch=%d)", my_id, self.channel_index) + The class is defined (and cached) on first call so that ``BasePlatformAdapter`` + is the REAL Hermes class — a fallback stub at this point would reproduce the + ``set_message_handler`` AttributeError this design exists to prevent. + """ + global _adapter_class + if _adapter_class is None: + _load_gateway_api() - pub.subscribe(self._on_meshtastic_receive, "meshtastic.receive.text") + class MeshtasticAdapter(BasePlatformAdapter): + """Hermes gateway adapter for Meshtastic LoRa radios over TCP.""" - # Keep-alive loop - while self._running and self._connected: - await asyncio.sleep(5) - if not self._iface or not self._iface.isConnected: - break - - except asyncio.CancelledError: - break - except Exception as exc: - logger.warning("[Meshtastic] Radio connection failed (%s). Retrying in %.1fs...", exc, backoff) + def __init__(self, config: Any): + super().__init__(config=config, platform=Platform("meshtastic")) + self.extra = getattr(config, "extra", {}) or {} + self.host = self.extra.get("host", "meshtastic.local") + self.port = int(self.extra.get("port", 4403)) + self.channel_index = int(self.extra.get("channel_index", 1)) + self.allowed_nodes: List[str] = [ + n.strip() for n in self.extra.get("allowed_nodes", []) if n.strip() + ] + self._iface = None + self._loop: Optional[asyncio.AbstractEventLoop] = None self._connected = False - await asyncio.sleep(backoff) - backoff = min(backoff * 1.5, 60.0) + self._running = False + self._task: Optional[asyncio.Task] = None + self._connecting = False - def _on_meshtastic_receive(self, packet: Dict[str, Any], interface: Any) -> None: - """Handle incoming text packet from Meshtastic pubsub (runs in worker thread).""" - try: - ch = packet.get("channel", 0) - if self.channel_index is not None and ch != self.channel_index: - # Ignore packets from different channels (e.g. public channel 0 when listening on 1) - return + async def connect(self, *, is_reconnect: bool = False) -> bool: + """Establish the Meshtastic TCP session; return success. - from_id = packet.get("fromId") or f"!{packet.get('from', 0):08x}" - my_info = getattr(interface, "myInfo", None) - my_node_num = getattr(my_info, "my_node_num", 0) - if packet.get("from") == my_node_num: - # Prevent self-message loops - return + Mirrors the gateway contract ``async connect(*, is_reconnect=False) + -> bool`` (see tests/contract/). On initial failure the watchdog + keeps retrying with exponential backoff in the background. + """ + self._loop = asyncio.get_running_loop() + self._running = True + try: + ok = await self._try_connect() + except Exception as exc: + logger.warning( + "[Meshtastic] Initial connect failed (%s); watchdog will retry.", exc + ) + ok = False + self._connected = ok + self._ensure_watchdog() + return ok - if self.allowed_nodes and from_id not in self.allowed_nodes: - logger.debug("[Meshtastic] Ignoring message from unauthorized node: %s", from_id) - return + async def _try_connect(self) -> bool: + if self._connecting: + return False # single-flight: gateway + watchdog must not double-connect + self._connecting = True + try: + import meshtastic.tcp_interface + from pubsub import pub - decoded = packet.get("decoded", {}) - text = decoded.get("text", "").strip() - if not text: - return + logger.info( + "[Meshtastic] Connecting to radio at %s:%s (is_reconnect=%s)...", + self.host, self.port, getattr(self, "_last_reconnect", False), + ) + # TCPInterface performs the protobuf handshake synchronously. + self._iface = await asyncio.to_thread( + meshtastic.tcp_interface.TCPInterface, + hostname=self.host, + portNumber=self.port, + noProto=False, + ) + my_id = getattr(self._iface.myInfo, "my_node_num", "unknown") + logger.info( + "[Meshtastic] Connected to radio (my_node_num=%s, listening on ch=%d)", + my_id, self.channel_index, + ) + pub.subscribe(self._on_meshtastic_receive, "meshtastic.receive.text") + return True + finally: + self._connecting = False - # Determine sender and target - to_id = packet.get("toId") - is_dm = to_id != "^all" and packet.get("to") == my_node_num - chat_id = from_id if is_dm else f"channel_{ch}" - chat_type = "dm" if is_dm else "channel" + def _ensure_watchdog(self) -> None: + if self._task is None or self._task.done(): + self._task = asyncio.create_task(self._connection_watchdog()) - rx_snr = packet.get("rxSnr", 0.0) - logger.info( - "[Meshtastic] Inbound message from %s on ch=%s (is_dm=%s, SNR=%.1fdB): %s", - from_id, ch, is_dm, rx_snr, text - ) + async def _connection_watchdog(self) -> None: + """Keep the radio session alive; reconnect with backoff on drop.""" + backoff = 2.0 + while self._running: + await asyncio.sleep(5) + try: + iface = self._iface + if iface is not None and not iface.isConnected: + self._connected = False + except Exception: + self._connected = False + if self._connected: + backoff = 2.0 + continue + # Radio went away (or never came up) — reconnect. + self._last_reconnect = True + try: + ok = await self._try_connect() + self._connected = ok + backoff = 2.0 + except Exception as exc: + logger.warning( + "[Meshtastic] Radio reconnect failed (%s). Retrying in %.1fs...", + exc, backoff, + ) + self._connected = False + await asyncio.sleep(backoff) + backoff = min(backoff * 1.5, 60.0) - # Build Hermes SessionSource and MessageEvent - source = self.build_source( - chat_id=chat_id, - chat_name=f"Meshtastic {chat_id}", - chat_type=chat_type, - user_id=from_id, - user_name=from_id, - message_id=str(packet.get("id", int(time.time()))), - ) + def _on_meshtastic_receive(self, packet: Dict[str, Any], interface: Any) -> None: + """Handle incoming text packet from Meshtastic pubsub (worker thread).""" + try: + ch = packet.get("channel", 0) + if self.channel_index is not None and ch != self.channel_index: + # Ignore packets from different channels (e.g. public channel 0). + return - event = MessageEvent( - text=text, - message_type=MessageType.TEXT, - user_id=from_id, - user_name=from_id, - source=source, - raw_message=packet, - message_id=str(packet.get("id", int(time.time()))), - ) + from_id = packet.get("fromId") or f"!{packet.get('from', 0):08x}" + my_info = getattr(interface, "myInfo", None) + my_node_num = getattr(my_info, "my_node_num", 0) + if packet.get("from") == my_node_num: + # Prevent self-message loops. + return - if self._loop and self._loop.is_running(): - asyncio.run_coroutine_threadsafe(self.handle_message(event), self._loop) + if self.allowed_nodes and from_id not in self.allowed_nodes: + logger.debug( + "[Meshtastic] Ignoring message from unauthorized node: %s", from_id + ) + return - except Exception as exc: - logger.exception("[Meshtastic] Error processing received packet: %s", exc) + decoded = packet.get("decoded", {}) + text = decoded.get("text", "").strip() + if not text: + return - async def send( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: - """Send a message out over LoRa (implements BasePlatformAdapter.send).""" - if not self._connected or not self._iface: - return SendResult(success=False, error="Meshtastic radio not connected") + to_id = packet.get("toId") + is_dm = to_id != "^all" and packet.get("to") == my_node_num + chat_id = from_id if is_dm else f"channel_{ch}" + chat_type = "dm" if is_dm else "channel" - clean_text = strip_markdown(content) - chunks = chunk_text(clean_text, MAX_LORA_MESSAGE_LENGTH) + rx_snr = packet.get("rxSnr", 0.0) + logger.info( + "[Meshtastic] Inbound message from %s on ch=%s (is_dm=%s, SNR=%.1fdB): %s", + from_id, ch, is_dm, rx_snr, text, + ) - # Destination: if chat_id starts with '!', it is a direct node ID - destination = chat_id if chat_id.startswith("!") else "^all" - channel_index = self.channel_index if destination == "^all" else 0 + source = self.build_source( + chat_id=chat_id, + chat_name=f"Meshtastic {chat_id}", + chat_type=chat_type, + user_id=from_id, + user_name=from_id, + message_id=str(packet.get("id", int(time.time()))), + ) - last_id = None - try: - for idx, chunk in enumerate(chunks): - if len(chunks) > 1: - formatted_chunk = f"({idx+1}/{len(chunks)}) {chunk}" - else: - formatted_chunk = chunk + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + user_id=from_id, + user_name=from_id, + source=source, + raw_message=packet, + message_id=str(packet.get("id", int(time.time()))), + ) - logger.info( - "[Meshtastic] Sending chunk to %s on ch=%s: %s", - destination, channel_index, formatted_chunk - ) + if self._loop and self._loop.is_running(): + asyncio.run_coroutine_threadsafe( + self.handle_message(event), self._loop + ) + except Exception as exc: + logger.exception("[Meshtastic] Error processing received packet: %s", exc) - # Send via mesh interface thread - await asyncio.to_thread( - self._iface.sendText, - text=formatted_chunk, - destinationId=destination, - channelIndex=channel_index, - ) - last_id = str(int(time.time() * 1000)) - # Inter-packet delay to comply with LoRa duty cycle - if len(chunks) > 1: - await asyncio.sleep(1.2) + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a message out over LoRa (implements BasePlatformAdapter.send).""" + if not self._connected or not self._iface: + return SendResult(success=False, error="Meshtastic radio not connected") - return SendResult(success=True, message_id=last_id or str(int(time.time()))) - except Exception as exc: - logger.exception("[Meshtastic] Failed to send message: %s", exc) - return SendResult(success=False, error=str(exc)) + clean_text = strip_markdown(content) + chunks = chunk_text(clean_text, MAX_LORA_MESSAGE_LENGTH) - async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: - """Get chat/channel info (implements BasePlatformAdapter.get_chat_info).""" - is_dm = chat_id.startswith("!") - return { - "name": f"Node {chat_id}" if is_dm else f"Channel {chat_id}", - "type": "dm" if is_dm else "channel", - } + # Destination: if chat_id starts with '!', it is a direct node ID. + destination = chat_id if chat_id.startswith("!") else "^all" + channel_index = self.channel_index if destination == "^all" else 0 - async def disconnect(self) -> None: - """Disconnect and clean up resources.""" - self._running = False - if self._task: - self._task.cancel() - if self._iface: - try: - from pubsub import pub - pub.unsubscribe(self._on_meshtastic_receive, "meshtastic.receive.text") - except Exception: - pass - await asyncio.to_thread(self._iface.close) - self._connected = False - logger.info("[Meshtastic] Radio interface closed") + last_id = None + try: + for idx, chunk in enumerate(chunks): + if len(chunks) > 1: + formatted_chunk = f"({idx+1}/{len(chunks)}) {chunk}" + else: + formatted_chunk = chunk + + logger.info( + "[Meshtastic] Sending chunk to %s on ch=%s: %s", + destination, channel_index, formatted_chunk, + ) + await asyncio.to_thread( + self._iface.sendText, + text=formatted_chunk, + destinationId=destination, + channelIndex=channel_index, + ) + last_id = str(int(time.time() * 1000)) + # Inter-packet delay to comply with LoRa duty cycle. + if len(chunks) > 1: + await asyncio.sleep(1.2) + return SendResult(success=True, message_id=last_id or str(int(time.time()))) + except Exception as exc: + logger.exception("[Meshtastic] Failed to send message: %s", exc) + return SendResult(success=False, error=str(exc)) + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + """Get chat/channel info (implements BasePlatformAdapter.get_chat_info).""" + is_dm = chat_id.startswith("!") + return { + "name": f"Node {chat_id}" if is_dm else f"Channel {chat_id}", + "type": "dm" if is_dm else "channel", + } + + async def disconnect(self) -> None: + """Disconnect and clean up resources.""" + self._running = False + if self._task: + self._task.cancel() + if self._iface: + try: + from pubsub import pub + pub.unsubscribe( + self._on_meshtastic_receive, "meshtastic.receive.text" + ) + except Exception: + pass + await asyncio.to_thread(self._iface.close) + self._connected = False + logger.info("[Meshtastic] Radio interface closed") + + _adapter_class = MeshtasticAdapter + return _adapter_class diff --git a/plugin/hermes_meshtastic/formatting.py b/plugin/hermes_meshtastic/formatting.py new file mode 100644 index 0000000..9ae5330 --- /dev/null +++ b/plugin/hermes_meshtastic/formatting.py @@ -0,0 +1,61 @@ +"""Pure formatting utilities for LoRa-friendly text. + +These helpers have NO dependency on the Hermes gateway package so they can be +imported and unit-tested in isolation (CI runs them without hermes-agent). +""" + +import re + +MAX_LORA_MESSAGE_LENGTH = 180 # Characters per packet to prevent LoRa airtime congestion + +_MARKDOWN_RULES = ( + (r"\*\*(.+?)\*\*", r"\1"), # bold + (r"__(.+?)__", r"\1"), + (r"\*(.+?)\*", r"\1"), # italic + (r"(? str: + """Convert markdown formatting into plain readable text suitable for radio.""" + if not text: + return "" + result = text + for pattern, replacement in _MARKDOWN_RULES: + result = re.sub(pattern, replacement, result, flags=re.MULTILINE) + # Collapse multiple blank lines + result = re.sub(r"\n{3,}", "\n\n", result) + return result.strip() + + +def chunk_text(text: str, limit: int = MAX_LORA_MESSAGE_LENGTH) -> list: + """Split text into smaller chunks for LoRa transmission, breaking at whitespace/newlines.""" + if not text: + return [] + if len(text) <= limit: + return [text] + + chunks = [] + remaining = text + while remaining: + if len(remaining) <= limit: + chunks.append(remaining.strip()) + break + split_at = remaining.rfind(" ", 0, limit) + if split_at <= 0 or split_at < (limit // 3): + newline_at = remaining.rfind("\n", 0, limit) + if newline_at > 0: + split_at = newline_at + else: + split_at = limit + chunk = remaining[:split_at].strip() + if chunk: + chunks.append(chunk) + remaining = remaining[split_at:].lstrip() + return chunks diff --git a/plugin/pyproject.toml b/plugin/pyproject.toml index e5f95f4..b641911 100644 --- a/plugin/pyproject.toml +++ b/plugin/pyproject.toml @@ -23,3 +23,10 @@ meshtastic-platform = "hermes_meshtastic" [tool.hatch.build.targets.wheel] packages = ["hermes_meshtastic"] + +[tool.hatch.build.targets.sdist] +exclude = [ + ".venv-contract", + ".hermes-src", + "tests", +] diff --git a/plugin/tests/__pycache__/test_adapter.cpython-314-pytest-9.0.3.pyc b/plugin/tests/__pycache__/test_adapter.cpython-314-pytest-9.0.3.pyc deleted file mode 100644 index ef9af661cd0b278072ffe32a337a804bc4680bbe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7292 zcmeHM%}*Rh7O$SE`Gx_E6UQ*%9usFh6NfP$c8oBr6USMs-B?yfJH8-~+cO>Lq?vB2 z8)LJI#32WwWLF&Lwz>MYE9DQtd|EA}J?FLxIr+5jRd-kS5V8)DvQe_MdS1Qv>ecJ2 zS4F*F6>sJ{vjm>ME&l!E?_z}1Q3xK8`E&3t6szPuQKZ|%k$4%Ki7&;@ks0bF&S6dL zc5)`=q-N4i`W#W>XNZ$gDA25u0Gd;zAyP_ygYNE>Vs74cm~Pb;RQ5^3TyDDA`utM! zlbWMFcG`N70{b}p4!#0zl_G7 z+@*d+q$m=lqZ%KRl+Js_vDmn8p~NDsk`Z&CEs|<1__h*P=xjQ$xyz#&NM2baondHWy7KXVfQUo`&NllcFl@z3%2!)A5t{Xft5|E`n$ zpG0%zargfeOASxoo-o&@>t{ru1@IR+|mEtUIcq*UR#SrM zUT=!J3`1n$W^Y(e9L;uWDsy{kb=5FxOtV)>IcXH~$VFrfxQGF=jHixAu|A7F4IEJ+=(BsX9|dcY9l_7`QzTG{29V(S2U*A9~=5o~8>_t&->2+0~o- z`9kZ%t$cCy56^R58+34e`B`pokNWQx9b6mtq@9oz)w^gfq|G)-sDXi+=ehg_9ax`x zmK)fk{<}p7*2X+(CuBwSF4_xevrQ6eV4&vL?R|8EPW=NSGqp$kcZ*JKf!U^02QcI9r->XWIF^3{R>SwP{Qa*L>1`jYThc6A@+U;XR_wmCI9iQg zn}J>ay#N>Al6*vsYm7g4BbYbP0nX1?Dd7CBdqYv2uf&gq-HRN9hhI7pyN4(`K*>R0 z8~umc=Y#%*Wihe~2kik%!YM;Z9ILP1IpZaL4Wldi=$$jdc1QLxYzKXVQ^oAd?8Ea? z>ZP{vVB8hjeo|4=N~W4Pbbd+B_5{(3cs>>A!V=Ce$-r|^!ucgTDOoW%!`!UG$@WrT zXur5zC48-&4`Sn=D8sS-&`OKJO6$I7MOGS(^zG<~rh+~xNhS9)%(OTy2cz*%l;LQO z?w4o>Go3qhqD(6tN@q2FXht%}n2}5{>Y(&ygfS%xh@xku_rrM;QvIJyMU^29+3K7& z{8VxihQ6RXnE5ZeaYJje1k9V8ngKuTG55&74VoCOk1;8&tTTLOPOn$0KJYuFl>FoT_j*mxP?C3mE( z0HXT}&(Yz1OU+&Y%)%;Ahx2cDcv(6pufDh6d8S3by1CtXarOQETz>7*7n2*bzg6Vq zS+0MN`tLUFUz-$?V7`Pj>sxHn{)i;h;A!oVkiaOAc0-Tgyj2u7Q3mXv=X%zQf1TK% zg%*95E9_DK-J*rH2~XMySy8=<_CnfhlY|5uOak|Be{PvVJtCTqw{hCf(#9tqEk zn6kZmhxwXaK=K9>j3Aun>nksKyZ=i9R}a!Oz>CU{gCx@&r*K5_B6j^4SmwIGG7NSD zQc53!A?!_@kxNM4LNbhG1c;6B31J039<(@p7+M%+bB^IZKM>~Fw|!)B<_C~PU#n+> z0{Q&+d$iAcw`m^&3ttipLJ42DN&6y_P=h0c5}{Eb?S>w~d8X|p-`Tq+?RTA8 zANk_ZM!L}I<>d2E_tFLL-A)(4XCMjYOIxs;=|V&jYH+kZA|x;hq}|XXIB)d|n<%rB zE*!*3{xpXrr4Bb8TqtpP>7K25w^761H4xko4Kr<-=?>gP36-1jbsU_!XXiBr(7G1h z@(lZTI{b#q!?Jjb!$BgS&!>cc-<$UI^#!xBWN6cD5{zJyiH%6}-*HKj{zb@VWRKkV UHkp#9zdcQ*f$#DOX@Ix>4=B)YR{#J2 diff --git a/plugin/tests/contract/test_gateway_contract.py b/plugin/tests/contract/test_gateway_contract.py new file mode 100644 index 0000000..af43967 --- /dev/null +++ b/plugin/tests/contract/test_gateway_contract.py @@ -0,0 +1,230 @@ +"""Contract tests: hermes-meshtastic vs the REAL Hermes gateway API. + +These tests exist so adapter development converges WITHOUT deploying: they +replay the gateway's adapter bootstrap against the actual ``hermes-agent`` +package (pinned to the deployed image tag, see ``scripts/contract-test.sh``) +and fail loudly on any API drift that used to surface only as a gateway +crash after release. + +Runtime ordering modelled here (verified against ``gateway/run.py`` and +``gateway/config.py``): + 1. plugin discovery runs the plugin's ``register(ctx)`` (registers the + platform in ``gateway.platform_registry``), + 2. the gateway later instantiates the adapter via the registry entry and + wires it: ``set_message_handler`` -> ... -> ``connect()``, + 3. ``Platform("meshtastic")`` (auto-created enum member) is only valid + AFTER registration — the enum's ``_missing_`` queries the registry. + +Layers covered +-------------- +L0 The adapter subclasses the REAL ``gateway.platforms.base.BasePlatformAdapter`` + (regression for the fallback-stub bug that caused the + ``AttributeError: ... 'set_message_handler'`` crash at ``gateway/run.py``). +L0b ABC instantiation gate — construction with a real ``PlatformConfig`` fails + if any abstract member is missing or the platform enum is misused. +L1 Wiring replay — every ``set_*`` call ``gateway/run.py`` makes at startup + must exist and accept a handler. +L2 Signature conformance — ``connect``/``send`` match the gateway contract. +L3 Event shape — the inbound ``MessageEvent``/``SessionSource`` construction + must be valid against the real gateway types. + +Skip policy: skipped when ``hermes-agent`` is not installed so the standalone +unit suite (CI without Hermes) stays green. Set ``HERMES_CONTRACT_REQUIRED=1`` +to turn absence into a hard failure — the dedicated contract CI job sets this +so the gate can never silently skip. +""" + +import inspect +import os + +import pytest + +try: + from gateway.config import Platform, PlatformConfig + from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType +except ImportError: # pragma: no cover - exercised when hermes-agent is absent + if os.environ.get("HERMES_CONTRACT_REQUIRED"): + raise AssertionError( + "hermes-agent is required for contract tests (HERMES_CONTRACT_REQUIRED=1). " + "Run `scripts/contract-test.sh` or the CI contract job." + ) + pytest.skip( + "hermes-agent not installed — contract tests skipped (scripts/contract-test.sh)", + allow_module_level=True, + ) + +import hermes_meshtastic # noqa: F401,E402 (package must import without gateway side effects) +from hermes_meshtastic import adapter as plugin_adapter # noqa: E402 + +MINIMAL_CONFIG = PlatformConfig( + enabled=True, + extra={"host": "127.0.0.1", "port": 1, "channel_index": 1}, +) + +# Exactly the set_* wiring gateway/run.py performs right before connect() +# (observed at the crash site: adapter.set_message_handler(...) etc.). +GATEWAY_WIRING_SETTERS = [ + "set_message_handler", + "set_fatal_error_handler", + "set_session_store", + "set_busy_session_handler", + "set_topic_recovery_fn", + "set_authorization_check", + "set_platform_event_handler", + "set_reaction_handler", +] + +_REGISTER_KWARGS = {} + + +class _Ctx: + """Minimal ctx mirroring how discovery calls the plugin's register().""" + + def register_platform(self, **kwargs): + _REGISTER_KWARGS.update(kwargs) + + +def _noop(*_args, **_kwargs): + return None + + +@pytest.fixture(scope="module", autouse=True) +def registered_entry(): + """Replay discovery: run register(), then register the PlatformEntry. + + Mirrors how gateway/run.py later instantiates the adapter from the + registry — and why Platform("meshtastic") is valid only after this. + """ + from gateway.platform_registry import PlatformEntry, platform_registry + + hermes_meshtastic.register(_Ctx()) + kw = dict(_REGISTER_KWARGS) + assert kw.get("name") == "meshtastic" + + if not platform_registry.is_registered("meshtastic"): + entry = PlatformEntry( + name=kw["name"], + label=kw["label"], + adapter_factory=kw["adapter_factory"], + check_fn=kw["check_fn"], + required_env=[], + max_message_length=kw.get("max_message_length", 0), + emoji=kw.get("emoji", "🔌"), + platform_hint=kw.get("platform_hint", ""), + ) + platform_registry.register(entry) + return platform_registry.get("meshtastic") + + +def _make_adapter(entry=None): + from gateway.platform_registry import platform_registry + + if entry is None: + entry = platform_registry.get("meshtastic") + assert entry is not None, "meshtastic platform not registered (fixture order broken)" + return entry.adapter_factory(MINIMAL_CONFIG) + + +# --- L0: real base binding ------------------------------------------------- + + +def test_adapter_subclasses_real_gateway_base(): + """Regression: adapter must bind the real base, never a local stub.""" + cls = plugin_adapter.get_adapter_class() + assert inspect.isclass(cls) + assert issubclass(cls, BasePlatformAdapter) + assert cls.__mro__[1].__module__ == "gateway.platforms.base" + + +def test_abstract_members_are_implemented(): + """ABC gate: BasePlatformAdapter requires connect/send/disconnect/get_chat_info.""" + cls = plugin_adapter.get_adapter_class() + for member in BasePlatformAdapter.__abstractmethods__: + impl = getattr(cls, member, None) + assert callable(impl), f"adapter missing abstract member: {member}" + + +# --- L0b: instantiation against the real contract -------------------------- + + +def test_adapter_instantiates_with_real_platform_config(registered_entry): + adapter = _make_adapter(registered_entry) + assert isinstance(adapter, BasePlatformAdapter) + # super().__init__ must receive the Platform enum (auto-created member), + # not a bare string — the gateway calls Platform methods on adapter.platform. + assert isinstance(adapter.platform, Platform) + assert adapter.platform.value == "meshtastic" + + +def test_registered_entry_carries_radio_limits(registered_entry): + assert registered_entry.max_message_length == plugin_adapter.MAX_LORA_MESSAGE_LENGTH + assert registered_entry.emoji == "📻" + + +# --- L1: gateway wiring replay --------------------------------------------- + + +def test_adapter_survives_gateway_wiring(registered_entry): + """Replay run.py's startup wiring; any missing setter crashes the gateway.""" + adapter = _make_adapter(registered_entry) + for setter in GATEWAY_WIRING_SETTERS: + fn = getattr(adapter, setter, None) + assert callable(fn), ( + f"adapter missing callable '{setter}' — gateway startup crashes here " + "(the original set_message_handler AttributeError)" + ) + fn(_noop) + # run.py also pokes these attributes directly after wiring. + adapter._busy_text_mode = False + + +# --- L2: signature conformance ---------------------------------------------- + + +def test_connect_signature_matches_gateway_contract(): + cls = plugin_adapter.get_adapter_class() + sig = inspect.signature(cls.connect) + assert inspect.iscoroutinefunction(cls.connect) + assert "is_reconnect" in sig.parameters, "connect() must accept is_reconnect kwarg" + param = sig.parameters["is_reconnect"] + assert param.kind == inspect.Parameter.KEYWORD_ONLY + assert param.default is False + + +def test_send_signature_matches_gateway_contract(): + base_sig = inspect.signature(BasePlatformAdapter.send) + cls = plugin_adapter.get_adapter_class() + sig = inspect.signature(cls.send) + base_params = [p for p in base_sig.parameters if p != "self"] + own_params = [p for p in sig.parameters if p != "self"] + assert own_params == base_params, ( + f"send() params {own_params} drift from gateway contract {base_params}" + ) + assert inspect.iscoroutinefunction(cls.send) + + +# --- L3: inbound event shape against real types ----------------------------- + + +def test_inbound_event_construction_is_valid(registered_entry): + """The exact source/event the adapter builds on receive must type-check.""" + adapter = _make_adapter(registered_entry) + source = adapter.build_source( + chat_id="!abcd1234", + chat_name="Meshtastic !abcd1234", + chat_type="dm", + user_id="!abcd1234", + user_name="!abcd1234", + message_id="42", + ) + event = MessageEvent( + text="hello over LoRa", + message_type=MessageType.TEXT, + user_id="!abcd1234", + user_name="!abcd1234", + source=source, + raw_message={"id": 42}, + message_id="42", + ) + assert event.source.platform.value == "meshtastic" + assert event.text == "hello over LoRa" diff --git a/scripts/contract-test.sh b/scripts/contract-test.sh new file mode 100755 index 0000000..d11d78d --- /dev/null +++ b/scripts/contract-test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Gateway contract tests for hermes-meshtastic. +# +# Runs the plugin's contract suite (tests/contract/) against the REAL Hermes +# gateway API, pinned to the same upstream commit the deployed container image +# is built from (NousResearch/hermes-agent @ v2026.8.31 — the image tag used in +# k8s-flux apps/base/coder/hermes-agent). +# +# Why: the adapter used to be developed by guessing the gateway API and only +# failing at gateway startup after a release (AttributeError: ... no attribute +# 'set_message_handler'). These tests replay the gateway's adapter bootstrap +# (registry registration -> factory -> set_* wiring) so API drift fails here, +# in seconds, without deploying. +# +# Requires: uv (https://docs.astral.sh/uv/) and network on first run. +# +# Usage: scripts/contract-test.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PLUGIN_DIR="$REPO_ROOT/plugin" +VENV="$PLUGIN_DIR/.venv-contract" +HERMES_SRC="$PLUGIN_DIR/.hermes-src" +HERMES_TAG="${HERMES_TAG:-v2026.8.31}" # must match k8s-flux hermes image tag +HERMES_COMMIT="${HERMES_COMMIT:-29112bef099274229cadff79cdff7bf7b99c4b77}" + +if ! command -v uv >/dev/null 2>&1; then + echo "error: uv is required (https://docs.astral.sh/uv/)" >&2 + exit 1 +fi + +if [ ! -d "$HERMES_SRC/.git" ]; then + echo "== cloning hermes-agent @ $HERMES_TAG (deployed image source) ==" + git clone --depth 1 --branch "$HERMES_TAG" \ + https://github.com/NousResearch/hermes-agent.git "$HERMES_SRC" +fi +actual="$(git -C "$HERMES_SRC" rev-parse HEAD)" +if [ "$actual" != "$HERMES_COMMIT" ]; then + echo "error: $HERMES_SRC is at $actual, expected $HERMES_COMMIT ($HERMES_TAG)." >&2 + echo " Refresh deliberately: HERMES_TAG= scripts/contract-test.sh" >&2 + exit 1 +fi + +if [ ! -x "$VENV/bin/python" ]; then + echo "== creating contract venv (python 3.13, matching the Hermes image) ==" + uv venv --python 3.13 "$VENV" +fi +uv pip install --python "$VENV/bin/python" -q -e "$HERMES_SRC" +uv pip install --python "$VENV/bin/python" -q pytest + +echo "== running contract tests (HERMES_CONTRACT_REQUIRED=1) ==" +( + cd "$PLUGIN_DIR" + HERMES_CONTRACT_REQUIRED=1 "$VENV/bin/python" -m pytest tests/contract -q "$@" +) + +echo "== running standalone unit tests (no gateway needed) ==" +( + cd "$PLUGIN_DIR" + "$VENV/bin/python" -m pytest tests/test_adapter.py -q +)