diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..9c780bf --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,61 @@ +name: release + +on: + push: + branches: + - main + tags: + - 'v*' + +jobs: + test-and-release: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies & build tool + run: | + pip install uv pytest + + - name: Run unit tests + run: | + PYTHONPATH=plugin pytest plugin/tests + + - name: Build wheel and sdist + run: | + uv build plugin/ + + - name: Publish Gitea Release (on tag or main) + if: startsWith(github.ref, 'refs/tags/v') + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }} + run: | + tag_name="${GITHUB_REF#refs/tags/}" + echo "Publishing release for $tag_name" + # Upload via Gitea Release API if curl & jq present + 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)" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$wheel_file" + curl -s -k -X POST "${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$release_id/assets?name=$(basename $tar_file)" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@$tar_file" + fi diff --git a/.gitignore b/.gitignore index ca8d7ed..b240a97 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.tar .pio/ *.log +plugin/dist/ diff --git a/plugin/README.md b/plugin/README.md new file mode 100644 index 0000000..ee490da --- /dev/null +++ b/plugin/README.md @@ -0,0 +1,12 @@ +# hermes-meshtastic + +Meshtastic LoRa mesh gateway platform adapter plugin for Hermes Agent. + +Allows bidirectional communication between Hermes AI agent profiles and off-grid Meshtastic nodes (e.g., Heltec V4, T-Beam, LilyGO) over 915 MHz LoRa mesh radio. + +## Features +- Connects directly to local Meshtastic radio via TCP (e.g. `meshtastic.local:4403`). +- Subscribes to LoRa text messages on designated channel index (e.g. Channel 1 `# private` with AES-256). +- Direct Message (1-on-1) routing between Meshtastic Node IDs and Hermes agent sessions. +- Automatic message chunking and markdown stripping to fit within LoRa RF packet budgets (<180 chars). +- Automatic reconnection with exponential backoff. diff --git a/plugin/hermes_meshtastic/__init__.py b/plugin/hermes_meshtastic/__init__.py new file mode 100644 index 0000000..3492fab --- /dev/null +++ b/plugin/hermes_meshtastic/__init__.py @@ -0,0 +1,22 @@ +"""Meshtastic Platform Adapter plugin for Hermes Agent.""" + +from .adapter import MeshtasticAdapter, check_requirements, MAX_LORA_MESSAGE_LENGTH + + +def register(ctx): + """Hermes plugin entrypoint.""" + from gateway.platform_registry import PlatformEntry + + ctx.register_platform( + name="meshtastic", + label="Meshtastic LoRa", + adapter_factory=lambda config: MeshtasticAdapter(config), + check_fn=check_requirements, + max_message_length=MAX_LORA_MESSAGE_LENGTH, + emoji="📻", + platform_hint=( + "You are chatting over Meshtastic LoRa radio. LoRa bandwidth is very low. " + "Respond in concise plain text without markdown, bold, tables, or lists. " + "Keep answers strictly under 140 characters whenever possible." + ), + ) diff --git a/plugin/hermes_meshtastic/__pycache__/__init__.cpython-314.pyc b/plugin/hermes_meshtastic/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..87d4515 Binary files /dev/null and b/plugin/hermes_meshtastic/__pycache__/__init__.cpython-314.pyc differ diff --git a/plugin/hermes_meshtastic/__pycache__/adapter.cpython-314.pyc b/plugin/hermes_meshtastic/__pycache__/adapter.cpython-314.pyc new file mode 100644 index 0000000..baeb1e1 Binary files /dev/null and b/plugin/hermes_meshtastic/__pycache__/adapter.cpython-314.pyc differ diff --git a/plugin/hermes_meshtastic/adapter.py b/plugin/hermes_meshtastic/adapter.py new file mode 100644 index 0000000..f902b60 --- /dev/null +++ b/plugin/hermes_meshtastic/adapter.py @@ -0,0 +1,281 @@ +"""Meshtastic LoRa platform adapter for Hermes Agent. + +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. +""" + +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, SendResult + from gateway.platforms.event import MessageEvent, MessageType +except ImportError: + # Allow importing and testing utility functions without gateway in environment + class BasePlatformAdapter: # type: ignore[no-redef] + def __init__(self, config: Any): + self.config = config + + 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) + +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() + + +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] + + 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 + + +def check_requirements() -> bool: + """Check if meshtastic is installed.""" + try: + import meshtastic + import meshtastic.tcp_interface + return True + except ImportError: + return False + + +class MeshtasticAdapter(BasePlatformAdapter): + """Hermes gateway adapter for Meshtastic LoRa radios over TCP.""" + + def __init__(self, config: Any): + super().__init__(config) + 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 + + 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 + + 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) + + pub.subscribe(self._on_meshtastic_receive, "meshtastic.receive.text") + + # 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) + self._connected = False + await asyncio.sleep(backoff) + backoff = min(backoff * 1.5, 60.0) + + 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 + + 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.allowed_nodes and from_id not in self.allowed_nodes: + logger.debug("[Meshtastic] Ignoring message from unauthorized node: %s", from_id) + return + + decoded = packet.get("decoded", {}) + text = decoded.get("text", "").strip() + if not text: + return + + # 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}" + + rx_snr = packet.get("rxSnr", 0.0) + hop_limit = packet.get("hopLimit", 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 + ) + + # Build Hermes MessageEvent + event = MessageEvent( + platform="meshtastic", + message_type=MessageType.TEXT, + text=text, + sender_id=from_id, + sender_name=from_id, + chat_id=chat_id, + message_id=str(packet.get("id", int(time.time()))), + raw_event=packet, + ) + + 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) + + async def send_message( + self, + chat_id: str, + message: str, + reply_to_id: Optional[str] = None, + **kwargs: Any, + ) -> SendResult: + """Send a message out over LoRa.""" + if not self._connected or not self._iface: + return SendResult(success=False, error="Meshtastic radio not connected") + + clean_text = strip_markdown(message) + chunks = chunk_text(clean_text, MAX_LORA_MESSAGE_LENGTH) + + # 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 + + 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 + ) + + # 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) + + 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 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") diff --git a/plugin/pyproject.toml b/plugin/pyproject.toml new file mode 100644 index 0000000..cac8707 --- /dev/null +++ b/plugin/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hermes-meshtastic" +version = "0.1.0" +description = "Meshtastic LoRa mesh gateway adapter plugin for Hermes Agent" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +authors = [ + { name = "Eric Liu", email = "eric@ericxliu.me" }, +] +dependencies = [ + "meshtastic>=2.7.11", + "pypubsub>=4.0.7", + "protobuf>=4.21.0", +] + +[project.entry-points."hermes_agent.plugins"] +meshtastic-platform = "hermes_meshtastic:register" + +[tool.hatch.build.targets.wheel] +packages = ["hermes_meshtastic"] 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 new file mode 100644 index 0000000..ef9af66 Binary files /dev/null and b/plugin/tests/__pycache__/test_adapter.cpython-314-pytest-9.0.3.pyc differ diff --git a/plugin/tests/test_adapter.py b/plugin/tests/test_adapter.py new file mode 100644 index 0000000..a9f18b8 --- /dev/null +++ b/plugin/tests/test_adapter.py @@ -0,0 +1,26 @@ +from hermes_meshtastic.adapter import strip_markdown, chunk_text + + +def test_strip_markdown(): + raw = "# Heading\n**bold text** and *italic* with `inline code`\n- bullet 1\n- bullet 2" + cleaned = strip_markdown(raw) + assert "**" not in cleaned + assert "*" not in cleaned + assert "`" not in cleaned + assert "#" not in cleaned + assert "bold text and italic with inline code" in cleaned + + +def test_chunk_text_small(): + text = "Short LoRa message." + chunks = chunk_text(text, limit=180) + assert len(chunks) == 1 + assert chunks[0] == text + + +def test_chunk_text_long(): + text = "Word " * 50 # 250 characters + chunks = chunk_text(text, limit=100) + assert len(chunks) > 1 + for c in chunks: + assert len(c) <= 100