#!/usr/bin/env python3 """ KosmoConnect Bridge Daemon Runs on a Raspberry Pi (or similar) connected to a Meshtastic device via USB. Bridges the local mesh to the cloud MQTT broker: - Mesh -> MQTT: forwards enviro data and general mesh messages - MQTT -> Mesh: injects outbound messages from the cloud gateway """ import json import logging import os import sys import time import uuid from datetime import datetime, timezone # Add src to path when running directly sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from meshtastic_client import MeshtasticClient from mqtt_client import MqttClient import config # noqa: F401 logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger("bridge") class BridgeDaemon: def __init__(self): self.mesh = MeshtasticClient(self._on_mesh_packet) self.mqtt = MqttClient(self._on_mqtt_message) def _extract_node_id(self, packet: dict) -> str: """Best-effort extraction of source node ID from a mesh packet.""" from_id = packet.get("fromId") if from_id: return from_id from_num = packet.get("from") if from_num is not None: return f"!{from_num:08x}" return "unknown" def _extract_hop_count(self, packet: dict) -> int: rx_snr = packet.get("rxSnr") # hopLimit can indicate hops remaining; approximate hop count hop_limit = packet.get("hopLimit", 7) # If hopStart is present, hop_count = hopStart - hopLimit hop_start = packet.get("hopStart", 7) return max(0, hop_start - hop_limit) def _extract_rssi_snr(self, packet: dict): return packet.get("rxRssi"), packet.get("rxSnr") def _on_mesh_packet(self, packet: dict): logger.debug("Mesh packet received: %s", packet) decoded = packet.get("decoded", {}) portnum = decoded.get("portnum") payload = decoded.get("payload", b"") # Convert bytes payload to string if needed if isinstance(payload, bytes): try: payload_str = payload.decode("utf-8") except UnicodeDecodeError: payload_str = None else: payload_str = str(payload) if payload else None # --------------------------------------------------------- # 1. Custom enviro packets (future firmware path) # --------------------------------------------------------- # TODO: when custom firmware is ready, match against a custom portnum # if portnum == "KOSMO_ENVIRO_APP": # self._forward_enviro(packet, payload) # return # --------------------------------------------------------- # 2. Text fallback that happens to be JSON enviro data # (useful for testing with generic Meshtastic devices) # --------------------------------------------------------- if portnum == "TEXT_MESSAGE_APP" and payload_str: stripped = payload_str.strip() if stripped.startswith('{"type": "enviro_reading"') or stripped.startswith("{\"type\":\"enviro_reading\""): try: enviro = json.loads(stripped) enviro["received_at"] = datetime.now(timezone.utc).isoformat() enviro.setdefault("node_id", self._extract_node_id(packet)) enviro.setdefault("hop_count", self._extract_hop_count(packet)) self.mqtt.publish(config.MQTT_TOPIC_INGEST, enviro) logger.info("Forwarded enviro JSON from text packet for %s", enviro.get("node_id")) return except json.JSONDecodeError: pass # General mesh text message -> inbound gateway gateway_id = config.GATEWAY_NODE_ID if not gateway_id and self.mesh.iface and hasattr(self.mesh.iface, 'myInfo'): my_num = getattr(self.mesh.iface.myInfo, 'my_node_num', None) if my_num is not None: gateway_id = f"!{my_num:08x}" inbound = { "message_id": str(uuid.uuid4()), "source_node_id": self._extract_node_id(packet), "gateway_node_id": gateway_id or "", "text": stripped, "hop_count": self._extract_hop_count(packet), "rssi": self._extract_rssi_snr(packet)[0], "snr": self._extract_rssi_snr(packet)[1], "received_at": datetime.now(timezone.utc).isoformat(), } self.mqtt.publish(config.MQTT_TOPIC_INBOUND, inbound) logger.info("Forwarded inbound text from %s", inbound["source_node_id"]) return # --------------------------------------------------------- # 3. Position packets -> could update node location in cloud # --------------------------------------------------------- if portnum == "POSITION_APP": pos = decoded.get("position", {}) if pos.get("latitude") and pos.get("longitude"): update = { "type": "position_update", "node_id": self._extract_node_id(packet), "lat": pos["latitude"], "lon": pos["longitude"], "altitude": pos.get("altitude"), "received_at": datetime.now(timezone.utc).isoformat(), } # Publish to a dedicated topic or reuse ingest with a different type # For now, we publish to ingest topic so the backend can optionally handle it self.mqtt.publish(config.MQTT_TOPIC_INGEST.replace("enviro", "position"), update) logger.info("Forwarded position update from %s", update["node_id"]) return logger.debug("Ignored packet with portnum=%s", portnum) def _on_mqtt_message(self, topic: str, payload: str): logger.debug("MQTT message on %s: %s", topic, payload) prefix = config.MQTT_TOPIC_OUTBOUND_PREFIX + "/" if not topic.startswith(prefix): return destination_id = topic[len(prefix):] if not destination_id: logger.warning("Outbound MQTT topic missing destination node ID: %s", topic) return try: data = json.loads(payload) except json.JSONDecodeError: logger.error("Invalid JSON in outbound MQTT message on %s", topic) return text = data.get("text", "") if not text: logger.warning("Empty text in outbound MQTT message on %s", topic) return # Tag messages originating from the gateway so mesh users know it's from the web tagged_text = f"[Web] {text}" success = self.mesh.send_text(tagged_text, destination_id=destination_id) if success: logger.info("Injected outbound message to %s", destination_id) else: logger.error("Failed to inject outbound message to %s", destination_id) def run(self): logger.info("Starting KosmoConnect Bridge Daemon") self.mesh.start() self.mqtt.start() try: while True: time.sleep(1) except KeyboardInterrupt: logger.info("Shutting down...") finally: self.mesh.stop() self.mqtt.stop() def main(): daemon = BridgeDaemon() daemon.run() if __name__ == "__main__": main()