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

95
firmware/README.md Normal file
View File

@@ -0,0 +1,95 @@
# Firmware
This directory contains all embedded software for KosmoConnect edge devices.
## Structure
```
firmware/
├── enviro-node/ # Firmware for solar-powered monitoring stations
│ ├── src/ # Main application source
│ ├── lib/ # Internal libraries
│ ├── meshtastic-patch/ # Patches or modules for Meshtastic firmware
│ └── tests/ # Unit tests (native/emu)
├── infrastructure-node/ # Software for bridge devices
│ ├── bridge-daemon/ # Python daemon running on bridge host
│ └── firmware/ # Any custom bridge device firmware
└── shared-libs/ # Libraries shared across both nodes
├── packet-format/ # Binary serialization for enviro packets
└── power-manager/ # Common power management utilities
```
## Enviro-Node Firmware
### Strategy
There are two architectural approaches:
#### A. Meshtastic Fork with Custom Module
Fork the official Meshtastic firmware and add a `kosmo_enviro` module that:
- Runs on the secondary CPU core or as a low-priority thread
- Interfaces with I2C/SPI sensors
- Manages the local data buffer
- Formats and injects data packets into the mesh router
**Pros**: Tight integration, single binary, leverages mature mesh stack
**Cons**: Build complexity, upstream sync overhead, limited to supported chipsets
#### B. Companion MCU Architecture
Use a dedicated sensor MCU (e.g., ESP32-S3 or STM32L4) that talks to a Meshtastic module (e.g., RAK4631 or T-Beam) via UART.
**Pros**: Complete isolation of concerns, easier sensor debugging, can use any MCU
**Cons**: More hardware, more power draw, inter-board communication complexity
**Decision**: Start with **Approach A** (Meshtastic fork with custom module) on ESP32-S3. This keeps the kit BOM simple and the software stack unified.
### Key Modules
1. **Sensor Manager**
- Abstracts BME680, SPS30, anemometer
- Handles sensor warmup, error recovery, calibration
2. **Data Logger**
- Ring buffer in SPIFFS / LittleFS on flash
- CRC-protected records
- Wear leveling
3. **Mesh Injector**
- Formats `kosmo_enviro_packet_t` into a Meshtastic `Data` payload
- Schedules transmissions during low-congestion windows
- Respects duty cycle limits
4. **Power Manager**
- Deep sleep orchestration
- Dynamic interval scaling based on battery voltage
- Solar charging state monitoring
5. **Config Manager**
- Persistent settings (intervals, sensor enable flags, channel keys)
- Remote config via Meshtastic admin messages
## Infrastructure Node Software
### Bridge Daemon
A Python daemon (`infrastructure-node/bridge-daemon/`) that runs on a Linux host (Raspberry Pi, etc.) connected to a Meshtastic device via USB/serial.
**Responsibilities**:
- Connect to Meshtastic device via `meshtastic` Python API
- Listen for environmental data packets and publish to cloud MQTT
- Subscribe to cloud MQTT topics and inject messages into the mesh
- Monitor device health and report bridge status
- Support multiple backhaul transports (WiFi, Ethernet, LTE)
See [`infrastructure-node/bridge-daemon/README.md`](./infrastructure-node/bridge-daemon/README.md) for full setup instructions and RPi installation guide.
### Runtime
- Python 3.10+
- `meshtastic` library
- `paho-mqtt`
- `systemd` service file for auto-start
## Build System
- **Enviro-Node**: PlatformIO with custom board definition
- **Bridge Daemon**: Poetry or `pip` with `requirements.txt`
- **CI**: GitHub Actions for firmware builds, flash size checks, and unit tests

View File

