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:
95
firmware/README.md
Normal file
95
firmware/README.md
Normal 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
|
||||
43
firmware/infrastructure-node/README.md
Normal file
43
firmware/infrastructure-node/README.md
Normal 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.
|
||||
175
firmware/infrastructure-node/bridge-daemon/README.md
Normal file
175
firmware/infrastructure-node/bridge-daemon/README.md
Normal 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
|
||||
44
firmware/infrastructure-node/bridge-daemon/deploy-pi.sh
Executable file
44
firmware/infrastructure-node/bridge-daemon/deploy-pi.sh
Executable 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 "==========================================="
|
||||
58
firmware/infrastructure-node/bridge-daemon/install.sh
Normal file
58
firmware/infrastructure-node/bridge-daemon/install.sh
Normal 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 "==========================================="
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
meshtastic>=2.3.8
|
||||
paho-mqtt>=2.1.0
|
||||
pyserial>=3.5
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
15
firmware/infrastructure-node/bridge-daemon/src/config.py
Normal file
15
firmware/infrastructure-node/bridge-daemon/src/config.py
Normal 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", "")
|
||||
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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
201
firmware/infrastructure-node/bridge-daemon/test_bridge_daemon.py
Normal file
201
firmware/infrastructure-node/bridge-daemon/test_bridge_daemon.py
Normal 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()
|
||||
Reference in New Issue
Block a user