Replace native interface with external packet capture and rename receiver to observer

Remove the meshcore_interface component in favor of external
meshcore-packet-capture for data ingestion. Rename receiver_node_id
to observer_node_id across all models, schemas, handlers, and API
routes. Add Alembic migration for the column/table renames. Fix
frontend JS property name mismatch that prevented the Receiver column
from displaying observer data.
This commit is contained in:
Louis King
2026-04-12 14:07:14 +01:00
parent c7655b5242
commit 58499c420b
61 changed files with 577 additions and 6106 deletions
+12 -51
View File
@@ -4,15 +4,15 @@
# Configuration is grouped by service. Most deployments only need:
# - Common Settings (always required)
# - MQTT Settings (always required)
# - Interface Settings (for receiver/sender services)
# - Packet Capture Settings (for observer nodes)
#
# The Collector, API, and Web services typically run as a combined "core"
# profile and share the same data directory.
#
# -----------------------------------------------------------------------------
# QUICK START: Receiver/Sender Only
# QUICK START: Observer Node
# -----------------------------------------------------------------------------
# For a minimal receiver or sender setup, you only need these settings:
# For an observer node capturing mesh traffic, you need:
#
# MQTT_HOST=your-mqtt-broker.example.com
# MQTT_PORT=1883
@@ -59,7 +59,7 @@ SEED_HOME=./seed
# =============================================================================
# MQTT SETTINGS
# =============================================================================
# MQTT broker connection settings for interface, collector, and API services
# MQTT broker connection settings for collector and API services
# MQTT Broker host
# When using the local MQTT broker (--profile mqtt), use "mqtt"
@@ -92,54 +92,21 @@ MQTT_WS_PATH=/mqtt
MQTT_EXTERNAL_PORT=1883
MQTT_WS_PORT=9001
# =============================================================================
# INTERFACE SETTINGS (Receiver/Sender)
# =============================================================================
# Settings for the MeshCore device interface services
# Serial port for receiver device
SERIAL_PORT=/dev/ttyUSB0
# Serial port for sender device (if using separate device)
SERIAL_PORT_SENDER=/dev/ttyUSB1
# Baud rate for serial communication
SERIAL_BAUD=115200
# Optional device/node name to set on startup
# This name is broadcast to the mesh network in advertisements
MESHCORE_DEVICE_NAME=
# Optional node address override (64-char hex string)
# Only set if you need to override the device's public key
NODE_ADDRESS=
NODE_ADDRESS_SENDER=
# -------------------
# Contact Cleanup Settings (RECEIVER mode only)
# -------------------
# Automatic removal of stale contacts from the MeshCore companion node
# Enable automatic removal of stale contacts from companion node
CONTACT_CLEANUP_ENABLED=true
# Remove contacts not advertised for this many days
CONTACT_CLEANUP_DAYS=7
# =============================================================================
# PACKET CAPTURE SETTINGS
# =============================================================================
# External packet capture service (ghcr.io/agessaman/meshcore-packet-capture)
# Replaces the native interface-receiver. Uses the "receiver" compose profile.
# See https://github.com/agessaman/meshcore-packet-capture for documentation.
# Uses the "receiver" compose profile.
# Publishes captured packets to MQTT in LetsMesh upload format, ingested by
# the collector with COLLECTOR_INGEST_MODE=letsmesh_upload.
# the collector.
# Serial port for the packet capture device
SERIAL_PORT=/dev/ttyUSB0
# Docker image version tag for the packet capture image
PACKETCAPTURE_IMAGE_VERSION=latest
# Serial port for the packet capture device (reuses SERIAL_PORT by default)
# PACKETCAPTURE_SERIAL_PORT=/dev/ttyUSB0
# Connection timeout and retry settings
PACKETCAPTURE_TIMEOUT=30
PACKETCAPTURE_MAX_CONNECTION_RETRIES=5
@@ -195,13 +162,7 @@ PACKETCAPTURE_EXIT_ON_RECONNECT_FAIL=true
# =============================================================================
# The collector subscribes to MQTT events and stores them in the database
# Collector MQTT ingest mode
# - native: expects <prefix>/<pubkey>/event/<event_name> topics (native interface-receiver)
# - letsmesh_upload: expects LetsMesh observer uploads on
# <prefix>/<pubkey>/(packets|status|internal)
COLLECTOR_INGEST_MODE=letsmesh_upload
# LetsMesh decoder support (used only when COLLECTOR_INGEST_MODE=letsmesh_upload)
# LetsMesh decoder support
# Set to false to disable external packet decoding
COLLECTOR_LETSMESH_DECODER_ENABLED=true
@@ -275,7 +236,7 @@ NODE_CLEANUP_DAYS=7
# =============================================================================
# API SETTINGS
# =============================================================================
# REST API for querying data and sending commands
# REST API for querying data
# External API port
API_PORT=8000
+10 -123
View File
@@ -18,16 +18,14 @@ This document provides context and guidelines for AI coding assistants working o
- `pytest tests/test_web/` for web-only changes (templates, static JS, web routes)
- `pytest tests/test_api/` for API changes
- `pytest tests/test_collector/` for collector changes
- `pytest tests/test_interface/` for interface/sender/receiver changes
- `pytest tests/test_common/` for common models/schemas/config changes
- Only run the full `pytest` if changes span multiple components
- Run `pre-commit run --all-files` to perform all quality checks
## Project Overview
MeshCore Hub is a Python 3.13+ monorepo for managing and orchestrating MeshCore mesh networks. It consists of five main components:
MeshCore Hub is a Python 3.13+ monorepo for managing and orchestrating MeshCore mesh networks. Data ingestion is done via [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture), which captures MeshCore mesh traffic and publishes events to MQTT. MeshCore Hub then collects, stores, and presents this data. It consists of four main components:
- **meshcore_interface**: Serial/USB interface to MeshCore companion nodes, publishes/subscribes to MQTT
- **meshcore_collector**: Collects MeshCore events from MQTT and stores them in a database
- **meshcore_api**: REST API for querying data and sending commands via MQTT
- **meshcore_web**: Web dashboard for visualizing network status
@@ -52,7 +50,6 @@ MeshCore Hub is a Python 3.13+ monorepo for managing and orchestrating MeshCore
| Migrations | Alembic |
| REST API | FastAPI |
| MQTT Client | paho-mqtt |
| MeshCore Interface | meshcore |
| Templates | Jinja2 (server), lit-html (SPA) |
| Frontend | ES Modules SPA with client-side routing |
| CSS Framework | Tailwind CSS + DaisyUI |
@@ -263,12 +260,6 @@ meshcore-hub/
│ │ └── schemas/ # Pydantic schemas
│ │ ├── members.py # Member API schemas
│ │ └── ...
│ ├── interface/
│ │ ├── cli.py
│ │ ├── device.py # MeshCore device wrapper
│ │ ├── mock_device.py # Mock for testing
│ │ ├── receiver.py # RECEIVER mode
│ │ └── sender.py # SENDER mode
│ ├── collector/
│ │ ├── cli.py # Collector CLI with seed commands
│ │ ├── subscriber.py # MQTT subscriber
@@ -305,7 +296,6 @@ meshcore-hub/
├── tests/
│ ├── conftest.py
│ ├── test_common/
│ ├── test_interface/
│ ├── test_collector/
│ ├── test_api/
│ └── test_web/
@@ -338,7 +328,7 @@ meshcore-hub/
## MQTT Topic Structure
### Events (Published by Interface RECEIVER)
### Events
```
<prefix>/<public_key>/event/<event_name>
```
@@ -348,16 +338,6 @@ Examples:
- `meshcore/abc123.../event/contact_msg_recv`
- `meshcore/abc123.../event/channel_msg_recv`
### Commands (Subscribed by Interface SENDER)
```
<prefix>/+/command/<command_name>
```
Examples:
- `meshcore/+/command/send_msg`
- `meshcore/+/command/send_channel_msg`
- `meshcore/+/command/send_advert`
## Database Conventions
- Use UUIDs for primary keys (stored as VARCHAR(36))
@@ -393,26 +373,16 @@ Node tags are flexible key-value pairs that allow custom metadata to be attached
import pytest
from unittest.mock import AsyncMock, patch
@pytest.fixture
def mock_mqtt_client():
client = AsyncMock()
client.publish = AsyncMock()
return client
@pytest.mark.asyncio
async def test_receiver_publishes_event(mock_mqtt_client):
"""Test that receiver publishes events to correct MQTT topic."""
# Arrange
receiver = Receiver(mqtt_client=mock_mqtt_client, prefix="test")
async def test_collector_handles_advertisement():
"""Test that collector handler processes advertisement events."""
handler = AdvertisementHandler(db_session=AsyncMock())
# Act
await receiver.handle_advertisement(event_data)
await handler.handle(event_data)
# Assert
mock_mqtt_client.publish.assert_called_once()
call_args = mock_mqtt_client.publish.call_args
assert "test/" in call_args[0][0]
assert "/event/advertisement" in call_args[0][0]
handler.db_session.add.assert_called_once()
node = handler.db_session.add.call_args[0][0]
assert node.public_key == event_data["public_key"]
```
### Integration Tests
@@ -597,7 +567,6 @@ pytest
# Run specific component
meshcore-hub api --reload
meshcore-hub collector
meshcore-hub interface receiver --mock
```
## Environment Variables
@@ -734,22 +703,6 @@ When enabled, the collector automatically removes nodes where:
**Note:** Both event data and node cleanup run on the same schedule (DATA_RETENTION_INTERVAL_HOURS).
**Contact Cleanup (Interface RECEIVER):**
The interface RECEIVER mode can automatically remove stale contacts from the MeshCore companion node's contact database. This prevents the companion node from resyncing old/dead contacts back to the collector, freeing up memory on the device (typically limited to ~100 contacts).
| Variable | Description |
|----------|-------------|
| `CONTACT_CLEANUP_ENABLED` | Enable automatic removal of stale contacts (default: true) |
| `CONTACT_CLEANUP_DAYS` | Remove contacts not advertised for this many days (default: 7) |
When enabled, during each contact sync the receiver checks each contact's `last_advert` timestamp:
- Contacts with `last_advert` older than `CONTACT_CLEANUP_DAYS` are removed from the device
- Stale contacts are not published to MQTT (preventing collector database pollution)
- Contacts without a `last_advert` timestamp are preserved (no removal without data)
This cleanup runs automatically whenever the receiver syncs contacts (on startup and after each advertisement event).
Manual cleanup can be triggered at any time with:
```bash
# Dry run to see what would be deleted
@@ -792,75 +745,9 @@ logging.basicConfig(level=logging.DEBUG)
export LOG_LEVEL=DEBUG
```
## MeshCore Library Integration
The interface component uses the `meshcore` Python library to communicate with MeshCore devices. Key patterns:
### Device Commands
Commands are accessed via `mc.commands.*` on the MeshCore instance:
```python
# Set device time
await mc.commands.set_time(unix_timestamp)
# Send advertisement
await mc.commands.send_advert(flood=False)
# Send messages
await mc.commands.send_msg(destination, text)
await mc.commands.send_chan_msg(channel_idx, text)
# Request data
await mc.commands.send_statusreq(target)
await mc.commands.send_telemetry_req(target)
```
### Event Subscription
Events are received via the subscription system. The `Event` object has:
- `event.type` - The event type enum
- `event.payload` - Full event data (dict with all fields like `text`, `pubkey_prefix`, etc.)
- `event.attributes` - Subset of fields for filtering
**Important**: Use `event.payload` (not `event.attributes`) to get full message data.
### Auto Message Fetching
The library requires explicit message fetching. Call `start_auto_message_fetching()` to:
1. Subscribe to `MESSAGES_WAITING` events
2. Automatically call `get_msg()` to fetch pending messages
3. Immediately fetch any queued messages on startup
```python
await mc.start_auto_message_fetching()
```
### Receiver Initialization
On startup, the receiver performs these initialization steps:
1. Set device clock to current Unix timestamp
2. Optionally set the device name (if `MESHCORE_DEVICE_NAME` is configured)
3. Send a flood advertisement (broadcasts device name to the mesh)
4. Start automatic message fetching
5. Sync the device's contact database
### Contact Sync Behavior
The receiver syncs the device's contact database in two scenarios:
1. **Startup**: Initial sync when receiver starts
2. **Advertisement Events**: Automatic sync triggered whenever an advertisement is received from the mesh
Since advertisements are typically received every ~20 minutes, contact sync happens automatically without manual intervention. Each contact from the device is published individually to MQTT:
- Topic: `{prefix}/{device_public_key}/event/contact`
- Payload: `{public_key, adv_name, type}`
This ensures the collector's database stays current with all nodes discovered on the mesh network.
## References
- [meshcore Documentation](https://github.com/fdlamotte/meshcore)
- [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture)
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
- [SQLAlchemy 2.0 Documentation](https://docs.sqlalchemy.org/en/20/)
- [Pydantic Documentation](https://docs.pydantic.dev/)
-700
View File
@@ -1,700 +0,0 @@
# MeshCore Hub - Implementation Plan
## Project Overview
MeshCore Hub is a Python 3.11+ monorepo for managing and orchestrating MeshCore mesh networks. It consists of five main components that work together to receive, store, query, and visualize mesh network data.
---
## Questions Requiring Clarification
Before implementation, the following questions need answers:
### Architecture & Design
1. **MQTT Broker Selection**: Should we include an embedded MQTT broker (like `hbmqtt`) or assume an external broker (Mosquitto) is always used? The spec mentions Docker but doesn't specify broker deployment.
2. **Database Deployment**: For production, should we support PostgreSQL/MySQL from the start, or focus on SQLite initially and add other backends later? This affects connection pooling and async driver choices.
3. **Web Dashboard Separation**: Should `meshcore_web` be a separate FastAPI app or integrated into `meshcore_api`? Running two FastAPI apps adds deployment complexity.
4. **Member Profiles JSON Location**: Where should the static JSON file for member profiles be stored? In the config directory, as a mounted volume, or embedded in the package?
### Interface Component
5. **Multiple Serial Devices**: Should a single Interface instance support multiple serial devices simultaneously, or should users run multiple instances (one per device)?
6. **Reconnection Strategy**: What should happen when the serial connection is lost? Automatic reconnect with backoff, or exit and let the container orchestrator restart?
7. **Mock Device Scope**: How comprehensive should the mock MeshCore device be? Should it simulate realistic timing, packet loss, and network topology?
### Collector Component
8. **Event Deduplication**: How should we handle duplicate events from multiple receiver nodes? By message signature/hash, or accept all duplicates?
9. **Data Retention Policy**: Should we implement automatic data pruning (e.g., delete messages older than X days)? This affects long-running deployments.
10. **Webhook Configuration**: The SCHEMAS.md mentions webhooks but PROMPT.md doesn't detail webhook management. Should webhooks be configured via API, config file, or environment variables?
### API Component
11. **API Key Management**: How should API keys be generated and managed? Static config, database-stored, or runtime generation? What about key rotation?
12. **Rate Limiting**: Should the API implement rate limiting? If so, what defaults?
13. **CORS Configuration**: Should CORS be configurable for web dashboard access from different domains?
### Web Dashboard
14. **Authentication**: Should the web dashboard have its own authentication, or rely on API bearer tokens? What about session management?
15. **Real-time Updates**: Should the dashboard support real-time updates (WebSocket/SSE), or polling-based refresh?
16. **Map Provider**: Which map provider for the Node Map view? OpenStreetMap/Leaflet (free) or allow configuration for commercial providers?
### Node Tags System
17. **Tag Value Types**: Should node tags support typed values (string, number, boolean, coordinates) or just strings? The spec mentions lat/lon for the map feature.
18. **Reserved Tag Names**: Should certain tag names be reserved for system use (e.g., `location`, `description`)?
### DevOps & Operations
19. **Health Checks**: What health check endpoints should each component expose for Docker/Kubernetes?
20. **Metrics/Observability**: Should we include Prometheus metrics endpoints or structured logging for observability?
21. **Log Level Configuration**: Per-component or global log level configuration?
---
## Proposed Architecture
```
+------------------+
| MQTT Broker |
| (Mosquitto) |
+--------+---------+
|
+------------------------------+------------------------------+
| | |
v v v
+---------+---------+ +---------+---------+ +---------+---------+
| meshcore_interface| | meshcore_interface| | meshcore_interface|
| (RECEIVER) | | (RECEIVER) | | (SENDER) |
+-------------------+ +-------------------+ +-------------------+
| | ^
| Publishes events | |
v v |
+----------------------------------------------------------+ |
| MQTT Topics | |
| <prefix>/<pubkey>/event/<event_name> | |
| <prefix>/+/command/<command_name> <--------------------+-----------+
+----------------------------------------------------------+
|
v
+---------+---------+
| meshcore_collector|
| (Subscriber) |
+---------+---------+
|
v
+---------+---------+
| Database |
| (SQLite/Postgres)|
+---------+---------+
^
|
+---------------+---------------+
| |
+---------+---------+ +---------+---------+
| meshcore_api | | meshcore_web |
| (REST API) | | (Dashboard) |
+-------------------+ +-------------------+
```
---
## Package Structure
```
meshcore-hub/
├── pyproject.toml # Root project configuration
├── README.md
├── PROMPT.md
├── SCHEMAS.md
├── PLAN.md
├── .env.example # Example environment variables
├── .pre-commit-config.yaml # Pre-commit hooks
├── alembic.ini # Alembic configuration
├── alembic/ # Database migrations
│ ├── env.py
│ ├── versions/
│ └── script.py.mako
├── docker/
│ ├── Dockerfile
│ └── docker-compose.yml
├── tests/
│ ├── conftest.py
│ ├── test_interface/
│ ├── test_collector/
│ ├── test_api/
│ ├── test_web/
│ └── test_common/
└── src/
└── meshcore_hub/
├── __init__.py
├── __main__.py # CLI entrypoint
├── common/
│ ├── __init__.py
│ ├── config.py # Pydantic settings
│ ├── models/ # SQLAlchemy models
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── node.py
│ │ ├── message.py
│ │ ├── advertisement.py
│ │ ├── trace_path.py
│ │ ├── telemetry.py
│ │ ├── node_tag.py
│ │ └── event_log.py
│ ├── schemas/ # Pydantic schemas (API request/response)
│ │ ├── __init__.py
│ │ ├── events.py
│ │ ├── nodes.py
│ │ ├── messages.py
│ │ └── commands.py
│ ├── mqtt.py # MQTT client utilities
│ ├── database.py # Database session management
│ └── logging.py # Logging configuration
├── interface/
│ ├── __init__.py
│ ├── cli.py # Click CLI for interface
│ ├── receiver.py # RECEIVER mode implementation
│ ├── sender.py # SENDER mode implementation
│ ├── device.py # MeshCore device wrapper
│ └── mock_device.py # Mock device for testing
├── collector/
│ ├── __init__.py
│ ├── cli.py # Click CLI for collector
│ ├── subscriber.py # MQTT subscriber
│ ├── handlers/ # Event handlers
│ │ ├── __init__.py
│ │ ├── message.py
│ │ ├── advertisement.py
│ │ ├── trace.py
│ │ ├── telemetry.py
│ │ └── contacts.py
│ └── webhook.py # Webhook dispatcher
├── api/
│ ├── __init__.py
│ ├── cli.py # Click CLI for API
│ ├── app.py # FastAPI application
│ ├── dependencies.py # FastAPI dependencies
│ ├── auth.py # Bearer token authentication
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── messages.py
│ │ ├── nodes.py
│ │ ├── advertisements.py
│ │ ├── trace_paths.py
│ │ ├── telemetry.py
│ │ ├── node_tags.py
│ │ ├── commands.py
│ │ └── dashboard.py # Simple HTML dashboard
│ └── templates/
│ └── dashboard.html
└── web/
├── __init__.py
├── cli.py # Click CLI for web dashboard
├── app.py # FastAPI application
├── routes/
│ ├── __init__.py
│ ├── home.py
│ ├── members.py
│ ├── network.py
│ ├── nodes.py
│ ├── map.py
│ └── messages.py
├── templates/
│ ├── base.html
│ ├── home.html
│ ├── members.html
│ ├── network.html
│ ├── nodes.html
│ ├── map.html
│ └── messages.html
└── static/
├── css/
└── js/
```
---
## Database Schema
### Core Tables
#### `nodes`
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| public_key | VARCHAR(64) | Unique, indexed |
| name | VARCHAR(255) | Node display name |
| adv_type | VARCHAR(20) | chat, repeater, room, none |
| flags | INTEGER | Capability flags |
| first_seen | TIMESTAMP | First advertisement |
| last_seen | TIMESTAMP | Most recent activity |
| created_at | TIMESTAMP | Record creation |
| updated_at | TIMESTAMP | Record update |
#### `node_tags`
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| node_id | UUID | FK to nodes |
| key | VARCHAR(100) | Tag name |
| value | TEXT | Tag value (JSON for typed values) |
| value_type | VARCHAR(20) | string, number, boolean, coordinate |
| created_at | TIMESTAMP | Record creation |
| updated_at | TIMESTAMP | Record update |
*Unique constraint on (node_id, key)*
#### `messages`
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| receiver_node_id | UUID | FK to nodes (receiving interface) |
| message_type | VARCHAR(20) | contact, channel |
| pubkey_prefix | VARCHAR(12) | Sender prefix (contact msgs) |
| channel_idx | INTEGER | Channel index (channel msgs) |
| text | TEXT | Message content |
| path_len | INTEGER | Hop count |
| txt_type | INTEGER | Message type indicator |
| signature | VARCHAR(8) | Message signature |
| snr | FLOAT | Signal-to-noise ratio |
| sender_timestamp | TIMESTAMP | Sender's timestamp |
| received_at | TIMESTAMP | When received by interface |
| created_at | TIMESTAMP | Record creation |
#### `advertisements`
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| receiver_node_id | UUID | FK to nodes (receiving interface) |
| node_id | UUID | FK to nodes (advertised node) |
| public_key | VARCHAR(64) | Advertised public key |
| name | VARCHAR(255) | Advertised name |
| adv_type | VARCHAR(20) | Node type |
| flags | INTEGER | Capability flags |
| received_at | TIMESTAMP | When received |
| created_at | TIMESTAMP | Record creation |
#### `trace_paths`
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| receiver_node_id | UUID | FK to nodes |
| initiator_tag | BIGINT | Trace identifier |
| path_len | INTEGER | Path length |
| flags | INTEGER | Trace flags |
| auth | INTEGER | Auth data |
| path_hashes | JSON | Array of node hashes |
| snr_values | JSON | Array of SNR values |
| hop_count | INTEGER | Total hops |
| received_at | TIMESTAMP | When received |
| created_at | TIMESTAMP | Record creation |
#### `telemetry`
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| receiver_node_id | UUID | FK to nodes |
| node_id | UUID | FK to nodes (reporting node) |
| node_public_key | VARCHAR(64) | Reporting node key |
| lpp_data | BYTEA | Raw LPP data |
| parsed_data | JSON | Decoded sensor readings |
| received_at | TIMESTAMP | When received |
| created_at | TIMESTAMP | Record creation |
#### `events_log`
| Column | Type | Description |
|--------|------|-------------|
| id | UUID | Primary key |
| receiver_node_id | UUID | FK to nodes |
| event_type | VARCHAR(50) | Event type name |
| payload | JSON | Full event payload |
| received_at | TIMESTAMP | When received |
| created_at | TIMESTAMP | Record creation |
---
## MQTT Topic Structure
### Event Topics (Published by RECEIVER)
```
meshcore/<public_key>/event/advertisement
meshcore/<public_key>/event/contact_msg_recv
meshcore/<public_key>/event/channel_msg_recv
meshcore/<public_key>/event/trace_data
meshcore/<public_key>/event/telemetry_response
meshcore/<public_key>/event/contacts
meshcore/<public_key>/event/send_confirmed
meshcore/<public_key>/event/status_response
meshcore/<public_key>/event/battery
meshcore/<public_key>/event/path_updated
```
### Command Topics (Subscribed by SENDER)
```
meshcore/+/command/send_msg
meshcore/+/command/send_channel_msg
meshcore/+/command/send_advert
meshcore/+/command/request_status
meshcore/+/command/request_telemetry
```
### Command Payloads
#### send_msg
```json
{
"destination": "public_key or pubkey_prefix",
"text": "message content",
"timestamp": 1732820498
}
```
#### send_channel_msg
```json
{
"channel_idx": 4,
"text": "message content",
"timestamp": 1732820498
}
```
#### send_advert
```json
{
"flood": true
}
```
---
## API Endpoints
### Authentication
- Bearer token in `Authorization` header
- Two token levels: `read` (query only) and `admin` (query + commands)
### Nodes
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | /api/v1/nodes | read | List all nodes with pagination/filtering |
| GET | /api/v1/nodes/{public_key} | read | Get single node details |
### Node Tags
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | /api/v1/nodes/{public_key}/tags | read | List node's tags |
| POST | /api/v1/nodes/{public_key}/tags | admin | Create tag |
| PUT | /api/v1/nodes/{public_key}/tags/{key} | admin | Update tag |
| DELETE | /api/v1/nodes/{public_key}/tags/{key} | admin | Delete tag |
### Messages
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | /api/v1/messages | read | List messages with filters |
| GET | /api/v1/messages/{id} | read | Get single message |
**Query Parameters:**
- `type`: contact, channel
- `pubkey_prefix`: Filter by sender
- `channel_idx`: Filter by channel
- `since`: Start timestamp
- `until`: End timestamp
- `limit`, `offset`: Pagination
### Advertisements
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | /api/v1/advertisements | read | List advertisements |
| GET | /api/v1/advertisements/{id} | read | Get single advertisement |
### Trace Paths
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | /api/v1/trace-paths | read | List trace paths |
| GET | /api/v1/trace-paths/{id} | read | Get single trace path |
### Telemetry
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | /api/v1/telemetry | read | List telemetry data |
| GET | /api/v1/telemetry/{id} | read | Get single telemetry record |
### Commands
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| POST | /api/v1/commands/send-message | admin | Send direct message |
| POST | /api/v1/commands/send-channel-message | admin | Send channel message |
| POST | /api/v1/commands/send-advertisement | admin | Send advertisement |
### Dashboard
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| GET | /api/v1/dashboard | read | HTML dashboard page |
| GET | /api/v1/stats | read | JSON statistics |
---
## Configuration (Environment Variables)
### Common
| Variable | Default | Description |
|----------|---------|-------------|
| DATA_HOME | ./data | Base directory for service data |
| LOG_LEVEL | INFO | Logging level |
| MQTT_HOST | localhost | MQTT broker host |
| MQTT_PORT | 1883 | MQTT broker port |
| MQTT_USERNAME | | MQTT username (optional) |
| MQTT_PASSWORD | | MQTT password (optional) |
| MQTT_PREFIX | meshcore | Topic prefix |
### Data Directory Structure
The `DATA_HOME` environment variable controls where all service data is stored:
```
${DATA_HOME}/
├── collector/
│ ├── meshcore.db # SQLite database
│ └── tags.json # Node tags for import
└── web/
└── members.json # Network members list
```
### Interface
| Variable | Default | Description |
|----------|---------|-------------|
| INTERFACE_MODE | RECEIVER | RECEIVER or SENDER |
| SERIAL_PORT | /dev/ttyUSB0 | Serial port path |
| SERIAL_BAUD | 115200 | Baud rate |
| MESHCORE_DEVICE_NAME | *(none)* | Device/node name set on startup |
| MOCK_DEVICE | false | Use mock device |
### Collector
| Variable | Default | Description |
|----------|---------|-------------|
| DATABASE_URL | sqlite:///{DATA_HOME}/collector/meshcore.db | SQLAlchemy URL |
| TAGS_FILE | {DATA_HOME}/collector/tags.json | Path to tags JSON file |
| COLLECTOR_INGEST_MODE | native | Ingest mode (`native` or `letsmesh_upload`) |
| COLLECTOR_LETSMESH_DECODER_ENABLED | true | Enable external packet decoding in LetsMesh mode |
LetsMesh compatibility parity note:
- `status` feed packets are stored as informational `letsmesh_status` events and do not create advertisement rows.
- Advertisement rows in LetsMesh mode are created from decoded payload type `4` only.
- Decoded payload type `11` is normalized to native `contact` updates.
- Decoded payload type `9` is normalized to native `trace_data`.
- Decoded payload type `8` is normalized to informational `path_updated`.
- Decoded payload type `1` can map to native response-style events when decrypted structured content is available.
### API
| Variable | Default | Description |
|----------|---------|-------------|
| API_HOST | 0.0.0.0 | API bind host |
| API_PORT | 8000 | API bind port |
| API_READ_KEY | | Read-only API key |
| API_ADMIN_KEY | | Admin API key |
| DATABASE_URL | sqlite:///{DATA_HOME}/collector/meshcore.db | SQLAlchemy URL |
### Web Dashboard
| Variable | Default | Description |
|----------|---------|-------------|
| WEB_HOST | 0.0.0.0 | Web bind host |
| WEB_PORT | 8080 | Web bind port |
| API_BASE_URL | http://localhost:8000 | API endpoint |
| API_KEY | | API key for queries |
| WEB_TRUSTED_PROXY_HOSTS | * | Comma-separated list of trusted proxy hosts for admin authentication headers. Default: `*` (all hosts). Recommended: set to your reverse proxy IP in production. |
| WEB_LOCALE | en | UI translation locale |
| WEB_DATETIME_LOCALE | en-US | Date formatting locale for UI timestamps |
| TZ | UTC | Timezone used for UI timestamp rendering |
| NETWORK_DOMAIN | | Network domain |
| NETWORK_NAME | MeshCore Network | Network name |
| NETWORK_CITY | | City location |
| NETWORK_COUNTRY | | Country code |
| NETWORK_LOCATION | | Lat,Lon |
| NETWORK_RADIO_CONFIG | | Radio config details |
| NETWORK_CONTACT_EMAIL | | Contact email |
| NETWORK_CONTACT_DISCORD | | Discord link |
| MEMBERS_FILE | {DATA_HOME}/web/members.json | Path to members JSON |
---
## Implementation Phases
### Phase 1: Foundation
1. Set up project structure with pyproject.toml
2. Configure development tools (black, flake8, mypy, pytest)
3. Set up pre-commit hooks
4. Implement `meshcore_common`:
- Pydantic settings/config
- SQLAlchemy models
- Database connection management
- MQTT client utilities
- Logging configuration
5. Set up Alembic for migrations
6. Create initial migration
### Phase 2: Interface Component
1. Implement MeshCore device wrapper
2. Implement RECEIVER mode:
- Event subscription
- MQTT publishing
3. Implement SENDER mode:
- MQTT subscription
- Command dispatching
4. Implement mock device for testing
5. Create Click CLI
6. Write unit tests
### Phase 3: Collector Component
1. Implement MQTT subscriber
2. Implement event handlers for each event type
3. Implement database persistence
4. Create Click CLI
5. Write unit tests
### Phase 4: API Component
1. Set up FastAPI application
2. Implement authentication middleware
3. Implement all REST endpoints
4. Implement MQTT command publishing
5. Implement simple HTML dashboard
6. Add OpenAPI documentation
7. Create Click CLI
8. Write unit and integration tests
### Phase 5: Web Dashboard
1. Set up FastAPI with Jinja2 templates
2. Configure Tailwind CSS / DaisyUI
3. Implement all dashboard views
4. Add map functionality (Leaflet.js)
5. Create Click CLI
6. Write tests
### Phase 6: Docker & Deployment
1. Create multi-stage Dockerfile
2. Create docker-compose.yml with all services
3. Add health check endpoints
4. Document deployment procedures
5. End-to-end testing
---
## Dependencies
```toml
[project]
requires-python = ">=3.11"
dependencies = [
"click>=8.1.0",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"sqlalchemy>=2.0.0",
"alembic>=1.12.0",
"fastapi>=0.100.0",
"uvicorn[standard]>=0.23.0",
"paho-mqtt>=2.0.0",
"meshcore-py>=0.1.0",
"jinja2>=3.1.0",
"python-multipart>=0.0.6",
"httpx>=0.25.0",
"aiosqlite>=0.19.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.1.0",
"black>=23.0.0",
"flake8>=6.1.0",
"mypy>=1.5.0",
"pre-commit>=3.4.0",
]
postgres = [
"asyncpg>=0.28.0",
"psycopg2-binary>=2.9.0",
]
```
---
## CLI Interface
```bash
# Main entrypoint
meshcore-hub <component> [options]
# Interface component
meshcore-hub interface --mode receiver --port /dev/ttyUSB0
meshcore-hub interface --mode sender --mock
# Collector component
meshcore-hub collector
# API component
meshcore-hub api --host 0.0.0.0 --port 8000
# Web dashboard
meshcore-hub web --host 0.0.0.0 --port 8080
# Database migrations
meshcore-hub db upgrade
meshcore-hub db downgrade
meshcore-hub db revision --message "description"
```
---
## Testing Strategy
### Unit Tests
- Test each module in isolation
- Mock external dependencies (MQTT, database, serial)
- Target 80%+ code coverage
### Integration Tests
- Test component interactions
- Use SQLite in-memory database
- Use mock MQTT broker
### End-to-End Tests
- Full system tests with Docker Compose
- Test complete event flow from mock device to API query
---
## Security Considerations
1. **API Authentication**: Bearer tokens with two permission levels
2. **Input Validation**: Pydantic validation on all inputs
3. **SQL Injection**: SQLAlchemy ORM prevents SQL injection
4. **MQTT Security**: Support for username/password authentication
5. **Secret Management**: Environment variables for secrets, never in code
6. **Rate Limiting**: Consider implementing for production deployments
---
## Future Enhancements (Out of Scope)
- WebSocket/SSE for real-time updates
- User management and role-based access
- Multi-tenant support
- Prometheus metrics
- Alerting system
- Mobile-responsive dashboard optimization
- Message encryption/decryption display
- Network topology visualization
-108
View File
@@ -1,108 +0,0 @@
# MeshCore Hub
Python 3.11+ project for managing and orchestrating MeshCore networks.
## Repo
Monorepo structure with the following packages:
- `meshcore_interface`: Component to interface with MeshCore companion nodes over Serial/USB and publish/subscribe to MQTT broker
- `meshcore_collector`: Component to collect and store MeshCore events from MQTT broker into a database
- `meshcore_api`: REST API to query collected data and send commands to the MeshCore network via MQTT broker
- `meshcore_web`: Frontend web dashboard for visualizing MeshCore network status and statistics
- `meshcore_common`: Shared utilities, models and configurations used by all components
Project configuration should use Pydantic for settings management, with all options available via environment variables for easy Docker deployment as well as command-line arguments.
Docker image contains all components, with entrypoint to select which component to run. Docker volumes for persistent storage of database.
MeshCore events and schemas are defined in [SCHEMAS.md](SCHEMAS.md) for reference.
## Dependencies
- Python 3.11+ (pyproject configured for 3.11)
- All development in virtual environments (`.venv`)
- Use `pip` for package management
- Use `black` for code formatting
- Use `flake8` for linting
- Use `mypy` for type checking
- Use `pytest` for testing
- Pre-commit hooks for code quality
- Click for CLI interfaces
- Pydantic for data validation and settings management
- SQLAlchemy for database interactions
- Alembic for database migrations
- FastAPI for REST API
- MQTT client library (e.g. `paho-mqtt` or `gmqtt`)
- meshcore_py for MeshCore device interactions
- Docker for containerization
- Single Dockerfile for all components
- Docker Compose for multi-container orchestration
## Testing
`meshcore_interface` should also include a mocked MeshCore device for testing purposes, allowing simulation of various events and conditions without requiring physical hardware. This should be enabled by a command-line flag or equivalent and will facilitate unit and integration testing without the need for actual MeshCore devices.
## Project Components
### Interface
This component interacts with a MeshCore companion node over Serial/USB. It should subscribe to all events provided by the [meshcore_py](https://github.com/meshcore-dev/meshcore_py) Python library. It should also support sending a selection of commands to the companion node, primarily sending messages to contacts or channels, and also sending node advertisments.
This component should run in one of two modes:
- RECEIVER: The component subscribes to all MeshCore events and then publishes them to an MQTT message broker
- SENDER: The component subscribes to an MQTT message broker and sends commands to the MeshCore companion node
The `meshcore_py` library provides a method of querying the connected MeshCore devices "public address" (64 character hex string) which uniquely identifies any MeshCore node on the network. The public key should be the primary identifier for the node in all communications.
Both sender and receiver modes should use a common MQTT topic structure including a customisable prefix, the nodes public address, and the event/command name. For example:
```
<prefix>/<public_address>/event/<event_name>
<prefix>/<public_address>/command/<command
```
The service can only be started in one mode at a time, either SENDER or RECEIVER. A typical MeshCore network might have several receiver nodes distributed around a location, all publishing events to a central MQTT broker, and then a single sender node which subscribes to the broker and sends commands to the network. There should only be one sender node in a network at any time to avoid message duplication.
### Collector
This service subscribes to the MQTT broker and stores all received events in a database for later retrieval. All relevant MeshCore events should be persisted including messages, node advertisments, trace data responses and include the node that provided them (public address in MQTT topic). The data should be persisted using SQLAlchemy to allow for flexibility in database backend (SQLite, Postgres, MySQL etc), but use SQLite as the default backend for simplicity. We should also support database migrations using Alembic from the outset.
Nodes should also be tracked in the database, with their latest known information (name, last seen timestamp etc) updated whenever a node advertisment is received. There should also be a Node Tag model to allow users to assign custom tags/labels to nodes for easier identification, using the public key as a foreign key. This will allow users to add arbitrary metadata to nodes without modifying the core node data.
### API
This service should provide a REST API (using FastAPI) to allow clients to retrieve stored data from the database used by the Collector. The API would need to have access to the same database as the Collector.
The API should support OpenAPI/Swagger documentation for easy exploration and testing. The API should also support optional HTTP bearer token authentication to restrict access to authorized users only. There should be two API keys, one that only allows query access, and another that allows full query and command access.
The API should support retrieving messages by various filters including sender, receiver, channel, date range etc. It should also support retrieving node advertisments and trace data. The API should also provide endpoints to manage Node Tags, allowing users to create, read, update and delete tags associated with nodes.
The API should also provide endpoints to send commands to the network, such as sending messages or advertisements. These commands would be published to the MQTT broker for the SENDER Interface component to pick up and execute. The API should support the same "prefix" configuration as the Interface component to ensure it publishes to the correct topics. The API would publish to a wildcard MQTT topic to allow for multiple sender nodes if required, e.g. `<prefix>/+/command/<command>`, but ideally there should only be one sender node active at any time.
The API should also provide a basic dashboard endpoint that provides summary statistics about the MeshCore network, such as number of nodes, number of messages sent/received, active channels etc. This would provide a quick overview of the network status without needing to query individual endpoints. This should use simple HTML templates served by FastAPI for easy access via web browsers. Styling should use simple CSS, no JavaScript required.
### Web Dashboard
This component provides a more user-friendly web interface for visualizing the MeshCore network status and statistics. It should connect to the API component to retrieve data and display it in an intuitive manner. The dashboard should include the following views:
- Front Page: Customisable welcome page with network name, details and radio configuration.
- Members List: List of all network member profiles (read from static JSON file)
- Network Overview: Summary statistics about the MeshCore network, including number of nodes, messages, advertisements etc.
- Node List: A list of all known nodes with their details and tags. Ability to filter and search nodes.
- Node Map: A visual map showing the locations of nodes based on latitude/longitude from node tags.
- Message Log: A log of all messages sent/received on the network with filtering options.
The following should be configurable via environment variables or command-line arguments:
- NETWORK_DOMAIN: Domain name for the web dashboard (e.g. "meshcore.example.com")
- NETWORK_NAME: Name of the MeshCore network
- NETWORK_CITY: Town/City where the network is located (e.g. "Ipswich")
- NETWORK_COUNTRY: Country where the network is located (ISO 3166-1 alpha-2 code)
- NETWORK_LOCATION: Latitude/Longitude of the network area location
- NETWORK_RADIO_CONFIG: Details about the radio configuration (frequency, power etc)
- NETWORK_CONTACT_EMAIL: Contact email address for network enquiries
- NETWORK_CONTACT_DISCORD: Discord server link for network community
The web dashboard should be a multi-page application using server-side rendering with FastAPI templates. It should use Tailwind CSS for styling and a modern UI component library (DaisyUI or similar) for consistent design. No JavaScript frameworks are required, any JS should be minimal and only for enhancing user experience (e.g. form validation, interactivity).
+23 -137
View File
@@ -15,11 +15,10 @@ Python 3.13+ platform for managing and orchestrating MeshCore mesh networks.
## Overview
MeshCore Hub provides a complete solution for monitoring, collecting, and interacting with MeshCore mesh networks. It consists of multiple components that work together:
MeshCore Hub provides a complete solution for monitoring, collecting, and interacting with MeshCore mesh networks. Data ingestion is handled by [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture), which observes MeshCore RF traffic and publishes decoded packets to MQTT. It consists of multiple components that work together:
| Component | Description |
|-----------|-------------|
| **Interface** | Connects to MeshCore companion nodes via Serial/USB, bridges events to/from MQTT |
| **Collector** | Subscribes to MQTT events and persists them to a database |
| **API** | REST API for querying data and sending commands to the network |
| **Web Dashboard** | Single Page Application (SPA) for visualizing network status |
@@ -34,21 +33,13 @@ flowchart LR
D3["Device 3"]
end
subgraph Interfaces["Interface Layer"]
I1["RECEIVER"]
I2["RECEIVER"]
I3["SENDER"]
end
PCAP["meshcore-packet-capture"]
D1 -->|Serial| I1
D2 -->|Serial| I2
D3 -->|Serial| I3
D1 -.->|RF| PCAP
D2 -.->|RF| PCAP
D3 -.->|RF| PCAP
I1 -->|Publish| MQTT
I2 -->|Publish| MQTT
MQTT -->|Subscribe| I3
MQTT["MQTT Broker"]
PCAP -->|Publish| MQTT["MQTT Broker"]
subgraph Backend["Backend Services"]
Collector --> Database --> API
@@ -58,7 +49,7 @@ flowchart LR
API --> Web["Web Dashboard"]
style Devices fill:none,stroke:#0288d1,stroke-width:2px
style Interfaces fill:none,stroke:#f57c00,stroke-width:2px
style PCAP fill:none,stroke:#f57c00,stroke-width:2px
style Backend fill:none,stroke:#388e3c,stroke-width:2px
style MQTT fill:none,stroke:#7b1fa2,stroke-width:3px
style Collector fill:none,stroke:#388e3c,stroke-width:2px
@@ -69,7 +60,6 @@ flowchart LR
## Features
- **Multi-node Support**: Connect multiple receiver nodes for better network coverage
- **Event Persistence**: Store messages, advertisements, telemetry, and trace data
- **REST API**: Query historical data with filtering and pagination
- **Command Dispatch**: Send messages and advertisements via the API
@@ -82,11 +72,10 @@ flowchart LR
### Simple Self-Hosted Setup
The quickest way to get started is running the entire stack on a single machine with a connected MeshCore device.
The quickest way to get started is running the entire stack on a single machine alongside [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture).
**Prerequisites:**
1. Flash the [USB Companion firmware](https://meshcore.dev/) onto a compatible device (e.g., Heltec V3, T-Beam)
2. Connect the device via USB to a machine that supports Docker or Python
1. Set up [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) on a device with a compatible LoRa radio (e.g., Heltec V3, T-Beam) to observe MeshCore RF traffic
**Steps:**
```bash
@@ -100,75 +89,16 @@ wget https://raw.githubusercontent.com/ipnet-mesh/meshcore-hub/refs/heads/main/.
# Copy and configure environment
cp .env.example .env
# Edit .env: set SERIAL_PORT to your device (e.g., /dev/ttyUSB0 or /dev/ttyACM0)
# Edit .env: set MQTT_HOST to your MQTT broker if not using the local one
# Start the entire stack with local MQTT broker
docker compose --profile mqtt --profile core --profile receiver up -d
docker compose --profile mqtt --profile core up -d
# View the web dashboard
open http://localhost:8080
```
This starts all services: MQTT broker, collector, API, web dashboard, and the interface receiver that bridges your MeshCore device to the system.
### Distributed Community Setup
For larger deployments, you can separate receiver nodes from the central infrastructure. This allows multiple community members to contribute receiver coverage while hosting the backend centrally.
```mermaid
flowchart TB
subgraph Community["Community Members"]
R1["Raspberry Pi + MeshCore"]
R2["Raspberry Pi + MeshCore"]
R3["Any Linux + MeshCore"]
end
subgraph Server["Community VPS / Server"]
MQTT["MQTT Broker"]
Collector
API
Web["Web Dashboard (public)"]
MQTT --> Collector --> API
API <--- Web
end
R1 -->|MQTT port 1883| MQTT
R2 -->|MQTT port 1883| MQTT
R3 -->|MQTT port 1883| MQTT
style Community fill:none,stroke:#0288d1,stroke-width:2px
style Server fill:none,stroke:#388e3c,stroke-width:2px
style MQTT fill:none,stroke:#7b1fa2,stroke-width:3px
style Collector fill:none,stroke:#388e3c,stroke-width:2px
style API fill:none,stroke:#1976d2,stroke-width:2px
style Web fill:none,stroke:#ffa000,stroke-width:2px
```
**On each receiver node (Raspberry Pi, etc.):**
```bash
# Only run the receiver component
# Configure .env with MQTT_HOST pointing to your central server
MQTT_HOST=your-community-server.com
SERIAL_PORT=/dev/ttyUSB0
docker compose --profile receiver up -d
```
**On the central server (VPS/cloud):**
```bash
# Run the core infrastructure with local MQTT broker
docker compose --profile mqtt --profile core up -d
# Or connect to an existing MQTT broker (set MQTT_HOST in .env)
docker compose --profile core up -d
```
This architecture allows:
- Multiple receivers for better RF coverage across a geographic area
- Centralized data storage and web interface
- Community members to contribute coverage with minimal setup
- The central server to be hosted anywhere with internet access
This starts all services: MQTT broker, collector, API, and web dashboard. MeshCore packet data is ingested via [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture), which publishes decoded packets to MQTT.
## Deployment
@@ -178,16 +108,15 @@ Docker Compose uses **profiles** to select which services to run:
| Profile | Services | Use Case |
|---------|----------|----------|
| `all` | db-migrate, collector, api, web | Everything on one host |
| `core` | db-migrate, collector, api, web | Central server infrastructure |
| `receiver` | interface-receiver | Receiver node (events to MQTT) |
| `sender` | interface-sender | Sender node (MQTT to device) |
| `mqtt` | mosquitto broker | Local MQTT broker (optional) |
| `mock` | interface-mock-receiver | Testing without hardware |
| `migrate` | db-migrate | One-time database migration |
| `seed` | seed | One-time seed data import |
| `receiver` | packet capture observer | Observes RF traffic and publishes to MQTT |
| `metrics` | prometheus, alertmanager | Prometheus metrics and alerting |
| `seed` | seed | One-time seed data import |
| `migrate` | db-migrate | One-time database migration |
**Note:** Most deployments connect to an external MQTT broker. Add `--profile mqtt` only if you need a local broker.
**Note:** Most deployments connect to an external MQTT broker. Add `--profile mqtt` only if you need a local broker. The `receiver` profile runs [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) to observe MeshCore RF traffic and publish decoded packets to MQTT.
```bash
# Create database schema
@@ -202,8 +131,8 @@ docker compose --profile mqtt --profile core up -d
# Or connect to external MQTT (configure MQTT_HOST in .env)
docker compose --profile core up -d
# Start just the receiver (connects to MQTT_HOST from .env)
docker compose --profile receiver up -d
# Start everything including packet capture observer
docker compose --profile mqtt --profile core --profile receiver up -d
# View logs
docker compose logs -f
@@ -212,35 +141,6 @@ docker compose logs -f
docker compose down
```
### Serial Device Access
For production with real MeshCore devices, ensure the serial port is accessible:
```bash
# Check device path
ls -la /dev/ttyUSB*
# Add user to dialout group (Linux)
sudo usermod -aG dialout $USER
# Configure in .env
SERIAL_PORT=/dev/ttyUSB0
SERIAL_PORT_SENDER=/dev/ttyUSB1 # If using separate sender device
```
**Tip:** If USB devices reconnect as different numeric IDs (e.g., `/dev/ttyUSB0` becomes `/dev/ttyUSB1`), use the stable `/dev/serial/by-id/` path instead:
```bash
# List available devices by ID
ls -la /dev/serial/by-id/
# Example output:
# usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0 -> ../../ttyUSB0
# Configure using the stable ID
SERIAL_PORT=/dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_abc123-if00-port0
```
### Manual Installation
```bash
@@ -255,7 +155,6 @@ pip install -e ".[dev]"
meshcore-hub db upgrade
# Start components (in separate terminals)
meshcore-hub interface receiver --port /dev/ttyUSB0
meshcore-hub collector
meshcore-hub api
meshcore-hub web
@@ -281,31 +180,18 @@ All components are configured via environment variables. Create a `.env` file or
| `MQTT_TRANSPORT` | `tcp` | MQTT transport (`tcp` or `websockets`) |
| `MQTT_WS_PATH` | `/mqtt` | MQTT WebSocket path (used when `MQTT_TRANSPORT=websockets`) |
### Interface Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `SERIAL_PORT` | `/dev/ttyUSB0` | Serial port for MeshCore device |
| `SERIAL_BAUD` | `115200` | Serial baud rate |
| `MESHCORE_DEVICE_NAME` | *(none)* | Device/node name set on startup (broadcast in advertisements) |
| `NODE_ADDRESS` | *(none)* | Override for device public key (64-char hex string) |
| `NODE_ADDRESS_SENDER` | *(none)* | Override for sender device public key |
| `CONTACT_CLEANUP_ENABLED` | `true` | Enable automatic removal of stale contacts from companion node |
| `CONTACT_CLEANUP_DAYS` | `7` | Remove contacts not advertised for this many days |
### Collector Settings
| Variable | Default | Description |
|----------|---------|-------------|
| `COLLECTOR_INGEST_MODE` | `native` | Ingest mode (`native` or `letsmesh_upload`) |
| `COLLECTOR_LETSMESH_DECODER_ENABLED` | `true` | Enable external LetsMesh packet decoding |
| `COLLECTOR_LETSMESH_DECODER_COMMAND` | `meshcore-decoder` | Decoder CLI command |
| `COLLECTOR_LETSMESH_DECODER_KEYS` | *(none)* | Additional decoder channel keys (`label=hex`, `label:hex`, or `hex`) |
| `COLLECTOR_LETSMESH_DECODER_TIMEOUT_SECONDS` | `2.0` | Timeout per decoder invocation |
#### LetsMesh Upload Compatibility Mode
#### LetsMesh Packet Decoding
When `COLLECTOR_INGEST_MODE=letsmesh_upload`, the collector subscribes to:
The collector subscribes to packets published by [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture):
- `<prefix>/+/packets`
- `<prefix>/+/status`
@@ -325,7 +211,7 @@ Normalization behavior:
- In the messages feed and dashboard channel sections, known channel indexes are preferred for labels (`17 -> Public`, `217 -> #test`) to avoid stale channel-name mismatches.
- Additional channel names are loaded from `COLLECTOR_LETSMESH_DECODER_KEYS` when entries are provided as `label=hex` (for example `bot=<key>`).
- Decoder-advertisement packets with location metadata update node GPS (`lat/lon`) for map display.
- This keeps advertisement listings closer to native mode behavior (node advert traffic only, not observer status telemetry).
- This keeps advertisement listings focused on node advert traffic only, not observer status telemetry.
- Packets without decryptable message text are kept as informational `letsmesh_packet` events and are not shown in the messages feed; when decode succeeds the decoded JSON is attached to those packet log events.
- When decoder output includes a human sender (`payload.decoded.decrypted.sender`), message text is normalized to `Name: Message` before storage; receiver/observer names are never used as sender fallback.
- The collector keeps built-in keys for `Public` and `#test`, and merges any additional keys from `COLLECTOR_LETSMESH_DECODER_KEYS`.
@@ -741,7 +627,6 @@ meshcore-hub db upgrade
meshcore-hub/
├── src/meshcore_hub/ # Main package
│ ├── common/ # Shared code (models, schemas, config)
│ ├── interface/ # MeshCore device interface
│ ├── collector/ # MQTT event collector
│ ├── api/ # REST API
│ └── web/ # Web dashboard
@@ -802,3 +687,4 @@ This project is licensed under the GNU General Public License v3.0 or later (GPL
- [MeshCore](https://meshcore.dev/) - The mesh networking protocol
- [meshcore](https://github.com/fdlamotte/meshcore) - Python library for MeshCore devices
- [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) - RF packet capture and MQTT publisher for data ingestion
+3 -3
View File
@@ -96,7 +96,7 @@ Direct/private messages between two nodes.
```
**Field Descriptions**:
- `pubkey_prefix`: First 12 characters of sender's public key (or source hash prefix in compatibility ingest modes)
- `pubkey_prefix`: First 12 characters of the source public key prefix, used for message identification (or source hash prefix in compatibility ingest modes)
- `path_len`: Number of hops message traveled
- `txt_type`: Message type indicator (0=plain, 2=signed, etc.)
- `signature`: Message signature (8 hex chars) when `txt_type=2`
@@ -149,7 +149,7 @@ Group/broadcast messages on specific channels.
**Field Descriptions**:
- `channel_idx`: Channel number (0-255) when available
- `channel_name`: Channel display label (e.g., `"Public"`, `"#test"`) when available
- `pubkey_prefix`: First 12 characters of sender's public key when available
- `pubkey_prefix`: First 12 characters of the source public key prefix, used for message identification when available
- `path_len`: Number of hops message traveled
- `txt_type`: Message type indicator (0=plain, 2=signed, etc.)
- `signature`: Message signature (8 hex chars) when `txt_type=2`
@@ -187,7 +187,7 @@ Group/broadcast messages on specific channels.
- In LetsMesh upload compatibility mode, `status` feed payloads are persisted as informational `letsmesh_status` events and are not normalized to `ADVERTISEMENT`.
- In LetsMesh upload compatibility mode, decoded payload type `4` is normalized to `ADVERTISEMENT` when node identity metadata is present.
- Payload type `4` location metadata (`appData.location.latitude/longitude`) is mapped to node `lat/lon` for map rendering.
- This keeps advertisement persistence aligned with native mode expectations (advertisement traffic only).
- This keeps advertisement persistence aligned with meshcore-packet-capture expectations (advertisement traffic only).
**Compatibility ingest note (non-message structured events)**:
- Decoded payload type `9` is normalized to `TRACE_DATA` (`traceTag`, flags, auth, path hashes, and SNR values).
-795
View File
@@ -1,795 +0,0 @@
# MeshCore Hub - Task Tracker
This document tracks implementation progress for the MeshCore Hub project. Each task can be checked off as completed.
---
## Phase 1: Foundation ✅
### 1.1 Project Setup
- [x] Create `pyproject.toml` with project metadata and dependencies
- [x] Configure Python 3.11+ requirement
- [x] Set up `src/meshcore_hub/` package structure
- [x] Create `__init__.py` files for all packages
- [x] Create `__main__.py` entry point
### 1.2 Development Tools
- [x] Configure `black` formatter settings in pyproject.toml
- [x] Configure `flake8` linting (create `.flake8` or add to pyproject.toml)
- [x] Configure `mypy` type checking settings
- [x] Configure `pytest` settings and test directory
- [x] Create `.pre-commit-config.yaml` with hooks:
- [x] black
- [x] flake8
- [x] mypy
- [x] trailing whitespace
- [x] end-of-file-fixer
- [x] Create `.env.example` with all environment variables
### 1.3 Common Package - Configuration
- [x] Create `common/config.py` with Pydantic Settings:
- [x] `CommonSettings` (logging, MQTT connection)
- [x] `InterfaceSettings` (mode, serial port, mock device)
- [x] `CollectorSettings` (database URL, webhook settings)
- [x] `APISettings` (host, port, API keys)
- [x] `WebSettings` (host, port, network info)
- [x] Implement environment variable loading
- [x] Implement CLI argument override support
- [x] Add configuration validation
### 1.4 Common Package - Database Models
- [x] Create `common/database.py`:
- [x] Database engine factory
- [x] Session management
- [x] Async session support
- [x] Create `common/models/base.py`:
- [x] Base model with UUID primary key
- [x] Timestamp mixins (created_at, updated_at)
- [x] Create `common/models/node.py`:
- [x] Node model (public_key, name, adv_type, flags, first_seen, last_seen)
- [x] Indexes on public_key
- [x] Create `common/models/node_tag.py`:
- [x] NodeTag model (node_id FK, key, value, value_type)
- [x] Unique constraint on (node_id, key)
- [x] Create `common/models/message.py`:
- [x] Message model (receiver_node_id, message_type, pubkey_prefix, channel_idx, text, etc.)
- [x] Indexes for common query patterns
- [x] Create `common/models/advertisement.py`:
- [x] Advertisement model (receiver_node_id, node_id, public_key, name, adv_type, flags)
- [x] Create `common/models/trace_path.py`:
- [x] TracePath model (receiver_node_id, initiator_tag, path_hashes JSON, snr_values JSON)
- [x] Create `common/models/telemetry.py`:
- [x] Telemetry model (receiver_node_id, node_id, node_public_key, lpp_data, parsed_data JSON)
- [x] Create `common/models/event_log.py`:
- [x] EventLog model (receiver_node_id, event_type, payload JSON)
- [x] Create `common/models/__init__.py` exporting all models
### 1.5 Common Package - Pydantic Schemas
- [x] Create `common/schemas/events.py`:
- [x] AdvertisementEvent schema
- [x] ContactMessageEvent schema
- [x] ChannelMessageEvent schema
- [x] TraceDataEvent schema
- [x] TelemetryResponseEvent schema
- [x] ContactsEvent schema
- [x] SendConfirmedEvent schema
- [x] StatusResponseEvent schema
- [x] BatteryEvent schema
- [x] PathUpdatedEvent schema
- [x] Create `common/schemas/nodes.py`:
- [x] NodeCreate, NodeRead, NodeList schemas
- [x] NodeTagCreate, NodeTagUpdate, NodeTagRead schemas
- [x] Create `common/schemas/messages.py`:
- [x] MessageRead, MessageList schemas
- [x] MessageFilters schema
- [x] Create `common/schemas/commands.py`:
- [x] SendMessageCommand schema
- [x] SendChannelMessageCommand schema
- [x] SendAdvertCommand schema
- [x] Create `common/schemas/__init__.py` exporting all schemas
### 1.6 Common Package - Utilities
- [x] Create `common/mqtt.py`:
- [x] MQTT client factory function
- [x] Topic builder utilities
- [x] Message serialization helpers
- [x] Async publish/subscribe wrappers
- [x] Create `common/logging.py`:
- [x] Logging configuration function
- [x] Structured logging format
- [x] Log level configuration from settings
### 1.7 Database Migrations
- [x] Create `alembic.ini` configuration
- [x] Create `alembic/env.py` with async support
- [x] Create `alembic/script.py.mako` template
- [x] Create initial migration with all tables:
- [x] nodes table
- [x] node_tags table
- [x] messages table
- [x] advertisements table
- [x] trace_paths table
- [x] telemetry table
- [x] events_log table
- [x] Test migration upgrade/downgrade
### 1.8 Main CLI Entry Point
- [x] Create root Click group in `__main__.py`
- [x] Add `--version` option
- [x] Add `--config` option for config file path
- [x] Add subcommand placeholders for: interface, collector, api, web, db
---
## Phase 2: Interface Component ✅
### 2.1 Device Abstraction
- [x] Create `interface/device.py`:
- [x] `MeshCoreDevice` class wrapping meshcore_py
- [x] Connection management (connect, disconnect, reconnect)
- [x] Get device public key via `send_appstart()`
- [x] Event subscription registration
- [x] Command sending methods
- [x] Create `interface/mock_device.py`:
- [x] `MockMeshCoreDevice` class
- [x] Configurable event generation
- [x] Simulated message sending
- [x] Simulated network topology (optional)
- [x] Configurable delays and error rates
### 2.2 Receiver Mode
- [x] Create `interface/receiver.py`:
- [x] `Receiver` class
- [x] Initialize MQTT client
- [x] Initialize MeshCore device
- [x] Subscribe to all relevant MeshCore events:
- [x] ADVERTISEMENT
- [x] CONTACT_MSG_RECV
- [x] CHANNEL_MSG_RECV
- [x] TRACE_DATA
- [x] TELEMETRY_RESPONSE
- [x] CONTACTS
- [x] SEND_CONFIRMED
- [x] STATUS_RESPONSE
- [x] BATTERY
- [x] PATH_UPDATED
- [x] Event handler that publishes to MQTT
- [x] Topic construction: `<prefix>/<pubkey>/event/<event_name>`
- [x] JSON serialization of event payloads
- [x] Graceful shutdown handling
### 2.3 Sender Mode
- [x] Create `interface/sender.py`:
- [x] `Sender` class
- [x] Initialize MQTT client
- [x] Initialize MeshCore device
- [x] Subscribe to command topics:
- [x] `<prefix>/+/command/send_msg`
- [x] `<prefix>/+/command/send_channel_msg`
- [x] `<prefix>/+/command/send_advert`
- [x] `<prefix>/+/command/request_status`
- [x] `<prefix>/+/command/request_telemetry`
- [x] Command handlers:
- [x] `handle_send_msg` - send direct message
- [x] `handle_send_channel_msg` - send channel message
- [x] `handle_send_advert` - send advertisement
- [x] `handle_request_status` - request node status
- [x] `handle_request_telemetry` - request telemetry
- [x] Error handling and logging
- [x] Graceful shutdown handling
### 2.4 Interface CLI
- [x] Create `interface/cli.py`:
- [x] `interface` Click command group
- [x] `--mode` option (receiver/sender, required)
- [x] `--port` option for serial port
- [x] `--baud` option for baud rate
- [x] `--mock` flag to use mock device
- [x] `--mqtt-host`, `--mqtt-port` options
- [x] `--prefix` option for MQTT topic prefix
- [x] Signal handlers for graceful shutdown
- [x] Register CLI with main entry point
### 2.5 Interface Tests
- [x] Create `tests/test_interface/conftest.py`:
- [x] Mock MQTT client fixture
- [x] Mock device fixture
- [x] Create `tests/test_interface/test_device.py`:
- [x] Test connection/disconnection
- [x] Test event subscription
- [x] Test command sending
- [x] Create `tests/test_interface/test_mock_device.py`:
- [x] Test mock event generation
- [x] Test mock command handling
- [x] Create `tests/test_interface/test_receiver.py`:
- [x] Test event to MQTT publishing
- [x] Test topic construction
- [x] Test payload serialization
- [x] Create `tests/test_interface/test_sender.py`:
- [x] Test MQTT to command dispatching
- [x] Test command payload parsing
- [x] Test error handling
---
## Phase 3: Collector Component ✅
### 3.1 MQTT Subscriber
- [x] Create `collector/subscriber.py`:
- [x] `Subscriber` class
- [x] Initialize MQTT client
- [x] Subscribe to all event topics: `<prefix>/+/event/#`
- [x] Parse topic to extract public_key and event_type
- [x] Route events to appropriate handlers
- [x] Handle connection/disconnection
- [x] Graceful shutdown
### 3.2 Event Handlers
- [x] Create `collector/handlers/__init__.py`:
- [x] Handler registry pattern
- [x] Create `collector/handlers/advertisement.py`:
- [x] Parse advertisement payload
- [x] Upsert node in nodes table
- [x] Insert advertisement record
- [x] Update node last_seen timestamp
- [x] Create `collector/handlers/message.py`:
- [x] Parse contact/channel message payload
- [x] Insert message record
- [x] Handle both CONTACT_MSG_RECV and CHANNEL_MSG_RECV
- [x] Create `collector/handlers/trace.py`:
- [x] Parse trace data payload
- [x] Insert trace_path record
- [x] Create `collector/handlers/telemetry.py`:
- [x] Parse telemetry payload
- [x] Insert telemetry record
- [x] Optionally upsert node
- [x] Create `collector/handlers/contacts.py`:
- [x] Parse contacts sync payload
- [x] Upsert multiple nodes
- [x] Create `collector/handlers/event_log.py`:
- [x] Generic handler for events_log table
- [x] Handle informational events (SEND_CONFIRMED, STATUS_RESPONSE, BATTERY, PATH_UPDATED)
### 3.3 Webhook Dispatcher (Optional based on Q10)
- [x] Create `collector/webhook.py`:
- [x] `WebhookDispatcher` class
- [x] Webhook configuration loading
- [x] JSONPath filtering support
- [x] Async HTTP POST sending
- [x] Retry logic with backoff
- [x] Error logging
### 3.4 Collector CLI
- [x] Create `collector/cli.py`:
- [x] `collector` Click command
- [x] `--mqtt-host`, `--mqtt-port` options
- [x] `--prefix` option
- [x] `--database-url` option
- [x] Signal handlers for graceful shutdown
- [x] Register CLI with main entry point
### 3.5 Collector Tests
- [x] Create `tests/test_collector/conftest.py`:
- [x] In-memory SQLite database fixture
- [x] Mock MQTT client fixture
- [x] Create `tests/test_collector/test_subscriber.py`:
- [x] Test topic parsing
- [x] Test event routing
- [x] Create `tests/test_collector/test_handlers/`:
- [x] `test_advertisement.py`
- [x] `test_message.py`
- [x] `test_trace.py`
- [x] `test_telemetry.py`
- [x] `test_contacts.py`
- [x] Create `tests/test_collector/test_webhook.py`:
- [x] Test webhook dispatching
- [x] Test JSONPath filtering
- [x] Test retry logic
---
## Phase 4: API Component ✅
### 4.1 FastAPI Application Setup
- [x] Create `api/app.py`:
- [x] FastAPI application instance
- [x] Lifespan handler for startup/shutdown
- [x] Include all routers
- [x] Exception handlers
- [x] CORS middleware configuration
- [x] Create `api/dependencies.py`:
- [x] Database session dependency
- [x] MQTT client dependency
- [x] Settings dependency
### 4.2 Authentication
- [x] Create `api/auth.py`:
- [x] Bearer token extraction
- [x] `require_read` dependency (read or admin key)
- [x] `require_admin` dependency (admin key only)
- [x] 401/403 error responses
### 4.3 Node Routes
- [x] Create `api/routes/nodes.py`:
- [x] `GET /api/v1/nodes` - list nodes with pagination
- [x] Query params: limit, offset, search, adv_type
- [x] `GET /api/v1/nodes/{public_key}` - get single node
- [x] Include related tags in response (optional)
### 4.4 Node Tag Routes
- [x] Create `api/routes/node_tags.py`:
- [x] `GET /api/v1/nodes/{public_key}/tags` - list tags
- [x] `POST /api/v1/nodes/{public_key}/tags` - create tag (admin)
- [x] `PUT /api/v1/nodes/{public_key}/tags/{key}` - update tag (admin)
- [x] `DELETE /api/v1/nodes/{public_key}/tags/{key}` - delete tag (admin)
### 4.5 Message Routes
- [x] Create `api/routes/messages.py`:
- [x] `GET /api/v1/messages` - list messages with filters
- [x] Query params: type, pubkey_prefix, channel_idx, since, until, limit, offset
- [x] `GET /api/v1/messages/{id}` - get single message
### 4.6 Advertisement Routes
- [x] Create `api/routes/advertisements.py`:
- [x] `GET /api/v1/advertisements` - list advertisements
- [x] Query params: public_key, since, until, limit, offset
- [x] `GET /api/v1/advertisements/{id}` - get single advertisement
### 4.7 Trace Path Routes
- [x] Create `api/routes/trace_paths.py`:
- [x] `GET /api/v1/trace-paths` - list trace paths
- [x] Query params: since, until, limit, offset
- [x] `GET /api/v1/trace-paths/{id}` - get single trace path
### 4.8 Telemetry Routes
- [x] Create `api/routes/telemetry.py`:
- [x] `GET /api/v1/telemetry` - list telemetry records
- [x] Query params: node_public_key, since, until, limit, offset
- [x] `GET /api/v1/telemetry/{id}` - get single telemetry record
### 4.9 Command Routes
- [x] Create `api/routes/commands.py`:
- [x] `POST /api/v1/commands/send-message` (admin)
- [x] Request body: destination, text, timestamp (optional)
- [x] Publish to MQTT command topic
- [x] `POST /api/v1/commands/send-channel-message` (admin)
- [x] Request body: channel_idx, text, timestamp (optional)
- [x] Publish to MQTT command topic
- [x] `POST /api/v1/commands/send-advertisement` (admin)
- [x] Request body: flood (boolean)
- [x] Publish to MQTT command topic
### 4.10 Dashboard Routes
- [x] Create `api/routes/dashboard.py`:
- [x] `GET /api/v1/stats` - JSON statistics
- [x] Total nodes count
- [x] Active nodes (last 24h)
- [x] Total messages count
- [x] Messages today
- [x] Total advertisements
- [x] Channel message counts
- [x] `GET /api/v1/dashboard` - HTML dashboard
- [x] Create `api/templates/dashboard.html`:
- [x] Simple HTML template
- [x] Display statistics
- [x] Basic CSS styling
- [x] Auto-refresh meta tag (optional)
### 4.11 API Router Registration
- [x] Create `api/routes/__init__.py`:
- [x] Create main API router
- [x] Include all sub-routers with prefixes
- [x] Add OpenAPI tags
### 4.12 API CLI
- [x] Create `api/cli.py`:
- [x] `api` Click command
- [x] `--host` option
- [x] `--port` option
- [x] `--database-url` option
- [x] `--read-key` option
- [x] `--admin-key` option
- [x] `--mqtt-host`, `--mqtt-port` options
- [x] `--reload` flag for development
- [x] Register CLI with main entry point
### 4.13 API Tests
- [x] Create `tests/test_api/conftest.py`:
- [x] Test client fixture
- [x] In-memory database fixture
- [x] Test API keys
- [x] Create `tests/test_api/test_auth.py`:
- [x] Test missing token
- [x] Test invalid token
- [x] Test read-only access
- [x] Test admin access
- [x] Create `tests/test_api/test_nodes.py`:
- [x] Test list nodes
- [x] Test get node
- [x] Test pagination
- [x] Test filtering
- [x] Create `tests/test_api/test_node_tags.py`:
- [x] Test CRUD operations
- [x] Test permission checks
- [x] Create `tests/test_api/test_messages.py`:
- [x] Test list messages
- [x] Test filtering
- [x] Create `tests/test_api/test_commands.py`:
- [x] Test send message command
- [x] Test permission checks
- [x] Test MQTT publishing
---
## Phase 5: Web Dashboard ✅
### 5.1 FastAPI Application Setup
- [x] Create `web/app.py`:
- [x] FastAPI application instance
- [x] Jinja2 templates configuration
- [x] Static files mounting
- [x] Lifespan handler
- [x] Include all routers
### 5.2 Frontend Assets
- [x] Create `web/static/css/` directory
- [x] Set up Tailwind CSS:
- [x] Using Tailwind CDN with DaisyUI plugin
- [x] Configured in base.html template
- [x] Create `web/static/js/` directory:
- [x] Minimal JS for interactivity (if needed)
### 5.3 Base Template
- [x] Create `web/templates/base.html`:
- [x] HTML5 doctype and structure
- [x] Meta tags (viewport, charset)
- [x] Tailwind CSS inclusion
- [x] Navigation header:
- [x] Network name
- [x] Links to all pages
- [x] Footer with contact info
- [x] Content block for page content
### 5.4 Home Page
- [x] Create `web/routes/home.py`:
- [x] `GET /` - home page route
- [x] Load network configuration
- [x] Create `web/templates/home.html`:
- [x] Welcome message with network name
- [x] Network description/details
- [x] Radio configuration display
- [x] Location information
- [x] Contact information (email, Discord)
- [x] Quick links to other sections
### 5.5 Members Page
- [x] Create `web/routes/members.py`:
- [x] `GET /members` - members list route
- [x] Load members from JSON file
- [x] Create `web/templates/members.html`:
- [x] Members list/grid
- [x] Member cards with:
- [x] Name
- [x] Callsign (if applicable)
- [x] Role/description
- [x] Contact info (optional)
### 5.6 Network Overview Page
- [x] Create `web/routes/network.py`:
- [x] `GET /network` - network stats route
- [x] Fetch stats from API
- [x] Create `web/templates/network.html`:
- [x] Statistics cards:
- [x] Total nodes
- [x] Active nodes
- [x] Total messages
- [x] Messages today
- [x] Channel statistics
- [x] Recent activity summary
### 5.7 Nodes Page
- [x] Create `web/routes/nodes.py`:
- [x] `GET /nodes` - nodes list route
- [x] `GET /nodes/{public_key}` - node detail route
- [x] Fetch from API with pagination
- [x] Create `web/templates/nodes.html`:
- [x] Search/filter form
- [x] Nodes table:
- [x] Name
- [x] Public key (truncated)
- [x] Type
- [x] Last seen
- [x] Tags
- [x] Pagination controls
- [x] Create `web/templates/node_detail.html`:
- [x] Full node information
- [x] All tags
- [x] Recent messages (if any)
- [x] Recent advertisements
### 5.8 Node Map Page
- [x] Create `web/routes/map.py`:
- [x] `GET /map` - map page route
- [x] `GET /map/data` - JSON endpoint for node locations
- [x] Filter nodes with location tags
- [x] Create `web/templates/map.html`:
- [x] Leaflet.js map container
- [x] Leaflet CSS/JS includes
- [x] JavaScript for:
- [x] Initialize map centered on NETWORK_LOCATION
- [x] Fetch node location data
- [x] Add markers for each node
- [x] Popup with node info on click
### 5.9 Messages Page
- [x] Create `web/routes/messages.py`:
- [x] `GET /messages` - messages list route
- [x] Fetch from API with filters
- [x] Create `web/templates/messages.html`:
- [x] Filter form:
- [x] Message type (contact/channel)
- [x] Channel selector
- [x] Date range
- [x] Search text
- [x] Messages table:
- [x] Timestamp
- [x] Type
- [x] Sender/Channel
- [x] Text (truncated)
- [x] SNR
- [x] Hops
- [x] Pagination controls
### 5.10 Web CLI
- [x] Create `web/cli.py`:
- [x] `web` Click command
- [x] `--host` option
- [x] `--port` option
- [x] `--api-url` option
- [x] `--api-key` option
- [x] Network configuration options:
- [x] `--network-name`
- [x] `--network-city`
- [x] `--network-country`
- [x] `--network-location`
- [x] `--network-radio-config`
- [x] `--network-contact-email`
- [x] `--network-contact-discord`
- [x] `--members-file` option
- [x] `--reload` flag for development
- [x] Register CLI with main entry point
### 5.11 Web Tests
- [x] Create `tests/test_web/conftest.py`:
- [x] Test client fixture
- [x] Mock API responses
- [x] Create `tests/test_web/test_home.py`
- [x] Create `tests/test_web/test_members.py`
- [x] Create `tests/test_web/test_network.py`
- [x] Create `tests/test_web/test_nodes.py`
- [x] Create `tests/test_web/test_map.py`
- [x] Create `tests/test_web/test_messages.py`
---
## Phase 6: Docker & Deployment
### 6.1 Dockerfile ✅
- [x] Create `docker/Dockerfile`:
- [x] Multi-stage build:
- [x] Stage 1: Build frontend assets (Tailwind)
- [x] Stage 2: Python dependencies
- [x] Stage 3: Final runtime image
- [x] Base image: python:3.11-slim
- [x] Install system dependencies
- [x] Copy and install Python package
- [x] Copy frontend assets
- [x] Set entrypoint to `meshcore-hub`
- [x] Default CMD (show help)
- [x] Health check instruction
### 6.2 Docker Compose ✅
- [x] Create `docker/docker-compose.yml`:
- [x] MQTT broker service (Eclipse Mosquitto):
- [x] Port mapping (1883, 9001)
- [x] Volume for persistence
- [x] Configuration file
- [x] Interface Receiver service:
- [x] Depends on MQTT
- [x] Device passthrough (/dev/ttyUSB0)
- [x] Environment variables
- [x] Interface Sender service:
- [x] Depends on MQTT
- [x] Device passthrough
- [x] Environment variables
- [x] Collector service:
- [x] Depends on MQTT
- [x] Database volume
- [x] Environment variables
- [x] API service:
- [x] Depends on Collector (for DB)
- [x] Port mapping (8000)
- [x] Database volume (shared)
- [x] Environment variables
- [x] Web service:
- [x] Depends on API
- [x] Port mapping (8080)
- [x] Environment variables
- [x] Create `docker/mosquitto.conf`:
- [x] Listener configuration
- [x] Anonymous access (or auth)
- [x] Persistence settings
### 6.3 Health Checks ✅
- [x] Add health check endpoint to API:
- [x] `GET /health` - basic health
- [x] `GET /health/ready` - includes DB check
- [x] Add health check endpoint to Web:
- [x] `GET /health` - basic health
- [x] `GET /health/ready` - includes API connectivity
- [x] Add health check to Interface:
- [x] Device connection status
- [x] MQTT connection status
- [x] Add health check to Collector:
- [x] MQTT connection status
- [x] Database connection status
### 6.4 Database CLI Commands ✅
- [x] Create `db` Click command group:
- [x] `meshcore-hub db upgrade` - run migrations
- [x] `meshcore-hub db downgrade` - rollback migration
- [x] `meshcore-hub db revision -m "message"` - create migration
- [x] `meshcore-hub db current` - show current revision
- [x] `meshcore-hub db history` - show migration history
### 6.5 Documentation
- [x] Update `README.md`:
- [x] Project description
- [x] Quick start guide
- [x] Docker deployment instructions
- [x] Manual installation instructions
- [x] Configuration reference
- [x] CLI reference
- [ ] Create `docs/` directory (optional):
- [ ] Architecture overview
- [ ] API documentation link
- [ ] Deployment guides
### 6.6 CI/CD ✅
- [x] Create `.github/workflows/ci.yml`:
- [x] Run on push/PR
- [x] Set up Python
- [x] Install dependencies
- [x] Run linting (black, flake8)
- [x] Run type checking (mypy)
- [x] Run tests (pytest)
- [x] Upload coverage report
- [x] Create `.github/workflows/docker.yml`:
- [x] Build Docker image
- [x] Push to registry (on release)
### 6.7 End-to-End Testing ✅
- [x] Create `tests/e2e/` directory
- [x] Create `tests/e2e/docker-compose.test.yml`:
- [x] All services with mock device
- [x] Test database
- [x] Create `tests/e2e/test_full_flow.py`:
- [x] Start all services
- [x] Generate mock events
- [x] Verify events stored in database
- [x] Verify API returns events
- [x] Verify web dashboard displays data
- [x] Test command flow (API -> MQTT -> Sender)
---
## Progress Summary
| Phase | Total Tasks | Completed | Progress |
|-------|-------------|-----------|----------|
| Phase 1: Foundation | 47 | 47 | 100% |
| Phase 2: Interface | 35 | 35 | 100% |
| Phase 3: Collector | 27 | 27 | 100% |
| Phase 4: API | 44 | 44 | 100% |
| Phase 5: Web Dashboard | 40 | 40 | 100% |
| Phase 6: Docker & Deployment | 28 | 25 | 89% |
| **Total** | **221** | **218** | **99%** |
*Note: Remaining 3 tasks are optional (creating a `docs/` directory).*
---
## Notes & Decisions
### Decisions Made
*(Record architectural decisions and answers to clarifying questions here)*
- [x] LetsMesh/native advertisement parity: in `letsmesh_upload` mode, observer `status` feed stays informational (`letsmesh_status`) and does not populate `advertisements`.
- [x] LetsMesh advertisement persistence source: decoded packet payload type `4` maps to `advertisement`; payload type `11` maps to `contact` parity updates.
- [x] LetsMesh native-event parity extensions: payload type `9` maps to `trace_data`, payload type `8` maps to informational `path_updated`, and payload type `1` can map to response-style native events when decryptable structured content exists.
- [ ] Q1 (MQTT Broker):
- [ ] Q2 (Database):
- [ ] Q3 (Web Dashboard Separation):
- [ ] Q4 (Members JSON Location):
- [ ] Q5 (Multiple Serial Devices):
- [ ] Q6 (Reconnection Strategy):
- [ ] Q7 (Mock Device Scope):
- [ ] Q8 (Event Deduplication):
- [ ] Q9 (Data Retention):
- [ ] Q10 (Webhook Configuration):
- [ ] Q11 (API Key Management):
- [ ] Q12 (Rate Limiting):
- [ ] Q13 (CORS):
- [ ] Q14 (Dashboard Authentication):
- [ ] Q15 (Real-time Updates):
- [ ] Q16 (Map Provider):
- [ ] Q17 (Tag Value Types):
- [ ] Q18 (Reserved Tag Names):
- [ ] Q19 (Health Checks):
- [ ] Q20 (Metrics/Observability):
- [ ] Q21 (Log Level Configuration):
### Blockers
*(Track any blockers or dependencies here)*
### Session Log
*(Track what was accomplished in each session)*
| Date | Session | Tasks Completed | Notes |
|------|---------|-----------------|-------|
| 2025-12-03 | 1 | Phase 1: Foundation | Project setup, common package, database models, schemas, migrations, CLI |
| 2025-12-03 | 2 | Phase 2: Interface | Device abstraction, mock device, receiver/sender modes, CLI, tests |
| 2025-12-03 | 3 | Phase 3: Collector | MQTT subscriber, event handlers, CLI, tests (webhook pending) |
| 2025-12-03 | 4 | Phase 4: API | FastAPI app, auth, all routes, CLI, tests (108 passed, 9 pre-existing failures) |
| 2025-12-03 | 5 | Phase 5: Web Dashboard | FastAPI + Jinja2, Tailwind/DaisyUI, Leaflet map, all pages, CLI (tests pending) |
| 2025-12-03 | 6 | Code quality | Aligned mypy settings between `mypy src/` and pre-commit hooks; added meshcore to ignore_missing_imports, added alembic to pre-commit dependencies |
| 2025-12-03 | 7 | Docker packaging | Fixed pyproject.toml package-data to include web/templates and api/templates in wheel builds |
@@ -0,0 +1,123 @@
"""Rename receiver_node_id to observer_node_id and event_receivers to event_observers
Revision ID: a1b2c3d4e5f6
Revises: 4e2e787a1660
Create Date: 2026-04-12
Note: The unique constraint on event_observers retains its original name
(uq_event_receivers_hash_node) since SQLite does not support renaming
constraints without fully recreating the table with explicit DDL. The
constraint correctly references the renamed column (observer_node_id)
and the ORM uses column-based conflict resolution, so this has no
functional impact.
"""
from alembic import op
revision = "a1b2c3d4e5f6"
down_revision = "4e2e787a1660"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.rename_table("event_receivers", "event_observers")
op.drop_index("ix_event_receivers_event_hash", table_name="event_observers")
op.drop_index("ix_event_receivers_receiver_node_id", table_name="event_observers")
op.drop_index("ix_event_receivers_type_hash", table_name="event_observers")
with op.batch_alter_table("event_observers", recreate="always") as batch_op:
batch_op.alter_column("receiver_node_id", new_column_name="observer_node_id")
batch_op.alter_column("received_at", new_column_name="observed_at")
op.create_index("ix_event_observers_event_hash", "event_observers", ["event_hash"])
op.create_index(
"ix_event_observers_observer_node_id", "event_observers", ["observer_node_id"]
)
op.create_index(
"ix_event_observers_type_hash", "event_observers", ["event_type", "event_hash"]
)
op.drop_index("ix_advertisements_receiver_node_id", table_name="advertisements")
with op.batch_alter_table("advertisements") as batch_op:
batch_op.alter_column("receiver_node_id", new_column_name="observer_node_id")
op.create_index(
"ix_advertisements_observer_node_id", "advertisements", ["observer_node_id"]
)
op.drop_index("ix_messages_receiver_node_id", table_name="messages")
with op.batch_alter_table("messages") as batch_op:
batch_op.alter_column("receiver_node_id", new_column_name="observer_node_id")
op.create_index("ix_messages_observer_node_id", "messages", ["observer_node_id"])
op.drop_index("ix_trace_paths_receiver_node_id", table_name="trace_paths")
with op.batch_alter_table("trace_paths") as batch_op:
batch_op.alter_column("receiver_node_id", new_column_name="observer_node_id")
op.create_index(
"ix_trace_paths_observer_node_id", "trace_paths", ["observer_node_id"]
)
op.drop_index("ix_telemetry_receiver_node_id", table_name="telemetry")
with op.batch_alter_table("telemetry") as batch_op:
batch_op.alter_column("receiver_node_id", new_column_name="observer_node_id")
op.create_index("ix_telemetry_observer_node_id", "telemetry", ["observer_node_id"])
op.drop_index("ix_events_log_receiver_node_id", table_name="events_log")
with op.batch_alter_table("events_log") as batch_op:
batch_op.alter_column("receiver_node_id", new_column_name="observer_node_id")
op.create_index(
"ix_events_log_observer_node_id", "events_log", ["observer_node_id"]
)
def downgrade() -> None:
op.drop_index("ix_events_log_observer_node_id", table_name="events_log")
with op.batch_alter_table("events_log") as batch_op:
batch_op.alter_column("observer_node_id", new_column_name="receiver_node_id")
op.create_index(
"ix_events_log_receiver_node_id", "events_log", ["receiver_node_id"]
)
op.drop_index("ix_telemetry_observer_node_id", table_name="telemetry")
with op.batch_alter_table("telemetry") as batch_op:
batch_op.alter_column("observer_node_id", new_column_name="receiver_node_id")
op.create_index("ix_telemetry_receiver_node_id", "telemetry", ["receiver_node_id"])
op.drop_index("ix_trace_paths_observer_node_id", table_name="trace_paths")
with op.batch_alter_table("trace_paths") as batch_op:
batch_op.alter_column("observer_node_id", new_column_name="receiver_node_id")
op.create_index(
"ix_trace_paths_receiver_node_id", "trace_paths", ["receiver_node_id"]
)
op.drop_index("ix_messages_observer_node_id", table_name="messages")
with op.batch_alter_table("messages") as batch_op:
batch_op.alter_column("observer_node_id", new_column_name="receiver_node_id")
op.create_index("ix_messages_receiver_node_id", "messages", ["receiver_node_id"])
op.drop_index("ix_advertisements_observer_node_id", table_name="advertisements")
with op.batch_alter_table("advertisements") as batch_op:
batch_op.alter_column("observer_node_id", new_column_name="receiver_node_id")
op.create_index(
"ix_advertisements_receiver_node_id", "advertisements", ["receiver_node_id"]
)
op.drop_index("ix_event_observers_event_hash", table_name="event_observers")
op.drop_index("ix_event_observers_observer_node_id", table_name="event_observers")
op.drop_index("ix_event_observers_type_hash", table_name="event_observers")
with op.batch_alter_table("event_observers", recreate="always") as batch_op:
batch_op.alter_column("observer_node_id", new_column_name="receiver_node_id")
batch_op.alter_column("observed_at", new_column_name="received_at")
op.create_index("ix_event_receivers_event_hash", "event_observers", ["event_hash"])
op.create_index(
"ix_event_receivers_receiver_node_id", "event_observers", ["receiver_node_id"]
)
op.create_index(
"ix_event_receivers_type_hash", "event_observers", ["event_type", "event_hash"]
)
op.rename_table("event_observers", "event_receivers")
+36 -122
View File
@@ -18,98 +18,34 @@ services:
- mosquitto_data:/mosquitto/data
- mosquitto_log:/mosquitto/log
healthcheck:
test: ["CMD", "mosquitto_sub", "-t", "$$SYS/#", "-C", "1", "-i", "healthcheck", "-W", "3"]
test:
[
"CMD",
"mosquitto_sub",
"-t",
"$$SYS/#",
"-C",
"1",
"-i",
"healthcheck",
"-W",
"3",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# ==========================================================================
# Interface Receiver - MeshCore device to MQTT bridge (events)
# Native receiver using the built-in meshcore-hub interface.
# For the packet-capture based receiver, use --profile receiver instead.
# ==========================================================================
interface-receiver:
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
build:
context: .
dockerfile: Dockerfile
container_name: meshcore-interface-receiver
profiles:
- all
- native-receiver
restart: unless-stopped
devices:
- "${SERIAL_PORT:-/dev/ttyUSB0}:${SERIAL_PORT:-/dev/ttyUSB0}"
user: root # Required for device access
environment:
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- MQTT_HOST=${MQTT_HOST:-mqtt}
- MQTT_PORT=${MQTT_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
- MQTT_TLS=${MQTT_TLS:-false}
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-tcp}
- MQTT_WS_PATH=${MQTT_WS_PATH:-/mqtt}
- SERIAL_PORT=${SERIAL_PORT:-/dev/ttyUSB0}
- SERIAL_BAUD=${SERIAL_BAUD:-115200}
- NODE_ADDRESS=${NODE_ADDRESS:-}
command: ["interface", "receiver"]
healthcheck:
test: ["CMD", "meshcore-hub", "health", "interface"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# ==========================================================================
# Interface Sender - MQTT to MeshCore device bridge (commands)
# ==========================================================================
interface-sender:
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
build:
context: .
dockerfile: Dockerfile
container_name: meshcore-interface-sender
profiles:
- all
- sender
restart: unless-stopped
devices:
- "${SERIAL_PORT_SENDER:-/dev/ttyUSB1}:${SERIAL_PORT_SENDER:-/dev/ttyUSB1}"
user: root # Required for device access
environment:
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- MQTT_HOST=${MQTT_HOST:-mqtt}
- MQTT_PORT=${MQTT_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
- MQTT_TLS=${MQTT_TLS:-false}
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-tcp}
- MQTT_WS_PATH=${MQTT_WS_PATH:-/mqtt}
- SERIAL_PORT=${SERIAL_PORT_SENDER:-/dev/ttyUSB1}
- SERIAL_BAUD=${SERIAL_BAUD:-115200}
- NODE_ADDRESS=${NODE_ADDRESS_SENDER:-}
command: ["interface", "sender"]
healthcheck:
test: ["CMD", "meshcore-hub", "health", "interface"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
# ==========================================================================
# Interface Packet Capture - MeshCore packet capture to MQTT (serial only)
# Uses ghcr.io/agessaman/meshcore-packet-capture (separate image)
# Packet Capture - MeshCore packet capture to MQTT (serial only)
# Uses ghcr.io/agessaman/meshcore-packet-capture (separate project)
# Publishes to local MQTT (broker 3) in LetsMesh upload format, which the
# collector ingests with COLLECTOR_INGEST_MODE=letsmesh_upload.
# Optionally publish to Let's Mesh (brokers 1 & 2) for cloud map integration.
# ==========================================================================
interface-packet-capture:
packet-capture:
image: ghcr.io/agessaman/meshcore-packet-capture:${PACKETCAPTURE_IMAGE_VERSION:-latest}
container_name: meshcore-interface-packet-capture
container_name: meshcore-packet-capture
profiles:
- all
- receiver
@@ -167,39 +103,6 @@ services:
volumes:
- packetcapture_data:/app/data
# ==========================================================================
# Interface Mock Receiver - For testing without real devices
# ==========================================================================
interface-mock-receiver:
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
build:
context: .
dockerfile: Dockerfile
container_name: meshcore-interface-mock-receiver
profiles:
- all
- native-receiver
restart: unless-stopped
environment:
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- MQTT_HOST=${MQTT_HOST:-mqtt}
- MQTT_PORT=${MQTT_PORT:-1883}
- MQTT_USERNAME=${MQTT_USERNAME:-}
- MQTT_PASSWORD=${MQTT_PASSWORD:-}
- MQTT_PREFIX=${MQTT_PREFIX:-meshcore}
- MQTT_TLS=${MQTT_TLS:-false}
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-tcp}
- MQTT_WS_PATH=${MQTT_WS_PATH:-/mqtt}
- MOCK_DEVICE=true
- NODE_ADDRESS=${NODE_ADDRESS:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
command: ["interface", "receiver", "--mock"]
healthcheck:
test: ["CMD", "meshcore-hub", "health", "interface"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# ==========================================================================
# Collector - MQTT subscriber and database storage
# ==========================================================================
@@ -229,7 +132,6 @@ services:
- MQTT_TLS=${MQTT_TLS:-false}
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-tcp}
- MQTT_WS_PATH=${MQTT_WS_PATH:-/mqtt}
- COLLECTOR_INGEST_MODE=${COLLECTOR_INGEST_MODE:-letsmesh_upload}
- COLLECTOR_LETSMESH_DECODER_ENABLED=${COLLECTOR_LETSMESH_DECODER_ENABLED:-true}
- COLLECTOR_LETSMESH_DECODER_COMMAND=${COLLECTOR_LETSMESH_DECODER_COMMAND:-meshcore-decoder}
- COLLECTOR_LETSMESH_DECODER_KEYS=${COLLECTOR_LETSMESH_DECODER_KEYS:-}
@@ -263,7 +165,7 @@ services:
start_period: 10s
# ==========================================================================
# API Server - REST API for querying data and sending commands
# API Server - REST API for querying data
# ==========================================================================
api:
image: ghcr.io/ipnet-mesh/meshcore-hub:${IMAGE_VERSION:-latest}
@@ -303,7 +205,13 @@ services:
- METRICS_CACHE_TTL=${METRICS_CACHE_TTL:-60}
command: ["api"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')",
]
interval: 30s
timeout: 10s
retries: 3
@@ -363,7 +271,13 @@ services:
- FEATURE_PAGES=${FEATURE_PAGES:-true}
command: ["web"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8080/health')",
]
interval: 30s
timeout: 10s
retries: 3
@@ -430,8 +344,8 @@ services:
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=30d'
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.retention.time=30d"
volumes:
- ./etc/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./etc/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
@@ -453,8 +367,8 @@ services:
- ./etc/alertmanager/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
- "--config.file=/etc/alertmanager/alertmanager.yml"
- "--storage.path=/alertmanager"
# ==========================================================================
# Volumes
-2
View File
@@ -38,7 +38,6 @@ dependencies = [
"python-multipart>=0.0.6",
"httpx>=0.25.0",
"aiosqlite>=0.19.0",
"meshcore>=2.3.0",
"pyyaml>=6.0.0",
"python-frontmatter>=1.0.0",
"markdown>=3.5.0",
@@ -115,7 +114,6 @@ module = [
"paho.*",
"uvicorn.*",
"alembic.*",
"meshcore.*",
"frontmatter.*",
"markdown.*",
"prometheus_client.*",
-24
View File
@@ -37,12 +37,10 @@ def cli(ctx: click.Context, log_level: str) -> None:
# Import and register component CLIs
from meshcore_hub.interface.cli import interface
from meshcore_hub.collector.cli import collector
from meshcore_hub.api.cli import api
from meshcore_hub.web.cli import web
cli.add_command(interface)
cli.add_command(collector)
cli.add_command(api)
cli.add_command(web)
@@ -220,28 +218,6 @@ def health() -> None:
pass
@health.command("interface")
@click.option(
"--timeout",
type=int,
default=60,
help="Maximum age of health status in seconds",
)
def health_interface(timeout: int) -> None:
"""Check interface component health status.
Returns exit code 0 if healthy, 1 if not.
"""
is_healthy, message = check_health("interface", stale_threshold=timeout)
if is_healthy:
click.echo(f"Interface health: {message}")
sys.exit(0)
else:
click.echo(f"Interface unhealthy: {message}", err=True)
sys.exit(1)
@health.command("collector")
@click.option(
"--timeout",
-2
View File
@@ -8,7 +8,6 @@ from meshcore_hub.api.routes.messages import router as messages_router
from meshcore_hub.api.routes.advertisements import router as advertisements_router
from meshcore_hub.api.routes.trace_paths import router as trace_paths_router
from meshcore_hub.api.routes.telemetry import router as telemetry_router
from meshcore_hub.api.routes.commands import router as commands_router
from meshcore_hub.api.routes.dashboard import router as dashboard_router
from meshcore_hub.api.routes.members import router as members_router
@@ -25,6 +24,5 @@ api_router.include_router(
trace_paths_router, prefix="/trace-paths", tags=["Trace Paths"]
)
api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetry"])
api_router.include_router(commands_router, prefix="/commands", tags=["Commands"])
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"])
api_router.include_router(members_router, prefix="/members", tags=["Members"])
+52 -52
View File
@@ -9,11 +9,11 @@ from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import Advertisement, EventReceiver, Node, NodeTag
from meshcore_hub.common.models import Advertisement, EventObserver, Node, NodeTag
from meshcore_hub.common.schemas.messages import (
AdvertisementList,
AdvertisementRead,
ReceiverInfo,
ObserverInfo,
)
router = APIRouter()
@@ -39,32 +39,32 @@ def _get_tag_description(node: Optional[Node]) -> Optional[str]:
return None
def _fetch_receivers_for_events(
def _fetch_observers_for_events(
session: DbSession,
event_type: str,
event_hashes: list[str],
) -> dict[str, list[ReceiverInfo]]:
) -> dict[str, list[ObserverInfo]]:
"""Fetch receiver info for a list of events by their hashes."""
if not event_hashes:
return {}
query = (
select(
EventReceiver.event_hash,
EventReceiver.snr,
EventReceiver.received_at,
EventObserver.event_hash,
EventObserver.snr,
EventObserver.observed_at,
Node.id.label("node_id"),
Node.public_key,
Node.name,
)
.join(Node, EventReceiver.receiver_node_id == Node.id)
.where(EventReceiver.event_type == event_type)
.where(EventReceiver.event_hash.in_(event_hashes))
.order_by(EventReceiver.received_at)
.join(Node, EventObserver.observer_node_id == Node.id)
.where(EventObserver.event_type == event_type)
.where(EventObserver.event_hash.in_(event_hashes))
.order_by(EventObserver.observed_at)
)
results = session.execute(query).all()
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
observers_by_hash: dict[str, list[ObserverInfo]] = {}
node_ids = [r.node_id for r in results]
tag_names: dict[str, str] = {}
@@ -78,21 +78,21 @@ def _fetch_receivers_for_events(
tag_names[node_id] = value
for row in results:
if row.event_hash not in receivers_by_hash:
receivers_by_hash[row.event_hash] = []
if row.event_hash not in observers_by_hash:
observers_by_hash[row.event_hash] = []
receivers_by_hash[row.event_hash].append(
ReceiverInfo(
observers_by_hash[row.event_hash].append(
ObserverInfo(
node_id=row.node_id,
public_key=row.public_key,
name=row.name,
tag_name=tag_names.get(row.node_id),
snr=row.snr,
received_at=row.received_at,
observed_at=row.observed_at,
)
)
return receivers_by_hash
return observers_by_hash
@router.get("", response_model=AdvertisementList)
@@ -103,7 +103,7 @@ async def list_advertisements(
None, description="Search in name tag, node name, or public key"
),
public_key: Optional[str] = Query(None, description="Filter by public key"),
received_by: Optional[str] = Query(
observed_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
),
member_id: Optional[str] = Query(
@@ -116,21 +116,21 @@ async def list_advertisements(
) -> AdvertisementList:
"""List advertisements with filtering and pagination."""
# Aliases for node joins
ReceiverNode = aliased(Node)
ObserverNode = aliased(Node)
SourceNode = aliased(Node)
# Build query with both receiver and source node joins
query = (
select(
Advertisement,
ReceiverNode.public_key.label("receiver_pk"),
ReceiverNode.name.label("receiver_name"),
ReceiverNode.id.label("receiver_id"),
ObserverNode.public_key.label("observer_pk"),
ObserverNode.name.label("observer_name"),
ObserverNode.id.label("observer_id"),
SourceNode.name.label("source_name"),
SourceNode.id.label("source_id"),
SourceNode.adv_type.label("source_adv_type"),
)
.outerjoin(ReceiverNode, Advertisement.receiver_node_id == ReceiverNode.id)
.outerjoin(ObserverNode, Advertisement.observer_node_id == ObserverNode.id)
.outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)
)
@@ -153,8 +153,8 @@ async def list_advertisements(
if public_key:
query = query.where(Advertisement.public_key == public_key)
if received_by:
query = query.where(ReceiverNode.public_key == received_by)
if observed_by:
query = query.where(ObserverNode.public_key == observed_by)
if member_id:
# Filter advertisements from nodes that have a member_id tag with the specified value
@@ -185,8 +185,8 @@ async def list_advertisements(
# Collect node IDs to fetch tags
node_ids = set()
for row in results:
if row.receiver_id:
node_ids.add(row.receiver_id)
if row.observer_id:
node_ids.add(row.observer_id)
if row.source_id:
node_ids.add(row.source_id)
@@ -199,9 +199,9 @@ async def list_advertisements(
nodes = session.execute(nodes_query).scalars().all()
nodes_by_id = {n.id: n for n in nodes}
# Fetch all receivers for these advertisements
# Fetch all observers for these advertisements
event_hashes = [r[0].event_hash for r in results if r[0].event_hash]
receivers_by_hash = _fetch_receivers_for_events(
observers_by_hash = _fetch_observers_for_events(
session, "advertisement", event_hashes
)
@@ -209,13 +209,13 @@ async def list_advertisements(
items = []
for row in results:
adv = row[0]
receiver_node = nodes_by_id.get(row.receiver_id) if row.receiver_id else None
observer_node = nodes_by_id.get(row.observer_id) if row.observer_id else None
source_node = nodes_by_id.get(row.source_id) if row.source_id else None
data = {
"received_by": row.receiver_pk,
"receiver_name": row.receiver_name,
"receiver_tag_name": _get_tag_name(receiver_node),
"observed_by": row.observer_pk,
"observer_name": row.observer_name,
"observer_tag_name": _get_tag_name(observer_node),
"public_key": adv.public_key,
"name": adv.name,
"node_name": row.source_name,
@@ -225,8 +225,8 @@ async def list_advertisements(
"flags": adv.flags,
"received_at": adv.received_at,
"created_at": adv.created_at,
"receivers": (
receivers_by_hash.get(adv.event_hash, []) if adv.event_hash else []
"observers": (
observers_by_hash.get(adv.event_hash, []) if adv.event_hash else []
),
}
items.append(AdvertisementRead(**data))
@@ -246,19 +246,19 @@ async def get_advertisement(
advertisement_id: str,
) -> AdvertisementRead:
"""Get a single advertisement by ID."""
ReceiverNode = aliased(Node)
ObserverNode = aliased(Node)
SourceNode = aliased(Node)
query = (
select(
Advertisement,
ReceiverNode.public_key.label("receiver_pk"),
ReceiverNode.name.label("receiver_name"),
ReceiverNode.id.label("receiver_id"),
ObserverNode.public_key.label("observer_pk"),
ObserverNode.name.label("observer_name"),
ObserverNode.id.label("observer_id"),
SourceNode.name.label("source_name"),
SourceNode.id.label("source_id"),
SourceNode.adv_type.label("source_adv_type"),
)
.outerjoin(ReceiverNode, Advertisement.receiver_node_id == ReceiverNode.id)
.outerjoin(ObserverNode, Advertisement.observer_node_id == ObserverNode.id)
.outerjoin(SourceNode, Advertisement.node_id == SourceNode.id)
.where(Advertisement.id == advertisement_id)
)
@@ -271,8 +271,8 @@ async def get_advertisement(
# Fetch nodes with tags for friendly names
node_ids = []
if result.receiver_id:
node_ids.append(result.receiver_id)
if result.observer_id:
node_ids.append(result.observer_id)
if result.source_id:
node_ids.append(result.source_id)
@@ -284,21 +284,21 @@ async def get_advertisement(
nodes = session.execute(nodes_query).scalars().all()
nodes_by_id = {n.id: n for n in nodes}
receiver_node = nodes_by_id.get(result.receiver_id) if result.receiver_id else None
observer_node = nodes_by_id.get(result.observer_id) if result.observer_id else None
source_node = nodes_by_id.get(result.source_id) if result.source_id else None
# Fetch receivers for this advertisement
receivers = []
# Fetch observers for this advertisement
observers = []
if adv.event_hash:
receivers_by_hash = _fetch_receivers_for_events(
observers_by_hash = _fetch_observers_for_events(
session, "advertisement", [adv.event_hash]
)
receivers = receivers_by_hash.get(adv.event_hash, [])
observers = observers_by_hash.get(adv.event_hash, [])
data = {
"received_by": result.receiver_pk,
"receiver_name": result.receiver_name,
"receiver_tag_name": _get_tag_name(receiver_node),
"observed_by": result.observer_pk,
"observer_name": result.observer_name,
"observer_tag_name": _get_tag_name(observer_node),
"public_key": adv.public_key,
"name": adv.name,
"node_name": result.source_name,
@@ -308,6 +308,6 @@ async def get_advertisement(
"flags": adv.flags,
"received_at": adv.received_at,
"created_at": adv.created_at,
"receivers": receivers,
"observers": observers,
}
return AdvertisementRead(**data)
-151
View File
@@ -1,151 +0,0 @@
"""Command API routes for sending messages to the mesh network."""
import logging
import time
from fastapi import APIRouter
from meshcore_hub.api.auth import RequireAdmin
from meshcore_hub.api.dependencies import MqttClient
from meshcore_hub.common.schemas.commands import (
CommandResponse,
SendAdvertCommand,
SendChannelMessageCommand,
SendMessageCommand,
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/send-message", response_model=CommandResponse)
async def send_message(
_: RequireAdmin,
mqtt: MqttClient,
command: SendMessageCommand,
) -> CommandResponse:
"""Send a direct message to a node.
Publishes a send_msg command to MQTT for the sender interface to process.
"""
try:
# Connect to MQTT
mqtt.connect()
mqtt.start_background()
# Build payload
payload = {
"destination": command.destination,
"text": command.text,
"timestamp": command.timestamp or int(time.time()),
}
# Publish to wildcard topic (any sender can pick it up)
mqtt.publish_command("+", "send_msg", payload)
# Cleanup
mqtt.stop()
mqtt.disconnect()
logger.info(f"Published send_msg command to {command.destination[:12]}...")
return CommandResponse(
success=True,
message=f"Message queued for {command.destination[:12]}...",
)
except Exception as e:
logger.error(f"Failed to send message: {e}")
return CommandResponse(
success=False,
message=f"Failed to send message: {str(e)}",
)
@router.post("/send-channel-message", response_model=CommandResponse)
async def send_channel_message(
_: RequireAdmin,
mqtt: MqttClient,
command: SendChannelMessageCommand,
) -> CommandResponse:
"""Send a message to a channel.
Publishes a send_channel_msg command to MQTT for the sender interface to process.
"""
try:
# Connect to MQTT
mqtt.connect()
mqtt.start_background()
# Build payload
payload = {
"channel_idx": command.channel_idx,
"text": command.text,
"timestamp": command.timestamp or int(time.time()),
}
# Publish to wildcard topic
mqtt.publish_command("+", "send_channel_msg", payload)
# Cleanup
mqtt.stop()
mqtt.disconnect()
logger.info(
f"Published send_channel_msg command to channel {command.channel_idx}"
)
return CommandResponse(
success=True,
message=f"Message queued for channel {command.channel_idx}",
)
except Exception as e:
logger.error(f"Failed to send channel message: {e}")
return CommandResponse(
success=False,
message=f"Failed to send channel message: {str(e)}",
)
@router.post("/send-advertisement", response_model=CommandResponse)
async def send_advertisement(
_: RequireAdmin,
mqtt: MqttClient,
command: SendAdvertCommand,
) -> CommandResponse:
"""Send a node advertisement.
Publishes a send_advert command to MQTT for the sender interface to process.
"""
try:
# Connect to MQTT
mqtt.connect()
mqtt.start_background()
# Build payload
payload = {
"flood": command.flood,
}
# Publish to wildcard topic
mqtt.publish_command("+", "send_advert", payload)
# Cleanup
mqtt.stop()
mqtt.disconnect()
logger.info(f"Published send_advert command (flood={command.flood})")
return CommandResponse(
success=True,
message=f"Advertisement queued (flood={command.flood})",
)
except Exception as e:
logger.error(f"Failed to send advertisement: {e}")
return CommandResponse(
success=False,
message=f"Failed to send advertisement: {str(e)}",
)
+61 -61
View File
@@ -9,8 +9,8 @@ from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import EventReceiver, Message, Node, NodeTag
from meshcore_hub.common.schemas.messages import MessageList, MessageRead, ReceiverInfo
from meshcore_hub.common.models import EventObserver, Message, Node, NodeTag
from meshcore_hub.common.schemas.messages import MessageList, MessageRead, ObserverInfo
router = APIRouter()
@@ -25,44 +25,44 @@ def _get_tag_name(node: Optional[Node]) -> Optional[str]:
return None
def _fetch_receivers_for_events(
def _fetch_observers_for_events(
session: DbSession,
event_type: str,
event_hashes: list[str],
) -> dict[str, list[ReceiverInfo]]:
) -> dict[str, list[ObserverInfo]]:
"""Fetch receiver info for a list of events by their hashes.
Args:
session: Database session
event_type: Type of event ('message', 'advertisement', etc.)
event_hashes: List of event hashes to fetch receivers for
event_hashes: List of event hashes to fetch observers for
Returns:
Dict mapping event_hash to list of ReceiverInfo objects
Dict mapping event_hash to list of ObserverInfo objects
"""
if not event_hashes:
return {}
# Query event_receivers with receiver node info
# Query event_observers with receiver node info
query = (
select(
EventReceiver.event_hash,
EventReceiver.snr,
EventReceiver.received_at,
EventObserver.event_hash,
EventObserver.snr,
EventObserver.observed_at,
Node.id.label("node_id"),
Node.public_key,
Node.name,
)
.join(Node, EventReceiver.receiver_node_id == Node.id)
.where(EventReceiver.event_type == event_type)
.where(EventReceiver.event_hash.in_(event_hashes))
.order_by(EventReceiver.received_at)
.join(Node, EventObserver.observer_node_id == Node.id)
.where(EventObserver.event_type == event_type)
.where(EventObserver.event_hash.in_(event_hashes))
.order_by(EventObserver.observed_at)
)
results = session.execute(query).all()
# Group by event_hash
receivers_by_hash: dict[str, list[ReceiverInfo]] = {}
observers_by_hash: dict[str, list[ObserverInfo]] = {}
# Get tag names for receiver nodes
node_ids = [r.node_id for r in results]
@@ -77,21 +77,21 @@ def _fetch_receivers_for_events(
tag_names[node_id] = value
for row in results:
if row.event_hash not in receivers_by_hash:
receivers_by_hash[row.event_hash] = []
if row.event_hash not in observers_by_hash:
observers_by_hash[row.event_hash] = []
receivers_by_hash[row.event_hash].append(
ReceiverInfo(
observers_by_hash[row.event_hash].append(
ObserverInfo(
node_id=row.node_id,
public_key=row.public_key,
name=row.name,
tag_name=tag_names.get(row.node_id),
snr=row.snr,
received_at=row.received_at,
observed_at=row.observed_at,
)
)
return receivers_by_hash
return observers_by_hash
@router.get("", response_model=MessageList)
@@ -101,7 +101,7 @@ async def list_messages(
message_type: Optional[str] = Query(None, description="Filter by message type"),
pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"),
channel_idx: Optional[int] = Query(None, description="Filter by channel"),
received_by: Optional[str] = Query(
observed_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
),
since: Optional[datetime] = Query(None, description="Start timestamp"),
@@ -112,15 +112,15 @@ async def list_messages(
) -> MessageList:
"""List messages with filtering and pagination."""
# Alias for receiver node join
ReceiverNode = aliased(Node)
ObserverNode = aliased(Node)
# Build query with receiver node join
query = select(
Message,
ReceiverNode.public_key.label("receiver_pk"),
ReceiverNode.name.label("receiver_name"),
ReceiverNode.id.label("receiver_id"),
).outerjoin(ReceiverNode, Message.receiver_node_id == ReceiverNode.id)
ObserverNode.public_key.label("observer_pk"),
ObserverNode.name.label("observer_name"),
ObserverNode.id.label("receiver_id"),
).outerjoin(ObserverNode, Message.observer_node_id == ObserverNode.id)
if message_type:
query = query.where(Message.message_type == message_type)
@@ -131,8 +131,8 @@ async def list_messages(
if channel_idx is not None:
query = query.where(Message.channel_idx == channel_idx)
if received_by:
query = query.where(ReceiverNode.public_key == received_by)
if observed_by:
query = query.where(ObserverNode.public_key == observed_by)
if since:
query = query.where(Message.received_at >= since)
@@ -179,42 +179,42 @@ async def list_messages(
sender_tag_names[public_key[:12]] = value
# Collect receiver node IDs to fetch tags
receiver_ids = set()
observer_ids = set()
for row in results:
if row.receiver_id:
receiver_ids.add(row.receiver_id)
observer_ids.add(row.receiver_id)
# Fetch receiver nodes with tags
receivers_by_id: dict[str, Node] = {}
if receiver_ids:
receivers_query = (
observers_by_id: dict[str, Node] = {}
if observer_ids:
observers_query = (
select(Node)
.where(Node.id.in_(receiver_ids))
.where(Node.id.in_(observer_ids))
.options(selectinload(Node.tags))
)
receivers = session.execute(receivers_query).scalars().all()
receivers_by_id = {n.id: n for n in receivers}
observers = session.execute(observers_query).scalars().all()
observers_by_id = {n.id: n for n in observers}
# Fetch all receivers for these messages
# Fetch all observers for these messages
event_hashes = [r[0].event_hash for r in results if r[0].event_hash]
receivers_by_hash = _fetch_receivers_for_events(session, "message", event_hashes)
observers_by_hash = _fetch_observers_for_events(session, "message", event_hashes)
# Build response with sender info and received_by
# Build response with sender info and observed_by
items = []
for row in results:
m = row[0]
receiver_pk = row.receiver_pk
receiver_name = row.receiver_name
receiver_node = (
receivers_by_id.get(row.receiver_id) if row.receiver_id else None
observer_pk = row.observer_pk
observer_name = row.observer_name
observer_node = (
observers_by_id.get(row.receiver_id) if row.receiver_id else None
)
msg_dict = {
"id": m.id,
"receiver_node_id": m.receiver_node_id,
"received_by": receiver_pk,
"receiver_name": receiver_name,
"receiver_tag_name": _get_tag_name(receiver_node),
"observer_node_id": m.observer_node_id,
"observed_by": observer_pk,
"observer_name": observer_name,
"observer_tag_name": _get_tag_name(observer_node),
"message_type": m.message_type,
"pubkey_prefix": m.pubkey_prefix,
"sender_name": (
@@ -232,8 +232,8 @@ async def list_messages(
"sender_timestamp": m.sender_timestamp,
"received_at": m.received_at,
"created_at": m.created_at,
"receivers": (
receivers_by_hash.get(m.event_hash, []) if m.event_hash else []
"observers": (
observers_by_hash.get(m.event_hash, []) if m.event_hash else []
),
}
items.append(MessageRead(**msg_dict))
@@ -253,10 +253,10 @@ async def get_message(
message_id: str,
) -> MessageRead:
"""Get a single message by ID."""
ReceiverNode = aliased(Node)
ObserverNode = aliased(Node)
query = (
select(Message, ReceiverNode.public_key.label("receiver_pk"))
.outerjoin(ReceiverNode, Message.receiver_node_id == ReceiverNode.id)
select(Message, ObserverNode.public_key.label("observer_pk"))
.outerjoin(ObserverNode, Message.observer_node_id == ObserverNode.id)
.where(Message.id == message_id)
)
result = session.execute(query).one_or_none()
@@ -264,20 +264,20 @@ async def get_message(
if not result:
raise HTTPException(status_code=404, detail="Message not found")
message, receiver_pk = result
message, observer_pk = result
# Fetch receivers for this message
receivers = []
# Fetch observers for this message
observers = []
if message.event_hash:
receivers_by_hash = _fetch_receivers_for_events(
observers_by_hash = _fetch_observers_for_events(
session, "message", [message.event_hash]
)
receivers = receivers_by_hash.get(message.event_hash, [])
observers = observers_by_hash.get(message.event_hash, [])
data = {
"id": message.id,
"receiver_node_id": message.receiver_node_id,
"received_by": receiver_pk,
"observer_node_id": message.observer_node_id,
"observed_by": observer_pk,
"message_type": message.message_type,
"pubkey_prefix": message.pubkey_prefix,
"channel_idx": message.channel_idx,
@@ -289,6 +289,6 @@ async def get_message(
"sender_timestamp": message.sender_timestamp,
"received_at": message.received_at,
"created_at": message.created_at,
"receivers": receivers,
"observers": observers,
}
return MessageRead(**data)
+16 -16
View File
@@ -20,7 +20,7 @@ async def list_telemetry(
_: RequireRead,
session: DbSession,
node_public_key: Optional[str] = Query(None, description="Filter by node"),
received_by: Optional[str] = Query(
observed_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
),
since: Optional[datetime] = Query(None, description="Start timestamp"),
@@ -30,18 +30,18 @@ async def list_telemetry(
) -> TelemetryList:
"""List telemetry records with filtering and pagination."""
# Alias for receiver node join
ReceiverNode = aliased(Node)
ObserverNode = aliased(Node)
# Build query with receiver node join
query = select(Telemetry, ReceiverNode.public_key.label("receiver_pk")).outerjoin(
ReceiverNode, Telemetry.receiver_node_id == ReceiverNode.id
query = select(Telemetry, ObserverNode.public_key.label("observer_pk")).outerjoin(
ObserverNode, Telemetry.observer_node_id == ObserverNode.id
)
if node_public_key:
query = query.where(Telemetry.node_public_key == node_public_key)
if received_by:
query = query.where(ReceiverNode.public_key == received_by)
if observed_by:
query = query.where(ObserverNode.public_key == observed_by)
if since:
query = query.where(Telemetry.received_at >= since)
@@ -59,13 +59,13 @@ async def list_telemetry(
# Execute
results = session.execute(query).all()
# Build response with received_by
# Build response with observed_by
items = []
for tel, receiver_pk in results:
for tel, observer_pk in results:
data = {
"id": tel.id,
"receiver_node_id": tel.receiver_node_id,
"received_by": receiver_pk,
"observer_node_id": tel.observer_node_id,
"observed_by": observer_pk,
"node_id": tel.node_id,
"node_public_key": tel.node_public_key,
"parsed_data": tel.parsed_data,
@@ -89,10 +89,10 @@ async def get_telemetry(
telemetry_id: str,
) -> TelemetryRead:
"""Get a single telemetry record by ID."""
ReceiverNode = aliased(Node)
ObserverNode = aliased(Node)
query = (
select(Telemetry, ReceiverNode.public_key.label("receiver_pk"))
.outerjoin(ReceiverNode, Telemetry.receiver_node_id == ReceiverNode.id)
select(Telemetry, ObserverNode.public_key.label("observer_pk"))
.outerjoin(ObserverNode, Telemetry.observer_node_id == ObserverNode.id)
.where(Telemetry.id == telemetry_id)
)
result = session.execute(query).one_or_none()
@@ -100,11 +100,11 @@ async def get_telemetry(
if not result:
raise HTTPException(status_code=404, detail="Telemetry record not found")
tel, receiver_pk = result
tel, observer_pk = result
data = {
"id": tel.id,
"receiver_node_id": tel.receiver_node_id,
"received_by": receiver_pk,
"observer_node_id": tel.observer_node_id,
"observed_by": observer_pk,
"node_id": tel.node_id,
"node_public_key": tel.node_public_key,
"parsed_data": tel.parsed_data,
+16 -16
View File
@@ -19,7 +19,7 @@ router = APIRouter()
async def list_trace_paths(
_: RequireRead,
session: DbSession,
received_by: Optional[str] = Query(
observed_by: Optional[str] = Query(
None, description="Filter by receiver node public key"
),
since: Optional[datetime] = Query(None, description="Start timestamp"),
@@ -29,15 +29,15 @@ async def list_trace_paths(
) -> TracePathList:
"""List trace paths with filtering and pagination."""
# Alias for receiver node join
ReceiverNode = aliased(Node)
ObserverNode = aliased(Node)
# Build query with receiver node join
query = select(TracePath, ReceiverNode.public_key.label("receiver_pk")).outerjoin(
ReceiverNode, TracePath.receiver_node_id == ReceiverNode.id
query = select(TracePath, ObserverNode.public_key.label("observer_pk")).outerjoin(
ObserverNode, TracePath.observer_node_id == ObserverNode.id
)
if received_by:
query = query.where(ReceiverNode.public_key == received_by)
if observed_by:
query = query.where(ObserverNode.public_key == observed_by)
if since:
query = query.where(TracePath.received_at >= since)
@@ -55,13 +55,13 @@ async def list_trace_paths(
# Execute
results = session.execute(query).all()
# Build response with received_by
# Build response with observed_by
items = []
for tp, receiver_pk in results:
for tp, observer_pk in results:
data = {
"id": tp.id,
"receiver_node_id": tp.receiver_node_id,
"received_by": receiver_pk,
"observer_node_id": tp.observer_node_id,
"observed_by": observer_pk,
"initiator_tag": tp.initiator_tag,
"path_len": tp.path_len,
"flags": tp.flags,
@@ -89,10 +89,10 @@ async def get_trace_path(
trace_path_id: str,
) -> TracePathRead:
"""Get a single trace path by ID."""
ReceiverNode = aliased(Node)
ObserverNode = aliased(Node)
query = (
select(TracePath, ReceiverNode.public_key.label("receiver_pk"))
.outerjoin(ReceiverNode, TracePath.receiver_node_id == ReceiverNode.id)
select(TracePath, ObserverNode.public_key.label("observer_pk"))
.outerjoin(ObserverNode, TracePath.observer_node_id == ObserverNode.id)
.where(TracePath.id == trace_path_id)
)
result = session.execute(query).one_or_none()
@@ -100,11 +100,11 @@ async def get_trace_path(
if not result:
raise HTTPException(status_code=404, detail="Trace path not found")
tp, receiver_pk = result
tp, observer_pk = result
data = {
"id": tp.id,
"receiver_node_id": tp.receiver_node_id,
"received_by": receiver_pk,
"observer_node_id": tp.observer_node_id,
"observed_by": observer_pk,
"initiator_tag": tp.initiator_tag,
"path_len": tp.path_len,
"flags": tp.flags,
+15 -35
View File
@@ -68,17 +68,6 @@ if TYPE_CHECKING:
envvar="MQTT_WS_PATH",
help="MQTT WebSocket path (used when transport=websockets)",
)
@click.option(
"--ingest-mode",
"collector_ingest_mode",
type=click.Choice(["native", "letsmesh_upload"], case_sensitive=False),
default="native",
envvar="COLLECTOR_INGEST_MODE",
help=(
"Collector ingest mode: native MeshCore events or LetsMesh upload "
"(packets/status/internal)"
),
)
@click.option(
"--data-home",
type=str,
@@ -117,7 +106,6 @@ def collector(
mqtt_tls: bool,
mqtt_transport: str,
mqtt_ws_path: str,
collector_ingest_mode: str,
data_home: str | None,
seed_home: str | None,
database_url: str | None,
@@ -164,7 +152,6 @@ def collector(
ctx.obj["mqtt_tls"] = mqtt_tls
ctx.obj["mqtt_transport"] = mqtt_transport
ctx.obj["mqtt_ws_path"] = mqtt_ws_path
ctx.obj["collector_ingest_mode"] = collector_ingest_mode
ctx.obj["data_home"] = data_home or settings.data_home
ctx.obj["seed_home"] = settings.effective_seed_home
ctx.obj["database_url"] = effective_db_url
@@ -182,7 +169,6 @@ def collector(
mqtt_tls=mqtt_tls,
mqtt_transport=mqtt_transport,
mqtt_ws_path=mqtt_ws_path,
ingest_mode=collector_ingest_mode,
database_url=effective_db_url,
log_level=log_level,
data_home=data_home or settings.data_home,
@@ -199,7 +185,6 @@ def _run_collector_service(
mqtt_tls: bool,
mqtt_transport: str,
mqtt_ws_path: str,
ingest_mode: str,
database_url: str,
log_level: str,
data_home: str,
@@ -229,7 +214,6 @@ def _run_collector_service(
click.echo(f"Seed home: {seed_home}")
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
click.echo(f"MQTT transport: {mqtt_transport} (ws_path: {mqtt_ws_path})")
click.echo(f"Ingest mode: {ingest_mode}")
click.echo(f"Database: {database_url}")
# Load webhook configuration from settings
@@ -274,23 +258,21 @@ def _run_collector_service(
if settings.data_retention_enabled or settings.node_cleanup_enabled:
click.echo(f" Interval: {settings.data_retention_interval_hours} hours")
if ingest_mode.lower() == "letsmesh_upload":
click.echo("")
click.echo("LetsMesh decode configuration:")
if settings.collector_letsmesh_decoder_enabled:
builtin_keys = len(LetsMeshPacketDecoder.BUILTIN_CHANNEL_KEYS)
env_keys = len(settings.collector_letsmesh_decoder_keys_list)
click.echo(
" Decoder: Enabled " f"({settings.collector_letsmesh_decoder_command})"
)
click.echo(f" Built-in keys: {builtin_keys}")
click.echo(" Additional keys from .env: " f"{env_keys} configured")
click.echo(
" Timeout: "
f"{settings.collector_letsmesh_decoder_timeout_seconds:.2f}s"
)
else:
click.echo(" Decoder: Disabled")
click.echo("")
click.echo("LetsMesh decode configuration:")
if settings.collector_letsmesh_decoder_enabled:
builtin_keys = len(LetsMeshPacketDecoder.BUILTIN_CHANNEL_KEYS)
env_keys = len(settings.collector_letsmesh_decoder_keys_list)
click.echo(
f" Decoder: Enabled ({settings.collector_letsmesh_decoder_command})"
)
click.echo(f" Built-in keys: {builtin_keys}")
click.echo(f" Additional keys from .env: {env_keys} configured")
click.echo(
f" Timeout: {settings.collector_letsmesh_decoder_timeout_seconds:.2f}s"
)
else:
click.echo(" Decoder: Disabled")
click.echo("")
click.echo("Starting MQTT subscriber...")
@@ -303,7 +285,6 @@ def _run_collector_service(
mqtt_tls=mqtt_tls,
mqtt_transport=mqtt_transport,
mqtt_ws_path=mqtt_ws_path,
ingest_mode=ingest_mode,
database_url=database_url,
webhook_dispatcher=webhook_dispatcher,
cleanup_enabled=settings.data_retention_enabled,
@@ -336,7 +317,6 @@ def run_cmd(ctx: click.Context) -> None:
mqtt_tls=ctx.obj["mqtt_tls"],
mqtt_transport=ctx.obj["mqtt_transport"],
mqtt_ws_path=ctx.obj["mqtt_ws_path"],
ingest_mode=ctx.obj["collector_ingest_mode"],
database_url=ctx.obj["database_url"],
log_level=ctx.obj["log_level"],
data_home=ctx.obj["data_home"],
@@ -9,7 +9,7 @@ from sqlalchemy.exc import IntegrityError
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.hash_utils import compute_advertisement_hash
from meshcore_hub.common.models import Advertisement, Node, add_event_receiver
from meshcore_hub.common.models import Advertisement, Node, add_event_observer
logger = logging.getLogger(__name__)
@@ -117,13 +117,13 @@ def handle_advertisement(
# Add this receiver to the junction table
if receiver_node:
added = add_event_receiver(
added = add_event_observer(
session=session,
event_type="advertisement",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None, # Advertisements don't have SNR
received_at=now,
observed_at=now,
)
if added:
logger.debug(
@@ -166,7 +166,7 @@ def handle_advertisement(
# Create advertisement record
advertisement = Advertisement(
receiver_node_id=receiver_node.id if receiver_node else None,
observer_node_id=receiver_node.id if receiver_node else None,
node_id=node.id,
public_key=adv_public_key,
name=name,
@@ -179,13 +179,13 @@ def handle_advertisement(
# Add first receiver to junction table
if receiver_node:
add_event_receiver(
add_event_observer(
session=session,
event_type="advertisement",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None,
received_at=now,
observed_at=now,
)
# Flush to check for duplicate constraint violation (race condition)
@@ -200,17 +200,16 @@ def handle_advertisement(
)
# Re-add receiver to existing event in a new transaction
if receiver_node:
add_event_receiver(
add_event_observer(
session=session,
event_type="advertisement",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None,
received_at=now,
observed_at=now,
)
return
logger.info(
f"Stored advertisement from {name or adv_public_key[:12]!r} "
f"(type={adv_type})"
f"Stored advertisement from {name or adv_public_key[:12]!r} (type={adv_type})"
)
@@ -51,7 +51,7 @@ def handle_event_log(
# Create event log record
event_log = EventLog(
receiver_node_id=receiver_node.id if receiver_node else None,
observer_node_id=receiver_node.id if receiver_node else None,
event_type=event_type,
payload=payload,
received_at=now,
+11 -11
View File
@@ -9,7 +9,7 @@ from sqlalchemy.exc import IntegrityError
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.hash_utils import compute_message_hash
from meshcore_hub.common.models import Message, Node, add_event_receiver
from meshcore_hub.common.models import Message, Node, add_event_observer
logger = logging.getLogger(__name__)
@@ -121,13 +121,13 @@ def _handle_message(
if existing:
# Event already exists - just add this receiver to the junction table
if receiver_node:
added = add_event_receiver(
added = add_event_observer(
session=session,
event_type="message",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=snr,
received_at=now,
observed_at=now,
)
if added:
logger.debug(
@@ -138,7 +138,7 @@ def _handle_message(
# Create message record
message = Message(
receiver_node_id=receiver_node.id if receiver_node else None,
observer_node_id=receiver_node.id if receiver_node else None,
message_type=message_type,
pubkey_prefix=pubkey_prefix,
channel_idx=channel_idx,
@@ -155,13 +155,13 @@ def _handle_message(
# Add first receiver to junction table
if receiver_node:
add_event_receiver(
add_event_observer(
session=session,
event_type="message",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=snr,
received_at=now,
observed_at=now,
)
# Flush to check for duplicate constraint violation (race condition)
@@ -175,13 +175,13 @@ def _handle_message(
)
# Re-add receiver to existing event in a new transaction
if receiver_node:
add_event_receiver(
add_event_observer(
session=session,
event_type="message",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=snr,
received_at=now,
observed_at=now,
)
return
@@ -9,7 +9,7 @@ from sqlalchemy.exc import IntegrityError
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.hash_utils import compute_telemetry_hash
from meshcore_hub.common.models import Node, Telemetry, add_event_receiver
from meshcore_hub.common.models import Node, Telemetry, add_event_observer
logger = logging.getLogger(__name__)
@@ -84,13 +84,13 @@ def handle_telemetry(
if existing:
# Event already exists - just add this receiver to the junction table
if receiver_node:
added = add_event_receiver(
added = add_event_observer(
session=session,
event_type="telemetry",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None,
received_at=now,
observed_at=now,
)
if added:
logger.debug(
@@ -118,7 +118,7 @@ def handle_telemetry(
# Create telemetry record
telemetry = Telemetry(
receiver_node_id=receiver_node.id if receiver_node else None,
observer_node_id=receiver_node.id if receiver_node else None,
node_id=reporting_node.id if reporting_node else None,
node_public_key=node_public_key,
lpp_data=lpp_bytes,
@@ -130,13 +130,13 @@ def handle_telemetry(
# Add first receiver to junction table
if receiver_node:
add_event_receiver(
add_event_observer(
session=session,
event_type="telemetry",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None,
received_at=now,
observed_at=now,
)
# Flush to check for duplicate constraint violation (race condition)
@@ -151,13 +151,13 @@ def handle_telemetry(
)
# Re-add receiver to existing event in a new transaction
if receiver_node:
add_event_receiver(
add_event_observer(
session=session,
event_type="telemetry",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None,
received_at=now,
observed_at=now,
)
return
+11 -11
View File
@@ -9,7 +9,7 @@ from sqlalchemy.exc import IntegrityError
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.hash_utils import compute_trace_hash
from meshcore_hub.common.models import Node, TracePath, add_event_receiver
from meshcore_hub.common.models import Node, TracePath, add_event_observer
logger = logging.getLogger(__name__)
@@ -71,13 +71,13 @@ def handle_trace_data(
if existing:
# Event already exists - just add this receiver to the junction table
if receiver_node:
added = add_event_receiver(
added = add_event_observer(
session=session,
event_type="trace",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None, # Trace events don't have a single SNR value
received_at=now,
observed_at=now,
)
if added:
logger.debug(
@@ -88,7 +88,7 @@ def handle_trace_data(
# Create trace path record
trace_path = TracePath(
receiver_node_id=receiver_node.id if receiver_node else None,
observer_node_id=receiver_node.id if receiver_node else None,
initiator_tag=initiator_tag,
path_len=path_len,
flags=flags,
@@ -103,13 +103,13 @@ def handle_trace_data(
# Add first receiver to junction table
if receiver_node:
add_event_receiver(
add_event_observer(
session=session,
event_type="trace",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None,
received_at=now,
observed_at=now,
)
# Flush to check for duplicate constraint violation (race condition)
@@ -123,13 +123,13 @@ def handle_trace_data(
)
# Re-add receiver to existing event in a new transaction
if receiver_node:
add_event_receiver(
add_event_observer(
session=session,
event_type="trace",
event_hash=event_hash,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
snr=None,
received_at=now,
observed_at=now,
)
return
+11 -43
View File
@@ -37,9 +37,6 @@ EventHandler = Callable[[str, str, dict[str, Any], DatabaseManager], None]
class Subscriber(LetsMeshNormalizer):
"""MQTT Subscriber for collecting and storing MeshCore events."""
INGEST_MODE_NATIVE = "native"
INGEST_MODE_LETSMESH_UPLOAD = "letsmesh_upload"
def __init__(
self,
mqtt_client: MQTTClient,
@@ -50,7 +47,6 @@ class Subscriber(LetsMeshNormalizer):
cleanup_interval_hours: int = 24,
node_cleanup_enabled: bool = False,
node_cleanup_days: int = 90,
ingest_mode: str = INGEST_MODE_NATIVE,
letsmesh_decoder_enabled: bool = True,
letsmesh_decoder_command: str = "meshcore-decoder",
letsmesh_decoder_channel_keys: list[str] | None = None,
@@ -67,7 +63,6 @@ class Subscriber(LetsMeshNormalizer):
cleanup_interval_hours: Hours between cleanup runs
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
node_cleanup_days: Remove nodes not seen for this many days
ingest_mode: Ingest mode ('native' or 'letsmesh_upload')
letsmesh_decoder_enabled: Enable external LetsMesh packet decoder
letsmesh_decoder_command: Decoder CLI command
letsmesh_decoder_channel_keys: Optional channel keys for decrypting group text
@@ -94,12 +89,6 @@ class Subscriber(LetsMeshNormalizer):
self._node_cleanup_days = node_cleanup_days
self._cleanup_thread: Optional[threading.Thread] = None
self._last_cleanup: Optional[datetime] = None
self._ingest_mode = ingest_mode.lower()
if self._ingest_mode not in {
self.INGEST_MODE_NATIVE,
self.INGEST_MODE_LETSMESH_UPLOAD,
}:
raise ValueError(f"Unsupported collector ingest mode: {ingest_mode}")
self._letsmesh_decoder = LetsMeshPacketDecoder(
enabled=letsmesh_decoder_enabled,
command=letsmesh_decoder_command,
@@ -153,20 +142,10 @@ class Subscriber(LetsMeshNormalizer):
payload: Message payload
"""
parsed: tuple[str, str, dict[str, Any]] | None
if self._ingest_mode == self.INGEST_MODE_LETSMESH_UPLOAD:
parsed = self._normalize_letsmesh_event(topic, payload)
else:
parsed_event = self.mqtt.topic_builder.parse_event_topic(topic)
parsed = (
(parsed_event[0], parsed_event[1], payload) if parsed_event else None
)
parsed = self._normalize_letsmesh_event(topic, payload)
if not parsed:
logger.warning(
"Could not parse topic for ingest mode %s: %s",
self._ingest_mode,
topic,
)
logger.warning("Could not parse topic: %s", topic)
return
public_key, event_type, normalized_payload = parsed
@@ -405,20 +384,15 @@ class Subscriber(LetsMeshNormalizer):
logger.error(f"Failed to connect to MQTT broker: {e}")
raise
# Subscribe to topics based on ingest mode
if self._ingest_mode == self.INGEST_MODE_LETSMESH_UPLOAD:
letsmesh_topics = [
f"{self.mqtt.topic_builder.prefix}/+/packets",
f"{self.mqtt.topic_builder.prefix}/+/status",
f"{self.mqtt.topic_builder.prefix}/+/internal",
]
for letsmesh_topic in letsmesh_topics:
self.mqtt.subscribe(letsmesh_topic, self._handle_mqtt_message)
logger.info(f"Subscribed to LetsMesh upload topic: {letsmesh_topic}")
else:
event_topic = self.mqtt.topic_builder.all_events_topic()
self.mqtt.subscribe(event_topic, self._handle_mqtt_message)
logger.info(f"Subscribed to event topic: {event_topic}")
# Subscribe to LetsMesh upload topics
letsmesh_topics = [
f"{self.mqtt.topic_builder.prefix}/+/packets",
f"{self.mqtt.topic_builder.prefix}/+/status",
f"{self.mqtt.topic_builder.prefix}/+/internal",
]
for letsmesh_topic in letsmesh_topics:
self.mqtt.subscribe(letsmesh_topic, self._handle_mqtt_message)
logger.info(f"Subscribed to LetsMesh upload topic: {letsmesh_topic}")
self._running = True
@@ -488,7 +462,6 @@ def create_subscriber(
mqtt_tls: bool = False,
mqtt_transport: str = "tcp",
mqtt_ws_path: str = "/mqtt",
ingest_mode: str = "native",
database_url: str = "sqlite:///./meshcore.db",
webhook_dispatcher: Optional["WebhookDispatcher"] = None,
cleanup_enabled: bool = False,
@@ -512,7 +485,6 @@ def create_subscriber(
mqtt_tls: Enable TLS/SSL for MQTT connection
mqtt_transport: MQTT transport protocol (tcp or websockets)
mqtt_ws_path: WebSocket path (used when transport=websockets)
ingest_mode: Ingest mode ('native' or 'letsmesh_upload')
database_url: Database connection URL
webhook_dispatcher: Optional webhook dispatcher for event forwarding
cleanup_enabled: Enable automatic event data cleanup
@@ -556,7 +528,6 @@ def create_subscriber(
cleanup_interval_hours=cleanup_interval_hours,
node_cleanup_enabled=node_cleanup_enabled,
node_cleanup_days=node_cleanup_days,
ingest_mode=ingest_mode,
letsmesh_decoder_enabled=letsmesh_decoder_enabled,
letsmesh_decoder_command=letsmesh_decoder_command,
letsmesh_decoder_channel_keys=letsmesh_decoder_channel_keys,
@@ -580,7 +551,6 @@ def run_collector(
mqtt_tls: bool = False,
mqtt_transport: str = "tcp",
mqtt_ws_path: str = "/mqtt",
ingest_mode: str = "native",
database_url: str = "sqlite:///./meshcore.db",
webhook_dispatcher: Optional["WebhookDispatcher"] = None,
cleanup_enabled: bool = False,
@@ -604,7 +574,6 @@ def run_collector(
mqtt_tls: Enable TLS/SSL for MQTT connection
mqtt_transport: MQTT transport protocol (tcp or websockets)
mqtt_ws_path: WebSocket path (used when transport=websockets)
ingest_mode: Ingest mode ('native' or 'letsmesh_upload')
database_url: Database connection URL
webhook_dispatcher: Optional webhook dispatcher for event forwarding
cleanup_enabled: Enable automatic event data cleanup
@@ -626,7 +595,6 @@ def run_collector(
mqtt_tls=mqtt_tls,
mqtt_transport=mqtt_transport,
mqtt_ws_path=mqtt_ws_path,
ingest_mode=ingest_mode,
database_url=database_url,
webhook_dispatcher=webhook_dispatcher,
cleanup_enabled=cleanup_enabled,
+1 -65
View File
@@ -18,13 +18,6 @@ class LogLevel(str, Enum):
CRITICAL = "CRITICAL"
class InterfaceMode(str, Enum):
"""Interface component mode."""
RECEIVER = "RECEIVER"
SENDER = "SENDER"
class MQTTTransport(str, Enum):
"""MQTT transport type."""
@@ -32,13 +25,6 @@ class MQTTTransport(str, Enum):
WEBSOCKETS = "websockets"
class CollectorIngestMode(str, Enum):
"""Collector MQTT ingest mode."""
NATIVE = "native"
LETSMESH_UPLOAD = "letsmesh_upload"
class CommonSettings(BaseSettings):
"""Common settings shared by all components."""
@@ -80,39 +66,6 @@ class CommonSettings(BaseSettings):
)
class InterfaceSettings(CommonSettings):
"""Settings for the Interface component."""
# Mode
interface_mode: InterfaceMode = Field(
default=InterfaceMode.RECEIVER,
description="Interface mode: RECEIVER or SENDER",
)
# Serial connection
serial_port: str = Field(default="/dev/ttyUSB0", description="Serial port path")
serial_baud: int = Field(default=115200, description="Serial baud rate")
# Mock device
mock_device: bool = Field(default=False, description="Use mock device for testing")
# Device name
meshcore_device_name: Optional[str] = Field(
default=None, description="Device/node name (optional)"
)
# Contact cleanup settings
contact_cleanup_enabled: bool = Field(
default=True,
description="Enable automatic removal of stale contacts from companion node",
)
contact_cleanup_days: int = Field(
default=7,
description="Remove contacts not advertised for this many days",
ge=1,
)
class CollectorSettings(CommonSettings):
"""Settings for the Collector component."""
@@ -185,21 +138,9 @@ class CollectorSettings(CommonSettings):
description="Remove nodes not seen for this many days (last_seen)",
ge=1,
)
collector_ingest_mode: CollectorIngestMode = Field(
default=CollectorIngestMode.LETSMESH_UPLOAD,
description=(
"Collector MQTT ingest mode. "
"'native' expects <prefix>/<pubkey>/event/<event_name>. "
"'letsmesh_upload' expects LetsMesh observer uploads on "
"<prefix>/<pubkey>/(packets|status|internal)."
),
)
collector_letsmesh_decoder_enabled: bool = Field(
default=True,
description=(
"Enable external LetsMesh packet decoding via meshcore-decoder. "
"Only applies when COLLECTOR_INGEST_MODE=letsmesh_upload."
),
description=("Enable external LetsMesh packet decoding via meshcore-decoder."),
)
collector_letsmesh_decoder_command: str = Field(
default="meshcore-decoder",
@@ -483,11 +424,6 @@ def get_common_settings() -> CommonSettings:
return CommonSettings()
def get_interface_settings() -> InterfaceSettings:
"""Get interface settings instance."""
return InterfaceSettings()
def get_collector_settings() -> CollectorSettings:
"""Get collector settings instance."""
return CollectorSettings()
+1 -4
View File
@@ -20,7 +20,6 @@ logger = logging.getLogger(__name__)
# Default health file locations
DEFAULT_HEALTH_DIR = "/tmp/meshcore-hub"
DEFAULT_HEALTH_FILE_INTERFACE = "interface-health.json"
DEFAULT_HEALTH_FILE_COLLECTOR = "collector-health.json"
# Health status is considered stale after this many seconds
@@ -94,9 +93,7 @@ def get_health_file(component: str) -> Path:
Path to health file
"""
health_dir = get_health_dir()
if component == "interface":
return health_dir / DEFAULT_HEALTH_FILE_INTERFACE
elif component == "collector":
if component == "collector":
return health_dir / DEFAULT_HEALTH_FILE_COLLECTOR
else:
return health_dir / f"{component}-health.json"
+3 -3
View File
@@ -9,7 +9,7 @@ from meshcore_hub.common.models.trace_path import TracePath
from meshcore_hub.common.models.telemetry import Telemetry
from meshcore_hub.common.models.event_log import EventLog
from meshcore_hub.common.models.member import Member
from meshcore_hub.common.models.event_receiver import EventReceiver, add_event_receiver
from meshcore_hub.common.models.event_observer import EventObserver, add_event_observer
__all__ = [
"Base",
@@ -22,6 +22,6 @@ __all__ = [
"Telemetry",
"EventLog",
"Member",
"EventReceiver",
"add_event_receiver",
"EventObserver",
"add_event_observer",
]
@@ -14,7 +14,7 @@ class Advertisement(Base, UUIDMixin, TimestampMixin):
Attributes:
id: UUID primary key
receiver_node_id: FK to nodes (receiving interface)
observer_node_id: FK to nodes (observing interface)
node_id: FK to nodes (advertised node)
public_key: Advertised public key
name: Advertised name
@@ -26,7 +26,7 @@ class Advertisement(Base, UUIDMixin, TimestampMixin):
__tablename__ = "advertisements"
receiver_node_id: Mapped[Optional[str]] = mapped_column(
observer_node_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("nodes.id", ondelete="SET NULL"),
nullable=True,
index=True,
+2 -2
View File
@@ -15,7 +15,7 @@ class EventLog(Base, UUIDMixin, TimestampMixin):
Attributes:
id: UUID primary key
receiver_node_id: FK to nodes (receiving interface)
observer_node_id: FK to nodes (observing interface)
event_type: Event type name
payload: Full event payload as JSON
received_at: When received by interface
@@ -24,7 +24,7 @@ class EventLog(Base, UUIDMixin, TimestampMixin):
__tablename__ = "events_log"
receiver_node_id: Mapped[Optional[str]] = mapped_column(
observer_node_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("nodes.id", ondelete="SET NULL"),
nullable=True,
index=True,
@@ -1,4 +1,4 @@
"""EventReceiver model for tracking which nodes received each event."""
"""EventObserver model for tracking which observer nodes captured each event."""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
@@ -14,25 +14,25 @@ if TYPE_CHECKING:
from meshcore_hub.common.models.node import Node
class EventReceiver(Base, UUIDMixin, TimestampMixin):
"""Junction model tracking which receivers observed each event.
class EventObserver(Base, UUIDMixin, TimestampMixin):
"""Junction model tracking which observers captured each event.
This table enables multi-receiver tracking for deduplicated events.
When multiple receiver nodes observe the same mesh event, each receiver
This table enables multi-observer tracking for deduplicated events.
When multiple observer nodes capture the same mesh event, each observer
gets an entry in this table linked by the event_hash.
Attributes:
id: UUID primary key
event_type: Type of event ('message', 'advertisement', 'trace', 'telemetry')
event_hash: Hash identifying the unique event (links to event tables)
receiver_node_id: FK to the node that received this event
snr: Signal-to-noise ratio at this receiver (if available)
received_at: When this specific receiver saw the event
observer_node_id: FK to the node that observed this event
snr: Signal-to-noise ratio at this observer (if available)
observed_at: When this specific observer captured the event
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = "event_receivers"
__tablename__ = "event_observers"
event_type: Mapped[str] = mapped_column(
String(20),
@@ -43,7 +43,7 @@ class EventReceiver(Base, UUIDMixin, TimestampMixin):
nullable=False,
index=True,
)
receiver_node_id: Mapped[str] = mapped_column(
observer_node_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("nodes.id", ondelete="CASCADE"),
nullable=False,
@@ -53,75 +53,73 @@ class EventReceiver(Base, UUIDMixin, TimestampMixin):
Float,
nullable=True,
)
received_at: Mapped[datetime] = mapped_column(
observed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utc_now,
nullable=False,
)
# Relationship to receiver node
receiver_node: Mapped["Node"] = relationship(
observer_node: Mapped["Node"] = relationship(
"Node",
foreign_keys=[receiver_node_id],
foreign_keys=[observer_node_id],
)
__table_args__ = (
UniqueConstraint(
"event_hash", "receiver_node_id", name="uq_event_receivers_hash_node"
"event_hash", "observer_node_id", name="uq_event_observers_hash_node"
),
Index("ix_event_receivers_type_hash", "event_type", "event_hash"),
Index("ix_event_observers_type_hash", "event_type", "event_hash"),
)
def __repr__(self) -> str:
return (
f"<EventReceiver(type={self.event_type}, "
f"<EventObserver(type={self.event_type}, "
f"hash={self.event_hash[:8]}..., "
f"node={self.receiver_node_id[:8]}...)>"
f"node={self.observer_node_id[:8]}...)>"
)
def add_event_receiver(
def add_event_observer(
session: Session,
event_type: str,
event_hash: str,
receiver_node_id: str,
observer_node_id: str,
snr: Optional[float] = None,
received_at: Optional[datetime] = None,
observed_at: Optional[datetime] = None,
) -> bool:
"""Add a receiver to an event, handling duplicates gracefully.
"""Add an observer to an event, handling duplicates gracefully.
Uses INSERT OR IGNORE to handle the unique constraint on (event_hash, receiver_node_id).
Uses INSERT OR IGNORE to handle the unique constraint on (event_hash, observer_node_id).
Args:
session: SQLAlchemy session
event_type: Type of event ('message', 'advertisement', 'trace', 'telemetry')
event_hash: Hash identifying the unique event
receiver_node_id: UUID of the receiver node
snr: Signal-to-noise ratio at this receiver (optional)
received_at: When this receiver saw the event (defaults to now)
observer_node_id: UUID of the observer node
snr: Signal-to-noise ratio at this observer (optional)
observed_at: When this observer captured the event (defaults to now)
Returns:
True if a new receiver entry was added, False if it already existed.
True if a new observer entry was added, False if it already existed.
"""
from datetime import timezone
now = received_at or datetime.now(timezone.utc)
now = observed_at or datetime.now(timezone.utc)
stmt = (
sqlite_insert(EventReceiver)
sqlite_insert(EventObserver)
.values(
id=str(uuid4()),
event_type=event_type,
event_hash=event_hash,
receiver_node_id=receiver_node_id,
observer_node_id=observer_node_id,
snr=snr,
received_at=now,
observed_at=now,
created_at=now,
updated_at=now,
)
.on_conflict_do_nothing(index_elements=["event_hash", "receiver_node_id"])
.on_conflict_do_nothing(index_elements=["event_hash", "observer_node_id"])
)
result = session.execute(stmt)
# CursorResult has rowcount attribute
rowcount = getattr(result, "rowcount", 0)
return bool(rowcount and rowcount > 0)
+2 -2
View File
@@ -14,7 +14,7 @@ class Message(Base, UUIDMixin, TimestampMixin):
Attributes:
id: UUID primary key
receiver_node_id: FK to nodes (receiving interface)
observer_node_id: FK to nodes (observing interface)
message_type: Message type (contact, channel)
pubkey_prefix: Sender's public key prefix (12 chars, contact msgs)
channel_idx: Channel index (channel msgs)
@@ -30,7 +30,7 @@ class Message(Base, UUIDMixin, TimestampMixin):
__tablename__ = "messages"
receiver_node_id: Mapped[Optional[str]] = mapped_column(
observer_node_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("nodes.id", ondelete="SET NULL"),
nullable=True,
index=True,
+2 -2
View File
@@ -15,7 +15,7 @@ class Telemetry(Base, UUIDMixin, TimestampMixin):
Attributes:
id: UUID primary key
receiver_node_id: FK to nodes (receiving interface)
observer_node_id: FK to nodes (observing interface)
node_id: FK to nodes (reporting node)
node_public_key: Reporting node's public key
lpp_data: Raw LPP-encoded sensor data
@@ -26,7 +26,7 @@ class Telemetry(Base, UUIDMixin, TimestampMixin):
__tablename__ = "telemetry"
receiver_node_id: Mapped[Optional[str]] = mapped_column(
observer_node_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("nodes.id", ondelete="SET NULL"),
nullable=True,
index=True,
+2 -2
View File
@@ -15,7 +15,7 @@ class TracePath(Base, UUIDMixin, TimestampMixin):
Attributes:
id: UUID primary key
receiver_node_id: FK to nodes (receiving interface)
observer_node_id: FK to nodes (observing interface)
initiator_tag: Unique trace identifier
path_len: Path length
flags: Trace flags
@@ -29,7 +29,7 @@ class TracePath(Base, UUIDMixin, TimestampMixin):
__tablename__ = "trace_paths"
receiver_node_id: Mapped[Optional[str]] = mapped_column(
observer_node_id: Mapped[Optional[str]] = mapped_column(
ForeignKey("nodes.id", ondelete="SET NULL"),
nullable=True,
index=True,
+2 -11
View File
@@ -20,16 +20,11 @@ from meshcore_hub.common.schemas.nodes import (
NodeTagRead,
)
from meshcore_hub.common.schemas.messages import (
ReceiverInfo,
ObserverInfo,
MessageRead,
MessageList,
MessageFilters,
)
from meshcore_hub.common.schemas.commands import (
SendMessageCommand,
SendChannelMessageCommand,
SendAdvertCommand,
)
from meshcore_hub.common.schemas.members import (
MemberCreate,
MemberUpdate,
@@ -59,14 +54,10 @@ __all__ = [
"NodeTagUpdate",
"NodeTagRead",
# Messages & Events
"ReceiverInfo",
"ObserverInfo",
"MessageRead",
"MessageList",
"MessageFilters",
# Commands
"SendMessageCommand",
"SendChannelMessageCommand",
"SendAdvertCommand",
# Members
"MemberCreate",
"MemberUpdate",
@@ -1,89 +0,0 @@
"""Pydantic schemas for command API endpoints."""
from typing import Optional
from pydantic import BaseModel, Field
class SendMessageCommand(BaseModel):
"""Schema for sending a direct message."""
destination: str = Field(
...,
min_length=12,
max_length=64,
description="Destination public key or prefix",
)
text: str = Field(
...,
min_length=1,
max_length=1000,
description="Message content",
)
timestamp: Optional[int] = Field(
default=None,
description="Unix timestamp (optional, defaults to current time)",
)
class SendChannelMessageCommand(BaseModel):
"""Schema for sending a channel message."""
channel_idx: int = Field(
...,
ge=0,
le=255,
description="Channel index (0-255)",
)
text: str = Field(
...,
min_length=1,
max_length=1000,
description="Message content",
)
timestamp: Optional[int] = Field(
default=None,
description="Unix timestamp (optional, defaults to current time)",
)
class SendAdvertCommand(BaseModel):
"""Schema for sending an advertisement."""
flood: bool = Field(
default=True,
description="Whether to flood the advertisement",
)
class RequestStatusCommand(BaseModel):
"""Schema for requesting node status."""
target_public_key: Optional[str] = Field(
default=None,
min_length=64,
max_length=64,
description="Target node public key (optional)",
)
class RequestTelemetryCommand(BaseModel):
"""Schema for requesting telemetry data."""
target_public_key: str = Field(
...,
min_length=64,
max_length=64,
description="Target node public key",
)
class CommandResponse(BaseModel):
"""Schema for command response."""
success: bool = Field(..., description="Whether command was accepted")
message: str = Field(..., description="Response message")
command_id: Optional[str] = Field(
default=None,
description="Command tracking ID (if applicable)",
)
+29 -27
View File
@@ -6,17 +6,19 @@ from typing import Literal, Optional
from pydantic import BaseModel, Field
class ReceiverInfo(BaseModel):
"""Information about a receiver that observed an event."""
class ObserverInfo(BaseModel):
"""Information about an observer that captured an event."""
node_id: str = Field(..., description="Receiver node UUID")
public_key: str = Field(..., description="Receiver node public key")
name: Optional[str] = Field(default=None, description="Receiver node name")
tag_name: Optional[str] = Field(default=None, description="Receiver name from tags")
node_id: str = Field(..., description="Observer node UUID")
public_key: str = Field(..., description="Observer node public key")
name: Optional[str] = Field(default=None, description="Observer node name")
tag_name: Optional[str] = Field(default=None, description="Observer name from tags")
snr: Optional[float] = Field(
default=None, description="Signal-to-noise ratio at this receiver"
default=None, description="Signal-to-noise ratio at this observer"
)
observed_at: datetime = Field(
..., description="When this observer captured the event"
)
received_at: datetime = Field(..., description="When this receiver saw the event")
class Config:
from_attributes = True
@@ -25,12 +27,12 @@ class ReceiverInfo(BaseModel):
class MessageRead(BaseModel):
"""Schema for reading a message."""
received_by: Optional[str] = Field(
default=None, description="Receiving interface node public key"
observed_by: Optional[str] = Field(
default=None, description="Observing interface node public key"
)
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
receiver_tag_name: Optional[str] = Field(
default=None, description="Receiver name from tags"
observer_name: Optional[str] = Field(default=None, description="Observer node name")
observer_tag_name: Optional[str] = Field(
default=None, description="Observer name from tags"
)
message_type: str = Field(..., description="Message type (contact, channel)")
pubkey_prefix: Optional[str] = Field(
@@ -53,8 +55,8 @@ class MessageRead(BaseModel):
)
received_at: datetime = Field(..., description="When received by interface")
created_at: datetime = Field(..., description="Record creation timestamp")
receivers: list[ReceiverInfo] = Field(
default_factory=list, description="All receivers that observed this message"
observers: list[ObserverInfo] = Field(
default_factory=list, description="All observers that captured this message"
)
class Config:
@@ -104,12 +106,12 @@ class MessageFilters(BaseModel):
class AdvertisementRead(BaseModel):
"""Schema for reading an advertisement."""
received_by: Optional[str] = Field(
observed_by: Optional[str] = Field(
default=None, description="Receiving interface node public key"
)
receiver_name: Optional[str] = Field(default=None, description="Receiver node name")
receiver_tag_name: Optional[str] = Field(
default=None, description="Receiver name from tags"
observer_name: Optional[str] = Field(default=None, description="Observer node name")
observer_tag_name: Optional[str] = Field(
default=None, description="Observer name from tags"
)
public_key: str = Field(..., description="Advertised public key")
name: Optional[str] = Field(default=None, description="Advertised name")
@@ -126,9 +128,9 @@ class AdvertisementRead(BaseModel):
flags: Optional[int] = Field(default=None, description="Capability flags")
received_at: datetime = Field(..., description="When received")
created_at: datetime = Field(..., description="Record creation timestamp")
receivers: list[ReceiverInfo] = Field(
observers: list[ObserverInfo] = Field(
default_factory=list,
description="All receivers that observed this advertisement",
description="All observers that captured this advertisement",
)
class Config:
@@ -147,7 +149,7 @@ class AdvertisementList(BaseModel):
class TracePathRead(BaseModel):
"""Schema for reading a trace path."""
received_by: Optional[str] = Field(
observed_by: Optional[str] = Field(
default=None, description="Receiving interface node public key"
)
initiator_tag: int = Field(..., description="Trace identifier")
@@ -164,9 +166,9 @@ class TracePathRead(BaseModel):
hop_count: Optional[int] = Field(default=None, description="Total hops")
received_at: datetime = Field(..., description="When received")
created_at: datetime = Field(..., description="Record creation timestamp")
receivers: list[ReceiverInfo] = Field(
observers: list[ObserverInfo] = Field(
default_factory=list,
description="All receivers that observed this trace",
description="All observers that captured this trace",
)
class Config:
@@ -185,7 +187,7 @@ class TracePathList(BaseModel):
class TelemetryRead(BaseModel):
"""Schema for reading a telemetry record."""
received_by: Optional[str] = Field(
observed_by: Optional[str] = Field(
default=None, description="Receiving interface node public key"
)
node_public_key: str = Field(..., description="Reporting node public key")
@@ -194,9 +196,9 @@ class TelemetryRead(BaseModel):
)
received_at: datetime = Field(..., description="When received")
created_at: datetime = Field(..., description="Record creation timestamp")
receivers: list[ReceiverInfo] = Field(
observers: list[ObserverInfo] = Field(
default_factory=list,
description="All receivers that observed this telemetry",
description="All observers that captured this telemetry",
)
class Config:
-1
View File
@@ -1 +0,0 @@
"""Interface component for MeshCore device communication."""
-455
View File
@@ -1,455 +0,0 @@
"""CLI for the Interface component."""
import click
from meshcore_hub.common.logging import configure_logging
@click.group()
def interface() -> None:
"""Interface component for MeshCore device communication.
Runs in RECEIVER or SENDER mode to bridge between
MeshCore devices and MQTT broker.
"""
pass
@interface.command("run")
@click.option(
"--mode",
type=click.Choice(["RECEIVER", "SENDER"], case_sensitive=False),
required=True,
envvar="INTERFACE_MODE",
help="Interface mode: RECEIVER or SENDER",
)
@click.option(
"--port",
type=str,
default="/dev/ttyUSB0",
envvar="SERIAL_PORT",
help="Serial port path",
)
@click.option(
"--baud",
type=int,
default=115200,
envvar="SERIAL_BAUD",
help="Serial baud rate",
)
@click.option(
"--mock",
is_flag=True,
default=False,
envvar="MOCK_DEVICE",
help="Use mock device for testing",
)
@click.option(
"--node-address",
type=str,
default=None,
envvar="NODE_ADDRESS",
help="Override for device public key/address (hex string)",
)
@click.option(
"--device-name",
type=str,
default=None,
envvar="MESHCORE_DEVICE_NAME",
help="Device/node name (optional)",
)
@click.option(
"--mqtt-host",
type=str,
default="localhost",
envvar="MQTT_HOST",
help="MQTT broker host",
)
@click.option(
"--mqtt-port",
type=int,
default=1883,
envvar="MQTT_PORT",
help="MQTT broker port",
)
@click.option(
"--mqtt-username",
type=str,
default=None,
envvar="MQTT_USERNAME",
help="MQTT username",
)
@click.option(
"--mqtt-password",
type=str,
default=None,
envvar="MQTT_PASSWORD",
help="MQTT password",
)
@click.option(
"--prefix",
type=str,
default="meshcore",
envvar="MQTT_PREFIX",
help="MQTT topic prefix",
)
@click.option(
"--mqtt-tls",
is_flag=True,
default=False,
envvar="MQTT_TLS",
help="Enable TLS/SSL for MQTT connection",
)
@click.option(
"--contact-cleanup/--no-contact-cleanup",
default=True,
envvar="CONTACT_CLEANUP_ENABLED",
help="Enable/disable automatic removal of stale contacts (RECEIVER mode only)",
)
@click.option(
"--contact-cleanup-days",
type=int,
default=7,
envvar="CONTACT_CLEANUP_DAYS",
help="Remove contacts not advertised for this many days (RECEIVER mode only)",
)
@click.option(
"--log-level",
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
default="INFO",
envvar="LOG_LEVEL",
help="Log level",
)
def run(
mode: str,
port: str,
baud: int,
mock: bool,
node_address: str | None,
device_name: str | None,
mqtt_host: str,
mqtt_port: int,
mqtt_username: str | None,
mqtt_password: str | None,
prefix: str,
mqtt_tls: bool,
contact_cleanup: bool,
contact_cleanup_days: int,
log_level: str,
) -> None:
"""Run the interface component.
The interface bridges MeshCore devices to an MQTT broker.
In RECEIVER mode:
- Connects to a MeshCore device
- Subscribes to device events
- Publishes events to MQTT
In SENDER mode:
- Connects to a MeshCore device
- Subscribes to MQTT command topics
- Executes commands on the device
"""
configure_logging(level=log_level)
click.echo(f"Starting interface in {mode} mode")
click.echo(f"Serial: {port} @ {baud} baud")
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
click.echo(f"Mock device: {mock}")
if node_address:
click.echo(f"Node address: {node_address}")
mode_upper = mode.upper()
if mode_upper == "RECEIVER":
from meshcore_hub.interface.receiver import run_receiver
run_receiver(
port=port,
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
contact_cleanup_enabled=contact_cleanup,
contact_cleanup_days=contact_cleanup_days,
)
elif mode_upper == "SENDER":
from meshcore_hub.interface.sender import run_sender
run_sender(
port=port,
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
)
else:
click.echo(f"Unknown mode: {mode}", err=True)
raise click.Abort()
@interface.command("receiver")
@click.option(
"--port",
type=str,
default="/dev/ttyUSB0",
envvar="SERIAL_PORT",
help="Serial port path",
)
@click.option(
"--baud",
type=int,
default=115200,
envvar="SERIAL_BAUD",
help="Serial baud rate",
)
@click.option(
"--mock",
is_flag=True,
default=False,
envvar="MOCK_DEVICE",
help="Use mock device for testing",
)
@click.option(
"--node-address",
type=str,
default=None,
envvar="NODE_ADDRESS",
help="Override for device public key/address (hex string)",
)
@click.option(
"--device-name",
type=str,
default=None,
envvar="MESHCORE_DEVICE_NAME",
help="Device/node name (optional)",
)
@click.option(
"--mqtt-host",
type=str,
default="localhost",
envvar="MQTT_HOST",
help="MQTT broker host",
)
@click.option(
"--mqtt-port",
type=int,
default=1883,
envvar="MQTT_PORT",
help="MQTT broker port",
)
@click.option(
"--mqtt-username",
type=str,
default=None,
envvar="MQTT_USERNAME",
help="MQTT username",
)
@click.option(
"--mqtt-password",
type=str,
default=None,
envvar="MQTT_PASSWORD",
help="MQTT password",
)
@click.option(
"--prefix",
type=str,
default="meshcore",
envvar="MQTT_PREFIX",
help="MQTT topic prefix",
)
@click.option(
"--mqtt-tls",
is_flag=True,
default=False,
envvar="MQTT_TLS",
help="Enable TLS/SSL for MQTT connection",
)
@click.option(
"--contact-cleanup/--no-contact-cleanup",
default=True,
envvar="CONTACT_CLEANUP_ENABLED",
help="Enable/disable automatic removal of stale contacts",
)
@click.option(
"--contact-cleanup-days",
type=int,
default=7,
envvar="CONTACT_CLEANUP_DAYS",
help="Remove contacts not advertised for this many days",
)
def receiver(
port: str,
baud: int,
mock: bool,
node_address: str | None,
device_name: str | None,
mqtt_host: str,
mqtt_port: int,
mqtt_username: str | None,
mqtt_password: str | None,
prefix: str,
mqtt_tls: bool,
contact_cleanup: bool,
contact_cleanup_days: int,
) -> None:
"""Run interface in RECEIVER mode.
Shortcut for: meshcore-hub interface run --mode RECEIVER
"""
from meshcore_hub.interface.receiver import run_receiver
click.echo("Starting interface in RECEIVER mode")
click.echo(f"Serial: {port} @ {baud} baud")
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
click.echo(f"Mock device: {mock}")
if node_address:
click.echo(f"Node address: {node_address}")
run_receiver(
port=port,
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
contact_cleanup_enabled=contact_cleanup,
contact_cleanup_days=contact_cleanup_days,
)
@interface.command("sender")
@click.option(
"--port",
type=str,
default="/dev/ttyUSB0",
envvar="SERIAL_PORT",
help="Serial port path",
)
@click.option(
"--baud",
type=int,
default=115200,
envvar="SERIAL_BAUD",
help="Serial baud rate",
)
@click.option(
"--mock",
is_flag=True,
default=False,
envvar="MOCK_DEVICE",
help="Use mock device for testing",
)
@click.option(
"--node-address",
type=str,
default=None,
envvar="NODE_ADDRESS",
help="Override for device public key/address (hex string)",
)
@click.option(
"--device-name",
type=str,
default=None,
envvar="MESHCORE_DEVICE_NAME",
help="Device/node name (optional)",
)
@click.option(
"--mqtt-host",
type=str,
default="localhost",
envvar="MQTT_HOST",
help="MQTT broker host",
)
@click.option(
"--mqtt-port",
type=int,
default=1883,
envvar="MQTT_PORT",
help="MQTT broker port",
)
@click.option(
"--mqtt-username",
type=str,
default=None,
envvar="MQTT_USERNAME",
help="MQTT username",
)
@click.option(
"--mqtt-password",
type=str,
default=None,
envvar="MQTT_PASSWORD",
help="MQTT password",
)
@click.option(
"--prefix",
type=str,
default="meshcore",
envvar="MQTT_PREFIX",
help="MQTT topic prefix",
)
@click.option(
"--mqtt-tls",
is_flag=True,
default=False,
envvar="MQTT_TLS",
help="Enable TLS/SSL for MQTT connection",
)
def sender(
port: str,
baud: int,
mock: bool,
node_address: str | None,
device_name: str | None,
mqtt_host: str,
mqtt_port: int,
mqtt_username: str | None,
mqtt_password: str | None,
prefix: str,
mqtt_tls: bool,
) -> None:
"""Run interface in SENDER mode.
Shortcut for: meshcore-hub interface run --mode SENDER
"""
from meshcore_hub.interface.sender import run_sender
click.echo("Starting interface in SENDER mode")
click.echo(f"Serial: {port} @ {baud} baud")
click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})")
click.echo(f"Mock device: {mock}")
if node_address:
click.echo(f"Node address: {node_address}")
run_sender(
port=port,
baud=baud,
mock=mock,
node_address=node_address,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=prefix,
mqtt_tls=mqtt_tls,
)
-753
View File
@@ -1,753 +0,0 @@
"""MeshCore device wrapper for serial communication."""
import asyncio
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
class EventType(str, Enum):
"""MeshCore event types."""
ADVERTISEMENT = "advertisement"
CONTACT_MSG_RECV = "contact_msg_recv"
CHANNEL_MSG_RECV = "channel_msg_recv"
TRACE_DATA = "trace_data"
TELEMETRY_RESPONSE = "telemetry_response"
CONTACTS = "contacts"
SEND_CONFIRMED = "send_confirmed"
STATUS_RESPONSE = "status_response"
BATTERY = "battery"
PATH_UPDATED = "path_updated"
EventHandler = Callable[[EventType, dict[str, Any]], None]
@dataclass
class DeviceConfig:
"""Device connection configuration."""
port: str = "/dev/ttyUSB0"
baud: int = 115200
timeout: float = 1.0
reconnect_delay: float = 5.0
max_reconnect_attempts: int = 10
node_address: Optional[str] = None # Override for device public key/address
class BaseMeshCoreDevice(ABC):
"""Abstract base class for MeshCore device interface."""
def __init__(self, config: DeviceConfig):
"""Initialize device.
Args:
config: Device configuration
"""
self.config = config
self._connected = False
self._public_key: Optional[str] = None
self._event_handlers: dict[EventType, list[EventHandler]] = {}
@property
def public_key(self) -> Optional[str]:
"""Get the device's public key."""
return self._public_key
@property
def is_connected(self) -> bool:
"""Check if device is connected."""
return self._connected
@abstractmethod
def connect(self) -> bool:
"""Connect to the device.
Returns:
True if connection successful
"""
pass
@abstractmethod
def disconnect(self) -> None:
"""Disconnect from the device."""
pass
@abstractmethod
def send_message(
self,
destination: str,
text: str,
timestamp: Optional[int] = None,
) -> bool:
"""Send a direct message.
Args:
destination: Destination public key or prefix
text: Message content
timestamp: Optional timestamp (defaults to current time)
Returns:
True if message was queued successfully
"""
pass
@abstractmethod
def send_channel_message(
self,
channel_idx: int,
text: str,
timestamp: Optional[int] = None,
) -> bool:
"""Send a channel message.
Args:
channel_idx: Channel index (0-255)
text: Message content
timestamp: Optional timestamp (defaults to current time)
Returns:
True if message was queued successfully
"""
pass
@abstractmethod
def send_advertisement(self, flood: bool = True) -> bool:
"""Send a node advertisement.
Args:
flood: Whether to flood the advertisement
Returns:
True if advertisement was queued successfully
"""
pass
@abstractmethod
def request_status(self, target: Optional[str] = None) -> bool:
"""Request status from a node.
Args:
target: Target node public key (optional)
Returns:
True if request was sent
"""
pass
@abstractmethod
def request_telemetry(self, target: str) -> bool:
"""Request telemetry from a node.
Args:
target: Target node public key
Returns:
True if request was sent
"""
pass
@abstractmethod
def set_time(self, timestamp: int) -> bool:
"""Set the device's hardware clock.
Args:
timestamp: Unix timestamp to set
Returns:
True if time was set successfully
"""
pass
@abstractmethod
def set_name(self, name: str) -> bool:
"""Set the device's node name.
Args:
name: Node name to set
Returns:
True if name was set successfully
"""
pass
@abstractmethod
def start_message_fetching(self) -> bool:
"""Start automatic message fetching.
Subscribes to MESSAGES_WAITING events and fetches pending messages.
Returns:
True if started successfully
"""
pass
@abstractmethod
def get_contacts(self) -> bool:
"""Fetch contacts from device contact database.
Triggers a CONTACTS event with all stored contacts from the device.
Note: This should only be called before the event loop is running.
Returns:
True if request was sent successfully
"""
pass
@abstractmethod
def schedule_get_contacts(self) -> bool:
"""Schedule a get_contacts request on the event loop.
This is safe to call from event handlers while the event loop is running.
Returns:
True if request was scheduled successfully
"""
pass
@abstractmethod
def remove_contact(self, public_key: str) -> bool:
"""Remove a contact from the device's contact database.
Args:
public_key: The 64-character hex public key of the contact to remove
Returns:
True if contact was removed successfully
"""
pass
@abstractmethod
def schedule_remove_contact(self, public_key: str) -> bool:
"""Schedule a remove_contact request on the event loop.
This is safe to call from event handlers while the event loop is running.
Args:
public_key: The 64-character hex public key of the contact to remove
Returns:
True if request was scheduled successfully
"""
pass
@abstractmethod
def run(self) -> None:
"""Run the device event loop (blocking)."""
pass
@abstractmethod
def stop(self) -> None:
"""Stop the device event loop."""
pass
def register_handler(
self,
event_type: EventType,
handler: EventHandler,
) -> None:
"""Register an event handler.
Args:
event_type: Event type to handle
handler: Handler function
"""
if event_type not in self._event_handlers:
self._event_handlers[event_type] = []
self._event_handlers[event_type].append(handler)
logger.debug(f"Registered handler for {event_type.value}")
def unregister_handler(
self,
event_type: EventType,
handler: EventHandler,
) -> None:
"""Unregister an event handler.
Args:
event_type: Event type
handler: Handler function to remove
"""
if event_type in self._event_handlers:
try:
self._event_handlers[event_type].remove(handler)
logger.debug(f"Unregistered handler for {event_type.value}")
except ValueError:
pass
def _dispatch_event(self, event_type: EventType, payload: dict[str, Any]) -> None:
"""Dispatch an event to registered handlers.
Args:
event_type: Event type
payload: Event payload
"""
handlers = self._event_handlers.get(event_type, [])
for handler in handlers:
try:
handler(event_type, payload)
except Exception as e:
logger.error(f"Error in event handler for {event_type.value}: {e}")
# Map meshcore library EventType to our EventType
MESHCORE_EVENT_MAP = {
"advertisement": EventType.ADVERTISEMENT,
"contact_message": EventType.CONTACT_MSG_RECV,
"channel_message": EventType.CHANNEL_MSG_RECV,
"trace_data": EventType.TRACE_DATA,
"telemetry_response": EventType.TELEMETRY_RESPONSE,
"contacts": EventType.CONTACTS,
"message_sent": EventType.SEND_CONFIRMED,
"status_response": EventType.STATUS_RESPONSE,
"battery_info": EventType.BATTERY,
"path_update": EventType.PATH_UPDATED,
}
class MeshCoreDevice(BaseMeshCoreDevice):
"""Real MeshCore device implementation using meshcore library."""
def __init__(self, config: DeviceConfig):
"""Initialize real device.
Args:
config: Device configuration
"""
super().__init__(config)
self._running = False
self._mc: Any = None
self._loop: Any = None
self._subscriptions: list[Any] = []
def connect(self) -> bool:
"""Connect to the MeshCore device."""
try:
from meshcore import MeshCore
from meshcore.serial_cx import SerialConnection
except ImportError:
logger.error(
"meshcore library not installed. " "Install with: pip install meshcore"
)
return False
try:
logger.info(f"Connecting to MeshCore device on {self.config.port}")
# Create event loop if needed
try:
self._loop = asyncio.get_event_loop()
except RuntimeError:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
# Create serial connection and MeshCore instance
cx = SerialConnection(
self.config.port,
baudrate=self.config.baud,
)
self._mc = MeshCore(cx, auto_reconnect=True)
# Connect asynchronously
self._loop.run_until_complete(self._mc.connect())
# Get device public key from self_info property
# After connect(), the library internally processes SELF_INFO
# and stores it in the self_info property
if self.config.node_address:
# Use configured override
self._public_key = self.config.node_address
logger.info(f"Using configured node address: {self._public_key}")
else:
# Get from device self_info
self_info = self._mc.self_info
if self_info:
self._public_key = self_info.get("public_key")
if self._public_key:
logger.info("Retrieved device public key from self_info")
else:
logger.warning(
"Device self_info missing public_key field. "
"Use --node-address to configure manually."
)
else:
logger.warning(
"Could not retrieve device self_info. "
"Use --node-address to configure manually."
)
self._connected = True
logger.info(f"Connected to MeshCore device, public_key: {self._public_key}")
# Set up event subscriptions so events can be received immediately
self._setup_event_subscriptions()
return True
except Exception as e:
logger.error(f"Failed to connect to device: {e}")
return False
def _setup_event_subscriptions(self) -> None:
"""Set up event subscriptions for the meshcore library."""
if not self._mc:
return
from meshcore import EventType as MCEventType
# Map of meshcore event types to subscribe to
event_map = {
MCEventType.ADVERTISEMENT: EventType.ADVERTISEMENT,
MCEventType.CONTACT_MSG_RECV: EventType.CONTACT_MSG_RECV,
MCEventType.CHANNEL_MSG_RECV: EventType.CHANNEL_MSG_RECV,
MCEventType.TRACE_DATA: EventType.TRACE_DATA,
MCEventType.TELEMETRY_RESPONSE: EventType.TELEMETRY_RESPONSE,
MCEventType.CONTACTS: EventType.CONTACTS,
MCEventType.MSG_SENT: EventType.SEND_CONFIRMED,
MCEventType.STATUS_RESPONSE: EventType.STATUS_RESPONSE,
MCEventType.BATTERY: EventType.BATTERY,
MCEventType.PATH_UPDATE: EventType.PATH_UPDATED,
}
for mc_event_type, our_event_type in event_map.items():
async def callback(event: Any, et: EventType = our_event_type) -> None:
# Convert event to dict and dispatch
# Use event.payload for the full data (text, etc.)
# event.attributes only contains filtering fields
payload = (
dict(event.payload)
if hasattr(event, "payload") and isinstance(event.payload, dict)
else {}
)
self._dispatch_event(et, payload)
sub = self._mc.subscribe(mc_event_type, callback)
self._subscriptions.append(sub)
logger.debug(f"Subscribed to {mc_event_type.name}")
def disconnect(self) -> None:
"""Disconnect from the device."""
if self._mc:
try:
# Unsubscribe from events
for sub in self._subscriptions:
self._mc.unsubscribe(sub)
self._subscriptions.clear()
# Disconnect
if self._loop:
self._loop.run_until_complete(self._mc.disconnect())
except Exception as e:
logger.error(f"Error disconnecting: {e}")
self._connected = False
self._mc = None
logger.info("Disconnected from MeshCore device")
def send_message(
self,
destination: str,
text: str,
timestamp: Optional[int] = None,
) -> bool:
"""Send a direct message."""
if not self._connected or not self._mc:
logger.error("Cannot send message: not connected")
return False
try:
async def _send() -> None:
await self._mc.commands.send_msg(destination, text)
self._loop.run_until_complete(_send())
logger.info(f"Sent message to {destination[:12]}...")
return True
except Exception as e:
logger.error(f"Failed to send message: {e}")
return False
def send_channel_message(
self,
channel_idx: int,
text: str,
timestamp: Optional[int] = None,
) -> bool:
"""Send a channel message."""
if not self._connected or not self._mc:
logger.error("Cannot send channel message: not connected")
return False
try:
async def _send() -> None:
await self._mc.commands.send_chan_msg(channel_idx, text)
self._loop.run_until_complete(_send())
logger.info(f"Sent message to channel {channel_idx}")
return True
except Exception as e:
logger.error(f"Failed to send channel message: {e}")
return False
def send_advertisement(self, flood: bool = True) -> bool:
"""Send a node advertisement."""
if not self._connected or not self._mc:
logger.error("Cannot send advertisement: not connected")
return False
try:
async def _send() -> None:
await self._mc.commands.send_advert(flood=flood)
self._loop.run_until_complete(_send())
logger.info(f"Sent advertisement (flood={flood})")
return True
except Exception as e:
logger.error(f"Failed to send advertisement: {e}")
return False
def request_status(self, target: Optional[str] = None) -> bool:
"""Request status from a node."""
if not self._connected or not self._mc:
logger.error("Cannot request status: not connected")
return False
try:
async def _request() -> None:
await self._mc.commands.send_statusreq(target)
self._loop.run_until_complete(_request())
logger.info(f"Requested status from {target or 'self'}")
return True
except Exception as e:
logger.error(f"Failed to request status: {e}")
return False
def request_telemetry(self, target: str) -> bool:
"""Request telemetry from a node."""
if not self._connected or not self._mc:
logger.error("Cannot request telemetry: not connected")
return False
try:
async def _request() -> None:
await self._mc.commands.send_telemetry_req(target)
self._loop.run_until_complete(_request())
logger.info(f"Requested telemetry from {target[:12]}...")
return True
except Exception as e:
logger.error(f"Failed to request telemetry: {e}")
return False
def set_time(self, timestamp: int) -> bool:
"""Set the device's hardware clock."""
if not self._connected or not self._mc:
logger.error("Cannot set time: not connected")
return False
try:
async def _set_time() -> None:
await self._mc.commands.set_time(timestamp)
self._loop.run_until_complete(_set_time())
logger.info(f"Set device time to {timestamp}")
return True
except Exception as e:
logger.error(f"Failed to set device time: {e}")
return False
def set_name(self, name: str) -> bool:
"""Set the device's node name."""
if not self._connected or not self._mc:
logger.error("Cannot set name: not connected")
return False
try:
async def _set_name() -> None:
await self._mc.commands.set_name(name)
self._loop.run_until_complete(_set_name())
logger.info(f"Set device name to '{name}'")
return True
except Exception as e:
logger.error(f"Failed to set device name: {e}")
return False
def start_message_fetching(self) -> bool:
"""Start automatic message fetching."""
if not self._connected or not self._mc:
logger.error("Cannot start message fetching: not connected")
return False
try:
async def _start_fetching() -> None:
await self._mc.start_auto_message_fetching()
self._loop.run_until_complete(_start_fetching())
logger.info("Started automatic message fetching")
return True
except Exception as e:
logger.error(f"Failed to start message fetching: {e}")
return False
def get_contacts(self) -> bool:
"""Fetch contacts from device contact database.
Note: This method should only be called before the event loop is running
(e.g., during initialization). For calling during event processing,
use schedule_get_contacts() instead.
"""
if not self._connected or not self._mc:
logger.error("Cannot get contacts: not connected")
return False
try:
async def _get_contacts() -> None:
await self._mc.commands.get_contacts()
self._loop.run_until_complete(_get_contacts())
logger.info("Requested contacts from device")
return True
except Exception as e:
logger.error(f"Failed to get contacts: {e}")
return False
def schedule_get_contacts(self) -> bool:
"""Schedule a get_contacts request on the event loop.
This is safe to call from event handlers while the event loop is running.
The request is scheduled as a task on the event loop.
Returns:
True if request was scheduled, False if device not connected
"""
if not self._connected or not self._mc:
logger.error("Cannot get contacts: not connected")
return False
try:
async def _get_contacts() -> None:
await self._mc.commands.get_contacts()
asyncio.run_coroutine_threadsafe(_get_contacts(), self._loop)
logger.info("Scheduled contact sync request")
return True
except Exception as e:
logger.error(f"Failed to schedule get contacts: {e}")
return False
def remove_contact(self, public_key: str) -> bool:
"""Remove a contact from the device's contact database.
Note: This method should only be called before the event loop is running
(e.g., during initialization). For calling during event processing,
use schedule_remove_contact() instead.
"""
if not self._connected or not self._mc:
logger.error("Cannot remove contact: not connected")
return False
try:
async def _remove_contact() -> None:
await self._mc.commands.remove_contact(public_key)
self._loop.run_until_complete(_remove_contact())
logger.info(f"Removed contact {public_key[:12]}...")
return True
except Exception as e:
logger.error(f"Failed to remove contact: {e}")
return False
def schedule_remove_contact(self, public_key: str) -> bool:
"""Schedule a remove_contact request on the event loop.
This is safe to call from event handlers while the event loop is running.
The request is scheduled as a task on the event loop.
Returns:
True if request was scheduled, False if device not connected
"""
if not self._connected or not self._mc:
logger.error("Cannot remove contact: not connected")
return False
try:
async def _remove_contact() -> None:
await self._mc.commands.remove_contact(public_key)
asyncio.run_coroutine_threadsafe(_remove_contact(), self._loop)
logger.debug(f"Scheduled removal of contact {public_key[:12]}...")
return True
except Exception as e:
logger.error(f"Failed to schedule remove contact: {e}")
return False
def run(self) -> None:
"""Run the device event loop."""
self._running = True
logger.info("Starting device event loop")
# Run the async event loop
async def _run_loop() -> None:
while self._running and self._connected:
await asyncio.sleep(0.1)
try:
self._loop.run_until_complete(_run_loop())
except Exception as e:
logger.error(f"Error in event loop: {e}")
logger.info("Device event loop stopped")
def stop(self) -> None:
"""Stop the device event loop."""
self._running = False
if self._mc:
self._mc.stop()
logger.info("Stopping device event loop")
def create_device(
port: str = "/dev/ttyUSB0",
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
) -> BaseMeshCoreDevice:
"""Create a MeshCore device instance.
Args:
port: Serial port path
baud: Baud rate
mock: Use mock device for testing
node_address: Optional override for device public key/address
Returns:
Device instance
"""
config = DeviceConfig(port=port, baud=baud, node_address=node_address)
if mock:
from meshcore_hub.interface.mock_device import MockMeshCoreDevice
return MockMeshCoreDevice(config)
return MeshCoreDevice(config)
-497
View File
@@ -1,497 +0,0 @@
"""Mock MeshCore device for testing without hardware."""
import logging
import random
import secrets
import threading
import time
from dataclasses import dataclass, field
from typing import Optional
from meshcore_hub.interface.device import (
BaseMeshCoreDevice,
DeviceConfig,
EventType,
)
logger = logging.getLogger(__name__)
@dataclass
class MockNodeConfig:
"""Configuration for a simulated node."""
public_key: str
name: str
adv_type: str = "chat"
flags: int = 218
@dataclass
class MockDeviceConfig:
"""Configuration for mock device behavior."""
# Device identity
public_key: Optional[str] = None
name: str = "MockNode"
# Simulated network nodes
nodes: list[MockNodeConfig] = field(default_factory=list)
# Event generation intervals (seconds)
advertisement_interval: float = 30.0
message_interval: float = 10.0
telemetry_interval: float = 60.0
# Simulation parameters
enable_auto_events: bool = True
message_delay_min: float = 0.1
message_delay_max: float = 1.0
error_rate: float = 0.0 # Probability of simulated errors
def generate_random_public_key() -> str:
"""Generate a random 64-character hex public key."""
return secrets.token_hex(32)
class MockMeshCoreDevice(BaseMeshCoreDevice):
"""Mock MeshCore device for testing.
Simulates a MeshCore device for unit and integration testing
without requiring physical hardware.
"""
def __init__(
self,
config: DeviceConfig,
mock_config: Optional[MockDeviceConfig] = None,
):
"""Initialize mock device.
Args:
config: Device configuration (port/baud are ignored)
mock_config: Mock-specific configuration
"""
super().__init__(config)
self.mock_config = mock_config or MockDeviceConfig()
# Generate public key if not provided
if self.mock_config.public_key:
self._public_key = self.mock_config.public_key
else:
self._public_key = generate_random_public_key()
# Initialize default simulated nodes if none provided
if not self.mock_config.nodes:
self.mock_config.nodes = self._create_default_nodes()
self._running = False
self._event_thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
logger.info(f"Initialized mock device with public key: {self._public_key}")
def _create_default_nodes(self) -> list[MockNodeConfig]:
"""Create default simulated network nodes."""
return [
MockNodeConfig(
public_key=generate_random_public_key(),
name="Alice",
adv_type="chat",
),
MockNodeConfig(
public_key=generate_random_public_key(),
name="Bob",
adv_type="chat",
),
MockNodeConfig(
public_key=generate_random_public_key(),
name="Repeater-01",
adv_type="repeater",
flags=128,
),
MockNodeConfig(
public_key=generate_random_public_key(),
name="ChatRoom",
adv_type="room",
),
]
def connect(self) -> bool:
"""Connect to the mock device."""
logger.info("Connecting to mock MeshCore device")
self._connected = True
# Simulate initial AppStart event
self._dispatch_event(
EventType.STATUS_RESPONSE,
{
"node_public_key": self._public_key,
"status": "connected",
"uptime": 0,
"message_count": 0,
},
)
logger.info(f"Mock device connected: {self._public_key}")
return True
def disconnect(self) -> None:
"""Disconnect from the mock device."""
self._connected = False
self.stop()
logger.info("Mock device disconnected")
def send_message(
self,
destination: str,
text: str,
timestamp: Optional[int] = None,
) -> bool:
"""Send a simulated direct message."""
if not self._connected:
logger.error("Cannot send message: not connected")
return False
if self._should_fail():
logger.warning("Simulated send failure")
return False
logger.info(f"Mock: Sending message to {destination[:12]}...: {text[:20]}...")
# Simulate send confirmation after delay
delay = random.uniform(
self.mock_config.message_delay_min,
self.mock_config.message_delay_max,
)
def send_confirmation() -> None:
time.sleep(delay)
self._dispatch_event(
EventType.SEND_CONFIRMED,
{
"destination_public_key": (
destination
if len(destination) == 64
else destination + "0" * (64 - len(destination))
),
"round_trip_ms": int(delay * 1000),
},
)
threading.Thread(target=send_confirmation, daemon=True).start()
return True
def send_channel_message(
self,
channel_idx: int,
text: str,
timestamp: Optional[int] = None,
) -> bool:
"""Send a simulated channel message."""
if not self._connected:
logger.error("Cannot send channel message: not connected")
return False
if self._should_fail():
logger.warning("Simulated send failure")
return False
logger.info(f"Mock: Sending message to channel {channel_idx}: {text[:20]}...")
return True
def send_advertisement(self, flood: bool = True) -> bool:
"""Send a simulated advertisement."""
if not self._connected:
logger.error("Cannot send advertisement: not connected")
return False
logger.info(f"Mock: Sending advertisement (flood={flood})")
return True
def request_status(self, target: Optional[str] = None) -> bool:
"""Request status from mock device."""
if not self._connected:
logger.error("Cannot request status: not connected")
return False
logger.info(f"Mock: Requesting status from {target or 'self'}")
# Generate status response
def send_status() -> None:
time.sleep(0.2)
self._dispatch_event(
EventType.STATUS_RESPONSE,
{
"node_public_key": target or self._public_key,
"status": "operational",
"uptime": random.randint(0, 86400),
"message_count": random.randint(0, 10000),
},
)
threading.Thread(target=send_status, daemon=True).start()
return True
def request_telemetry(self, target: str) -> bool:
"""Request telemetry from mock device."""
if not self._connected:
logger.error("Cannot request telemetry: not connected")
return False
logger.info(f"Mock: Requesting telemetry from {target[:12]}...")
# Generate telemetry response
def send_telemetry() -> None:
time.sleep(0.3)
self._dispatch_event(
EventType.TELEMETRY_RESPONSE,
{
"node_public_key": target,
"parsed_data": {
"temperature": round(random.uniform(15.0, 35.0), 1),
"humidity": random.randint(30, 90),
"battery": round(random.uniform(3.2, 4.2), 2),
"pressure": round(random.uniform(980.0, 1040.0), 2),
},
},
)
threading.Thread(target=send_telemetry, daemon=True).start()
return True
def set_time(self, timestamp: int) -> bool:
"""Set the mock device's hardware clock."""
if not self._connected:
logger.error("Cannot set time: not connected")
return False
logger.info(f"Mock: Set device time to {timestamp}")
return True
def set_name(self, name: str) -> bool:
"""Set the mock device's node name."""
if not self._connected:
logger.error("Cannot set name: not connected")
return False
logger.info(f"Mock: Set device name to '{name}'")
# Update the mock config name
self.mock_config.name = name
return True
def start_message_fetching(self) -> bool:
"""Start automatic message fetching (mock)."""
if not self._connected:
logger.error("Cannot start message fetching: not connected")
return False
logger.info("Mock: Started automatic message fetching")
return True
def get_contacts(self) -> bool:
"""Fetch contacts from mock device contact database.
Note: This should only be called before the event loop is running.
"""
if not self._connected:
logger.error("Cannot get contacts: not connected")
return False
logger.info("Mock: Requesting contacts from device")
# Generate CONTACTS event with all configured mock nodes
def send_contacts() -> None:
time.sleep(0.2)
contacts = [
{
"public_key": node.public_key,
"name": node.name,
"node_type": node.adv_type,
}
for node in self.mock_config.nodes
]
self._dispatch_event(
EventType.CONTACTS,
{"contacts": contacts},
)
threading.Thread(target=send_contacts, daemon=True).start()
return True
def schedule_get_contacts(self) -> bool:
"""Schedule a get_contacts request.
For the mock device, this is the same as get_contacts() since we
don't have a real async event loop. The contacts are sent via a thread.
"""
return self.get_contacts()
def remove_contact(self, public_key: str) -> bool:
"""Remove a contact from the mock device's contact database."""
if not self._connected:
logger.error("Cannot remove contact: not connected")
return False
# Find and remove the contact from mock_config.nodes
for i, node in enumerate(self.mock_config.nodes):
if node.public_key == public_key:
del self.mock_config.nodes[i]
logger.info(f"Mock: Removed contact {public_key[:12]}...")
return True
logger.warning(f"Mock: Contact {public_key[:12]}... not found")
return True # Return True even if not found (idempotent)
def schedule_remove_contact(self, public_key: str) -> bool:
"""Schedule a remove_contact request.
For the mock device, this is the same as remove_contact() since we
don't have a real async event loop.
"""
return self.remove_contact(public_key)
def run(self) -> None:
"""Run the mock device event loop."""
self._running = True
logger.info("Starting mock device event loop")
# Start auto event generation thread if enabled
if self.mock_config.enable_auto_events:
self._event_thread = threading.Thread(
target=self._auto_event_generator,
daemon=True,
)
self._event_thread.start()
while self._running and self._connected:
time.sleep(0.1)
logger.info("Mock device event loop stopped")
def stop(self) -> None:
"""Stop the mock device event loop."""
self._running = False
if self._event_thread and self._event_thread.is_alive():
self._event_thread.join(timeout=1.0)
logger.info("Mock device stopped")
def _should_fail(self) -> bool:
"""Check if operation should fail based on error rate."""
return random.random() < self.mock_config.error_rate
def _auto_event_generator(self) -> None:
"""Generate automatic events for simulation."""
last_adv = time.time()
last_msg = time.time()
last_telemetry = time.time()
while self._running:
now = time.time()
# Generate advertisements
if now - last_adv >= self.mock_config.advertisement_interval:
self._generate_advertisement()
last_adv = now
# Generate messages
if now - last_msg >= self.mock_config.message_interval:
self._generate_message()
last_msg = now
# Generate telemetry
if now - last_telemetry >= self.mock_config.telemetry_interval:
self._generate_telemetry()
last_telemetry = now
time.sleep(1.0)
def _generate_advertisement(self) -> None:
"""Generate a random advertisement event."""
node = random.choice(self.mock_config.nodes)
self._dispatch_event(
EventType.ADVERTISEMENT,
{
"public_key": node.public_key,
"name": node.name,
"adv_type": node.adv_type,
"flags": node.flags,
},
)
logger.debug(f"Generated advertisement from {node.name}")
def _generate_message(self) -> None:
"""Generate a random message event."""
node = random.choice(self.mock_config.nodes)
# Decide between contact and channel message
if random.random() < 0.5:
# Contact message
sample_messages = [
"Hello!",
"How's the signal?",
"Testing 1, 2, 3",
"Great weather today!",
"Anyone copy?",
"Loud and clear!",
]
self._dispatch_event(
EventType.CONTACT_MSG_RECV,
{
"pubkey_prefix": node.public_key[:12],
"text": random.choice(sample_messages),
"path_len": random.randint(1, 10),
"txt_type": 0,
"SNR": round(random.uniform(-5.0, 25.0), 1),
"sender_timestamp": int(time.time()),
},
)
logger.debug(f"Generated contact message from {node.name}")
else:
# Channel message
channel_messages = [
"Hello everyone!",
"Network check",
"CQ CQ CQ",
"Mesh is working great!",
"Any repeaters online?",
]
self._dispatch_event(
EventType.CHANNEL_MSG_RECV,
{
"channel_idx": random.choice([0, 1, 4, 7]),
"text": random.choice(channel_messages),
"path_len": random.randint(1, 15),
"txt_type": 0,
"SNR": round(random.uniform(-5.0, 25.0), 1),
"sender_timestamp": int(time.time()),
},
)
logger.debug("Generated channel message")
def _generate_telemetry(self) -> None:
"""Generate a random telemetry event."""
node = random.choice(self.mock_config.nodes)
self._dispatch_event(
EventType.TELEMETRY_RESPONSE,
{
"node_public_key": node.public_key,
"parsed_data": {
"temperature": round(random.uniform(15.0, 35.0), 1),
"humidity": random.randint(30, 90),
"battery": round(random.uniform(3.2, 4.2), 2),
},
},
)
logger.debug(f"Generated telemetry from {node.name}")
def inject_event(self, event_type: EventType, payload: dict) -> None:
"""Inject a custom event for testing.
Args:
event_type: Event type
payload: Event payload
"""
self._dispatch_event(event_type, payload)
-463
View File
@@ -1,463 +0,0 @@
"""RECEIVER mode implementation for MeshCore Interface.
In RECEIVER mode, the interface:
1. Connects to a MeshCore device
2. Subscribes to all device events
3. Publishes events to MQTT broker
"""
import logging
import signal
import threading
import time
from typing import Any, Optional
from meshcore_hub.common.health import HealthReporter
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
from meshcore_hub.interface.device import (
BaseMeshCoreDevice,
EventType,
create_device,
)
# Default contact cleanup settings
DEFAULT_CONTACT_CLEANUP_DAYS = 7
logger = logging.getLogger(__name__)
class Receiver:
"""RECEIVER mode implementation.
Bridges MeshCore device events to MQTT broker.
"""
def __init__(
self,
device: BaseMeshCoreDevice,
mqtt_client: MQTTClient,
device_name: Optional[str] = None,
contact_cleanup_enabled: bool = True,
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
):
"""Initialize receiver.
Args:
device: MeshCore device instance
mqtt_client: MQTT client instance
device_name: Optional device/node name to set on startup
contact_cleanup_enabled: Whether to remove stale contacts from device
contact_cleanup_days: Remove contacts not advertised for this many days
"""
self.device = device
self.mqtt = mqtt_client
self.device_name = device_name
self.contact_cleanup_enabled = contact_cleanup_enabled
self.contact_cleanup_days = contact_cleanup_days
self._running = False
self._shutdown_event = threading.Event()
self._device_connected = False
self._mqtt_connected = False
self._health_reporter: Optional[HealthReporter] = None
@property
def is_healthy(self) -> bool:
"""Check if the receiver is healthy.
Returns:
True if device and MQTT are connected
"""
return self._running and self._device_connected and self._mqtt_connected
def get_health_status(self) -> dict[str, Any]:
"""Get detailed health status.
Returns:
Dictionary with health status details
"""
return {
"healthy": self.is_healthy,
"running": self._running,
"device_connected": self._device_connected,
"mqtt_connected": self._mqtt_connected,
"device_public_key": self.device.public_key,
}
def _initialize_device(self, device_name: Optional[str] = None) -> None:
"""Initialize device after connection.
Sets the hardware clock, optionally sets device name, sends a local advertisement,
starts message fetching, and syncs the contact database.
Args:
device_name: Optional device/node name to set
"""
# Set device time to current Unix timestamp
current_time = int(time.time())
if self.device.set_time(current_time):
logger.info(f"Synchronized device clock to {current_time}")
else:
logger.warning("Failed to synchronize device clock")
# Set device name if provided
if device_name:
if self.device.set_name(device_name):
logger.info(f"Set device name to '{device_name}'")
else:
logger.warning(f"Failed to set device name to '{device_name}'")
# Send a flood advertisement to broadcast device name
if self.device.send_advertisement(flood=True):
logger.info("Sent flood advertisement")
else:
logger.warning("Failed to send flood advertisement")
# Start automatic message fetching
if self.device.start_message_fetching():
logger.info("Started automatic message fetching")
else:
logger.warning("Failed to start automatic message fetching")
# Fetch contact database to sync known nodes
if self.device.get_contacts():
logger.info("Requested contact database sync")
else:
logger.warning("Failed to request contact database")
def _handle_event(self, event_type: EventType, payload: dict[str, Any]) -> None:
"""Handle device event and publish to MQTT.
Args:
event_type: Event type
payload: Event payload
"""
if not self.device.public_key:
logger.warning("Cannot publish event: device public key not available")
return
try:
# Convert event type to MQTT topic name
event_name = event_type.value
# Special handling for CONTACTS: split into individual messages
if event_type == EventType.CONTACTS:
self._publish_contacts(payload)
return
# Publish to MQTT
self.mqtt.publish_event(
self.device.public_key,
event_name,
payload,
)
logger.debug(f"Published {event_name} event to MQTT")
# Trigger contact sync on advertisements
if event_type == EventType.ADVERTISEMENT:
self._sync_contacts()
except Exception as e:
logger.error(f"Failed to publish event to MQTT: {e}")
def _sync_contacts(self) -> None:
"""Request contact sync from device.
Called when advertisements are received to ensure contact database
stays current with all nodes on the mesh.
"""
logger.info("Advertisement received, triggering contact sync")
success = self.device.schedule_get_contacts()
if not success:
logger.warning("Contact sync request failed")
def _publish_contacts(self, payload: dict[str, Any]) -> None:
"""Publish each contact as a separate MQTT message.
The device returns contacts as a dict keyed by public_key.
We split this into individual 'contact' events for cleaner processing.
Stale contacts (not advertised for > contact_cleanup_days) are removed
from the device and not published.
Args:
payload: Dict of contacts keyed by public_key
"""
if not self.device.public_key:
logger.warning("Cannot publish contacts: device public key not available")
return
# Handle both formats:
# - Dict keyed by public_key (real device)
# - Dict with "contacts" array (mock device)
if "contacts" in payload:
contacts = payload["contacts"]
else:
contacts = list(payload.values())
if not contacts:
logger.debug("Empty contacts list received")
return
device_key = self.device.public_key # Capture for type narrowing
current_time = int(time.time())
stale_threshold = current_time - (self.contact_cleanup_days * 24 * 60 * 60)
published_count = 0
removed_count = 0
for contact in contacts:
if not isinstance(contact, dict):
continue
public_key = contact.get("public_key")
if not public_key:
continue
# Check if contact is stale based on last_advert timestamp
# Only check if cleanup is enabled and last_advert exists
if self.contact_cleanup_enabled:
last_advert = contact.get("last_advert")
if last_advert is not None and last_advert > 0:
if last_advert < stale_threshold:
# Contact is stale - remove from device
adv_name = contact.get("adv_name", contact.get("name", ""))
logger.info(
f"Removing stale contact {public_key[:12]}... "
f"({adv_name}) - last advertised "
f"{(current_time - last_advert) // 86400} days ago"
)
self.device.schedule_remove_contact(public_key)
removed_count += 1
continue # Don't publish stale contacts
try:
self.mqtt.publish_event(
device_key,
"contact", # Use singular 'contact' for individual events
contact,
)
published_count += 1
except Exception as e:
logger.error(f"Failed to publish contact event: {e}")
if removed_count > 0:
logger.info(
f"Contact sync: published {published_count}, "
f"removed {removed_count} stale contacts"
)
else:
logger.info(f"Published {published_count} contact events to MQTT")
def start(self) -> None:
"""Start the receiver."""
logger.info("Starting RECEIVER mode")
# Register event handlers for all event types
for event_type in EventType:
self.device.register_handler(event_type, self._handle_event)
logger.debug(f"Registered handler for {event_type.value}")
# Connect to MQTT broker
try:
self.mqtt.connect()
self.mqtt.start_background()
self._mqtt_connected = True
logger.info("Connected to MQTT broker")
except Exception as e:
self._mqtt_connected = False
logger.error(f"Failed to connect to MQTT broker: {e}")
raise
# Device should already be connected (from create_receiver)
# but handle case where start() is called directly
if not self.device.is_connected:
if not self.device.connect():
self._device_connected = False
logger.error("Failed to connect to MeshCore device")
self.mqtt.stop()
self.mqtt.disconnect()
self._mqtt_connected = False
raise RuntimeError("Failed to connect to MeshCore device")
logger.info(f"Connected to MeshCore device: {self.device.public_key}")
self._device_connected = True
# Initialize device: set time, optionally set name, and send local advertisement
self._initialize_device(device_name=self.device_name)
self._running = True
# Start health reporter for Docker health checks
self._health_reporter = HealthReporter(
component="interface",
status_fn=self.get_health_status,
interval=10.0,
)
self._health_reporter.start()
def run(self) -> None:
"""Run the receiver event loop (blocking)."""
if not self._running:
self.start()
logger.info("Receiver running. Press Ctrl+C to stop.")
try:
# Run device event loop
self.device.run()
except KeyboardInterrupt:
logger.info("Keyboard interrupt received")
finally:
self.stop()
def stop(self) -> None:
"""Stop the receiver."""
if not self._running:
return
logger.info("Stopping receiver")
self._running = False
self._shutdown_event.set()
# Stop health reporter
if self._health_reporter:
self._health_reporter.stop()
self._health_reporter = None
# Stop device
self.device.stop()
self.device.disconnect()
self._device_connected = False
# Stop MQTT
self.mqtt.stop()
self.mqtt.disconnect()
self._mqtt_connected = False
logger.info("Receiver stopped")
def create_receiver(
port: str = "/dev/ttyUSB0",
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
device_name: Optional[str] = None,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
contact_cleanup_enabled: bool = True,
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
) -> Receiver:
"""Create a configured receiver instance.
Args:
port: Serial port path
baud: Baud rate
mock: Use mock device
node_address: Optional override for device public key/address
device_name: Optional device/node name to set on startup
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
contact_cleanup_enabled: Whether to remove stale contacts from device
contact_cleanup_days: Remove contacts not advertised for this many days
Returns:
Configured Receiver instance
"""
# Create and connect device first to get public key
device = create_device(port=port, baud=baud, mock=mock, node_address=node_address)
if not device.connect():
raise RuntimeError("Failed to connect to MeshCore device")
logger.info(f"Connected to MeshCore device: {device.public_key}")
# Create MQTT client with device's public key for unique client ID
mqtt_config = MQTTConfig(
host=mqtt_host,
port=mqtt_port,
username=mqtt_username,
password=mqtt_password,
prefix=mqtt_prefix,
client_id=f"meshcore-receiver-{device.public_key[:12] if device.public_key else 'unknown'}",
tls=mqtt_tls,
)
mqtt_client = MQTTClient(mqtt_config)
return Receiver(
device,
mqtt_client,
device_name=device_name,
contact_cleanup_enabled=contact_cleanup_enabled,
contact_cleanup_days=contact_cleanup_days,
)
def run_receiver(
port: str = "/dev/ttyUSB0",
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
device_name: Optional[str] = None,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
contact_cleanup_enabled: bool = True,
contact_cleanup_days: int = DEFAULT_CONTACT_CLEANUP_DAYS,
) -> None:
"""Run the receiver (blocking).
This is the main entry point for running the receiver component.
Args:
port: Serial port path
baud: Baud rate
mock: Use mock device
node_address: Optional override for device public key/address
device_name: Optional device/node name to set on startup
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
contact_cleanup_enabled: Whether to remove stale contacts from device
contact_cleanup_days: Remove contacts not advertised for this many days
"""
receiver = create_receiver(
port=port,
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=mqtt_prefix,
mqtt_tls=mqtt_tls,
contact_cleanup_enabled=contact_cleanup_enabled,
contact_cleanup_days=contact_cleanup_days,
)
# Set up signal handlers
def signal_handler(signum: int, frame: Any) -> None:
logger.info(f"Received signal {signum}")
receiver.stop()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Run
receiver.run()
-392
View File
@@ -1,392 +0,0 @@
"""SENDER mode implementation for MeshCore Interface.
In SENDER mode, the interface:
1. Connects to a MeshCore device
2. Subscribes to command topics on MQTT broker
3. Executes received commands on the device
"""
import logging
import signal
import threading
import time
from typing import Any, Optional
from meshcore_hub.common.health import HealthReporter
from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig
from meshcore_hub.interface.device import (
BaseMeshCoreDevice,
create_device,
)
logger = logging.getLogger(__name__)
class Sender:
"""SENDER mode implementation.
Bridges MQTT commands to MeshCore device.
"""
def __init__(
self,
device: BaseMeshCoreDevice,
mqtt_client: MQTTClient,
):
"""Initialize sender.
Args:
device: MeshCore device instance
mqtt_client: MQTT client instance
"""
self.device = device
self.mqtt = mqtt_client
self._running = False
self._shutdown_event = threading.Event()
self._device_connected = False
self._mqtt_connected = False
self._health_reporter: Optional[HealthReporter] = None
@property
def is_healthy(self) -> bool:
"""Check if the sender is healthy.
Returns:
True if device and MQTT are connected
"""
return self._running and self._device_connected and self._mqtt_connected
def get_health_status(self) -> dict[str, Any]:
"""Get detailed health status.
Returns:
Dictionary with health status details
"""
return {
"healthy": self.is_healthy,
"running": self._running,
"device_connected": self._device_connected,
"mqtt_connected": self._mqtt_connected,
"device_public_key": self.device.public_key,
}
def _handle_mqtt_message(
self,
topic: str,
pattern: str,
payload: dict[str, Any],
) -> None:
"""Handle incoming MQTT command message.
Args:
topic: MQTT topic
pattern: Subscription pattern
payload: Message payload
"""
# Parse command from topic
parsed = self.mqtt.topic_builder.parse_command_topic(topic)
if not parsed:
logger.warning(f"Could not parse command topic: {topic}")
return
target_key, command_name = parsed
logger.info(f"Received command: {command_name} for {target_key[:12]}...")
# Dispatch command
try:
if command_name == "send_msg":
self._handle_send_msg(payload)
elif command_name == "send_channel_msg":
self._handle_send_channel_msg(payload)
elif command_name == "send_advert":
self._handle_send_advert(payload)
elif command_name == "request_status":
self._handle_request_status(payload)
elif command_name == "request_telemetry":
self._handle_request_telemetry(payload)
else:
logger.warning(f"Unknown command: {command_name}")
except Exception as e:
logger.error(f"Error handling command {command_name}: {e}")
def _handle_send_msg(self, payload: dict[str, Any]) -> None:
"""Handle send_msg command.
Args:
payload: Command payload with destination, text, timestamp
"""
destination = payload.get("destination")
text = payload.get("text")
timestamp = payload.get("timestamp")
if not destination or not text:
logger.error("send_msg: missing destination or text")
return
success = self.device.send_message(destination, text, timestamp)
if success:
logger.info(f"Message sent to {destination[:12]}...")
else:
logger.error(f"Failed to send message to {destination[:12]}...")
def _handle_send_channel_msg(self, payload: dict[str, Any]) -> None:
"""Handle send_channel_msg command.
Args:
payload: Command payload with channel_idx, text, timestamp
"""
channel_idx = payload.get("channel_idx")
text = payload.get("text")
timestamp = payload.get("timestamp")
if channel_idx is None or not text:
logger.error("send_channel_msg: missing channel_idx or text")
return
success = self.device.send_channel_message(channel_idx, text, timestamp)
if success:
logger.info(f"Channel message sent to channel {channel_idx}")
else:
logger.error(f"Failed to send message to channel {channel_idx}")
def _handle_send_advert(self, payload: dict[str, Any]) -> None:
"""Handle send_advert command.
Args:
payload: Command payload with flood flag
"""
flood = payload.get("flood", True)
success = self.device.send_advertisement(flood)
if success:
logger.info(f"Advertisement sent (flood={flood})")
else:
logger.error("Failed to send advertisement")
def _handle_request_status(self, payload: dict[str, Any]) -> None:
"""Handle request_status command.
Args:
payload: Command payload with optional target
"""
target = payload.get("target_public_key")
success = self.device.request_status(target)
if success:
logger.info(f"Status requested from {target or 'self'}")
else:
logger.error("Failed to request status")
def _handle_request_telemetry(self, payload: dict[str, Any]) -> None:
"""Handle request_telemetry command.
Args:
payload: Command payload with target
"""
target = payload.get("target_public_key")
if not target:
logger.error("request_telemetry: missing target_public_key")
return
success = self.device.request_telemetry(target)
if success:
logger.info(f"Telemetry requested from {target[:12]}...")
else:
logger.error("Failed to request telemetry")
def start(self) -> None:
"""Start the sender."""
logger.info("Starting SENDER mode")
# Device should already be connected (from create_sender)
# but handle case where start() is called directly
if not self.device.is_connected:
if not self.device.connect():
self._device_connected = False
logger.error("Failed to connect to MeshCore device")
raise RuntimeError("Failed to connect to MeshCore device")
logger.info(f"Connected to MeshCore device: {self.device.public_key}")
self._device_connected = True
# Connect to MQTT broker
try:
self.mqtt.connect()
self.mqtt.start_background()
self._mqtt_connected = True
logger.info("Connected to MQTT broker")
except Exception as e:
self._mqtt_connected = False
logger.error(f"Failed to connect to MQTT broker: {e}")
self.device.disconnect()
self._device_connected = False
raise
# Subscribe to command topics
# Using wildcard to receive commands for any node
command_topic = self.mqtt.topic_builder.all_commands_topic()
self.mqtt.subscribe(command_topic, self._handle_mqtt_message)
logger.info(f"Subscribed to command topic: {command_topic}")
self._running = True
# Start health reporter for Docker health checks
self._health_reporter = HealthReporter(
component="interface",
status_fn=self.get_health_status,
interval=10.0,
)
self._health_reporter.start()
def run(self) -> None:
"""Run the sender event loop (blocking)."""
if not self._running:
self.start()
logger.info("Sender running. Press Ctrl+C to stop.")
try:
while self._running and not self._shutdown_event.is_set():
time.sleep(0.1)
except KeyboardInterrupt:
logger.info("Keyboard interrupt received")
finally:
self.stop()
def stop(self) -> None:
"""Stop the sender."""
if not self._running:
return
logger.info("Stopping sender")
self._running = False
self._shutdown_event.set()
# Stop health reporter
if self._health_reporter:
self._health_reporter.stop()
self._health_reporter = None
# Stop MQTT
self.mqtt.stop()
self.mqtt.disconnect()
self._mqtt_connected = False
# Stop device
self.device.stop()
self.device.disconnect()
self._device_connected = False
logger.info("Sender stopped")
def create_sender(
port: str = "/dev/ttyUSB0",
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
device_name: Optional[str] = None,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
) -> Sender:
"""Create a configured sender instance.
Args:
port: Serial port path
baud: Baud rate
mock: Use mock device
node_address: Optional override for device public key/address
device_name: Optional device/node name (not used in SENDER mode)
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
Returns:
Configured Sender instance
"""
# Create and connect device first to get public key
device = create_device(port=port, baud=baud, mock=mock, node_address=node_address)
if not device.connect():
raise RuntimeError("Failed to connect to MeshCore device")
logger.info(f"Connected to MeshCore device: {device.public_key}")
# Create MQTT client with device's public key for unique client ID
mqtt_config = MQTTConfig(
host=mqtt_host,
port=mqtt_port,
username=mqtt_username,
password=mqtt_password,
prefix=mqtt_prefix,
client_id=f"meshcore-sender-{device.public_key[:12] if device.public_key else 'unknown'}",
tls=mqtt_tls,
)
mqtt_client = MQTTClient(mqtt_config)
return Sender(device, mqtt_client)
def run_sender(
port: str = "/dev/ttyUSB0",
baud: int = 115200,
mock: bool = False,
node_address: Optional[str] = None,
device_name: Optional[str] = None,
mqtt_host: str = "localhost",
mqtt_port: int = 1883,
mqtt_username: Optional[str] = None,
mqtt_password: Optional[str] = None,
mqtt_prefix: str = "meshcore",
mqtt_tls: bool = False,
) -> None:
"""Run the sender (blocking).
This is the main entry point for running the sender component.
Args:
port: Serial port path
baud: Baud rate
mock: Use mock device
node_address: Optional override for device public key/address
device_name: Optional device/node name (not used in SENDER mode)
mqtt_host: MQTT broker host
mqtt_port: MQTT broker port
mqtt_username: MQTT username
mqtt_password: MQTT password
mqtt_prefix: MQTT topic prefix
mqtt_tls: Enable TLS/SSL for MQTT connection
"""
sender = create_sender(
port=port,
baud=baud,
mock=mock,
node_address=node_address,
device_name=device_name,
mqtt_host=mqtt_host,
mqtt_port=mqtt_port,
mqtt_username=mqtt_username,
mqtt_password=mqtt_password,
mqtt_prefix=mqtt_prefix,
mqtt_tls=mqtt_tls,
)
# Set up signal handlers
def signal_handler(signum: int, frame: Any) -> None:
logger.info(f"Received signal {signum}")
sender.stop()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# Run
sender.run()
@@ -97,15 +97,15 @@ ${content}`, container);
const adName = ad.node_tag_name || ad.node_name || ad.name;
const adDescription = ad.node_tag_description;
let receiversBlock = nothing;
if (ad.receivers && ad.receivers.length >= 1) {
if (ad.observers && ad.observers.length >= 1) {
receiversBlock = html`<div class="flex gap-0.5 justify-end mt-1">
${ad.receivers.map(recv => {
${ad.observers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<span class="text-sm" title=${recvName}>\u{1F4E1}</span>`;
})}
</div>`;
} else if (ad.received_by) {
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
} else if (ad.observed_by) {
const recvTitle = ad.observer_tag_name || ad.observer_name || truncateKey(ad.observed_by, 12);
receiversBlock = html`<span class="text-sm" title=${recvTitle}>\u{1F4E1}</span>`;
}
return html`<a href="/nodes/${ad.public_key}" class="card bg-base-100 shadow-sm block">
@@ -133,16 +133,16 @@ ${content}`, container);
const adName = ad.node_tag_name || ad.node_name || ad.name;
const adDescription = ad.node_tag_description;
let receiversBlock;
if (ad.receivers && ad.receivers.length >= 1) {
if (ad.observers && ad.observers.length >= 1) {
receiversBlock = html`<div class="flex gap-1">
${ad.receivers.map(recv => {
${ad.observers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
})}
</div>`;
} else if (ad.received_by) {
const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12);
receiversBlock = html`<a href="/nodes/${ad.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
} else if (ad.observed_by) {
const recvTitle = ad.observer_tag_name || ad.observer_name || truncateKey(ad.observed_by, 12);
receiversBlock = html`<a href="/nodes/${ad.observed_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
} else {
receiversBlock = html`<span class="opacity-50">-</span>`;
}
@@ -120,25 +120,25 @@ export async function render(container, params, router) {
if (!existing) {
const clone = {
...msg,
receivers: [...(msg.receivers || [])],
observers: [...(msg.observers || [])],
};
bySignature.set(signature, clone);
deduped.push(clone);
continue;
}
const combined = [...(existing.receivers || []), ...(msg.receivers || [])];
const combined = [...(existing.observers || []), ...(msg.observers || [])];
const seenReceivers = new Set();
existing.receivers = combined.filter((recv) => {
const key = recv?.public_key || recv?.node_id || `${recv?.received_at || ''}:${recv?.snr || ''}`;
existing.observers = combined.filter((recv) => {
const key = recv?.public_key || recv?.node_id || `${recv?.observed_at || ''}:${recv?.snr || ''}`;
if (seenReceivers.has(key)) return false;
seenReceivers.add(key);
return true;
});
if (!existing.received_by && msg.received_by) existing.received_by = msg.received_by;
if (!existing.receiver_name && msg.receiver_name) existing.receiver_name = msg.receiver_name;
if (!existing.receiver_tag_name && msg.receiver_tag_name) existing.receiver_tag_name = msg.receiver_tag_name;
if (!existing.observed_by && msg.observed_by) existing.observed_by = msg.observed_by;
if (!existing.observer_name && msg.observer_name) existing.observer_name = msg.observer_name;
if (!existing.observer_tag_name && msg.observer_tag_name) existing.observer_tag_name = msg.observer_tag_name;
if (!existing.pubkey_prefix && msg.pubkey_prefix) existing.pubkey_prefix = msg.pubkey_prefix;
if (!existing.sender_name && msg.sender_name) existing.sender_name = msg.sender_name;
if (!existing.sender_tag_name && msg.sender_tag_name) existing.sender_tag_name = msg.sender_tag_name;
@@ -203,16 +203,16 @@ ${content}`, container);
? html`<span class="font-medium">${chInfo.label || t('messages.type_channel')}</span>`
: sender;
let receiversBlock = nothing;
if (msg.receivers && msg.receivers.length >= 1) {
if (msg.observers && msg.observers.length >= 1) {
receiversBlock = html`<div class="flex gap-0.5">
${msg.receivers.map(recv => {
${msg.observers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<a href="/nodes/${recv.public_key}" class="text-sm hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
})}
</div>`;
} else if (msg.received_by) {
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-sm hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
} else if (msg.observed_by) {
const recvTitle = msg.observer_tag_name || msg.observer_name || truncateKey(msg.observed_by, 12);
receiversBlock = html`<a href="/nodes/${msg.observed_by}" class="text-sm hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
}
return html`<div class="card bg-base-100 shadow-sm">
<div class="card-body p-3">
@@ -252,16 +252,16 @@ ${content}`, container);
? html`<span class="font-medium">${chInfo.label || t('messages.type_channel')}</span>`
: sender;
let receiversBlock;
if (msg.receivers && msg.receivers.length >= 1) {
if (msg.observers && msg.observers.length >= 1) {
receiversBlock = html`<div class="flex gap-1">
${msg.receivers.map(recv => {
${msg.observers.map(recv => {
const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12);
return html`<a href="/nodes/${recv.public_key}" class="text-lg hover:opacity-70" title=${recvName}>\u{1F4E1}</a>`;
})}
</div>`;
} else if (msg.received_by) {
const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12);
receiversBlock = html`<a href="/nodes/${msg.received_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
} else if (msg.observed_by) {
const recvTitle = msg.observer_tag_name || msg.observer_name || truncateKey(msg.observed_by, 12);
receiversBlock = html`<a href="/nodes/${msg.observed_by}" class="text-lg hover:opacity-70" title=${recvTitle}>\u{1F4E1}</a>`;
} else {
receiversBlock = html`<span class="opacity-50">-</span>`;
}
@@ -82,16 +82,16 @@ export async function render(container, params, router) {
const advTypeHtml = adv.adv_type
? html`<span title=${adv.adv_type.charAt(0).toUpperCase() + adv.adv_type.slice(1)}>${advEmoji}</span>`
: html`<span class="opacity-50">-</span>`;
const recvName = adv.received_by ? (adv.receiver_tag_name || adv.receiver_name) : null;
const receiverHtml = !adv.received_by
const recvName = adv.observed_by ? (adv.observer_tag_name || adv.observer_name) : null;
const receiverHtml = !adv.observed_by
? html`<span class="opacity-50">-</span>`
: recvName
? html`<a href="/nodes/${adv.received_by}" class="link link-hover">
? html`<a href="/nodes/${adv.observed_by}" class="link link-hover">
<div class="font-medium text-sm">${recvName}</div>
<div class="text-xs font-mono opacity-70">${adv.received_by.slice(0, 16)}...</div>
<div class="text-xs font-mono opacity-70">${adv.observed_by.slice(0, 16)}...</div>
</a>`
: html`<a href="/nodes/${adv.received_by}" class="link link-hover">
<span class="font-mono text-xs">${adv.received_by.slice(0, 16)}...</span>
: html`<a href="/nodes/${adv.observed_by}" class="link link-hover">
<span class="font-mono text-xs">${adv.observed_by.slice(0, 16)}...</span>
</a>`;
return html`<tr>
<td class="text-xs whitespace-nowrap">${formatDateTime(adv.received_at)}</td>
+4 -4
View File
@@ -324,7 +324,7 @@ def sample_message_with_receiver(api_db_session, receiver_node):
pubkey_prefix="xyz789",
text="Channel message with receiver",
received_at=datetime.now(timezone.utc),
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
)
api_db_session.add(message)
api_db_session.commit()
@@ -341,7 +341,7 @@ def sample_advertisement_with_receiver(api_db_session, sample_node, receiver_nod
adv_type="REPEATER",
received_at=datetime.now(timezone.utc),
node_id=sample_node.id,
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
)
api_db_session.add(advert)
api_db_session.commit()
@@ -356,7 +356,7 @@ def sample_telemetry_with_receiver(api_db_session, receiver_node):
node_public_key="xyz789xyz789xyz789xyz789xyz789xy",
parsed_data={"battery_level": 50.0},
received_at=datetime.now(timezone.utc),
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
)
api_db_session.add(telemetry)
api_db_session.commit()
@@ -372,7 +372,7 @@ def sample_trace_path_with_receiver(api_db_session, receiver_node):
path_hashes=["aaa111", "bbb222"],
hop_count=2,
received_at=datetime.now(timezone.utc),
receiver_node_id=receiver_node.id,
observer_node_id=receiver_node.id,
)
api_db_session.add(trace)
api_db_session.commit()
+2 -2
View File
@@ -83,7 +83,7 @@ class TestListAdvertisementsFilters:
data = response.json()
assert len(data["items"]) == 1
def test_filter_by_received_by(
def test_filter_by_observed_by(
self,
client_no_auth,
sample_advertisement,
@@ -92,7 +92,7 @@ class TestListAdvertisementsFilters:
):
"""Test filtering advertisements by receiver node."""
response = client_no_auth.get(
f"/api/v1/advertisements?received_by={receiver_node.public_key}"
f"/api/v1/advertisements?observed_by={receiver_node.public_key}"
)
assert response.status_code == 200
data = response.json()
+13 -22
View File
@@ -33,15 +33,6 @@ class TestReadAuthentication:
response = client_no_auth.get("/api/v1/messages")
assert response.status_code == 200
response = client_no_auth.post(
"/api/v1/commands/send-message",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Test",
},
)
assert response.status_code == 200
def test_read_endpoints_accept_read_key(self, client_with_auth):
"""Test that read endpoints accept read key."""
response = client_with_auth.get(
@@ -114,22 +105,22 @@ class TestAdminAuthentication:
def test_admin_endpoints_accept_admin_key(self, client_with_auth):
"""Test that admin endpoints accept admin key."""
response = client_with_auth.post(
"/api/v1/commands/send-message",
"/api/v1/nodes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/tags",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Test",
"tag_key": "name",
"tag_value": "test-node",
},
headers={"Authorization": "Bearer test-admin-key"},
)
assert response.status_code == 200
assert response.status_code in (200, 201, 404, 422)
def test_admin_endpoints_reject_read_key(self, client_with_auth):
"""Test that admin endpoints reject read key with 403."""
response = client_with_auth.post(
"/api/v1/commands/send-message",
"/api/v1/nodes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/tags",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Test",
"tag_key": "name",
"tag_value": "test-node",
},
headers={"Authorization": "Bearer test-read-key"},
)
@@ -138,10 +129,10 @@ class TestAdminAuthentication:
def test_admin_endpoints_reject_invalid_key(self, client_with_auth):
"""Test that admin endpoints reject invalid keys with 403."""
response = client_with_auth.post(
"/api/v1/commands/send-message",
"/api/v1/nodes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/tags",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Test",
"tag_key": "name",
"tag_value": "test-node",
},
headers={"Authorization": "Bearer completely-wrong-key"},
)
@@ -150,10 +141,10 @@ class TestAdminAuthentication:
def test_admin_endpoints_reject_no_auth_header(self, client_with_auth):
"""Test that admin endpoints reject missing auth header with 401."""
response = client_with_auth.post(
"/api/v1/commands/send-message",
"/api/v1/nodes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/tags",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Test",
"tag_key": "name",
"tag_value": "test-node",
},
)
assert response.status_code == 401
-116
View File
@@ -1,116 +0,0 @@
"""Tests for command API routes."""
class TestSendMessage:
"""Tests for POST /commands/send-message endpoint."""
def test_send_message_success(self, client_no_auth, mock_mqtt):
"""Test sending a direct message."""
response = client_no_auth.post(
"/api/v1/commands/send-message",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Hello World",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "queued" in data["message"].lower()
def test_send_message_requires_admin(self, client_with_auth):
"""Test sending message requires admin authentication."""
# Without auth
response = client_with_auth.post(
"/api/v1/commands/send-message",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Hello",
},
)
assert response.status_code == 401
# With read key (not admin)
response = client_with_auth.post(
"/api/v1/commands/send-message",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Hello",
},
headers={"Authorization": "Bearer test-read-key"},
)
assert response.status_code == 403
# With admin key
response = client_with_auth.post(
"/api/v1/commands/send-message",
json={
"destination": "abc123def456abc123def456abc123de",
"text": "Hello",
},
headers={"Authorization": "Bearer test-admin-key"},
)
assert response.status_code == 200
class TestSendChannelMessage:
"""Tests for POST /commands/send-channel-message endpoint."""
def test_send_channel_message_success(self, client_no_auth, mock_mqtt):
"""Test sending a channel message."""
response = client_no_auth.post(
"/api/v1/commands/send-channel-message",
json={
"channel_idx": 1,
"text": "Hello Channel",
},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "channel 1" in data["message"].lower()
def test_send_channel_message_requires_admin(self, client_with_auth):
"""Test sending channel message requires admin authentication."""
response = client_with_auth.post(
"/api/v1/commands/send-channel-message",
json={
"channel_idx": 1,
"text": "Hello",
},
)
assert response.status_code == 401
class TestSendAdvertisement:
"""Tests for POST /commands/send-advertisement endpoint."""
def test_send_advertisement_success(self, client_no_auth, mock_mqtt):
"""Test sending an advertisement."""
response = client_no_auth.post(
"/api/v1/commands/send-advertisement",
json={"flood": False},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "advertisement" in data["message"].lower()
def test_send_advertisement_with_flood(self, client_no_auth, mock_mqtt):
"""Test sending an advertisement with flood enabled."""
response = client_no_auth.post(
"/api/v1/commands/send-advertisement",
json={"flood": True},
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert "flood=True" in data["message"]
def test_send_advertisement_requires_admin(self, client_with_auth):
"""Test sending advertisement requires admin authentication."""
response = client_with_auth.post(
"/api/v1/commands/send-advertisement",
json={"flood": False},
)
assert response.status_code == 401
+2 -2
View File
@@ -95,7 +95,7 @@ class TestListMessagesFilters:
data = response.json()
assert len(data["items"]) == 0
def test_filter_by_received_by(
def test_filter_by_observed_by(
self,
client_no_auth,
sample_message,
@@ -104,7 +104,7 @@ class TestListMessagesFilters:
):
"""Test filtering messages by receiver node."""
response = client_no_auth.get(
f"/api/v1/messages?received_by={receiver_node.public_key}"
f"/api/v1/messages?observed_by={receiver_node.public_key}"
)
assert response.status_code == 200
data = response.json()
+2 -2
View File
@@ -58,7 +58,7 @@ class TestGetTelemetry:
class TestListTelemetryFilters:
"""Tests for telemetry list query filters."""
def test_filter_by_received_by(
def test_filter_by_observed_by(
self,
client_no_auth,
sample_telemetry,
@@ -67,7 +67,7 @@ class TestListTelemetryFilters:
):
"""Test filtering telemetry by receiver node."""
response = client_no_auth.get(
f"/api/v1/telemetry?received_by={receiver_node.public_key}"
f"/api/v1/telemetry?observed_by={receiver_node.public_key}"
)
assert response.status_code == 200
data = response.json()
+2 -2
View File
@@ -91,7 +91,7 @@ class TestGetTracePath:
class TestListTracePathsFilters:
"""Tests for trace path list query filters."""
def test_filter_by_received_by(
def test_filter_by_observed_by(
self,
client_no_auth,
sample_trace_path,
@@ -100,7 +100,7 @@ class TestListTracePathsFilters:
):
"""Test filtering trace paths by receiver node."""
response = client_no_auth.get(
f"/api/v1/trace-paths?received_by={receiver_node.public_key}"
f"/api/v1/trace-paths?observed_by={receiver_node.public_key}"
)
assert response.status_code == 200
data = response.json()
+4 -4
View File
@@ -88,7 +88,7 @@ async def test_cleanup_old_data_live(async_db_session: AsyncSession) -> None:
async_db_session.add(old_adv)
old_msg = Message(
receiver_node_id=node.id,
observer_node_id=node.id,
message_type="channel",
text="old message",
created_at=old_date,
@@ -97,7 +97,7 @@ async def test_cleanup_old_data_live(async_db_session: AsyncSession) -> None:
async_db_session.add(old_msg)
old_telemetry = Telemetry(
receiver_node_id=node.id,
observer_node_id=node.id,
node_id=node.id,
node_public_key=node.public_key,
created_at=old_date,
@@ -106,7 +106,7 @@ async def test_cleanup_old_data_live(async_db_session: AsyncSession) -> None:
async_db_session.add(old_telemetry)
old_trace = TracePath(
receiver_node_id=node.id,
observer_node_id=node.id,
initiator_tag="test",
created_at=old_date,
updated_at=old_date,
@@ -114,7 +114,7 @@ async def test_cleanup_old_data_live(async_db_session: AsyncSession) -> None:
async_db_session.add(old_trace)
old_event = EventLog(
receiver_node_id=node.id,
observer_node_id=node.id,
event_type="test_event",
created_at=old_date,
updated_at=old_date,
+15 -25
View File
@@ -45,7 +45,7 @@ class TestSubscriber:
mock_mqtt_client.connect.assert_called_once()
mock_mqtt_client.start_background.assert_called_once()
mock_mqtt_client.subscribe.assert_called_once()
assert mock_mqtt_client.subscribe.call_count == 3
def test_stop_disconnects_mqtt(self, subscriber, mock_mqtt_client):
"""Test that stop disconnects MQTT."""
@@ -63,9 +63,21 @@ class TestSubscriber:
subscriber.register_handler("advertisement", handler)
subscriber.start()
mock_mqtt_client.topic_builder.parse_letsmesh_upload_topic.return_value = (
"a" * 64,
"status",
)
subscriber._normalize_letsmesh_event = MagicMock(
return_value=(
"a" * 64,
"advertisement",
{"public_key": "b" * 64, "name": "Test"},
)
)
subscriber._handle_mqtt_message(
topic="meshcore/abc/event/advertisement",
pattern="meshcore/+/event/#",
topic="meshcore/abc/status",
pattern="meshcore/+/status",
payload={"public_key": "b" * 64, "name": "Test"},
)
@@ -76,7 +88,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
subscriber.start()
@@ -96,7 +107,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
advert_handler = MagicMock()
status_handler = MagicMock()
@@ -132,7 +142,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
advert_handler = MagicMock()
status_handler = MagicMock()
@@ -163,7 +172,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
advert_handler = MagicMock()
status_handler = MagicMock()
@@ -183,11 +191,6 @@ class TestSubscriber:
advert_handler.assert_not_called()
status_handler.assert_called_once()
def test_invalid_ingest_mode_raises(self, mock_mqtt_client, db_manager) -> None:
"""Invalid ingest mode values are rejected."""
with pytest.raises(ValueError):
Subscriber(mock_mqtt_client, db_manager, ingest_mode="invalid_mode")
def test_letsmesh_packet_maps_to_channel_message(
self, mock_mqtt_client, db_manager
) -> None:
@@ -199,7 +202,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
handler = MagicMock()
subscriber.register_handler("channel_msg_recv", handler)
@@ -252,7 +254,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
letsmesh_packet_handler = MagicMock()
channel_handler = MagicMock()
@@ -289,7 +290,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
handler = MagicMock()
subscriber.register_handler("channel_msg_recv", handler)
@@ -354,7 +354,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
handler = MagicMock()
subscriber.register_handler("contact_msg_recv", handler)
@@ -403,7 +402,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
handler = MagicMock()
subscriber.register_handler("channel_msg_recv", handler)
@@ -453,7 +451,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
handler = MagicMock()
subscriber.register_handler("advertisement", handler)
@@ -513,7 +510,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
contact_handler = MagicMock()
advert_handler = MagicMock()
@@ -566,7 +562,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
trace_handler = MagicMock()
subscriber.register_handler("trace_data", trace_handler)
@@ -619,7 +614,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
trace_handler = MagicMock()
subscriber.register_handler("trace_data", trace_handler)
@@ -670,7 +664,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
path_handler = MagicMock()
subscriber.register_handler("path_updated", path_handler)
@@ -720,7 +713,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
path_handler = MagicMock()
packet_handler = MagicMock()
@@ -775,7 +767,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
packet_handler = MagicMock()
subscriber.register_handler("letsmesh_packet", packet_handler)
@@ -822,7 +813,6 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
ingest_mode="letsmesh_upload",
)
handler = MagicMock()
subscriber.register_handler("channel_msg_recv", handler)
-23
View File
@@ -2,7 +2,6 @@
from meshcore_hub.common.config import (
CommonSettings,
InterfaceSettings,
CollectorSettings,
APISettings,
WebSettings,
@@ -30,19 +29,6 @@ class TestCommonSettings:
assert settings.mqtt_ws_path == "/"
class TestInterfaceSettings:
"""Tests for InterfaceSettings."""
def test_custom_values(self) -> None:
"""Test custom setting values."""
settings = InterfaceSettings(
_env_file=None, serial_port="/dev/ttyACM0", serial_baud=9600
)
assert settings.serial_port == "/dev/ttyACM0"
assert settings.serial_baud == 9600
class TestCollectorSettings:
"""Tests for CollectorSettings."""
@@ -74,15 +60,6 @@ class TestCollectorSettings:
assert settings.node_tags_file == "/seed/data/node_tags.yaml"
assert settings.members_file == "/seed/data/members.yaml"
def test_collector_ingest_mode_letsmesh_upload(self) -> None:
"""Test collector ingest mode can be set to LetsMesh upload."""
settings = CollectorSettings(
_env_file=None,
collector_ingest_mode="letsmesh_upload",
)
assert settings.collector_ingest_mode.value == "letsmesh_upload"
def test_collector_letsmesh_decoder_keys_list(self) -> None:
"""LetsMesh decoder keys are parsed from comma/space-separated env values."""
settings = CollectorSettings(
-1
View File
@@ -1 +0,0 @@
"""Interface component tests."""
-39
View File
@@ -1,39 +0,0 @@
"""Fixtures for interface component tests."""
from collections.abc import Generator
import pytest
from meshcore_hub.interface.device import DeviceConfig
from meshcore_hub.interface.mock_device import MockDeviceConfig, MockMeshCoreDevice
@pytest.fixture
def device_config() -> DeviceConfig:
"""Create a device configuration for testing."""
return DeviceConfig(
port="/dev/ttyUSB0",
baud=115200,
timeout=1.0,
)
@pytest.fixture
def mock_device_config() -> MockDeviceConfig:
"""Create a mock device configuration for testing."""
return MockDeviceConfig(
public_key="a" * 64,
name="TestNode",
enable_auto_events=False, # Disable auto events for testing
)
@pytest.fixture
def mock_device(
device_config: DeviceConfig, mock_device_config: MockDeviceConfig
) -> Generator[MockMeshCoreDevice, None, None]:
"""Create a mock device instance for testing."""
device = MockMeshCoreDevice(device_config, mock_device_config)
yield device
if device.is_connected:
device.disconnect()
-65
View File
@@ -1,65 +0,0 @@
"""Tests for device abstraction."""
from meshcore_hub.interface.device import (
DeviceConfig,
EventType,
MeshCoreDevice,
create_device,
)
class TestDeviceConfig:
"""Tests for DeviceConfig."""
def test_default_values(self) -> None:
"""Test default configuration values."""
config = DeviceConfig()
assert config.port == "/dev/ttyUSB0"
assert config.baud == 115200
assert config.timeout == 1.0
assert config.reconnect_delay == 5.0
assert config.max_reconnect_attempts == 10
def test_custom_values(self) -> None:
"""Test custom configuration values."""
config = DeviceConfig(
port="/dev/ttyACM0",
baud=9600,
timeout=2.0,
)
assert config.port == "/dev/ttyACM0"
assert config.baud == 9600
assert config.timeout == 2.0
class TestEventType:
"""Tests for EventType enumeration."""
def test_event_types(self) -> None:
"""Test event type values."""
assert EventType.ADVERTISEMENT.value == "advertisement"
assert EventType.CONTACT_MSG_RECV.value == "contact_msg_recv"
assert EventType.CHANNEL_MSG_RECV.value == "channel_msg_recv"
assert EventType.TRACE_DATA.value == "trace_data"
assert EventType.TELEMETRY_RESPONSE.value == "telemetry_response"
class TestCreateDevice:
"""Tests for create_device factory function."""
def test_create_mock_device(self) -> None:
"""Test creating a mock device."""
device = create_device(mock=True)
assert device is not None
assert device.public_key is not None
assert len(device.public_key) == 64
def test_create_real_device(self) -> None:
"""Test creating a real device."""
device = create_device(mock=False)
assert device is not None
assert isinstance(device, MeshCoreDevice)
-204
View File
@@ -1,204 +0,0 @@
"""Tests for mock device implementation."""
import time
from meshcore_hub.interface.device import EventType
from meshcore_hub.interface.mock_device import (
MockDeviceConfig,
MockMeshCoreDevice,
MockNodeConfig,
generate_random_public_key,
)
class TestGenerateRandomPublicKey:
"""Tests for public key generation."""
def test_generates_64_char_hex(self) -> None:
"""Test that public key is 64 hex characters."""
key = generate_random_public_key()
assert len(key) == 64
assert all(c in "0123456789abcdef" for c in key)
def test_generates_unique_keys(self) -> None:
"""Test that generated keys are unique."""
keys = [generate_random_public_key() for _ in range(100)]
assert len(set(keys)) == 100
class TestMockDeviceConfig:
"""Tests for MockDeviceConfig."""
def test_default_values(self) -> None:
"""Test default configuration values."""
config = MockDeviceConfig()
assert config.public_key is None
assert config.name == "MockNode"
assert config.enable_auto_events is True
assert config.advertisement_interval == 30.0
assert config.message_interval == 10.0
def test_custom_values(self) -> None:
"""Test custom configuration values."""
config = MockDeviceConfig(
public_key="a" * 64,
name="CustomNode",
enable_auto_events=False,
)
assert config.public_key == "a" * 64
assert config.name == "CustomNode"
assert config.enable_auto_events is False
class TestMockMeshCoreDevice:
"""Tests for MockMeshCoreDevice."""
def test_connection(self, mock_device) -> None:
"""Test device connection."""
assert not mock_device.is_connected
result = mock_device.connect()
assert result is True
assert mock_device.is_connected
def test_public_key(self, mock_device) -> None:
"""Test public key assignment."""
assert mock_device.public_key == "a" * 64
def test_disconnect(self, mock_device) -> None:
"""Test device disconnection."""
mock_device.connect()
assert mock_device.is_connected
mock_device.disconnect()
assert not mock_device.is_connected
def test_send_message(self, mock_device) -> None:
"""Test sending a message."""
mock_device.connect()
result = mock_device.send_message(
destination="b" * 64,
text="Hello!",
)
assert result is True
def test_send_message_not_connected(self, mock_device) -> None:
"""Test sending message when not connected."""
result = mock_device.send_message(
destination="b" * 64,
text="Hello!",
)
assert result is False
def test_send_channel_message(self, mock_device) -> None:
"""Test sending a channel message."""
mock_device.connect()
result = mock_device.send_channel_message(
channel_idx=4,
text="Channel message",
)
assert result is True
def test_send_advertisement(self, mock_device) -> None:
"""Test sending an advertisement."""
mock_device.connect()
result = mock_device.send_advertisement(flood=True)
assert result is True
def test_request_status(self, mock_device) -> None:
"""Test requesting status."""
mock_device.connect()
result = mock_device.request_status()
assert result is True
def test_request_telemetry(self, mock_device) -> None:
"""Test requesting telemetry."""
mock_device.connect()
result = mock_device.request_telemetry(target="c" * 64)
assert result is True
def test_event_handler_registration(self, mock_device) -> None:
"""Test event handler registration."""
events_received = []
def handler(event_type, payload):
events_received.append((event_type, payload))
mock_device.register_handler(EventType.ADVERTISEMENT, handler)
mock_device.connect()
# Inject an event
mock_device.inject_event(
EventType.ADVERTISEMENT,
{"public_key": "d" * 64, "name": "TestNode"},
)
# Give time for event processing
time.sleep(0.1)
assert len(events_received) >= 1
event_type, payload = events_received[-1]
assert event_type == EventType.ADVERTISEMENT
assert payload["name"] == "TestNode"
def test_event_handler_unregistration(self, mock_device) -> None:
"""Test event handler unregistration."""
events_received = []
def handler(event_type, payload):
events_received.append((event_type, payload))
mock_device.register_handler(EventType.ADVERTISEMENT, handler)
mock_device.unregister_handler(EventType.ADVERTISEMENT, handler)
mock_device.connect()
mock_device.inject_event(
EventType.ADVERTISEMENT,
{"public_key": "d" * 64, "name": "TestNode"},
)
time.sleep(0.1)
# Should only have the status event from connect(), not the advertisement
advert_events = [e for e in events_received if e[0] == EventType.ADVERTISEMENT]
assert len(advert_events) == 0
def test_default_nodes_created(self, device_config) -> None:
"""Test that default nodes are created when none provided."""
device = MockMeshCoreDevice(device_config)
assert len(device.mock_config.nodes) > 0
assert any(n.adv_type == "chat" for n in device.mock_config.nodes)
assert any(n.adv_type == "repeater" for n in device.mock_config.nodes)
def test_custom_nodes(self, device_config) -> None:
"""Test custom node configuration."""
custom_nodes = [
MockNodeConfig(
public_key="e" * 64,
name="CustomAlice",
adv_type="chat",
),
]
config = MockDeviceConfig(nodes=custom_nodes)
device = MockMeshCoreDevice(device_config, config)
assert len(device.mock_config.nodes) == 1
assert device.mock_config.nodes[0].name == "CustomAlice"
-143
View File
@@ -1,143 +0,0 @@
"""Tests for receiver mode implementation."""
import pytest
from unittest.mock import MagicMock, patch
from meshcore_hub.interface.device import EventType
from meshcore_hub.interface.receiver import Receiver, create_receiver
class TestReceiver:
"""Tests for Receiver class."""
@pytest.fixture
def mock_mqtt_client(self):
"""Create a mock MQTT client."""
client = MagicMock()
client.topic_builder = MagicMock()
client.topic_builder.event_topic.return_value = "meshcore/abc/event/test"
return client
@pytest.fixture
def receiver(self, mock_device, mock_mqtt_client):
"""Create a receiver instance."""
return Receiver(mock_device, mock_mqtt_client)
def test_start_connects_device_and_mqtt(
self, receiver, mock_device, mock_mqtt_client
):
"""Test that start connects to device and MQTT."""
receiver.start()
assert mock_device.is_connected
mock_mqtt_client.connect.assert_called_once()
mock_mqtt_client.start_background.assert_called_once()
def test_stop_disconnects_device_and_mqtt(
self, receiver, mock_device, mock_mqtt_client
):
"""Test that stop disconnects device and MQTT."""
receiver.start()
receiver.stop()
assert not mock_device.is_connected
mock_mqtt_client.stop.assert_called_once()
mock_mqtt_client.disconnect.assert_called_once()
def test_events_published_to_mqtt(self, receiver, mock_device, mock_mqtt_client):
"""Test that device events are published to MQTT."""
receiver.start()
# Inject an event
mock_device.inject_event(
EventType.ADVERTISEMENT,
{"public_key": "b" * 64, "name": "TestNode"},
)
# Allow time for event processing
import time
time.sleep(0.1)
# Verify MQTT publish was called
mock_mqtt_client.publish_event.assert_called()
def test_receiver_syncs_contacts_on_advertisement(
self, receiver, mock_device, mock_mqtt_client
):
"""Test that receiver syncs contacts when advertisement is received."""
import time
from unittest.mock import patch
receiver.start()
# Patch schedule_get_contacts to track calls
with patch.object(
mock_device, "schedule_get_contacts", return_value=True
) as mock_get:
# Inject an advertisement event
mock_device.inject_event(
EventType.ADVERTISEMENT,
{"pubkey_prefix": "b" * 64, "adv_name": "TestNode", "type": 1},
)
# Allow time for event processing
time.sleep(0.1)
# Verify schedule_get_contacts was called
mock_get.assert_called()
def test_receiver_handles_contact_sync_failure(
self, receiver, mock_device, mock_mqtt_client
):
"""Test that receiver handles contact sync failures gracefully."""
import time
from unittest.mock import patch
receiver.start()
# Patch schedule_get_contacts to return False (failure)
with patch.object(
mock_device, "schedule_get_contacts", return_value=False
) as mock_get:
# Should not raise exception even if sync fails
mock_device.inject_event(
EventType.ADVERTISEMENT,
{"pubkey_prefix": "c" * 64, "adv_name": "FailNode", "type": 1},
)
# Allow time for event processing
time.sleep(0.1)
# Verify it was attempted
mock_get.assert_called()
class TestCreateReceiver:
"""Tests for create_receiver factory function."""
def test_creates_receiver_with_mock_device(self):
"""Test creating receiver with mock device."""
with patch("meshcore_hub.interface.receiver.MQTTClient"):
receiver = create_receiver(mock=True)
assert receiver is not None
assert receiver.device is not None
assert receiver.device.public_key is not None
def test_creates_receiver_with_custom_mqtt_config(self):
"""Test creating receiver with custom MQTT configuration."""
with patch("meshcore_hub.interface.receiver.MQTTClient") as mock_mqtt:
create_receiver(
mock=True,
mqtt_host="mqtt.example.com",
mqtt_port=8883,
mqtt_prefix="custom",
)
# Verify MQTT client was created with correct config
mock_mqtt.assert_called_once()
config = mock_mqtt.call_args[0][0]
assert config.host == "mqtt.example.com"
assert config.port == 8883
assert config.prefix == "custom"
-129
View File
@@ -1,129 +0,0 @@
"""Tests for sender mode implementation."""
import pytest
from unittest.mock import MagicMock, patch
from meshcore_hub.interface.sender import Sender, create_sender
class TestSender:
"""Tests for Sender class."""
@pytest.fixture
def mock_mqtt_client(self):
"""Create a mock MQTT client."""
client = MagicMock()
client.topic_builder = MagicMock()
client.topic_builder.parse_command_topic.return_value = ("abc123", "send_msg")
client.topic_builder.all_commands_topic.return_value = "meshcore/+/command/#"
return client
@pytest.fixture
def sender(self, mock_device, mock_mqtt_client):
"""Create a sender instance."""
return Sender(mock_device, mock_mqtt_client)
def test_start_connects_device_and_mqtt(
self, sender, mock_device, mock_mqtt_client
):
"""Test that start connects to device and MQTT."""
sender.start()
assert mock_device.is_connected
mock_mqtt_client.connect.assert_called_once()
mock_mqtt_client.start_background.assert_called_once()
mock_mqtt_client.subscribe.assert_called_once()
def test_stop_disconnects_device_and_mqtt(
self, sender, mock_device, mock_mqtt_client
):
"""Test that stop disconnects device and MQTT."""
sender.start()
sender.stop()
assert not mock_device.is_connected
mock_mqtt_client.stop.assert_called_once()
mock_mqtt_client.disconnect.assert_called_once()
def test_handle_send_msg_command(self, sender, mock_device, mock_mqtt_client):
"""Test handling send_msg command."""
sender.start()
# Simulate receiving a send_msg command
sender._handle_mqtt_message(
topic="meshcore/abc/command/send_msg",
pattern="meshcore/+/command/#",
payload={
"destination": "b" * 64,
"text": "Hello!",
},
)
# Verify message was sent (device is mocked, so just check no error)
assert mock_device.is_connected
def test_handle_send_channel_msg_command(
self, sender, mock_device, mock_mqtt_client
):
"""Test handling send_channel_msg command."""
mock_mqtt_client.topic_builder.parse_command_topic.return_value = (
"abc123",
"send_channel_msg",
)
sender.start()
sender._handle_mqtt_message(
topic="meshcore/abc/command/send_channel_msg",
pattern="meshcore/+/command/#",
payload={
"channel_idx": 4,
"text": "Channel broadcast",
},
)
assert mock_device.is_connected
def test_handle_send_advert_command(self, sender, mock_device, mock_mqtt_client):
"""Test handling send_advert command."""
mock_mqtt_client.topic_builder.parse_command_topic.return_value = (
"abc123",
"send_advert",
)
sender.start()
sender._handle_mqtt_message(
topic="meshcore/abc/command/send_advert",
pattern="meshcore/+/command/#",
payload={"flood": True},
)
assert mock_device.is_connected
class TestCreateSender:
"""Tests for create_sender factory function."""
def test_creates_sender_with_mock_device(self):
"""Test creating sender with mock device."""
with patch("meshcore_hub.interface.sender.MQTTClient"):
sender = create_sender(mock=True)
assert sender is not None
assert sender.device is not None
assert sender.device.public_key is not None
def test_creates_sender_with_custom_mqtt_config(self):
"""Test creating sender with custom MQTT configuration."""
with patch("meshcore_hub.interface.sender.MQTTClient") as mock_mqtt:
create_sender(
mock=True,
mqtt_host="mqtt.example.com",
mqtt_port=8883,
mqtt_prefix="custom",
)
mock_mqtt.assert_called_once()
config = mock_mqtt.call_args[0][0]
assert config.host == "mqtt.example.com"
assert config.port == 8883
assert config.prefix == "custom"