feat: initial KosmoConnect platform v0.1
Some checks failed
CI / lint-docs (push) Has been cancelled
CI / build-firmware (push) Has been cancelled
CI / test-backend (push) Has been cancelled
CI / test-web (push) Has been cancelled

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:
2026-04-12 17:30:15 +02:00
commit 0a4fb7b55e
95 changed files with 9903 additions and 0 deletions

View 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()