feat: initial KosmoConnect platform v0.1
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
This commit is contained in:
193
firmware/infrastructure-node/bridge-daemon/src/main.py
Normal file
193
firmware/infrastructure-node/bridge-daemon/src/main.py
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user