Includes: - Backend services: ingestion (:8001), weather API (:8002), gateway (:8003), billing (:8004) with BTCPay integration - Shared asyncpg pool, TimescaleDB hypertable, Redis, Mosquitto MQTT - React frontend: Dashboard (MapLibre) and Messaging (chat UI) - Bridge daemon for Pi + Meshtastic (Serial/TCP T-Deck support) - Production Docker Compose, Nginx reverse proxy, ops scripts - DEPLOY.md with step-by-step deployment guide
194 lines
7.4 KiB
Python
194 lines
7.4 KiB
Python
#!/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()
|