From d7162ccfaed882e146efb510f6d3e0151f4c1d5b Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Mon, 7 Sep 2026 19:24:10 -0700 Subject: [PATCH] feat: add hermes-meshtastic platform plugin and Gitea Actions release workflow --- .gitea/workflows/release.yaml | 61 ++++ .gitignore | 1 + plugin/README.md | 12 + plugin/hermes_meshtastic/__init__.py | 22 ++ .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 1164 bytes .../__pycache__/adapter.cpython-314.pyc | Bin 0 -> 16279 bytes plugin/hermes_meshtastic/adapter.py | 281 ++++++++++++++++++ plugin/pyproject.toml | 25 ++ .../test_adapter.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 7292 bytes plugin/tests/test_adapter.py | 26 ++ 10 files changed, 428 insertions(+) create mode 100644 .gitea/workflows/release.yaml create mode 100644 plugin/README.md create mode 100644 plugin/hermes_meshtastic/__init__.py create mode 100644 plugin/hermes_meshtastic/__pycache__/__init__.cpython-314.pyc create mode 100644 plugin/hermes_meshtastic/__pycache__/adapter.cpython-314.pyc create mode 100644 plugin/hermes_meshtastic/adapter.py create mode 100644 plugin/pyproject.toml create mode 100644 plugin/tests/__pycache__/test_adapter.cpython-314-pytest-9.0.3.pyc create mode 100644 plugin/tests/test_adapter.py 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 0000000000000000000000000000000000000000..87d45159ff254468dca56229c2b0b88f1517c674 GIT binary patch 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..baeb1e1cc167d2f734b27fa3b5c13ccb55c4213b GIT binary patch literal 16279 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 Xk~!cn?VTR(El>L)m-_8G9{&FWN2>1t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ef9af661cd0b278072ffe32a337a804bc4680bbe GIT binary patch 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 literal 0 HcmV?d00001 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