@@ -0,0 +1,43 @@
# Infrastructure Node
This directory contains the software for bridge nodes that connect the Meshtastic mesh to the internet.
## Components
### `bridge-daemon/`
The **KosmoConnect Bridge Daemon** is a Python service that runs on a Raspberry Pi (or any Linux host) connected to a Meshtastic device via USB.
**What it does:**
- Receives mesh packets and publishes them to the cloud MQTT broker
- Detects environmental data (JSON enviro packets) and routes them to `kosmo/ingest/enviro`
- Subscribes to `kosmo/mesh/outbound/#` and injects web messages back into the mesh
- Runs as a `systemd` service with auto-restart
See [`bridge-daemon/README.md`](./bridge-daemon/README.md) for setup and RPi installation instructions.
### `firmware/`
Placeholder for any custom firmware required specifically for the bridge radio (usually not needed; standard Meshtastic firmware works).
## Recommended Bridge Hardware
| Device | Notes |
|--------|-------|
| **LILYGO T-Beam 868/915MHz** | Great for Pi USB bridge; ESP32 + GPS + LoRa |
| **RAK4631** | Low power, nRF52840 based |
| **LILYGO T-Deck** | Can work if put into USB-serial mode; better used as a handheld mesh client |
## Quick Test
If you have a T-Beam or T-Deck connected to your computer:
```bash
cd bridge-daemon
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
export MESHTASTIC_DEVICE=/dev/ttyUSB0 # adjust for your OS
export MQTT_HOST=localhost
python3 -m src.main
```
Send a text from any mesh node and watch it appear on `kosmo/mesh/inbound` in your MQTT broker.

View File

