diff --git a/.env.example b/.env.example index 34b553c..7ef9556 100644 --- a/.env.example +++ b/.env.example @@ -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 //event/ topics (native interface-receiver) -# - letsmesh_upload: expects LetsMesh observer uploads on -# //(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 diff --git a/AGENTS.md b/AGENTS.md index 56ec311..725a98b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``` //event/ ``` @@ -348,16 +338,6 @@ Examples: - `meshcore/abc123.../event/contact_msg_recv` - `meshcore/abc123.../event/channel_msg_recv` -### Commands (Subscribed by Interface SENDER) -``` -/+/command/ -``` - -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/) diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 9b91633..0000000 --- a/PLAN.md +++ /dev/null @@ -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 | | - | //event/ | | - | /+/command/ <--------------------+-----------+ - +----------------------------------------------------------+ - | - 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//event/advertisement -meshcore//event/contact_msg_recv -meshcore//event/channel_msg_recv -meshcore//event/trace_data -meshcore//event/telemetry_response -meshcore//event/contacts -meshcore//event/send_confirmed -meshcore//event/status_response -meshcore//event/battery -meshcore//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 [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 diff --git a/PROMPT.md b/PROMPT.md deleted file mode 100644 index c7c8227..0000000 --- a/PROMPT.md +++ /dev/null @@ -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: - -``` -//event/ -//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). diff --git a/README.md b/README.md index 33dd167..5bd1e4f 100644 --- a/README.md +++ b/README.md @@ -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): - `/+/packets` - `/+/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=`). - 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 diff --git a/SCHEMAS.md b/SCHEMAS.md index 9b0847f..0276dd7 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -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). diff --git a/TASKS.md b/TASKS.md deleted file mode 100644 index 2745a0e..0000000 --- a/TASKS.md +++ /dev/null @@ -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: `//event/` - - [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] `/+/command/send_msg` - - [x] `/+/command/send_channel_msg` - - [x] `/+/command/send_advert` - - [x] `/+/command/request_status` - - [x] `/+/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: `/+/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 | diff --git a/alembic/versions/20260412_0001_rename_receiver_to_observer.py b/alembic/versions/20260412_0001_rename_receiver_to_observer.py new file mode 100644 index 0000000..6600bcb --- /dev/null +++ b/alembic/versions/20260412_0001_rename_receiver_to_observer.py @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index f24fb53..a924b10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 1a60290..3d544fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.*", diff --git a/src/meshcore_hub/__main__.py b/src/meshcore_hub/__main__.py index 871615b..43e8f75 100644 --- a/src/meshcore_hub/__main__.py +++ b/src/meshcore_hub/__main__.py @@ -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", diff --git a/src/meshcore_hub/api/routes/__init__.py b/src/meshcore_hub/api/routes/__init__.py index de4051c..771b5b3 100644 --- a/src/meshcore_hub/api/routes/__init__.py +++ b/src/meshcore_hub/api/routes/__init__.py @@ -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"]) diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 23666f4..a3d7b87 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -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) diff --git a/src/meshcore_hub/api/routes/commands.py b/src/meshcore_hub/api/routes/commands.py deleted file mode 100644 index 2608f53..0000000 --- a/src/meshcore_hub/api/routes/commands.py +++ /dev/null @@ -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)}", - ) diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index d4a2e45..29dea68 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -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) diff --git a/src/meshcore_hub/api/routes/telemetry.py b/src/meshcore_hub/api/routes/telemetry.py index adab7db..e56743f 100644 --- a/src/meshcore_hub/api/routes/telemetry.py +++ b/src/meshcore_hub/api/routes/telemetry.py @@ -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, diff --git a/src/meshcore_hub/api/routes/trace_paths.py b/src/meshcore_hub/api/routes/trace_paths.py index e72569e..e9c576d 100644 --- a/src/meshcore_hub/api/routes/trace_paths.py +++ b/src/meshcore_hub/api/routes/trace_paths.py @@ -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, diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index f86f8c2..0f531b4 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -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"], diff --git a/src/meshcore_hub/collector/handlers/advertisement.py b/src/meshcore_hub/collector/handlers/advertisement.py index ef4ec28..fb3d1e1 100644 --- a/src/meshcore_hub/collector/handlers/advertisement.py +++ b/src/meshcore_hub/collector/handlers/advertisement.py @@ -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})" ) diff --git a/src/meshcore_hub/collector/handlers/event_log.py b/src/meshcore_hub/collector/handlers/event_log.py index 8553780..2956590 100644 --- a/src/meshcore_hub/collector/handlers/event_log.py +++ b/src/meshcore_hub/collector/handlers/event_log.py @@ -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, diff --git a/src/meshcore_hub/collector/handlers/message.py b/src/meshcore_hub/collector/handlers/message.py index 0938599..8797145 100644 --- a/src/meshcore_hub/collector/handlers/message.py +++ b/src/meshcore_hub/collector/handlers/message.py @@ -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 diff --git a/src/meshcore_hub/collector/handlers/telemetry.py b/src/meshcore_hub/collector/handlers/telemetry.py index 086135a..43b9a95 100644 --- a/src/meshcore_hub/collector/handlers/telemetry.py +++ b/src/meshcore_hub/collector/handlers/telemetry.py @@ -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 diff --git a/src/meshcore_hub/collector/handlers/trace.py b/src/meshcore_hub/collector/handlers/trace.py index e283c6a..98b233f 100644 --- a/src/meshcore_hub/collector/handlers/trace.py +++ b/src/meshcore_hub/collector/handlers/trace.py @@ -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 diff --git a/src/meshcore_hub/collector/subscriber.py b/src/meshcore_hub/collector/subscriber.py index 865e774..a0e7e8a 100644 --- a/src/meshcore_hub/collector/subscriber.py +++ b/src/meshcore_hub/collector/subscriber.py @@ -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, diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py index fe195f0..9d4b6e3 100644 --- a/src/meshcore_hub/common/config.py +++ b/src/meshcore_hub/common/config.py @@ -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 //event/. " - "'letsmesh_upload' expects LetsMesh observer uploads on " - "//(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() diff --git a/src/meshcore_hub/common/health.py b/src/meshcore_hub/common/health.py index fd40fb2..c5008cd 100644 --- a/src/meshcore_hub/common/health.py +++ b/src/meshcore_hub/common/health.py @@ -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" diff --git a/src/meshcore_hub/common/models/__init__.py b/src/meshcore_hub/common/models/__init__.py index f0f1dd9..8fcb3e9 100644 --- a/src/meshcore_hub/common/models/__init__.py +++ b/src/meshcore_hub/common/models/__init__.py @@ -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", ] diff --git a/src/meshcore_hub/common/models/advertisement.py b/src/meshcore_hub/common/models/advertisement.py index 0ca55eb..4e31d29 100644 --- a/src/meshcore_hub/common/models/advertisement.py +++ b/src/meshcore_hub/common/models/advertisement.py @@ -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, diff --git a/src/meshcore_hub/common/models/event_log.py b/src/meshcore_hub/common/models/event_log.py index 4e03629..13e0a11 100644 --- a/src/meshcore_hub/common/models/event_log.py +++ b/src/meshcore_hub/common/models/event_log.py @@ -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, diff --git a/src/meshcore_hub/common/models/event_receiver.py b/src/meshcore_hub/common/models/event_observer.py similarity index 57% rename from src/meshcore_hub/common/models/event_receiver.py rename to src/meshcore_hub/common/models/event_observer.py index c17f447..36fd1c0 100644 --- a/src/meshcore_hub/common/models/event_receiver.py +++ b/src/meshcore_hub/common/models/event_observer.py @@ -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"" + 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) diff --git a/src/meshcore_hub/common/models/message.py b/src/meshcore_hub/common/models/message.py index 6d0ae1f..ca2c01e 100644 --- a/src/meshcore_hub/common/models/message.py +++ b/src/meshcore_hub/common/models/message.py @@ -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, diff --git a/src/meshcore_hub/common/models/telemetry.py b/src/meshcore_hub/common/models/telemetry.py index 7205e0d..abca8ac 100644 --- a/src/meshcore_hub/common/models/telemetry.py +++ b/src/meshcore_hub/common/models/telemetry.py @@ -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, diff --git a/src/meshcore_hub/common/models/trace_path.py b/src/meshcore_hub/common/models/trace_path.py index e880140..df0d48b 100644 --- a/src/meshcore_hub/common/models/trace_path.py +++ b/src/meshcore_hub/common/models/trace_path.py @@ -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, diff --git a/src/meshcore_hub/common/schemas/__init__.py b/src/meshcore_hub/common/schemas/__init__.py index f9b2580..e202eaf 100644 --- a/src/meshcore_hub/common/schemas/__init__.py +++ b/src/meshcore_hub/common/schemas/__init__.py @@ -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", diff --git a/src/meshcore_hub/common/schemas/commands.py b/src/meshcore_hub/common/schemas/commands.py deleted file mode 100644 index ca61694..0000000 --- a/src/meshcore_hub/common/schemas/commands.py +++ /dev/null @@ -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)", - ) diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index 908c208..7e84b38 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -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: diff --git a/src/meshcore_hub/interface/__init__.py b/src/meshcore_hub/interface/__init__.py deleted file mode 100644 index 9b0b3d2..0000000 --- a/src/meshcore_hub/interface/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Interface component for MeshCore device communication.""" diff --git a/src/meshcore_hub/interface/cli.py b/src/meshcore_hub/interface/cli.py deleted file mode 100644 index 09f8db2..0000000 --- a/src/meshcore_hub/interface/cli.py +++ /dev/null @@ -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, - ) diff --git a/src/meshcore_hub/interface/device.py b/src/meshcore_hub/interface/device.py deleted file mode 100644 index 814457d..0000000 --- a/src/meshcore_hub/interface/device.py +++ /dev/null @@ -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) diff --git a/src/meshcore_hub/interface/mock_device.py b/src/meshcore_hub/interface/mock_device.py deleted file mode 100644 index b3038ab..0000000 --- a/src/meshcore_hub/interface/mock_device.py +++ /dev/null @@ -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) diff --git a/src/meshcore_hub/interface/receiver.py b/src/meshcore_hub/interface/receiver.py deleted file mode 100644 index e9f71c9..0000000 --- a/src/meshcore_hub/interface/receiver.py +++ /dev/null @@ -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() diff --git a/src/meshcore_hub/interface/sender.py b/src/meshcore_hub/interface/sender.py deleted file mode 100644 index b23947f..0000000 --- a/src/meshcore_hub/interface/sender.py +++ /dev/null @@ -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() diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 45abf16..f35cd2f 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -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`
- ${ad.receivers.map(recv => { + ${ad.observers.map(recv => { const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); return html`\u{1F4E1}`; })}
`; - } 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`\u{1F4E1}`; } return html` @@ -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``; - } else if (ad.received_by) { - const recvTitle = ad.receiver_tag_name || ad.receiver_name || truncateKey(ad.received_by, 12); - receiversBlock = html`\u{1F4E1}`; + } else if (ad.observed_by) { + const recvTitle = ad.observer_tag_name || ad.observer_name || truncateKey(ad.observed_by, 12); + receiversBlock = html`\u{1F4E1}`; } else { receiversBlock = html`-`; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 4ef4bdd..247e74f 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -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`${chInfo.label || t('messages.type_channel')}` : sender; let receiversBlock = nothing; - if (msg.receivers && msg.receivers.length >= 1) { + if (msg.observers && msg.observers.length >= 1) { receiversBlock = html`
- ${msg.receivers.map(recv => { + ${msg.observers.map(recv => { const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); return html`\u{1F4E1}`; })}
`; - } else if (msg.received_by) { - const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12); - receiversBlock = html`\u{1F4E1}`; + } else if (msg.observed_by) { + const recvTitle = msg.observer_tag_name || msg.observer_name || truncateKey(msg.observed_by, 12); + receiversBlock = html`\u{1F4E1}`; } return html`
@@ -252,16 +252,16 @@ ${content}`, container); ? html`${chInfo.label || t('messages.type_channel')}` : sender; let receiversBlock; - if (msg.receivers && msg.receivers.length >= 1) { + if (msg.observers && msg.observers.length >= 1) { receiversBlock = html`
- ${msg.receivers.map(recv => { + ${msg.observers.map(recv => { const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); return html`\u{1F4E1}`; })}
`; - } else if (msg.received_by) { - const recvTitle = msg.receiver_tag_name || msg.receiver_name || truncateKey(msg.received_by, 12); - receiversBlock = html`\u{1F4E1}`; + } else if (msg.observed_by) { + const recvTitle = msg.observer_tag_name || msg.observer_name || truncateKey(msg.observed_by, 12); + receiversBlock = html`\u{1F4E1}`; } else { receiversBlock = html`-`; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js index 381a3f9..0e7d110 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/node-detail.js +++ b/src/meshcore_hub/web/static/js/spa/pages/node-detail.js @@ -82,16 +82,16 @@ export async function render(container, params, router) { const advTypeHtml = adv.adv_type ? html`${advEmoji}` : html`-`; - 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`-` : recvName - ? html` + ? html`
${recvName}
-
${adv.received_by.slice(0, 16)}...
+
${adv.observed_by.slice(0, 16)}...
` - : html` - ${adv.received_by.slice(0, 16)}... + : html` + ${adv.observed_by.slice(0, 16)}... `; return html` ${formatDateTime(adv.received_at)} diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py index 4cbbb41..9c61fe3 100644 --- a/tests/test_api/conftest.py +++ b/tests/test_api/conftest.py @@ -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() diff --git a/tests/test_api/test_advertisements.py b/tests/test_api/test_advertisements.py index eea0b3b..1b3d5d7 100644 --- a/tests/test_api/test_advertisements.py +++ b/tests/test_api/test_advertisements.py @@ -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() diff --git a/tests/test_api/test_auth.py b/tests/test_api/test_auth.py index e05a5c2..e0ccf87 100644 --- a/tests/test_api/test_auth.py +++ b/tests/test_api/test_auth.py @@ -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 diff --git a/tests/test_api/test_commands.py b/tests/test_api/test_commands.py deleted file mode 100644 index f0d5b0b..0000000 --- a/tests/test_api/test_commands.py +++ /dev/null @@ -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 diff --git a/tests/test_api/test_messages.py b/tests/test_api/test_messages.py index ef35709..1ad3958 100644 --- a/tests/test_api/test_messages.py +++ b/tests/test_api/test_messages.py @@ -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() diff --git a/tests/test_api/test_telemetry.py b/tests/test_api/test_telemetry.py index e33e513..9d7ba1a 100644 --- a/tests/test_api/test_telemetry.py +++ b/tests/test_api/test_telemetry.py @@ -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() diff --git a/tests/test_api/test_trace_paths.py b/tests/test_api/test_trace_paths.py index 306bcea..fd34344 100644 --- a/tests/test_api/test_trace_paths.py +++ b/tests/test_api/test_trace_paths.py @@ -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() diff --git a/tests/test_collector/test_cleanup.py b/tests/test_collector/test_cleanup.py index 0b67688..11bc508 100644 --- a/tests/test_collector/test_cleanup.py +++ b/tests/test_collector/test_cleanup.py @@ -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, diff --git a/tests/test_collector/test_subscriber.py b/tests/test_collector/test_subscriber.py index f8c064b..1852d11 100644 --- a/tests/test_collector/test_subscriber.py +++ b/tests/test_collector/test_subscriber.py @@ -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) diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py index cb02f1d..19e0ae4 100644 --- a/tests/test_common/test_config.py +++ b/tests/test_common/test_config.py @@ -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( diff --git a/tests/test_interface/__init__.py b/tests/test_interface/__init__.py deleted file mode 100644 index aca483f..0000000 --- a/tests/test_interface/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Interface component tests.""" diff --git a/tests/test_interface/conftest.py b/tests/test_interface/conftest.py deleted file mode 100644 index c3b5611..0000000 --- a/tests/test_interface/conftest.py +++ /dev/null @@ -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() diff --git a/tests/test_interface/test_device.py b/tests/test_interface/test_device.py deleted file mode 100644 index 83cf900..0000000 --- a/tests/test_interface/test_device.py +++ /dev/null @@ -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) diff --git a/tests/test_interface/test_mock_device.py b/tests/test_interface/test_mock_device.py deleted file mode 100644 index 89e5062..0000000 --- a/tests/test_interface/test_mock_device.py +++ /dev/null @@ -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" diff --git a/tests/test_interface/test_receiver.py b/tests/test_interface/test_receiver.py deleted file mode 100644 index 9e8fad4..0000000 --- a/tests/test_interface/test_receiver.py +++ /dev/null @@ -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" diff --git a/tests/test_interface/test_sender.py b/tests/test_interface/test_sender.py deleted file mode 100644 index 08044ec..0000000 --- a/tests/test_interface/test_sender.py +++ /dev/null @@ -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"