@@ -0,0 +1,175 @@
# KosmoConnect Bridge Daemon
A Python daemon that bridges a local Meshtastic mesh network to the KosmoConnect cloud MQTT broker.
## What It Does
1. **Mesh → Cloud**
- Receives text messages from the mesh and publishes them to `kosmo/mesh/inbound`
- Detects JSON environmental data packets (sent as text) and forwards them to `kosmo/ingest/enviro`
- Forwards position updates to `kosmo/position/position`
- *(Future)* Will handle custom `KOSMO_ENVIRO_APP` portnum packets from enviro-node firmware
2. **Cloud → Mesh**
- Subscribes to `kosmo/mesh/outbound/#`
- Injects text messages into the mesh, tagged with `[Web]` prefix so users know the origin
## Hardware Requirements
- **Raspberry Pi** (3B+ or 4 recommended) running Raspberry Pi OS or similar Debian-based Linux
- **Meshtastic device** with USB-serial interface (e.g., LILYGO T-Beam, RAK4631, or your T-Deck in USB-serial mode) connected via USB
- Reliable internet backhaul (WiFi or Ethernet)
## Quick Start (Local Dev)
You can test the daemon on your laptop without a Pi by using the Mosquitto broker from the backend stack:
```bash
cd firmware/infrastructure-node/bridge-daemon
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# Option A: USB serial device
export MESHTASTIC_DEVICE=/dev/ttyUSB0 # or /dev/ttyACM0 on Linux, COM3 on Windows, /dev/cu.usbserial-* on macOS
export MQTT_HOST=localhost
export MQTT_PORT=1883
python3 -m src.main
# Option B: Network-connected device (e.g., T-Deck on WiFi)
export MESHTASTIC_HOST=192.168.1.45
export MESHTASTIC_TCP_PORT=4403
export MQTT_HOST=localhost
export MQTT_PORT=1883
python3 -m src.main
```
## Raspberry Pi Production Setup
### 1. Install Dependencies
```bash
sudo apt update
sudo apt install -y python3-venv python3-pip git
```
### 2. Clone / Copy This Directory to the Pi
```bash
cd /opt
sudo git clone https://your-repo/kosmo-connect.git
# or rsync the bridge-daemon folder
```
### 3. Run the Installer
```bash
cd /opt/kosmo-connect/firmware/infrastructure-node/bridge-daemon
sudo ./install.sh
```
### 4. Configure the Service
Edit the systemd service to point to your actual MQTT broker:
```bash
sudo systemctl edit --full kosmo-bridge
```
Update the `Environment=` lines, for example:
```ini
Environment="MQTT_HOST=your-broker.example.com"
Environment="MQTT_PORT=1883"
Environment="MQTT_USER=kosmo"
Environment="MQTT_PASS=your_secure_password"
Environment="MESHTASTIC_DEVICE=/dev/ttyUSB0"
Environment="GATEWAY_NODE_ID=!yourgateway01"
```
Save and reload:
```bash
sudo systemctl daemon-reload
sudo systemctl restart kosmo-bridge
```
### 5. Monitor Logs
```bash
sudo journalctl -u kosmo-bridge -f
```
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `MQTT_HOST` | `localhost` | MQTT broker hostname |
| `MQTT_PORT` | `1883` | MQTT broker port |
| `MQTT_USER` | *(empty)* | MQTT username |
| `MQTT_PASS` | *(empty)* | MQTT password |
| `MESHTASTIC_DEVICE` | `/dev/ttyUSB0` | Serial path to the Meshtastic radio (used when `MESHTASTIC_HOST` is empty) |
| `MESHTASTIC_HOST` | *(empty)* | IP address or hostname of a network-connected Meshtastic device |
| `MESHTASTIC_TCP_PORT` | `4403` | TCP port for the Meshtastic network API |
| `GATEWAY_NODE_ID` | *(empty)* | Identifier for this bridge in the cloud |
## Finding the Serial Port
On the Pi, plug in your T-Beam or T-Deck and run:
```bash
ls -l /dev/ttyUSB* /dev/ttyACM* /dev/serial/by-id/
```
Use the path that appears when the device is connected.
## Using T-Deck Over WiFi (No USB Cable)
Your T-Deck can connect to your home WiFi and expose the Meshtastic TCP API on port `4403`.
### 1. Enable WiFi on the T-Deck
Using the Meshtastic app or CLI:
```bash
meshtastic --host <t-deck-ip> --set wifi_ssid "YourNetwork" --set wifi_psk "YourPassword"
```
Or via the on-screen menu if your T-Deck firmware supports it.
### 2. Find the T-Deck IP Address
Check your router's DHCP client list, or use a network scanner:
```bash
nmap -p 4403 192.168.1.0/24
```
### 3. Run the Bridge in TCP Mode
```bash
export MESHTASTIC_HOST=192.168.1.45
export MESHTASTIC_TCP_PORT=4403
export MQTT_HOST=your-broker.example.com
python3 -m src.main
```
The bridge will connect over TCP instead of USB-serial. This is perfect for keeping the T-Deck portable while the Pi sits near your router.
## Testing with T-Deck
When the bridge is running (serial or TCP):
1. Send a text message from any mesh node
2. Check `kosmo/mesh/inbound` on your MQTT broker — the message should appear within seconds
3. Publish a message to `kosmo/mesh/outbound/!{target_node_id}` — the target node should receive it prefixed with `[Web]`
## Troubleshooting
- **Permission denied on `/dev/ttyUSB0`**: Add the `kosmo` user to the `dialout` group:
```bash
sudo usermod -a -G dialout kosmo
sudo systemctl restart kosmo-bridge
```
- **MQTT connection refused**: Verify your broker is reachable from the Pi (`nc -vz MQTT_HOST MQTT_PORT`)
- **Meshtastic device not found over serial**: Check cables and power supply; some devices need a powered USB hub on the Pi
- **T-Deck TCP connection refused**: Ensure the T-Deck is on the same network and the TCP API port (4403) is not blocked by a firewall

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
# One-command deploy of the KosmoConnect Bridge Daemon to a Raspberry Pi
# Run this script from your dev machine. Requires ssh access to the Pi.
PI_HOST="${1:-}"
PI_USER="${2:-pi}"
INSTALL_DIR="/opt/kosmo-bridge"
if [ -z "$PI_HOST" ]; then
echo "Usage: ./deploy-pi.sh <pi-hostname-or-ip> [pi-user]"
echo "Example: ./deploy-pi.sh 192.168.1.50 pi"
exit 1
fi
echo "=== Deploying KosmoConnect Bridge Daemon to $PI_USER@$PI_HOST ==="
# 1. Ensure target directory exists
ssh "$PI_USER@$PI_HOST" "sudo mkdir -p $INSTALL_DIR && sudo chown $PI_USER:$PI_USER $INSTALL_DIR"
# 2. Sync source files
rsync -avz --delete \
src/ \
requirements.txt \
kosmo-bridge.service \
install.sh \
"$PI_USER@$PI_HOST:$INSTALL_DIR/"
# 3. Run installer remotely
ssh "$PI_USER@$PI_HOST" "cd $INSTALL_DIR && sudo ./install.sh"
echo ""
echo "==========================================="
echo "Deployment complete."
echo ""
echo "Next steps on the Pi:"
echo " ssh $PI_USER@$PI_HOST"
echo " sudo systemctl edit --full kosmo-bridge"
echo " # Set MQTT_HOST, MESHTASTIC_HOST, etc."
echo " sudo systemctl daemon-reload"
echo " sudo systemctl restart kosmo-bridge"
echo " sudo journalctl -u kosmo-bridge -f"
echo "==========================================="

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
# KosmoConnect Bridge Daemon Installer for Raspberry Pi
# Run this script as root (or with sudo)
INSTALL_DIR="/opt/kosmo-bridge"
SERVICE_FILE="kosmo-bridge.service"
if [ "$EUID" -ne 0 ]; then
echo "Please run as root (e.g., sudo ./install.sh)"
exit 1
fi
echo "=== KosmoConnect Bridge Installer ==="
# 1. Create user
if ! id -u kosmo &>/dev/null; then
echo "Creating kosmo user..."
useradd --system --no-create-home --home-dir "$INSTALL_DIR" kosmo
fi
# 2. Install directory
echo "Setting up $INSTALL_DIR ..."
mkdir -p "$INSTALL_DIR"
cp -r src "$INSTALL_DIR/"
chown -R kosmo:kosmo "$INSTALL_DIR"
# 3. Python virtual environment
echo "Creating Python venv..."
python3 -m venv "$INSTALL_DIR/venv"
"$INSTALL_DIR/venv/bin/pip" install --upgrade pip
"$INSTALL_DIR/venv/bin/pip" install -r requirements.txt
# 4. Systemd service
echo "Installing systemd service..."
cp "$SERVICE_FILE" /etc/systemd/system/
systemctl daemon-reload
systemctl enable kosmo-bridge.service
echo ""
echo "==========================================="
echo "Installation complete."
echo ""
echo "Before starting the service, edit:"
echo " /etc/systemd/system/kosmo-bridge.service"
echo "to set your MQTT_HOST, MQTT_USER, MQTT_PASS, etc."
echo ""
echo "For network-connected devices (e.g., T-Deck over WiFi), uncomment:"
echo " Environment=\"MESHTASTIC_HOST=192.168.1.45\""
echo " Environment=\"MESHTASTIC_TCP_PORT=4403\""
echo "and comment out MESHTASTIC_DEVICE."
echo ""
echo "Then run:"
echo " sudo systemctl start kosmo-bridge"
echo " sudo systemctl status kosmo-bridge"
echo " sudo journalctl -u kosmo-bridge -f"
echo "==========================================="

View File

@@ -0,0 +1,22 @@
[Unit]
Description=KosmoConnect Bridge Daemon
After=network.target
[Service]
Type=simple
User=kosmo
Group=kosmo
WorkingDirectory=/opt/kosmo-bridge
Environment="PYTHONUNBUFFERED=1"
Environment="MQTT_HOST=mqtt.kosmoconnect.example"
Environment="MQTT_PORT=1883"
Environment="MESHTASTIC_DEVICE=/dev/ttyUSB0"
# Environment="MESHTASTIC_HOST=192.168.1.45"
# Environment="MESHTASTIC_TCP_PORT=4403"
Environment="GATEWAY_NODE_ID=!gateway01"
ExecStart=/opt/kosmo-bridge/venv/bin/python -m src.main
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,3 @@
meshtastic>=2.3.8
paho-mqtt>=2.1.0
pyserial>=3.5

View File

@@ -0,0 +1,15 @@
import os
MQTT_HOST = os.getenv("MQTT_HOST", "localhost")
MQTT_PORT = int(os.getenv("MQTT_PORT", "1883"))
MQTT_USER = os.getenv("MQTT_USER", "")
MQTT_PASS = os.getenv("MQTT_PASS", "")
MQTT_TOPIC_INGEST = os.getenv("MQTT_TOPIC_INGEST", "kosmo/ingest/enviro")
MQTT_TOPIC_INBOUND = os.getenv("MQTT_TOPIC_INBOUND", "kosmo/mesh/inbound")
MQTT_TOPIC_OUTBOUND_PREFIX = os.getenv("MQTT_TOPIC_OUTBOUND_PREFIX", "kosmo/mesh/outbound")
MESHTASTIC_DEVICE = os.getenv("MESHTASTIC_DEVICE", "/dev/ttyUSB0")
MESHTASTIC_HOST = os.getenv("MESHTASTIC_HOST", "")
MESHTASTIC_TCP_PORT = int(os.getenv("MESHTASTIC_TCP_PORT", "4403"))
GATEWAY_NODE_ID = os.getenv("GATEWAY_NODE_ID", "")

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

View File

@@ -0,0 +1,75 @@
import logging
import time
import threading
from meshtastic.serial_interface import SerialInterface
from meshtastic.tcp_interface import TCPInterface
import config
logger = logging.getLogger("bridge.meshtastic")
class MeshtasticClient:
def __init__(self, on_packet_callback):
self.on_packet_callback = on_packet_callback
self.iface = None
self._running = True
self._thread = None
def _connect(self):
while self._running:
try:
if config.MESHTASTIC_HOST:
logger.info("Connecting to Meshtastic TCP host %s:%s", config.MESHTASTIC_HOST, config.MESHTASTIC_TCP_PORT)
self.iface = TCPInterface(hostname=config.MESHTASTIC_HOST, portNumber=config.MESHTASTIC_TCP_PORT)
else:
logger.info("Connecting to Meshtastic serial device %s", config.MESHTASTIC_DEVICE)
self.iface = SerialInterface(devPath=config.MESHTASTIC_DEVICE)
self.iface.onReceive = self._on_receive
# Try to read our own node ID
my_info = getattr(self.iface, 'myInfo', None)
if my_info:
logger.info("Connected. My node ID: %s", getattr(my_info, 'my_node_num', 'unknown'))
else:
logger.info("Connected to Meshtastic device.")
return
except Exception as e:
logger.error("Failed to connect to Meshtastic: %s. Retrying in 5s...", e)
time.sleep(5)
def _on_receive(self, packet, interface):
try:
self.on_packet_callback(packet)
except Exception as e:
logger.exception("Error handling mesh packet: %s", e)
def start(self):
self._thread = threading.Thread(target=self._connect, daemon=True)
self._thread.start()
def stop(self):
self._running = False
if self.iface:
try:
self.iface.close()
except Exception:
pass
def send_text(self, text: str, destination_id: str = None, channel_index: int = 0):
if not self.iface:
logger.warning("Meshtastic not connected, cannot send text")
return False
try:
logger.info("Sending text to %s: %s", destination_id or "broadcast", text)
self.iface.sendText(
text=text,
destinationId=destination_id,
channelIndex=channel_index,
wantAck=True,
)
return True
except Exception as e:
logger.exception("Failed to send text: %s", e)
return False

View File

@@ -0,0 +1,66 @@
import json
import logging
import threading
import uuid
import paho.mqtt.client as mqtt
import config
logger = logging.getLogger("bridge.mqtt")
class MqttClient:
def __init__(self, on_message_callback):
self.on_message_callback = on_message_callback
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
if config.MQTT_USER:
self.client.username_pw_set(config.MQTT_USER, config.MQTT_PASS)
self.client.on_connect = self._on_connect
self.client.on_message = self._on_message
self.client.on_disconnect = self._on_disconnect
self._connected = False
def _on_connect(self, client, userdata, flags, rc, properties=None):
if rc == 0:
self._connected = True
logger.info("MQTT connected to %s:%s", config.MQTT_HOST, config.MQTT_PORT)
topic = f"{config.MQTT_TOPIC_OUTBOUND_PREFIX}/#"
client.subscribe(topic)
logger.info("Subscribed to %s", topic)
else:
logger.error("MQTT connection failed with code %s", rc)
def _on_disconnect(self, client, userdata, disconnect_flags, rc, properties=None):
self._connected = False
logger.warning("MQTT disconnected (rc=%s). Reconnecting...", rc)
def _on_message(self, client, userdata, msg):
try:
self.on_message_callback(msg.topic, msg.payload.decode("utf-8"))
except Exception as e:
logger.exception("Error handling MQTT message: %s", e)
def start(self):
def _loop():
while True:
try:
self.client.connect(config.MQTT_HOST, config.MQTT_PORT, 60)
self.client.loop_forever(retry_first_connection=True)
except Exception as e:
logger.error("MQTT loop error: %s. Reconnecting in 5s...", e)
time.sleep(5)
import time
t = threading.Thread(target=_loop, daemon=True)
t.start()
def stop(self):
self.client.disconnect()
def publish(self, topic: str, payload: dict):
try:
self.client.publish(topic, json.dumps(payload))
logger.debug("Published to %s", topic)
except Exception as e:
logger.exception("Failed to publish to %s: %s", topic, e)

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
Integration test for the Bridge Daemon.
Requires a running MQTT broker on localhost:1883 (e.g., Mosquitto from backend docker-compose).
"""
import json
import os
import sys
import time
import unittest
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch
import paho.mqtt.client as mqtt
# Ensure src is importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
os.environ.setdefault("MQTT_HOST", "localhost")
os.environ.setdefault("MQTT_PORT", "1883")
os.environ.setdefault("MESHTASTIC_DEVICE", "/dev/fake")
os.environ.setdefault("MESHTASTIC_HOST", "")
os.environ.setdefault("GATEWAY_NODE_ID", "!testgateway")
from main import BridgeDaemon
import config
class TestBridgeDaemon(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
cls.mqtt_client.connect("localhost", 1883, 60)
cls.mqtt_client.loop_start()
cls.inbound_msgs = []
cls.ingest_msgs = []
def on_message(client, userdata, msg):
payload = json.loads(msg.payload.decode())
if msg.topic == "kosmo/mesh/inbound":
cls.inbound_msgs.append(payload)
elif msg.topic == "kosmo/ingest/enviro":
cls.ingest_msgs.append(payload)
cls.mqtt_client.on_message = on_message
cls.mqtt_client.subscribe("kosmo/mesh/inbound")
cls.mqtt_client.subscribe("kosmo/ingest/enviro")
@classmethod
def tearDownClass(cls):
cls.mqtt_client.loop_stop()
cls.mqtt_client.disconnect()
def test_outbound_mesh_injection(self):
"""MQTT -> Mesh: outbound message should trigger sendText on the mock interface."""
mock_iface = MagicMock()
mock_iface.myInfo = MagicMock()
mock_iface.myInfo.my_node_num = 0xDEADBEEF
with patch("main.MeshtasticClient") as MockMesh:
instance = MockMesh.return_value
instance.iface = mock_iface
instance.start = MagicMock()
instance.stop = MagicMock()
instance.send_text = MagicMock(return_value=True)
daemon = BridgeDaemon()
daemon.mesh = instance
daemon.mqtt.start()
time.sleep(1)
# Publish outbound message
outbound = {"message_id": "msg-123", "text": "Hello mesh"}
self.mqtt_client.publish("kosmo/mesh/outbound/!a1b2c3d4", json.dumps(outbound))
time.sleep(1.5)
instance.send_text.assert_called()
args, kwargs = instance.send_text.call_args
self.assertIn("[Web] Hello mesh", args[0])
self.assertEqual(kwargs.get("destination_id"), "!a1b2c3d4")
daemon.mqtt.stop()
def test_inbound_mesh_to_mqtt(self):
"""Mesh -> MQTT: text packet from mesh should appear on kosmo/mesh/inbound."""
mock_iface = MagicMock()
mock_iface.myInfo = MagicMock()
mock_iface.myInfo.my_node_num = 0xDEADBEEF
with patch("main.MeshtasticClient") as MockMesh:
instance = MockMesh.return_value
instance.iface = mock_iface
instance.start = MagicMock()
instance.stop = MagicMock()
daemon = BridgeDaemon()
daemon.mesh = instance
daemon.mqtt.start()
time.sleep(0.5)
# Simulate mesh text packet
packet = {
"fromId": "!deadbeef",
"decoded": {
"portnum": "TEXT_MESSAGE_APP",
"payload": b"Hello from the woods",
},
"hopLimit": 5,
"hopStart": 7,
"rxRssi": -88,
"rxSnr": 9.5,
}
daemon._on_mesh_packet(packet)
time.sleep(1)
daemon.mqtt.stop()
# Give MQTT a moment to flush
time.sleep(0.5)
self.assertTrue(
any(m.get("source_node_id") == "!deadbeef" and m.get("text") == "Hello from the woods" for m in self.inbound_msgs),
f"Expected inbound message not found. Got: {self.inbound_msgs}"
)
def test_enviro_json_text_fallback(self):
"""Mesh -> MQTT: text packet containing enviro JSON should be routed to kosmo/ingest/enviro."""
mock_iface = MagicMock()
mock_iface.myInfo = MagicMock()
mock_iface.myInfo.my_node_num = 0xCAFEBABE
with patch("main.MeshtasticClient") as MockMesh:
instance = MockMesh.return_value
instance.iface = mock_iface
instance.start = MagicMock()
instance.stop = MagicMock()
daemon = BridgeDaemon()
daemon.mesh = instance
daemon.mqtt.start()
time.sleep(0.5)
enviro_payload = {
"type": "enviro_reading",
"node_id": "!enviro01",
"payload": {
"temperature_c": 21.5,
"humidity_percent": 55.0,
},
}
packet = {
"fromId": "!enviro01",
"decoded": {
"portnum": "TEXT_MESSAGE_APP",
"payload": json.dumps(enviro_payload).encode(),
},
"hopLimit": 3,
"hopStart": 5,
}
daemon._on_mesh_packet(packet)
time.sleep(1)
daemon.mqtt.stop()
time.sleep(0.5)
self.assertTrue(
any(m.get("node_id") == "!enviro01" and m.get("payload", {}).get("temperature_c") == 21.5 for m in self.ingest_msgs),
f"Expected ingest message not found. Got: {self.ingest_msgs}"
)
class TestMeshtasticClientConnection(unittest.TestCase):
def test_serial_interface_used_by_default(self):
with patch("meshtastic_client.SerialInterface") as MockSerial, \
patch("meshtastic_client.TCPInterface") as MockTCP:
from meshtastic_client import MeshtasticClient
client = MeshtasticClient(lambda p: None)
client._connect()
MockSerial.assert_called_once_with(devPath="/dev/fake")
MockTCP.assert_not_called()
client.stop()
def test_tcp_interface_used_when_host_set(self):
with patch.dict(os.environ, {"MESHTASTIC_HOST": "192.168.1.45", "MESHTASTIC_TCP_PORT": "4403"}, clear=False):
# re-import config to pick up env change
import importlib
import config as cfg_module
importlib.reload(cfg_module)
with patch("meshtastic_client.SerialInterface") as MockSerial, \
patch("meshtastic_client.TCPInterface") as MockTCP:
from meshtastic_client import MeshtasticClient
client = MeshtasticClient(lambda p: None)
client._connect()
MockTCP.assert_called_once_with(hostname="192.168.1.45", portNumber=4403)
MockSerial.assert_not_called()
client.stop()
if __name__ == "__main__":
unittest.main()