diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ecc0b27 --- /dev/null +++ b/.env.example @@ -0,0 +1,77 @@ +# MeshCore Hub - Environment Configuration Example +# Copy this file to .env and customize values + +# =================== +# Common Settings +# =================== + +# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) +LOG_LEVEL=INFO + +# MQTT Broker Settings +MQTT_HOST=localhost +MQTT_PORT=1883 +MQTT_USERNAME= +MQTT_PASSWORD= +MQTT_PREFIX=meshcore + +# =================== +# Interface Settings +# =================== + +# Mode of operation (RECEIVER or SENDER) +INTERFACE_MODE=RECEIVER + +# Serial port for MeshCore device +SERIAL_PORT=/dev/ttyUSB0 +SERIAL_BAUD=115200 + +# Use mock device for testing (true/false) +MOCK_DEVICE=false + +# =================== +# Collector Settings +# =================== + +# Database connection URL +# SQLite: sqlite:///./meshcore.db +# PostgreSQL: postgresql://user:password@localhost/meshcore +DATABASE_URL=sqlite:///./meshcore.db + +# =================== +# API Settings +# =================== + +# API Server binding +API_HOST=0.0.0.0 +API_PORT=8000 + +# API Keys for authentication +# Generate secure keys for production! +API_READ_KEY= +API_ADMIN_KEY= + +# =================== +# Web Dashboard Settings +# =================== + +# Web Server binding +WEB_HOST=0.0.0.0 +WEB_PORT=8080 + +# API connection for web dashboard +API_BASE_URL=http://localhost:8000 +API_KEY= + +# Network Information (displayed on web dashboard) +NETWORK_DOMAIN= +NETWORK_NAME=MeshCore Network +NETWORK_CITY= +NETWORK_COUNTRY= +NETWORK_LOCATION= +NETWORK_RADIO_CONFIG= +NETWORK_CONTACT_EMAIL= +NETWORK_CONTACT_DISCORD= + +# Path to members JSON file +MEMBERS_FILE=members.json diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..decce21 --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +[flake8] +max-line-length = 88 +extend-ignore = E203, E501, W503 +exclude = + .git, + __pycache__, + .venv, + venv, + build, + dist, + *.egg-info, + alembic/versions, + .mypy_cache, + .pytest_cache +per-file-ignores = + __init__.py: F401 diff --git a/.gitignore b/.gitignore index b7faf40..72aeb22 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,8 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# MeshCore Hub specific +*.db +meshcore.db +members.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b631e9e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: check-toml + - id: debug-statements + + - repo: https://github.com/psf/black + rev: 24.3.0 + hooks: + - id: black + language_version: python3.11 + args: ["--line-length=88"] + + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.9.0 + hooks: + - id: mypy + additional_dependencies: + - pydantic>=2.0.0 + - pydantic-settings>=2.0.0 + - sqlalchemy>=2.0.0 + - fastapi>=0.100.0 + - types-paho-mqtt>=1.6.0 + args: ["--ignore-missing-imports"] diff --git a/AGENTS.md b/AGENTS.md index 32941f6..cf7b328 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ MeshCore Hub is a Python 3.11+ monorepo for managing and orchestrating MeshCore | Migrations | Alembic | | REST API | FastAPI | | MQTT Client | paho-mqtt | -| MeshCore Interface | meshcore-py | +| MeshCore Interface | meshcore | | Templates | Jinja2 | | CSS Framework | Tailwind CSS + DaisyUI | | Testing | pytest, pytest-asyncio | @@ -432,9 +432,60 @@ 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. Send a local (non-flood) advertisement +3. Start automatic message fetching + ## References -- [meshcore_py Documentation](https://github.com/meshcore-dev/meshcore_py) +- [meshcore Documentation](https://github.com/fdlamotte/meshcore) - [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/README.md b/README.md index 79c2986..4a7490b 100644 --- a/README.md +++ b/README.md @@ -316,4 +316,4 @@ See [LICENSE](LICENSE) for details. ## Acknowledgments - [MeshCore](https://meshcore.dev/) - The mesh networking protocol -- [meshcore_py](https://github.com/meshcore-dev/meshcore_py) - Python library for MeshCore devices +- [meshcore](https://github.com/fdlamotte/meshcore) - Python library for MeshCore devices diff --git a/TASKS.md b/TASKS.md index b94d666..d310c3b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -4,266 +4,266 @@ This document tracks implementation progress for the MeshCore Hub project. Each --- -## Phase 1: Foundation +## Phase 1: Foundation ✅ ### 1.1 Project Setup -- [ ] Create `pyproject.toml` with project metadata and dependencies -- [ ] Configure Python 3.11+ requirement -- [ ] Set up `src/meshcore_hub/` package structure -- [ ] Create `__init__.py` files for all packages -- [ ] Create `__main__.py` entry point +- [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 -- [ ] Configure `black` formatter settings in pyproject.toml -- [ ] Configure `flake8` linting (create `.flake8` or add to pyproject.toml) -- [ ] Configure `mypy` type checking settings -- [ ] Configure `pytest` settings and test directory -- [ ] Create `.pre-commit-config.yaml` with hooks: - - [ ] black - - [ ] flake8 - - [ ] mypy - - [ ] trailing whitespace - - [ ] end-of-file-fixer -- [ ] Create `.env.example` with all environment variables +- [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 -- [ ] Create `common/config.py` with Pydantic Settings: - - [ ] `CommonSettings` (logging, MQTT connection) - - [ ] `InterfaceSettings` (mode, serial port, mock device) - - [ ] `CollectorSettings` (database URL, webhook settings) - - [ ] `APISettings` (host, port, API keys) - - [ ] `WebSettings` (host, port, network info) -- [ ] Implement environment variable loading -- [ ] Implement CLI argument override support -- [ ] Add configuration validation +- [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 -- [ ] Create `common/database.py`: - - [ ] Database engine factory - - [ ] Session management - - [ ] Async session support -- [ ] Create `common/models/base.py`: - - [ ] Base model with UUID primary key - - [ ] Timestamp mixins (created_at, updated_at) -- [ ] Create `common/models/node.py`: - - [ ] Node model (public_key, name, adv_type, flags, first_seen, last_seen) - - [ ] Indexes on public_key -- [ ] Create `common/models/node_tag.py`: - - [ ] NodeTag model (node_id FK, key, value, value_type) - - [ ] Unique constraint on (node_id, key) -- [ ] Create `common/models/message.py`: - - [ ] Message model (receiver_node_id, message_type, pubkey_prefix, channel_idx, text, etc.) - - [ ] Indexes for common query patterns -- [ ] Create `common/models/advertisement.py`: - - [ ] Advertisement model (receiver_node_id, node_id, public_key, name, adv_type, flags) -- [ ] Create `common/models/trace_path.py`: - - [ ] TracePath model (receiver_node_id, initiator_tag, path_hashes JSON, snr_values JSON) -- [ ] Create `common/models/telemetry.py`: - - [ ] Telemetry model (receiver_node_id, node_id, node_public_key, lpp_data, parsed_data JSON) -- [ ] Create `common/models/event_log.py`: - - [ ] EventLog model (receiver_node_id, event_type, payload JSON) -- [ ] Create `common/models/__init__.py` exporting all 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 -- [ ] Create `common/schemas/events.py`: - - [ ] AdvertisementEvent schema - - [ ] ContactMessageEvent schema - - [ ] ChannelMessageEvent schema - - [ ] TraceDataEvent schema - - [ ] TelemetryResponseEvent schema - - [ ] ContactsEvent schema - - [ ] SendConfirmedEvent schema - - [ ] StatusResponseEvent schema - - [ ] BatteryEvent schema - - [ ] PathUpdatedEvent schema -- [ ] Create `common/schemas/nodes.py`: - - [ ] NodeCreate, NodeRead, NodeList schemas - - [ ] NodeTagCreate, NodeTagUpdate, NodeTagRead schemas -- [ ] Create `common/schemas/messages.py`: - - [ ] MessageRead, MessageList schemas - - [ ] MessageFilters schema -- [ ] Create `common/schemas/commands.py`: - - [ ] SendMessageCommand schema - - [ ] SendChannelMessageCommand schema - - [ ] SendAdvertCommand schema -- [ ] Create `common/schemas/__init__.py` exporting all 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 -- [ ] Create `common/mqtt.py`: - - [ ] MQTT client factory function - - [ ] Topic builder utilities - - [ ] Message serialization helpers - - [ ] Async publish/subscribe wrappers -- [ ] Create `common/logging.py`: - - [ ] Logging configuration function - - [ ] Structured logging format - - [ ] Log level configuration from settings +- [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 -- [ ] Create `alembic.ini` configuration -- [ ] Create `alembic/env.py` with async support -- [ ] Create `alembic/script.py.mako` template -- [ ] Create initial migration with all tables: - - [ ] nodes table - - [ ] node_tags table - - [ ] messages table - - [ ] advertisements table - - [ ] trace_paths table - - [ ] telemetry table - - [ ] events_log table -- [ ] Test migration upgrade/downgrade +- [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 -- [ ] Create root Click group in `__main__.py` -- [ ] Add `--version` option -- [ ] Add `--config` option for config file path -- [ ] Add subcommand placeholders for: interface, collector, api, web, db +- [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 +## Phase 2: Interface Component ✅ ### 2.1 Device Abstraction -- [ ] Create `interface/device.py`: - - [ ] `MeshCoreDevice` class wrapping meshcore_py - - [ ] Connection management (connect, disconnect, reconnect) - - [ ] Get device public key via `send_appstart()` - - [ ] Event subscription registration - - [ ] Command sending methods -- [ ] Create `interface/mock_device.py`: - - [ ] `MockMeshCoreDevice` class - - [ ] Configurable event generation - - [ ] Simulated message sending - - [ ] Simulated network topology (optional) - - [ ] Configurable delays and error rates +- [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 -- [ ] Create `interface/receiver.py`: - - [ ] `Receiver` class - - [ ] Initialize MQTT client - - [ ] Initialize MeshCore device - - [ ] Subscribe to all relevant MeshCore events: - - [ ] ADVERTISEMENT - - [ ] CONTACT_MSG_RECV - - [ ] CHANNEL_MSG_RECV - - [ ] TRACE_DATA - - [ ] TELEMETRY_RESPONSE - - [ ] CONTACTS - - [ ] SEND_CONFIRMED - - [ ] STATUS_RESPONSE - - [ ] BATTERY - - [ ] PATH_UPDATED - - [ ] Event handler that publishes to MQTT - - [ ] Topic construction: `//event/` - - [ ] JSON serialization of event payloads - - [ ] Graceful shutdown handling +- [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 -- [ ] Create `interface/sender.py`: - - [ ] `Sender` class - - [ ] Initialize MQTT client - - [ ] Initialize MeshCore device - - [ ] Subscribe to command topics: - - [ ] `/+/command/send_msg` - - [ ] `/+/command/send_channel_msg` - - [ ] `/+/command/send_advert` - - [ ] `/+/command/request_status` - - [ ] `/+/command/request_telemetry` - - [ ] Command handlers: - - [ ] `handle_send_msg` - send direct message - - [ ] `handle_send_channel_msg` - send channel message - - [ ] `handle_send_advert` - send advertisement - - [ ] `handle_request_status` - request node status - - [ ] `handle_request_telemetry` - request telemetry - - [ ] Error handling and logging - - [ ] Graceful shutdown handling +- [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 -- [ ] Create `interface/cli.py`: - - [ ] `interface` Click command group - - [ ] `--mode` option (receiver/sender, required) - - [ ] `--port` option for serial port - - [ ] `--baud` option for baud rate - - [ ] `--mock` flag to use mock device - - [ ] `--mqtt-host`, `--mqtt-port` options - - [ ] `--prefix` option for MQTT topic prefix - - [ ] Signal handlers for graceful shutdown -- [ ] Register CLI with main entry point +- [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 -- [ ] Create `tests/test_interface/conftest.py`: - - [ ] Mock MQTT client fixture - - [ ] Mock device fixture -- [ ] Create `tests/test_interface/test_device.py`: - - [ ] Test connection/disconnection - - [ ] Test event subscription - - [ ] Test command sending -- [ ] Create `tests/test_interface/test_mock_device.py`: - - [ ] Test mock event generation - - [ ] Test mock command handling -- [ ] Create `tests/test_interface/test_receiver.py`: - - [ ] Test event to MQTT publishing - - [ ] Test topic construction - - [ ] Test payload serialization -- [ ] Create `tests/test_interface/test_sender.py`: - - [ ] Test MQTT to command dispatching - - [ ] Test command payload parsing - - [ ] Test error handling +- [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 +## Phase 3: Collector Component ✅ ### 3.1 MQTT Subscriber -- [ ] Create `collector/subscriber.py`: - - [ ] `Subscriber` class - - [ ] Initialize MQTT client - - [ ] Subscribe to all event topics: `/+/event/#` - - [ ] Parse topic to extract public_key and event_type - - [ ] Route events to appropriate handlers - - [ ] Handle connection/disconnection - - [ ] Graceful shutdown +- [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 -- [ ] Create `collector/handlers/__init__.py`: - - [ ] Handler registry pattern -- [ ] Create `collector/handlers/advertisement.py`: - - [ ] Parse advertisement payload - - [ ] Upsert node in nodes table - - [ ] Insert advertisement record - - [ ] Update node last_seen timestamp -- [ ] Create `collector/handlers/message.py`: - - [ ] Parse contact/channel message payload - - [ ] Insert message record - - [ ] Handle both CONTACT_MSG_RECV and CHANNEL_MSG_RECV -- [ ] Create `collector/handlers/trace.py`: - - [ ] Parse trace data payload - - [ ] Insert trace_path record -- [ ] Create `collector/handlers/telemetry.py`: - - [ ] Parse telemetry payload - - [ ] Insert telemetry record - - [ ] Optionally upsert node -- [ ] Create `collector/handlers/contacts.py`: - - [ ] Parse contacts sync payload - - [ ] Upsert multiple nodes -- [ ] Create `collector/handlers/event_log.py`: - - [ ] Generic handler for events_log table - - [ ] Handle informational events (SEND_CONFIRMED, STATUS_RESPONSE, BATTERY, PATH_UPDATED) +- [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) @@ -277,28 +277,28 @@ This document tracks implementation progress for the MeshCore Hub project. Each ### 3.4 Collector CLI -- [ ] Create `collector/cli.py`: - - [ ] `collector` Click command - - [ ] `--mqtt-host`, `--mqtt-port` options - - [ ] `--prefix` option - - [ ] `--database-url` option - - [ ] Signal handlers for graceful shutdown -- [ ] Register CLI with main entry point +- [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 -- [ ] Create `tests/test_collector/conftest.py`: - - [ ] In-memory SQLite database fixture - - [ ] Mock MQTT client fixture -- [ ] Create `tests/test_collector/test_subscriber.py`: - - [ ] Test topic parsing - - [ ] Test event routing -- [ ] Create `tests/test_collector/test_handlers/`: - - [ ] `test_advertisement.py` - - [ ] `test_message.py` - - [ ] `test_trace.py` - - [ ] `test_telemetry.py` - - [ ] `test_contacts.py` +- [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` - [ ] Create `tests/test_collector/test_webhook.py`: - [ ] Test webhook dispatching - [ ] Test JSONPath filtering @@ -306,301 +306,299 @@ This document tracks implementation progress for the MeshCore Hub project. Each --- -## Phase 4: API Component +## Phase 4: API Component ✅ ### 4.1 FastAPI Application Setup -- [ ] Create `api/app.py`: - - [ ] FastAPI application instance - - [ ] Lifespan handler for startup/shutdown - - [ ] Include all routers - - [ ] Exception handlers - - [ ] CORS middleware configuration -- [ ] Create `api/dependencies.py`: - - [ ] Database session dependency - - [ ] MQTT client dependency - - [ ] Settings dependency +- [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 -- [ ] Create `api/auth.py`: - - [ ] Bearer token extraction - - [ ] `require_read` dependency (read or admin key) - - [ ] `require_admin` dependency (admin key only) - - [ ] 401/403 error responses +- [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 -- [ ] Create `api/routes/nodes.py`: - - [ ] `GET /api/v1/nodes` - list nodes with pagination - - [ ] Query params: limit, offset, search, adv_type - - [ ] `GET /api/v1/nodes/{public_key}` - get single node - - [ ] Include related tags in response (optional) +- [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 -- [ ] Create `api/routes/node_tags.py`: - - [ ] `GET /api/v1/nodes/{public_key}/tags` - list tags - - [ ] `POST /api/v1/nodes/{public_key}/tags` - create tag (admin) - - [ ] `PUT /api/v1/nodes/{public_key}/tags/{key}` - update tag (admin) - - [ ] `DELETE /api/v1/nodes/{public_key}/tags/{key}` - delete tag (admin) +- [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 -- [ ] Create `api/routes/messages.py`: - - [ ] `GET /api/v1/messages` - list messages with filters - - [ ] Query params: type, pubkey_prefix, channel_idx, since, until, limit, offset - - [ ] `GET /api/v1/messages/{id}` - get single message +- [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 -- [ ] Create `api/routes/advertisements.py`: - - [ ] `GET /api/v1/advertisements` - list advertisements - - [ ] Query params: public_key, since, until, limit, offset - - [ ] `GET /api/v1/advertisements/{id}` - get single advertisement +- [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 -- [ ] Create `api/routes/trace_paths.py`: - - [ ] `GET /api/v1/trace-paths` - list trace paths - - [ ] Query params: since, until, limit, offset - - [ ] `GET /api/v1/trace-paths/{id}` - get single trace path +- [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 -- [ ] Create `api/routes/telemetry.py`: - - [ ] `GET /api/v1/telemetry` - list telemetry records - - [ ] Query params: node_public_key, since, until, limit, offset - - [ ] `GET /api/v1/telemetry/{id}` - get single telemetry record +- [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 -- [ ] Create `api/routes/commands.py`: - - [ ] `POST /api/v1/commands/send-message` (admin) - - [ ] Request body: destination, text, timestamp (optional) - - [ ] Publish to MQTT command topic - - [ ] `POST /api/v1/commands/send-channel-message` (admin) - - [ ] Request body: channel_idx, text, timestamp (optional) - - [ ] Publish to MQTT command topic - - [ ] `POST /api/v1/commands/send-advertisement` (admin) - - [ ] Request body: flood (boolean) - - [ ] Publish to MQTT command topic +- [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 -- [ ] Create `api/routes/dashboard.py`: - - [ ] `GET /api/v1/stats` - JSON statistics - - [ ] Total nodes count - - [ ] Active nodes (last 24h) - - [ ] Total messages count - - [ ] Messages today - - [ ] Total advertisements - - [ ] Channel message counts - - [ ] `GET /api/v1/dashboard` - HTML dashboard -- [ ] Create `api/templates/dashboard.html`: - - [ ] Simple HTML template - - [ ] Display statistics - - [ ] Basic CSS styling - - [ ] Auto-refresh meta tag (optional) +- [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 -- [ ] Create `api/routes/__init__.py`: - - [ ] Create main API router - - [ ] Include all sub-routers with prefixes - - [ ] Add OpenAPI tags +- [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 -- [ ] Create `api/cli.py`: - - [ ] `api` Click command - - [ ] `--host` option - - [ ] `--port` option - - [ ] `--database-url` option - - [ ] `--read-key` option - - [ ] `--admin-key` option - - [ ] `--mqtt-host`, `--mqtt-port` options - - [ ] `--reload` flag for development -- [ ] Register CLI with main entry point +- [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 -- [ ] Create `tests/test_api/conftest.py`: - - [ ] Test client fixture - - [ ] In-memory database fixture - - [ ] Test API keys -- [ ] Create `tests/test_api/test_auth.py`: - - [ ] Test missing token - - [ ] Test invalid token - - [ ] Test read-only access - - [ ] Test admin access -- [ ] Create `tests/test_api/test_nodes.py`: - - [ ] Test list nodes - - [ ] Test get node - - [ ] Test pagination - - [ ] Test filtering -- [ ] Create `tests/test_api/test_node_tags.py`: - - [ ] Test CRUD operations - - [ ] Test permission checks -- [ ] Create `tests/test_api/test_messages.py`: - - [ ] Test list messages - - [ ] Test filtering -- [ ] Create `tests/test_api/test_commands.py`: - - [ ] Test send message command - - [ ] Test permission checks - - [ ] Test MQTT publishing +- [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 +## Phase 5: Web Dashboard ✅ ### 5.1 FastAPI Application Setup -- [ ] Create `web/app.py`: - - [ ] FastAPI application instance - - [ ] Jinja2 templates configuration - - [ ] Static files mounting - - [ ] Lifespan handler - - [ ] Include all routers +- [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 -- [ ] Create `web/static/css/` directory -- [ ] Set up Tailwind CSS: - - [ ] Create `tailwind.config.js` - - [ ] Create source CSS with Tailwind directives - - [ ] Configure DaisyUI plugin - - [ ] Build pipeline (npm script or standalone CLI) -- [ ] Create `web/static/js/` directory: - - [ ] Minimal JS for interactivity (if needed) +- [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 -- [ ] Create `web/templates/base.html`: - - [ ] HTML5 doctype and structure - - [ ] Meta tags (viewport, charset) - - [ ] Tailwind CSS inclusion - - [ ] Navigation header: - - [ ] Network name - - [ ] Links to all pages - - [ ] Footer with contact info - - [ ] Content block for page content +- [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 -- [ ] Create `web/routes/home.py`: - - [ ] `GET /` - home page route - - [ ] Load network configuration -- [ ] Create `web/templates/home.html`: - - [ ] Welcome message with network name - - [ ] Network description/details - - [ ] Radio configuration display - - [ ] Location information - - [ ] Contact information (email, Discord) - - [ ] Quick links to other sections +- [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 -- [ ] Create `web/routes/members.py`: - - [ ] `GET /members` - members list route - - [ ] Load members from JSON file -- [ ] Create `web/templates/members.html`: - - [ ] Members list/grid - - [ ] Member cards with: - - [ ] Name - - [ ] Callsign (if applicable) - - [ ] Role/description - - [ ] Contact info (optional) +- [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 -- [ ] Create `web/routes/network.py`: - - [ ] `GET /network` - network stats route - - [ ] Fetch stats from API -- [ ] Create `web/templates/network.html`: - - [ ] Statistics cards: - - [ ] Total nodes - - [ ] Active nodes - - [ ] Total messages - - [ ] Messages today - - [ ] Channel statistics - - [ ] Recent activity summary +- [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 -- [ ] Create `web/routes/nodes.py`: - - [ ] `GET /nodes` - nodes list route - - [ ] `GET /nodes/{public_key}` - node detail route - - [ ] Fetch from API with pagination -- [ ] Create `web/templates/nodes.html`: - - [ ] Search/filter form - - [ ] Nodes table: - - [ ] Name - - [ ] Public key (truncated) - - [ ] Type - - [ ] Last seen - - [ ] Tags - - [ ] Pagination controls -- [ ] Create `web/templates/node_detail.html`: - - [ ] Full node information - - [ ] All tags - - [ ] Recent messages (if any) - - [ ] Recent advertisements +- [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 -- [ ] Create `web/routes/map.py`: - - [ ] `GET /map` - map page route - - [ ] `GET /map/data` - JSON endpoint for node locations - - [ ] Filter nodes with location tags -- [ ] Create `web/templates/map.html`: - - [ ] Leaflet.js map container - - [ ] Leaflet CSS/JS includes - - [ ] JavaScript for: - - [ ] Initialize map centered on NETWORK_LOCATION - - [ ] Fetch node location data - - [ ] Add markers for each node - - [ ] Popup with node info on click +- [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 -- [ ] Create `web/routes/messages.py`: - - [ ] `GET /messages` - messages list route - - [ ] Fetch from API with filters -- [ ] Create `web/templates/messages.html`: - - [ ] Filter form: - - [ ] Message type (contact/channel) - - [ ] Channel selector - - [ ] Date range - - [ ] Search text - - [ ] Messages table: - - [ ] Timestamp - - [ ] Type - - [ ] Sender/Channel - - [ ] Text (truncated) - - [ ] SNR - - [ ] Hops - - [ ] Pagination controls +- [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 -- [ ] Create `web/cli.py`: - - [ ] `web` Click command - - [ ] `--host` option - - [ ] `--port` option - - [ ] `--api-url` option - - [ ] `--api-key` option - - [ ] Network configuration options: - - [ ] `--network-name` - - [ ] `--network-city` - - [ ] `--network-country` - - [ ] `--network-location` - - [ ] `--network-radio-config` - - [ ] `--network-contact-email` - - [ ] `--network-contact-discord` - - [ ] `--members-file` option - - [ ] `--reload` flag for development -- [ ] Register CLI with main entry point +- [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 @@ -738,13 +736,13 @@ This document tracks implementation progress for the MeshCore Hub project. Each | Phase | Total Tasks | Completed | Progress | |-------|-------------|-----------|----------| -| Phase 1: Foundation | 47 | 0 | 0% | -| Phase 2: Interface | 35 | 0 | 0% | -| Phase 3: Collector | 27 | 0 | 0% | -| Phase 4: API | 44 | 0 | 0% | -| Phase 5: Web Dashboard | 40 | 0 | 0% | +| Phase 1: Foundation | 47 | 47 | 100% | +| Phase 2: Interface | 35 | 35 | 100% | +| Phase 3: Collector | 27 | 20 | 74% | +| Phase 4: API | 44 | 44 | 100% | +| Phase 5: Web Dashboard | 40 | 33 | 83% | | Phase 6: Docker & Deployment | 28 | 0 | 0% | -| **Total** | **221** | **0** | **0%** | +| **Total** | **221** | **179** | **81%** | --- @@ -783,5 +781,9 @@ This document tracks implementation progress for the MeshCore Hub project. Each | 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) | diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..ee03a0b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,87 @@ +# A generic, single database configuration for Alembic. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +prepend_sys_path = src + +# timezone to use when rendering the date within the migration file +# as well as the filename. +timezone = UTC + +# max length of characters to apply to the "slug" field +truncate_slug_length = 40 + +# set to 'true' to run the environment during the 'revision' command +revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without a source .py file +# to be detected as revisions in the versions/ directory +sourceless = false + +# version location specification; This defaults to alembic/versions. +version_locations = %(here)s/alembic/versions + +# version path separator +version_path_separator = os + +# set to 'true' to search source files recursively +recursive_version_locations = false + +# the output encoding used when revision files are written from script.py.mako +output_encoding = utf-8 + +# Database URL - can be overridden by environment variable +sqlalchemy.url = sqlite:///./meshcore.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. + +# format using "black" - only if black is installed +hooks = black +black.type = console_scripts +black.entrypoint = black +black.options = -q + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..aafb93a --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,85 @@ +"""Alembic environment configuration.""" + +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from meshcore_hub.common.models import Base + +# this is the Alembic Config object +config = context.config + +# Interpret the config file for Python logging. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Model's MetaData object for 'autogenerate' support +target_metadata = Base.metadata + + +def get_database_url() -> str: + """Get database URL from environment or config.""" + # First try environment variable + url = os.environ.get("DATABASE_URL") + if url: + return url + # Fall back to alembic.ini + return config.get_main_option("sqlalchemy.url", "sqlite:///./meshcore.db") + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + """ + url = get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, # SQLite batch mode for ALTER TABLE + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + """ + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_database_url() + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, # SQLite batch mode for ALTER TABLE + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/20241202_0001_001_initial_schema.py b/alembic/versions/20241202_0001_001_initial_schema.py new file mode 100644 index 0000000..d69a640 --- /dev/null +++ b/alembic/versions/20241202_0001_001_initial_schema.py @@ -0,0 +1,244 @@ +"""Initial database schema + +Revision ID: 001 +Revises: +Create Date: 2024-12-02 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create nodes table + op.create_table( + "nodes", + sa.Column("id", sa.String(), nullable=False), + sa.Column("public_key", sa.String(64), nullable=False), + sa.Column("name", sa.String(255), nullable=True), + sa.Column("adv_type", sa.String(20), nullable=True), + sa.Column("flags", sa.Integer(), nullable=True), + sa.Column("first_seen", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("public_key"), + ) + op.create_index("ix_nodes_public_key", "nodes", ["public_key"]) + op.create_index("ix_nodes_last_seen", "nodes", ["last_seen"]) + op.create_index("ix_nodes_adv_type", "nodes", ["adv_type"]) + + # Create node_tags table + op.create_table( + "node_tags", + sa.Column("id", sa.String(), nullable=False), + sa.Column("node_id", sa.String(), nullable=False), + sa.Column("key", sa.String(100), nullable=False), + sa.Column("value", sa.Text(), nullable=True), + sa.Column("value_type", sa.String(20), nullable=False, server_default="string"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["node_id"], ["nodes.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("node_id", "key", name="uq_node_tags_node_key"), + ) + op.create_index("ix_node_tags_node_id", "node_tags", ["node_id"]) + op.create_index("ix_node_tags_key", "node_tags", ["key"]) + + # Create messages table + op.create_table( + "messages", + sa.Column("id", sa.String(), nullable=False), + sa.Column("receiver_node_id", sa.String(), nullable=True), + sa.Column("message_type", sa.String(20), nullable=False), + sa.Column("pubkey_prefix", sa.String(12), nullable=True), + sa.Column("channel_idx", sa.Integer(), nullable=True), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("path_len", sa.Integer(), nullable=True), + sa.Column("txt_type", sa.Integer(), nullable=True), + sa.Column("signature", sa.String(8), nullable=True), + sa.Column("snr", sa.Float(), nullable=True), + sa.Column("sender_timestamp", sa.DateTime(timezone=True), nullable=True), + sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_messages_receiver_node_id", "messages", ["receiver_node_id"]) + op.create_index("ix_messages_message_type", "messages", ["message_type"]) + op.create_index("ix_messages_pubkey_prefix", "messages", ["pubkey_prefix"]) + op.create_index("ix_messages_channel_idx", "messages", ["channel_idx"]) + op.create_index("ix_messages_received_at", "messages", ["received_at"]) + + # Create advertisements table + op.create_table( + "advertisements", + sa.Column("id", sa.String(), nullable=False), + sa.Column("receiver_node_id", sa.String(), nullable=True), + sa.Column("node_id", sa.String(), nullable=True), + sa.Column("public_key", sa.String(64), nullable=False), + sa.Column("name", sa.String(255), nullable=True), + sa.Column("adv_type", sa.String(20), nullable=True), + sa.Column("flags", sa.Integer(), nullable=True), + sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_advertisements_receiver_node_id", "advertisements", ["receiver_node_id"]) + op.create_index("ix_advertisements_node_id", "advertisements", ["node_id"]) + op.create_index("ix_advertisements_public_key", "advertisements", ["public_key"]) + op.create_index("ix_advertisements_received_at", "advertisements", ["received_at"]) + + # Create trace_paths table + op.create_table( + "trace_paths", + sa.Column("id", sa.String(), nullable=False), + sa.Column("receiver_node_id", sa.String(), nullable=True), + sa.Column("initiator_tag", sa.BigInteger(), nullable=False), + sa.Column("path_len", sa.Integer(), nullable=True), + sa.Column("flags", sa.Integer(), nullable=True), + sa.Column("auth", sa.Integer(), nullable=True), + sa.Column("path_hashes", sa.JSON(), nullable=True), + sa.Column("snr_values", sa.JSON(), nullable=True), + sa.Column("hop_count", sa.Integer(), nullable=True), + sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_trace_paths_receiver_node_id", "trace_paths", ["receiver_node_id"]) + op.create_index("ix_trace_paths_initiator_tag", "trace_paths", ["initiator_tag"]) + op.create_index("ix_trace_paths_received_at", "trace_paths", ["received_at"]) + + # Create telemetry table + op.create_table( + "telemetry", + sa.Column("id", sa.String(), nullable=False), + sa.Column("receiver_node_id", sa.String(), nullable=True), + sa.Column("node_id", sa.String(), nullable=True), + sa.Column("node_public_key", sa.String(64), nullable=False), + sa.Column("lpp_data", sa.LargeBinary(), nullable=True), + sa.Column("parsed_data", sa.JSON(), nullable=True), + sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_telemetry_receiver_node_id", "telemetry", ["receiver_node_id"]) + op.create_index("ix_telemetry_node_id", "telemetry", ["node_id"]) + op.create_index("ix_telemetry_node_public_key", "telemetry", ["node_public_key"]) + op.create_index("ix_telemetry_received_at", "telemetry", ["received_at"]) + + # Create events_log table + op.create_table( + "events_log", + sa.Column("id", sa.String(), nullable=False), + sa.Column("receiver_node_id", sa.String(), nullable=True), + sa.Column("event_type", sa.String(50), nullable=False), + sa.Column("payload", sa.JSON(), nullable=True), + sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + nullable=False, + ), + sa.ForeignKeyConstraint(["receiver_node_id"], ["nodes.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_events_log_receiver_node_id", "events_log", ["receiver_node_id"]) + op.create_index("ix_events_log_event_type", "events_log", ["event_type"]) + op.create_index("ix_events_log_received_at", "events_log", ["received_at"]) + + +def downgrade() -> None: + op.drop_table("events_log") + op.drop_table("telemetry") + op.drop_table("trace_paths") + op.drop_table("advertisements") + op.drop_table("messages") + op.drop_table("node_tags") + op.drop_table("nodes") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..edea9de --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,145 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "meshcore-hub" +version = "0.1.0" +description = "Python monorepo for managing and orchestrating MeshCore mesh networks" +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.11" +authors = [ + {name = "MeshCore Hub Contributors"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Communications", + "Topic :: System :: Networking", +] +keywords = ["meshcore", "mesh", "network", "mqtt", "lora"] +dependencies = [ + "click>=8.1.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "python-dotenv>=1.0.0", + "sqlalchemy>=2.0.0", + "alembic>=1.12.0", + "fastapi>=0.100.0", + "uvicorn[standard]>=0.23.0", + "paho-mqtt>=2.0.0", + "jinja2>=3.1.0", + "python-multipart>=0.0.6", + "httpx>=0.25.0", + "aiosqlite>=0.19.0", + "meshcore>=2.2.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", + "types-paho-mqtt>=1.6.0", +] +postgres = [ + "asyncpg>=0.28.0", + "psycopg2-binary>=2.9.0", +] + +[project.scripts] +meshcore-hub = "meshcore_hub.__main__:main" + +[project.urls] +Homepage = "https://github.com/meshcore-dev/meshcore-hub" +Documentation = "https://github.com/meshcore-dev/meshcore-hub#readme" +Repository = "https://github.com/meshcore-dev/meshcore-hub" +Issues = "https://github.com/meshcore-dev/meshcore-hub/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +meshcore_hub = ["py.typed", "templates/**/*", "static/**/*"] + +[tool.black] +line-length = 88 +target-version = ["py311"] +include = '\.pyi?$' +extend-exclude = ''' +/( + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | alembic/versions +)/ +''' + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +strict_optional = true +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = [ + "paho.*", + "uvicorn.*", +] +ignore_missing_imports = true + +[tool.pytest.ini_options] +minversion = "7.0" +asyncio_mode = "auto" +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "-q", + "--strict-markers", + "--cov=meshcore_hub", + "--cov-report=term-missing", +] +filterwarnings = [ + "ignore::DeprecationWarning", +] + +[tool.coverage.run] +source = ["src/meshcore_hub"] +branch = true +omit = [ + "*/tests/*", + "*/__pycache__/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] diff --git a/src/meshcore_hub/__init__.py b/src/meshcore_hub/__init__.py new file mode 100644 index 0000000..2febe3d --- /dev/null +++ b/src/meshcore_hub/__init__.py @@ -0,0 +1,3 @@ +"""MeshCore Hub - Python monorepo for managing MeshCore mesh networks.""" + +__version__ = "0.1.0" diff --git a/src/meshcore_hub/__main__.py b/src/meshcore_hub/__main__.py new file mode 100644 index 0000000..7e1de6a --- /dev/null +++ b/src/meshcore_hub/__main__.py @@ -0,0 +1,180 @@ +"""MeshCore Hub CLI entry point.""" + +import click +from dotenv import load_dotenv + +from meshcore_hub import __version__ +from meshcore_hub.common.config import LogLevel +from meshcore_hub.common.logging import configure_logging + +# Load .env file early so Click's envvar parameter picks up values +load_dotenv() + + +@click.group() +@click.version_option(version=__version__, prog_name="meshcore-hub") +@click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default="INFO", + envvar="LOG_LEVEL", + help="Set logging level", +) +@click.pass_context +def cli(ctx: click.Context, log_level: str) -> None: + """MeshCore Hub - Mesh network management and orchestration. + + A Python monorepo for managing and orchestrating MeshCore mesh networks. + Provides components for interfacing with devices, collecting data, + REST API access, and web dashboard visualization. + """ + ctx.ensure_object(dict) + ctx.obj["log_level"] = LogLevel(log_level) + configure_logging(level=ctx.obj["log_level"]) + + +# 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) + + +@cli.group() +def db() -> None: + """Database migration commands. + + Manage database schema migrations using Alembic. + """ + pass + + +@db.command("upgrade") +@click.option( + "--revision", + type=str, + default="head", + help="Target revision (default: head)", +) +@click.option( + "--database-url", + type=str, + default=None, + envvar="DATABASE_URL", + help="Database connection URL", +) +def db_upgrade(revision: str, database_url: str | None) -> None: + """Upgrade database to a later version.""" + import os + from alembic import command + from alembic.config import Config + + click.echo(f"Upgrading database to revision: {revision}") + + alembic_cfg = Config("alembic.ini") + if database_url: + os.environ["DATABASE_URL"] = database_url + + command.upgrade(alembic_cfg, revision) + click.echo("Database upgrade complete.") + + +@db.command("downgrade") +@click.option( + "--revision", + type=str, + required=True, + help="Target revision", +) +@click.option( + "--database-url", + type=str, + default=None, + envvar="DATABASE_URL", + help="Database connection URL", +) +def db_downgrade(revision: str, database_url: str | None) -> None: + """Revert database to a previous version.""" + import os + from alembic import command + from alembic.config import Config + + click.echo(f"Downgrading database to revision: {revision}") + + alembic_cfg = Config("alembic.ini") + if database_url: + os.environ["DATABASE_URL"] = database_url + + command.downgrade(alembic_cfg, revision) + click.echo("Database downgrade complete.") + + +@db.command("revision") +@click.option( + "-m", + "--message", + type=str, + required=True, + help="Revision message", +) +@click.option( + "--autogenerate", + is_flag=True, + default=True, + help="Autogenerate migration from models", +) +def db_revision(message: str, autogenerate: bool) -> None: + """Create a new database migration.""" + from alembic import command + from alembic.config import Config + + click.echo(f"Creating new revision: {message}") + + alembic_cfg = Config("alembic.ini") + command.revision(alembic_cfg, message=message, autogenerate=autogenerate) + click.echo("Revision created.") + + +@db.command("current") +@click.option( + "--database-url", + type=str, + default=None, + envvar="DATABASE_URL", + help="Database connection URL", +) +def db_current(database_url: str | None) -> None: + """Show current database revision.""" + import os + from alembic import command + from alembic.config import Config + + alembic_cfg = Config("alembic.ini") + if database_url: + os.environ["DATABASE_URL"] = database_url + + command.current(alembic_cfg) + + +@db.command("history") +def db_history() -> None: + """Show database migration history.""" + from alembic import command + from alembic.config import Config + + alembic_cfg = Config("alembic.ini") + command.history(alembic_cfg) + + +def main() -> None: + """Main entry point.""" + cli() + + +if __name__ == "__main__": + main() diff --git a/src/meshcore_hub/api/__init__.py b/src/meshcore_hub/api/__init__.py new file mode 100644 index 0000000..ce848ab --- /dev/null +++ b/src/meshcore_hub/api/__init__.py @@ -0,0 +1 @@ +"""REST API component for querying data and sending commands.""" diff --git a/src/meshcore_hub/api/app.py b/src/meshcore_hub/api/app.py new file mode 100644 index 0000000..a2f5857 --- /dev/null +++ b/src/meshcore_hub/api/app.py @@ -0,0 +1,123 @@ +"""FastAPI application for MeshCore Hub API.""" + +import logging +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from meshcore_hub import __version__ +from meshcore_hub.common.database import DatabaseManager + +logger = logging.getLogger(__name__) + +# Global database manager (set during startup) +_db_manager: DatabaseManager | None = None + + +def get_db_manager() -> DatabaseManager: + """Get the global database manager.""" + if _db_manager is None: + raise RuntimeError("Database not initialized") + return _db_manager + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Application lifespan handler.""" + global _db_manager + + # Get database URL from app state + database_url = getattr(app.state, "database_url", "sqlite:///./meshcore.db") + + # Initialize database + logger.info(f"Initializing database: {database_url}") + _db_manager = DatabaseManager(database_url) + _db_manager.create_tables() + + yield + + # Cleanup + if _db_manager: + _db_manager.dispose() + _db_manager = None + logger.info("Database connection closed") + + +def create_app( + database_url: str = "sqlite:///./meshcore.db", + read_key: str | None = None, + admin_key: str | None = None, + mqtt_host: str = "localhost", + mqtt_port: int = 1883, + mqtt_prefix: str = "meshcore", + cors_origins: list[str] | None = None, +) -> FastAPI: + """Create and configure the FastAPI application. + + Args: + database_url: Database connection URL + read_key: Read-only API key + admin_key: Admin API key + mqtt_host: MQTT broker host + mqtt_port: MQTT broker port + mqtt_prefix: MQTT topic prefix + cors_origins: Allowed CORS origins + + Returns: + Configured FastAPI application + """ + app = FastAPI( + title="MeshCore Hub API", + description="REST API for querying MeshCore network data and sending commands", + version=__version__, + lifespan=lifespan, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", + ) + + # Store configuration in app state + app.state.database_url = database_url + app.state.read_key = read_key + app.state.admin_key = admin_key + app.state.mqtt_host = mqtt_host + app.state.mqtt_port = mqtt_port + app.state.mqtt_prefix = mqtt_prefix + + # Configure CORS + if cors_origins is None: + cors_origins = ["*"] + + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Include routers + from meshcore_hub.api.routes import api_router + + app.include_router(api_router, prefix="/api/v1") + + # Health check endpoints + @app.get("/health", tags=["Health"]) + async def health() -> dict: + """Basic health check.""" + return {"status": "healthy", "version": __version__} + + @app.get("/health/ready", tags=["Health"]) + async def health_ready() -> dict: + """Readiness check including database.""" + try: + db = get_db_manager() + with db.session_scope() as session: + session.execute("SELECT 1") + return {"status": "ready", "database": "connected"} + except Exception as e: + return {"status": "not_ready", "database": str(e)} + + return app diff --git a/src/meshcore_hub/api/auth.py b/src/meshcore_hub/api/auth.py new file mode 100644 index 0000000..dddb38d --- /dev/null +++ b/src/meshcore_hub/api/auth.py @@ -0,0 +1,137 @@ +"""Authentication middleware for the API.""" + +import logging +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +logger = logging.getLogger(__name__) + +# Security scheme +security = HTTPBearer(auto_error=False) + + +def get_api_keys(request: Request) -> tuple[str | None, str | None]: + """Get API keys from app state. + + Args: + request: FastAPI request + + Returns: + Tuple of (read_key, admin_key) + """ + return ( + getattr(request.app.state, "read_key", None), + getattr(request.app.state, "admin_key", None), + ) + + +async def get_current_token( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)], +) -> str | None: + """Extract bearer token from request. + + Args: + credentials: HTTP authorization credentials + + Returns: + Token string or None + """ + if credentials is None: + return None + return credentials.credentials + + +async def require_read( + request: Request, + token: Annotated[str | None, Depends(get_current_token)], +) -> str | None: + """Require read-level authentication. + + Allows access if: + - No API keys are configured (open access) + - Token matches read key + - Token matches admin key + + Args: + request: FastAPI request + token: Bearer token + + Returns: + Token string + + Raises: + HTTPException: If authentication fails + """ + read_key, admin_key = get_api_keys(request) + + # If no keys configured, allow access + if not read_key and not admin_key: + return token + + # Require token if keys are configured + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Check if token matches any key + if token == read_key or token == admin_key: + return token + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid API key", + ) + + +async def require_admin( + request: Request, + token: Annotated[str | None, Depends(get_current_token)], +) -> str: + """Require admin-level authentication. + + Allows access if: + - No admin key is configured (open access) + - Token matches admin key + + Args: + request: FastAPI request + token: Bearer token + + Returns: + Token string + + Raises: + HTTPException: If authentication fails + """ + read_key, admin_key = get_api_keys(request) + + # If no admin key configured, allow access + if not admin_key: + return token or "" + + # Require token + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Check if token matches admin key + if token == admin_key: + return token + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin access required", + ) + + +# Dependency types for use in routes +RequireRead = Annotated[str | None, Depends(require_read)] +RequireAdmin = Annotated[str, Depends(require_admin)] diff --git a/src/meshcore_hub/api/cli.py b/src/meshcore_hub/api/cli.py new file mode 100644 index 0000000..9df0011 --- /dev/null +++ b/src/meshcore_hub/api/cli.py @@ -0,0 +1,157 @@ +"""API CLI commands.""" + +import click + + +@click.command() +@click.option( + "--host", + type=str, + default="0.0.0.0", + envvar="API_HOST", + help="API server host", +) +@click.option( + "--port", + type=int, + default=8000, + envvar="API_PORT", + help="API server port", +) +@click.option( + "--database-url", + type=str, + default="sqlite:///./meshcore.db", + envvar="DATABASE_URL", + help="Database connection URL", +) +@click.option( + "--read-key", + type=str, + default=None, + envvar="API_READ_KEY", + help="Read-only API key (optional, enables read-level auth)", +) +@click.option( + "--admin-key", + type=str, + default=None, + envvar="API_ADMIN_KEY", + help="Admin API key (optional, enables admin-level auth)", +) +@click.option( + "--mqtt-host", + type=str, + default="localhost", + envvar="MQTT_HOST", + help="MQTT broker host for commands", +) +@click.option( + "--mqtt-port", + type=int, + default=1883, + envvar="MQTT_PORT", + help="MQTT broker port", +) +@click.option( + "--mqtt-prefix", + type=str, + default="meshcore", + envvar="MQTT_TOPIC_PREFIX", + help="MQTT topic prefix", +) +@click.option( + "--cors-origins", + type=str, + default=None, + envvar="CORS_ORIGINS", + help="Comma-separated list of allowed CORS origins", +) +@click.option( + "--reload", + is_flag=True, + default=False, + help="Enable auto-reload for development", +) +@click.pass_context +def api( + ctx: click.Context, + host: str, + port: int, + database_url: str, + read_key: str | None, + admin_key: str | None, + mqtt_host: str, + mqtt_port: int, + mqtt_prefix: str, + cors_origins: str | None, + reload: bool, +) -> None: + """Run the REST API server. + + Provides REST API endpoints for querying mesh network data and sending + commands to devices via MQTT. + + Examples: + + # Run with defaults (no auth) + meshcore-hub api + + # Run with authentication + meshcore-hub api --read-key secret --admin-key supersecret + + # Run with CORS for web frontend + meshcore-hub api --cors-origins "http://localhost:8080,http://localhost:3000" + + # Development mode with auto-reload + meshcore-hub api --reload + """ + import uvicorn + + from meshcore_hub.api.app import create_app + + click.echo("=" * 50) + click.echo("MeshCore Hub API Server") + click.echo("=" * 50) + click.echo(f"Host: {host}") + click.echo(f"Port: {port}") + click.echo(f"Database: {database_url}") + click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {mqtt_prefix})") + click.echo(f"Read key configured: {read_key is not None}") + click.echo(f"Admin key configured: {admin_key is not None}") + click.echo(f"CORS origins: {cors_origins or 'none'}") + click.echo(f"Reload mode: {reload}") + click.echo("=" * 50) + + # Parse CORS origins + origins_list: list[str] | None = None + if cors_origins: + origins_list = [o.strip() for o in cors_origins.split(",")] + + if reload: + # For development, use uvicorn's reload feature + # We need to pass app as string for reload to work + click.echo("\nStarting in development mode with auto-reload...") + click.echo("Note: Using default settings for reload mode.") + + uvicorn.run( + "meshcore_hub.api.app:create_app", + host=host, + port=port, + reload=True, + factory=True, + ) + else: + # For production, create app directly + app = create_app( + database_url=database_url, + read_key=read_key, + admin_key=admin_key, + mqtt_host=mqtt_host, + mqtt_port=mqtt_port, + mqtt_prefix=mqtt_prefix, + cors_origins=origins_list, + ) + + click.echo("\nStarting API server...") + uvicorn.run(app, host=host, port=port) diff --git a/src/meshcore_hub/api/dependencies.py b/src/meshcore_hub/api/dependencies.py new file mode 100644 index 0000000..5b68652 --- /dev/null +++ b/src/meshcore_hub/api/dependencies.py @@ -0,0 +1,73 @@ +"""FastAPI dependencies for the API.""" + +import logging +from typing import Annotated, Generator + +from fastapi import Depends, Request +from sqlalchemy.orm import Session + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig + +logger = logging.getLogger(__name__) + + +def get_db_manager(request: Request) -> DatabaseManager: + """Get database manager from app. + + Args: + request: FastAPI request + + Returns: + DatabaseManager instance + """ + from meshcore_hub.api.app import get_db_manager as _get_db_manager + + return _get_db_manager() + + +def get_db_session( + db_manager: Annotated[DatabaseManager, Depends(get_db_manager)], +) -> Generator[Session, None, None]: + """Get a database session. + + Args: + db_manager: Database manager + + Yields: + Database session + """ + session = db_manager.get_session() + try: + yield session + finally: + session.close() + + +def get_mqtt_client(request: Request) -> MQTTClient: + """Get an MQTT client for publishing commands. + + Args: + request: FastAPI request + + Returns: + MQTTClient instance + """ + mqtt_host = getattr(request.app.state, "mqtt_host", "localhost") + mqtt_port = getattr(request.app.state, "mqtt_port", 1883) + mqtt_prefix = getattr(request.app.state, "mqtt_prefix", "meshcore") + + config = MQTTConfig( + host=mqtt_host, + port=mqtt_port, + prefix=mqtt_prefix, + client_id="meshcore-api", + ) + + client = MQTTClient(config) + return client + + +# Dependency types for use in routes +DbSession = Annotated[Session, Depends(get_db_session)] +MqttClient = Annotated[MQTTClient, Depends(get_mqtt_client)] diff --git a/src/meshcore_hub/api/routes/__init__.py b/src/meshcore_hub/api/routes/__init__.py new file mode 100644 index 0000000..c9a5934 --- /dev/null +++ b/src/meshcore_hub/api/routes/__init__.py @@ -0,0 +1,28 @@ +"""API route handlers.""" + +from fastapi import APIRouter + +from meshcore_hub.api.routes.nodes import router as nodes_router +from meshcore_hub.api.routes.node_tags import router as node_tags_router +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 + +api_router = APIRouter() + +# Include all routers +api_router.include_router(nodes_router, prefix="/nodes", tags=["Nodes"]) +api_router.include_router(node_tags_router, tags=["Node Tags"]) +api_router.include_router(messages_router, prefix="/messages", tags=["Messages"]) +api_router.include_router( + advertisements_router, prefix="/advertisements", tags=["Advertisements"] +) +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, tags=["Dashboard"]) diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py new file mode 100644 index 0000000..378b505 --- /dev/null +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -0,0 +1,71 @@ +"""Advertisement API routes.""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from sqlalchemy import func, select + +from meshcore_hub.api.auth import RequireRead +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import Advertisement +from meshcore_hub.common.schemas.messages import AdvertisementList, AdvertisementRead + +router = APIRouter() + + +@router.get("", response_model=AdvertisementList) +async def list_advertisements( + _: RequireRead, + session: DbSession, + public_key: Optional[str] = Query(None, description="Filter by public key"), + since: Optional[datetime] = Query(None, description="Start timestamp"), + until: Optional[datetime] = Query(None, description="End timestamp"), + limit: int = Query(50, ge=1, le=100, description="Page size"), + offset: int = Query(0, ge=0, description="Page offset"), +) -> AdvertisementList: + """List advertisements with filtering and pagination.""" + # Build query + query = select(Advertisement) + + if public_key: + query = query.where(Advertisement.public_key == public_key) + + if since: + query = query.where(Advertisement.received_at >= since) + + if until: + query = query.where(Advertisement.received_at <= until) + + # Get total count + count_query = select(func.count()).select_from(query.subquery()) + total = session.execute(count_query).scalar() or 0 + + # Apply pagination + query = query.order_by(Advertisement.received_at.desc()).offset(offset).limit(limit) + + # Execute + advertisements = session.execute(query).scalars().all() + + return AdvertisementList( + items=[AdvertisementRead.model_validate(a) for a in advertisements], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/{advertisement_id}", response_model=AdvertisementRead) +async def get_advertisement( + _: RequireRead, + session: DbSession, + advertisement_id: str, +) -> AdvertisementRead: + """Get a single advertisement by ID.""" + query = select(Advertisement).where(Advertisement.id == advertisement_id) + advertisement = session.execute(query).scalar_one_or_none() + + if not advertisement: + raise HTTPException(status_code=404, detail="Advertisement not found") + + return AdvertisementRead.model_validate(advertisement) diff --git a/src/meshcore_hub/api/routes/commands.py b/src/meshcore_hub/api/routes/commands.py new file mode 100644 index 0000000..3c62f4d --- /dev/null +++ b/src/meshcore_hub/api/routes/commands.py @@ -0,0 +1,149 @@ +"""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/dashboard.py b/src/meshcore_hub/api/routes/dashboard.py new file mode 100644 index 0000000..679c56f --- /dev/null +++ b/src/meshcore_hub/api/routes/dashboard.py @@ -0,0 +1,237 @@ +"""Dashboard API routes.""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import func, select + +from meshcore_hub.api.auth import RequireRead +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import Advertisement, Message, Node +from meshcore_hub.common.schemas.messages import DashboardStats + +router = APIRouter() + + +@router.get("/stats", response_model=DashboardStats) +async def get_stats( + _: RequireRead, + session: DbSession, +) -> DashboardStats: + """Get dashboard statistics.""" + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + yesterday = now - timedelta(days=1) + + # Total nodes + total_nodes = session.execute( + select(func.count()).select_from(Node) + ).scalar() or 0 + + # Active nodes (last 24h) + active_nodes = session.execute( + select(func.count()).select_from(Node).where(Node.last_seen >= yesterday) + ).scalar() or 0 + + # Total messages + total_messages = session.execute( + select(func.count()).select_from(Message) + ).scalar() or 0 + + # Messages today + messages_today = session.execute( + select(func.count()) + .select_from(Message) + .where(Message.received_at >= today_start) + ).scalar() or 0 + + # Total advertisements + total_advertisements = session.execute( + select(func.count()).select_from(Advertisement) + ).scalar() or 0 + + # Channel message counts + channel_counts_query = ( + select(Message.channel_idx, func.count()) + .where(Message.message_type == "channel") + .where(Message.channel_idx.isnot(None)) + .group_by(Message.channel_idx) + ) + channel_results = session.execute(channel_counts_query).all() + channel_message_counts = { + int(channel): int(count) for channel, count in channel_results + } + + return DashboardStats( + total_nodes=total_nodes, + active_nodes=active_nodes, + total_messages=total_messages, + messages_today=messages_today, + total_advertisements=total_advertisements, + channel_message_counts=channel_message_counts, + ) + + +@router.get("/dashboard", response_class=HTMLResponse) +async def dashboard( + request: Request, + session: DbSession, +) -> HTMLResponse: + """Simple HTML dashboard page.""" + now = datetime.now(timezone.utc) + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + yesterday = now - timedelta(days=1) + + # Get stats + total_nodes = session.execute( + select(func.count()).select_from(Node) + ).scalar() or 0 + + active_nodes = session.execute( + select(func.count()).select_from(Node).where(Node.last_seen >= yesterday) + ).scalar() or 0 + + total_messages = session.execute( + select(func.count()).select_from(Message) + ).scalar() or 0 + + messages_today = session.execute( + select(func.count()) + .select_from(Message) + .where(Message.received_at >= today_start) + ).scalar() or 0 + + # Get recent nodes + recent_nodes = session.execute( + select(Node).order_by(Node.last_seen.desc()).limit(10) + ).scalars().all() + + # Get recent messages + recent_messages = session.execute( + select(Message).order_by(Message.received_at.desc()).limit(10) + ).scalars().all() + + # Build HTML + html = f""" + + + + MeshCore Hub Dashboard + + + + + + +
+

MeshCore Hub Dashboard

+

Last updated: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}

+ +
+
+

Total Nodes

+
{total_nodes}
+
+
+

Active Nodes (24h)

+
{active_nodes}
+
+
+

Total Messages

+
{total_messages}
+
+
+

Messages Today

+
{messages_today}
+
+
+ +
+

Recent Nodes

+ + + + + + + + + + + {"".join(f''' + + + + + + + ''' for n in recent_nodes)} + +
NamePublic KeyTypeLast Seen
{n.name or '-'}{n.public_key[:16]}...{n.adv_type or '-'}{n.last_seen.strftime('%Y-%m-%d %H:%M') if n.last_seen else '-'}
+
+ +
+

Recent Messages

+ + + + + + + + + + + {"".join(f''' + + + + + + + ''' for m in recent_messages)} + +
TypeFrom/ChannelTextReceived
{m.message_type}{m.pubkey_prefix or f'Ch {m.channel_idx}' or '-'}{m.text[:50]}{'...' if len(m.text) > 50 else ''}{m.received_at.strftime('%Y-%m-%d %H:%M') if m.received_at else '-'}
+
+
+ + +""" + return HTMLResponse(content=html) diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py new file mode 100644 index 0000000..e2f255a --- /dev/null +++ b/src/meshcore_hub/api/routes/messages.py @@ -0,0 +1,83 @@ +"""Message API routes.""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from sqlalchemy import func, select + +from meshcore_hub.api.auth import RequireRead +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import Message +from meshcore_hub.common.schemas.messages import MessageList, MessageRead + +router = APIRouter() + + +@router.get("", response_model=MessageList) +async def list_messages( + _: RequireRead, + session: DbSession, + 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"), + since: Optional[datetime] = Query(None, description="Start timestamp"), + until: Optional[datetime] = Query(None, description="End timestamp"), + search: Optional[str] = Query(None, description="Search in message text"), + limit: int = Query(50, ge=1, le=100, description="Page size"), + offset: int = Query(0, ge=0, description="Page offset"), +) -> MessageList: + """List messages with filtering and pagination.""" + # Build query + query = select(Message) + + if type: + query = query.where(Message.message_type == type) + + if pubkey_prefix: + query = query.where(Message.pubkey_prefix == pubkey_prefix) + + if channel_idx is not None: + query = query.where(Message.channel_idx == channel_idx) + + if since: + query = query.where(Message.received_at >= since) + + if until: + query = query.where(Message.received_at <= until) + + if search: + query = query.where(Message.text.ilike(f"%{search}%")) + + # Get total count + count_query = select(func.count()).select_from(query.subquery()) + total = session.execute(count_query).scalar() or 0 + + # Apply pagination + query = query.order_by(Message.received_at.desc()).offset(offset).limit(limit) + + # Execute + messages = session.execute(query).scalars().all() + + return MessageList( + items=[MessageRead.model_validate(m) for m in messages], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/{message_id}", response_model=MessageRead) +async def get_message( + _: RequireRead, + session: DbSession, + message_id: str, +) -> MessageRead: + """Get a single message by ID.""" + query = select(Message).where(Message.id == message_id) + message = session.execute(query).scalar_one_or_none() + + if not message: + raise HTTPException(status_code=404, detail="Message not found") + + return MessageRead.model_validate(message) diff --git a/src/meshcore_hub/api/routes/node_tags.py b/src/meshcore_hub/api/routes/node_tags.py new file mode 100644 index 0000000..2da9c2b --- /dev/null +++ b/src/meshcore_hub/api/routes/node_tags.py @@ -0,0 +1,131 @@ +"""Node tag API routes.""" + +from fastapi import APIRouter, HTTPException +from sqlalchemy import select + +from meshcore_hub.api.auth import RequireAdmin, RequireRead +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import Node, NodeTag +from meshcore_hub.common.schemas.nodes import NodeTagCreate, NodeTagRead, NodeTagUpdate + +router = APIRouter() + + +@router.get("/nodes/{public_key}/tags", response_model=list[NodeTagRead]) +async def list_node_tags( + _: RequireRead, + session: DbSession, + public_key: str, +) -> list[NodeTagRead]: + """List all tags for a node.""" + # Find node + node_query = select(Node).where(Node.public_key == public_key) + node = session.execute(node_query).scalar_one_or_none() + + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + return [NodeTagRead.model_validate(t) for t in node.tags] + + +@router.post("/nodes/{public_key}/tags", response_model=NodeTagRead, status_code=201) +async def create_node_tag( + _: RequireAdmin, + session: DbSession, + public_key: str, + tag: NodeTagCreate, +) -> NodeTagRead: + """Create a new tag for a node.""" + # Find node + node_query = select(Node).where(Node.public_key == public_key) + node = session.execute(node_query).scalar_one_or_none() + + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + # Check if tag already exists + existing_query = select(NodeTag).where( + (NodeTag.node_id == node.id) & (NodeTag.key == tag.key) + ) + existing = session.execute(existing_query).scalar_one_or_none() + + if existing: + raise HTTPException(status_code=409, detail="Tag already exists") + + # Create tag + node_tag = NodeTag( + node_id=node.id, + key=tag.key, + value=tag.value, + value_type=tag.value_type, + ) + session.add(node_tag) + session.commit() + session.refresh(node_tag) + + return NodeTagRead.model_validate(node_tag) + + +@router.put("/nodes/{public_key}/tags/{key}", response_model=NodeTagRead) +async def update_node_tag( + _: RequireAdmin, + session: DbSession, + public_key: str, + key: str, + tag: NodeTagUpdate, +) -> NodeTagRead: + """Update a node tag.""" + # Find node + node_query = select(Node).where(Node.public_key == public_key) + node = session.execute(node_query).scalar_one_or_none() + + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + # Find tag + tag_query = select(NodeTag).where( + (NodeTag.node_id == node.id) & (NodeTag.key == key) + ) + node_tag = session.execute(tag_query).scalar_one_or_none() + + if not node_tag: + raise HTTPException(status_code=404, detail="Tag not found") + + # Update tag + if tag.value is not None: + node_tag.value = tag.value + if tag.value_type is not None: + node_tag.value_type = tag.value_type + + session.commit() + session.refresh(node_tag) + + return NodeTagRead.model_validate(node_tag) + + +@router.delete("/nodes/{public_key}/tags/{key}", status_code=204) +async def delete_node_tag( + _: RequireAdmin, + session: DbSession, + public_key: str, + key: str, +) -> None: + """Delete a node tag.""" + # Find node + node_query = select(Node).where(Node.public_key == public_key) + node = session.execute(node_query).scalar_one_or_none() + + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + # Find and delete tag + tag_query = select(NodeTag).where( + (NodeTag.node_id == node.id) & (NodeTag.key == key) + ) + node_tag = session.execute(tag_query).scalar_one_or_none() + + if not node_tag: + raise HTTPException(status_code=404, detail="Tag not found") + + session.delete(node_tag) + session.commit() diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py new file mode 100644 index 0000000..27a1d66 --- /dev/null +++ b/src/meshcore_hub/api/routes/nodes.py @@ -0,0 +1,68 @@ +"""Node API routes.""" + +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from sqlalchemy import func, select + +from meshcore_hub.api.auth import RequireRead +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import Node +from meshcore_hub.common.schemas.nodes import NodeList, NodeRead + +router = APIRouter() + + +@router.get("", response_model=NodeList) +async def list_nodes( + _: RequireRead, + session: DbSession, + search: Optional[str] = Query(None, description="Search in name or public key"), + adv_type: Optional[str] = Query(None, description="Filter by advertisement type"), + limit: int = Query(50, ge=1, le=100, description="Page size"), + offset: int = Query(0, ge=0, description="Page offset"), +) -> NodeList: + """List all nodes with pagination and filtering.""" + # Build query + query = select(Node) + + if search: + query = query.where( + (Node.name.ilike(f"%{search}%")) | (Node.public_key.ilike(f"%{search}%")) + ) + + if adv_type: + query = query.where(Node.adv_type == adv_type) + + # Get total count + count_query = select(func.count()).select_from(query.subquery()) + total = session.execute(count_query).scalar() or 0 + + # Apply pagination + query = query.order_by(Node.last_seen.desc()).offset(offset).limit(limit) + + # Execute + nodes = session.execute(query).scalars().all() + + return NodeList( + items=[NodeRead.model_validate(n) for n in nodes], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/{public_key}", response_model=NodeRead) +async def get_node( + _: RequireRead, + session: DbSession, + public_key: str, +) -> NodeRead: + """Get a single node by public key.""" + query = select(Node).where(Node.public_key == public_key) + node = session.execute(query).scalar_one_or_none() + + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + return NodeRead.model_validate(node) diff --git a/src/meshcore_hub/api/routes/telemetry.py b/src/meshcore_hub/api/routes/telemetry.py new file mode 100644 index 0000000..0d954cb --- /dev/null +++ b/src/meshcore_hub/api/routes/telemetry.py @@ -0,0 +1,71 @@ +"""Telemetry API routes.""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from sqlalchemy import func, select + +from meshcore_hub.api.auth import RequireRead +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import Telemetry +from meshcore_hub.common.schemas.messages import TelemetryList, TelemetryRead + +router = APIRouter() + + +@router.get("", response_model=TelemetryList) +async def list_telemetry( + _: RequireRead, + session: DbSession, + node_public_key: Optional[str] = Query(None, description="Filter by node"), + since: Optional[datetime] = Query(None, description="Start timestamp"), + until: Optional[datetime] = Query(None, description="End timestamp"), + limit: int = Query(50, ge=1, le=100, description="Page size"), + offset: int = Query(0, ge=0, description="Page offset"), +) -> TelemetryList: + """List telemetry records with filtering and pagination.""" + # Build query + query = select(Telemetry) + + if node_public_key: + query = query.where(Telemetry.node_public_key == node_public_key) + + if since: + query = query.where(Telemetry.received_at >= since) + + if until: + query = query.where(Telemetry.received_at <= until) + + # Get total count + count_query = select(func.count()).select_from(query.subquery()) + total = session.execute(count_query).scalar() or 0 + + # Apply pagination + query = query.order_by(Telemetry.received_at.desc()).offset(offset).limit(limit) + + # Execute + records = session.execute(query).scalars().all() + + return TelemetryList( + items=[TelemetryRead.model_validate(t) for t in records], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/{telemetry_id}", response_model=TelemetryRead) +async def get_telemetry( + _: RequireRead, + session: DbSession, + telemetry_id: str, +) -> TelemetryRead: + """Get a single telemetry record by ID.""" + query = select(Telemetry).where(Telemetry.id == telemetry_id) + telemetry = session.execute(query).scalar_one_or_none() + + if not telemetry: + raise HTTPException(status_code=404, detail="Telemetry record not found") + + return TelemetryRead.model_validate(telemetry) diff --git a/src/meshcore_hub/api/routes/trace_paths.py b/src/meshcore_hub/api/routes/trace_paths.py new file mode 100644 index 0000000..38a5b42 --- /dev/null +++ b/src/meshcore_hub/api/routes/trace_paths.py @@ -0,0 +1,67 @@ +"""Trace path API routes.""" + +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from sqlalchemy import func, select + +from meshcore_hub.api.auth import RequireRead +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import TracePath +from meshcore_hub.common.schemas.messages import TracePathList, TracePathRead + +router = APIRouter() + + +@router.get("", response_model=TracePathList) +async def list_trace_paths( + _: RequireRead, + session: DbSession, + since: Optional[datetime] = Query(None, description="Start timestamp"), + until: Optional[datetime] = Query(None, description="End timestamp"), + limit: int = Query(50, ge=1, le=100, description="Page size"), + offset: int = Query(0, ge=0, description="Page offset"), +) -> TracePathList: + """List trace paths with filtering and pagination.""" + # Build query + query = select(TracePath) + + if since: + query = query.where(TracePath.received_at >= since) + + if until: + query = query.where(TracePath.received_at <= until) + + # Get total count + count_query = select(func.count()).select_from(query.subquery()) + total = session.execute(count_query).scalar() or 0 + + # Apply pagination + query = query.order_by(TracePath.received_at.desc()).offset(offset).limit(limit) + + # Execute + trace_paths = session.execute(query).scalars().all() + + return TracePathList( + items=[TracePathRead.model_validate(t) for t in trace_paths], + total=total, + limit=limit, + offset=offset, + ) + + +@router.get("/{trace_path_id}", response_model=TracePathRead) +async def get_trace_path( + _: RequireRead, + session: DbSession, + trace_path_id: str, +) -> TracePathRead: + """Get a single trace path by ID.""" + query = select(TracePath).where(TracePath.id == trace_path_id) + trace_path = session.execute(query).scalar_one_or_none() + + if not trace_path: + raise HTTPException(status_code=404, detail="Trace path not found") + + return TracePathRead.model_validate(trace_path) diff --git a/src/meshcore_hub/collector/__init__.py b/src/meshcore_hub/collector/__init__.py new file mode 100644 index 0000000..f6008cb --- /dev/null +++ b/src/meshcore_hub/collector/__init__.py @@ -0,0 +1 @@ +"""Collector component for storing MeshCore events from MQTT.""" diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py new file mode 100644 index 0000000..7f41fc2 --- /dev/null +++ b/src/meshcore_hub/collector/cli.py @@ -0,0 +1,94 @@ +"""CLI for the Collector component.""" + +import click + +from meshcore_hub.common.logging import configure_logging + + +@click.command("collector") +@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( + "--database-url", + type=str, + default="sqlite:///./meshcore.db", + envvar="DATABASE_URL", + help="Database connection URL", +) +@click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default="INFO", + envvar="LOG_LEVEL", + help="Log level", +) +def collector( + mqtt_host: str, + mqtt_port: int, + mqtt_username: str | None, + mqtt_password: str | None, + prefix: str, + database_url: str, + log_level: str, +) -> None: + """Run the collector component. + + The collector subscribes to MQTT broker and stores + MeshCore events in the database for later retrieval. + + Events stored include: + - Node advertisements + - Contact and channel messages + - Trace path data + - Telemetry responses + - Informational events (battery, status, etc.) + """ + configure_logging(level=log_level) + + click.echo("Starting MeshCore Collector") + click.echo(f"MQTT: {mqtt_host}:{mqtt_port} (prefix: {prefix})") + click.echo(f"Database: {database_url}") + + from meshcore_hub.collector.subscriber import run_collector + + run_collector( + mqtt_host=mqtt_host, + mqtt_port=mqtt_port, + mqtt_username=mqtt_username, + mqtt_password=mqtt_password, + mqtt_prefix=prefix, + database_url=database_url, + ) diff --git a/src/meshcore_hub/collector/handlers/__init__.py b/src/meshcore_hub/collector/handlers/__init__.py new file mode 100644 index 0000000..b857aff --- /dev/null +++ b/src/meshcore_hub/collector/handlers/__init__.py @@ -0,0 +1,37 @@ +"""Event handlers for processing MQTT messages.""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from meshcore_hub.collector.subscriber import Subscriber + + +def register_all_handlers(subscriber: "Subscriber") -> None: + """Register all event handlers with the subscriber. + + Args: + subscriber: Subscriber instance + """ + from meshcore_hub.collector.handlers.advertisement import handle_advertisement + from meshcore_hub.collector.handlers.message import ( + handle_contact_message, + handle_channel_message, + ) + from meshcore_hub.collector.handlers.trace import handle_trace_data + from meshcore_hub.collector.handlers.telemetry import handle_telemetry + from meshcore_hub.collector.handlers.contacts import handle_contacts + from meshcore_hub.collector.handlers.event_log import handle_event_log + + # Persisted events with specific handlers + subscriber.register_handler("advertisement", handle_advertisement) + subscriber.register_handler("contact_msg_recv", handle_contact_message) + subscriber.register_handler("channel_msg_recv", handle_channel_message) + subscriber.register_handler("trace_data", handle_trace_data) + subscriber.register_handler("telemetry_response", handle_telemetry) + subscriber.register_handler("contacts", handle_contacts) + + # Informational events (logged only) + subscriber.register_handler("send_confirmed", handle_event_log) + subscriber.register_handler("status_response", handle_event_log) + subscriber.register_handler("battery", handle_event_log) + subscriber.register_handler("path_updated", handle_event_log) diff --git a/src/meshcore_hub/collector/handlers/advertisement.py b/src/meshcore_hub/collector/handlers/advertisement.py new file mode 100644 index 0000000..1588a61 --- /dev/null +++ b/src/meshcore_hub/collector/handlers/advertisement.py @@ -0,0 +1,100 @@ +"""Handler for advertisement events.""" + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import Advertisement, Node + +logger = logging.getLogger(__name__) + + +def handle_advertisement( + public_key: str, + event_type: str, + payload: dict[str, Any], + db: DatabaseManager, +) -> None: + """Handle an advertisement event. + + 1. Upserts the node in the nodes table + 2. Creates an advertisement record + 3. Updates node last_seen timestamp + + Args: + public_key: Receiver node's public key (from MQTT topic) + event_type: Event type name + payload: Advertisement payload + db: Database manager + """ + adv_public_key = payload.get("public_key") + if not adv_public_key: + logger.warning("Advertisement missing public_key") + return + + name = payload.get("name") + adv_type = payload.get("adv_type") + flags = payload.get("flags") + now = datetime.now(timezone.utc) + + with db.session_scope() as session: + # Find or create receiver node + receiver_node = None + if public_key: + receiver_query = select(Node).where(Node.public_key == public_key) + receiver_node = session.execute(receiver_query).scalar_one_or_none() + + if not receiver_node: + receiver_node = Node( + public_key=public_key, + first_seen=now, + last_seen=now, + ) + session.add(receiver_node) + session.flush() + + # Find or create advertised node + node_query = select(Node).where(Node.public_key == adv_public_key) + node = session.execute(node_query).scalar_one_or_none() + + if node: + # Update existing node + if name: + node.name = name + if adv_type: + node.adv_type = adv_type + if flags is not None: + node.flags = flags + node.last_seen = now + else: + # Create new node + node = Node( + public_key=adv_public_key, + name=name, + adv_type=adv_type, + flags=flags, + first_seen=now, + last_seen=now, + ) + session.add(node) + session.flush() + + # Create advertisement record + advertisement = Advertisement( + receiver_node_id=receiver_node.id if receiver_node else None, + node_id=node.id, + public_key=adv_public_key, + name=name, + adv_type=adv_type, + flags=flags, + received_at=now, + ) + session.add(advertisement) + + logger.info( + f"Stored advertisement from {name or adv_public_key[:12]!r} " + f"(type={adv_type})" + ) diff --git a/src/meshcore_hub/collector/handlers/contacts.py b/src/meshcore_hub/collector/handlers/contacts.py new file mode 100644 index 0000000..096fb3e --- /dev/null +++ b/src/meshcore_hub/collector/handlers/contacts.py @@ -0,0 +1,75 @@ +"""Handler for contacts sync events.""" + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import Node + +logger = logging.getLogger(__name__) + + +def handle_contacts( + public_key: str, + event_type: str, + payload: dict[str, Any], + db: DatabaseManager, +) -> None: + """Handle a contacts sync event. + + Upserts all contacts in the contacts list. + + Args: + public_key: Receiver node's public key (from MQTT topic) + event_type: Event type name + payload: Contacts payload + db: Database manager + """ + contacts = payload.get("contacts", []) + if not contacts: + logger.debug("Empty contacts list received") + return + + now = datetime.now(timezone.utc) + created_count = 0 + updated_count = 0 + + with db.session_scope() as session: + for contact in contacts: + contact_key = contact.get("public_key") + if not contact_key: + continue + + name = contact.get("name") + node_type = contact.get("node_type") + + # Find or create node + node_query = select(Node).where(Node.public_key == contact_key) + node = session.execute(node_query).scalar_one_or_none() + + if node: + # Update existing node + if name and not node.name: + node.name = name + if node_type and not node.adv_type: + node.adv_type = node_type + node.last_seen = now + updated_count += 1 + else: + # Create new node + node = Node( + public_key=contact_key, + name=name, + adv_type=node_type, + first_seen=now, + last_seen=now, + ) + session.add(node) + created_count += 1 + + logger.info( + f"Processed contacts sync: {created_count} new, {updated_count} updated" + ) diff --git a/src/meshcore_hub/collector/handlers/event_log.py b/src/meshcore_hub/collector/handlers/event_log.py new file mode 100644 index 0000000..8553780 --- /dev/null +++ b/src/meshcore_hub/collector/handlers/event_log.py @@ -0,0 +1,61 @@ +"""Generic event log handler for informational events.""" + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import EventLog, Node + +logger = logging.getLogger(__name__) + + +def handle_event_log( + public_key: str, + event_type: str, + payload: dict[str, Any], + db: DatabaseManager, +) -> None: + """Handle an event by logging it to the events_log table. + + This is used for informational events that don't need + specific processing but should be recorded. + + Args: + public_key: Receiver node's public key (from MQTT topic) + event_type: Event type name + payload: Event payload + db: Database manager + """ + now = datetime.now(timezone.utc) + + with db.session_scope() as session: + # Find receiver node + receiver_node = None + if public_key: + receiver_query = select(Node).where(Node.public_key == public_key) + receiver_node = session.execute(receiver_query).scalar_one_or_none() + + if not receiver_node: + receiver_node = Node( + public_key=public_key, + first_seen=now, + last_seen=now, + ) + session.add(receiver_node) + session.flush() + else: + receiver_node.last_seen = now + + # Create event log record + event_log = EventLog( + receiver_node_id=receiver_node.id if receiver_node else None, + event_type=event_type, + payload=payload, + received_at=now, + ) + session.add(event_log) + + logger.debug(f"Logged event: {event_type}") diff --git a/src/meshcore_hub/collector/handlers/message.py b/src/meshcore_hub/collector/handlers/message.py new file mode 100644 index 0000000..28614f9 --- /dev/null +++ b/src/meshcore_hub/collector/handlers/message.py @@ -0,0 +1,130 @@ +"""Handler for message events.""" + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import Message, Node + +logger = logging.getLogger(__name__) + + +def handle_contact_message( + public_key: str, + event_type: str, + payload: dict[str, Any], + db: DatabaseManager, +) -> None: + """Handle a contact message event. + + Args: + public_key: Receiver node's public key (from MQTT topic) + event_type: Event type name + payload: Message payload + db: Database manager + """ + _handle_message(public_key, "contact", payload, db) + + +def handle_channel_message( + public_key: str, + event_type: str, + payload: dict[str, Any], + db: DatabaseManager, +) -> None: + """Handle a channel message event. + + Args: + public_key: Receiver node's public key (from MQTT topic) + event_type: Event type name + payload: Message payload + db: Database manager + """ + _handle_message(public_key, "channel", payload, db) + + +def _handle_message( + public_key: str, + message_type: str, + payload: dict[str, Any], + db: DatabaseManager, +) -> None: + """Handle a message event (contact or channel). + + Args: + public_key: Receiver node's public key + message_type: Message type ('contact' or 'channel') + payload: Message payload + db: Database manager + """ + text = payload.get("text") + if not text: + logger.warning(f"Message missing text content") + return + + now = datetime.now(timezone.utc) + + # Extract fields based on message type + pubkey_prefix = payload.get("pubkey_prefix") if message_type == "contact" else None + channel_idx = payload.get("channel_idx") if message_type == "channel" else None + path_len = payload.get("path_len") + txt_type = payload.get("txt_type") + signature = payload.get("signature") + snr = payload.get("SNR") or payload.get("snr") + + # Parse sender timestamp + sender_ts = payload.get("sender_timestamp") + sender_timestamp = None + if sender_ts: + try: + sender_timestamp = datetime.fromtimestamp(sender_ts, tz=timezone.utc) + except (ValueError, OSError): + pass + + with db.session_scope() as session: + # Find receiver node + receiver_node = None + if public_key: + receiver_query = select(Node).where(Node.public_key == public_key) + receiver_node = session.execute(receiver_query).scalar_one_or_none() + + if not receiver_node: + receiver_node = Node( + public_key=public_key, + first_seen=now, + last_seen=now, + ) + session.add(receiver_node) + session.flush() + else: + receiver_node.last_seen = now + + # Create message record + message = Message( + receiver_node_id=receiver_node.id if receiver_node else None, + message_type=message_type, + pubkey_prefix=pubkey_prefix, + channel_idx=channel_idx, + text=text, + path_len=path_len, + txt_type=txt_type, + signature=signature, + snr=snr, + sender_timestamp=sender_timestamp, + received_at=now, + ) + session.add(message) + + if message_type == "contact": + logger.info( + f"Stored contact message from {pubkey_prefix!r}: " + f"{text[:30]}{'...' if len(text) > 30 else ''}" + ) + else: + logger.info( + f"Stored channel {channel_idx} message: " + f"{text[:30]}{'...' if len(text) > 30 else ''}" + ) diff --git a/src/meshcore_hub/collector/handlers/telemetry.py b/src/meshcore_hub/collector/handlers/telemetry.py new file mode 100644 index 0000000..40c14e1 --- /dev/null +++ b/src/meshcore_hub/collector/handlers/telemetry.py @@ -0,0 +1,103 @@ +"""Handler for telemetry events.""" + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import Node, Telemetry + +logger = logging.getLogger(__name__) + + +def handle_telemetry( + public_key: str, + event_type: str, + payload: dict[str, Any], + db: DatabaseManager, +) -> None: + """Handle a telemetry response event. + + Args: + public_key: Receiver node's public key (from MQTT topic) + event_type: Event type name + payload: Telemetry payload + db: Database manager + """ + node_public_key = payload.get("node_public_key") + if not node_public_key: + logger.warning("Telemetry missing node_public_key") + return + + now = datetime.now(timezone.utc) + + lpp_data = payload.get("lpp_data") + parsed_data = payload.get("parsed_data") + + # Convert lpp_data to bytes if it's a string or list + lpp_bytes = None + if lpp_data: + if isinstance(lpp_data, bytes): + lpp_bytes = lpp_data + elif isinstance(lpp_data, list): + lpp_bytes = bytes(lpp_data) + elif isinstance(lpp_data, str): + try: + lpp_bytes = bytes.fromhex(lpp_data) + except ValueError: + lpp_bytes = lpp_data.encode() + + with db.session_scope() as session: + # Find receiver node + receiver_node = None + if public_key: + receiver_query = select(Node).where(Node.public_key == public_key) + receiver_node = session.execute(receiver_query).scalar_one_or_none() + + if not receiver_node: + receiver_node = Node( + public_key=public_key, + first_seen=now, + last_seen=now, + ) + session.add(receiver_node) + session.flush() + else: + receiver_node.last_seen = now + + # Find or create reporting node + reporting_node = None + if node_public_key: + node_query = select(Node).where(Node.public_key == node_public_key) + reporting_node = session.execute(node_query).scalar_one_or_none() + + if not reporting_node: + reporting_node = Node( + public_key=node_public_key, + first_seen=now, + last_seen=now, + ) + session.add(reporting_node) + session.flush() + else: + reporting_node.last_seen = now + + # Create telemetry record + telemetry = Telemetry( + receiver_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, + parsed_data=parsed_data, + received_at=now, + ) + session.add(telemetry) + + # Log telemetry values + if parsed_data: + values = ", ".join(f"{k}={v}" for k, v in parsed_data.items()) + logger.info(f"Stored telemetry from {node_public_key[:12]!r}: {values}") + else: + logger.info(f"Stored telemetry from {node_public_key[:12]!r}") diff --git a/src/meshcore_hub/collector/handlers/trace.py b/src/meshcore_hub/collector/handlers/trace.py new file mode 100644 index 0000000..13e42b4 --- /dev/null +++ b/src/meshcore_hub/collector/handlers/trace.py @@ -0,0 +1,77 @@ +"""Handler for trace data events.""" + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import Node, TracePath + +logger = logging.getLogger(__name__) + + +def handle_trace_data( + public_key: str, + event_type: str, + payload: dict[str, Any], + db: DatabaseManager, +) -> None: + """Handle a trace data event. + + Args: + public_key: Receiver node's public key (from MQTT topic) + event_type: Event type name + payload: Trace data payload + db: Database manager + """ + initiator_tag = payload.get("initiator_tag") + if initiator_tag is None: + logger.warning("Trace data missing initiator_tag") + return + + now = datetime.now(timezone.utc) + + path_len = payload.get("path_len") + flags = payload.get("flags") + auth = payload.get("auth") + path_hashes = payload.get("path_hashes") + snr_values = payload.get("snr_values") + hop_count = payload.get("hop_count") + + with db.session_scope() as session: + # Find receiver node + receiver_node = None + if public_key: + receiver_query = select(Node).where(Node.public_key == public_key) + receiver_node = session.execute(receiver_query).scalar_one_or_none() + + if not receiver_node: + receiver_node = Node( + public_key=public_key, + first_seen=now, + last_seen=now, + ) + session.add(receiver_node) + session.flush() + else: + receiver_node.last_seen = now + + # Create trace path record + trace_path = TracePath( + receiver_node_id=receiver_node.id if receiver_node else None, + initiator_tag=initiator_tag, + path_len=path_len, + flags=flags, + auth=auth, + path_hashes=path_hashes, + snr_values=snr_values, + hop_count=hop_count, + received_at=now, + ) + session.add(trace_path) + + logger.info( + f"Stored trace data: tag={initiator_tag}, hops={hop_count}" + ) diff --git a/src/meshcore_hub/collector/subscriber.py b/src/meshcore_hub/collector/subscriber.py new file mode 100644 index 0000000..456c9cc --- /dev/null +++ b/src/meshcore_hub/collector/subscriber.py @@ -0,0 +1,230 @@ +"""MQTT Subscriber for collecting MeshCore events. + +The subscriber: +1. Connects to MQTT broker +2. Subscribes to all event topics +3. Routes events to appropriate handlers +4. Persists data to database +""" + +import logging +import signal +import threading +import time +from typing import Any, Callable, Optional + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig + +logger = logging.getLogger(__name__) + + +# Handler type: receives (public_key, event_type, payload, db_manager) +EventHandler = Callable[[str, str, dict[str, Any], DatabaseManager], None] + + +class Subscriber: + """MQTT Subscriber for collecting and storing MeshCore events.""" + + def __init__( + self, + mqtt_client: MQTTClient, + db_manager: DatabaseManager, + ): + """Initialize subscriber. + + Args: + mqtt_client: MQTT client instance + db_manager: Database manager instance + """ + self.mqtt = mqtt_client + self.db = db_manager + self._running = False + self._shutdown_event = threading.Event() + self._handlers: dict[str, EventHandler] = {} + + def register_handler(self, event_type: str, handler: EventHandler) -> None: + """Register a handler for an event type. + + Args: + event_type: Event type name (e.g., 'advertisement') + handler: Handler function + """ + self._handlers[event_type] = handler + logger.debug(f"Registered handler for {event_type}") + + def _handle_mqtt_message( + self, + topic: str, + pattern: str, + payload: dict[str, Any], + ) -> None: + """Handle incoming MQTT event message. + + Args: + topic: MQTT topic + pattern: Subscription pattern + payload: Message payload + """ + # Parse event from topic + parsed = self.mqtt.topic_builder.parse_event_topic(topic) + if not parsed: + logger.warning(f"Could not parse event topic: {topic}") + return + + public_key, event_type = parsed + logger.debug(f"Received event: {event_type} from {public_key[:12]}...") + + # Find and call handler + handler = self._handlers.get(event_type) + if handler: + try: + handler(public_key, event_type, payload, self.db) + except Exception as e: + logger.error(f"Error handling {event_type}: {e}") + else: + # Use generic event log handler if no specific handler + from meshcore_hub.collector.handlers.event_log import handle_event_log + + try: + handle_event_log(public_key, event_type, payload, self.db) + except Exception as e: + logger.error(f"Error logging event {event_type}: {e}") + + def start(self) -> None: + """Start the subscriber.""" + logger.info("Starting collector subscriber") + + # Create database tables if needed + self.db.create_tables() + + # Connect to MQTT broker + try: + self.mqtt.connect() + self.mqtt.start_background() + logger.info("Connected to MQTT broker") + except Exception as e: + logger.error(f"Failed to connect to MQTT broker: {e}") + raise + + # Subscribe to all event topics + 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}") + + self._running = True + + def run(self) -> None: + """Run the subscriber event loop (blocking).""" + if not self._running: + self.start() + + logger.info("Collector 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 subscriber.""" + if not self._running: + return + + logger.info("Stopping collector subscriber") + self._running = False + self._shutdown_event.set() + + # Stop MQTT + self.mqtt.stop() + self.mqtt.disconnect() + + logger.info("Collector subscriber stopped") + + +def create_subscriber( + mqtt_host: str = "localhost", + mqtt_port: int = 1883, + mqtt_username: Optional[str] = None, + mqtt_password: Optional[str] = None, + mqtt_prefix: str = "meshcore", + database_url: str = "sqlite:///./meshcore.db", +) -> Subscriber: + """Create a configured subscriber instance. + + Args: + mqtt_host: MQTT broker host + mqtt_port: MQTT broker port + mqtt_username: MQTT username + mqtt_password: MQTT password + mqtt_prefix: MQTT topic prefix + database_url: Database connection URL + + Returns: + Configured Subscriber instance + """ + # Create MQTT client + mqtt_config = MQTTConfig( + host=mqtt_host, + port=mqtt_port, + username=mqtt_username, + password=mqtt_password, + prefix=mqtt_prefix, + client_id="meshcore-collector", + ) + mqtt_client = MQTTClient(mqtt_config) + + # Create database manager + db_manager = DatabaseManager(database_url) + + # Create subscriber + subscriber = Subscriber(mqtt_client, db_manager) + + # Register handlers + from meshcore_hub.collector.handlers import register_all_handlers + + register_all_handlers(subscriber) + + return subscriber + + +def run_collector( + mqtt_host: str = "localhost", + mqtt_port: int = 1883, + mqtt_username: Optional[str] = None, + mqtt_password: Optional[str] = None, + mqtt_prefix: str = "meshcore", + database_url: str = "sqlite:///./meshcore.db", +) -> None: + """Run the collector (blocking). + + Args: + mqtt_host: MQTT broker host + mqtt_port: MQTT broker port + mqtt_username: MQTT username + mqtt_password: MQTT password + mqtt_prefix: MQTT topic prefix + database_url: Database connection URL + """ + subscriber = create_subscriber( + mqtt_host=mqtt_host, + mqtt_port=mqtt_port, + mqtt_username=mqtt_username, + mqtt_password=mqtt_password, + mqtt_prefix=mqtt_prefix, + database_url=database_url, + ) + + # Set up signal handlers + def signal_handler(signum: int, frame: Any) -> None: + logger.info(f"Received signal {signum}") + subscriber.stop() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # Run + subscriber.run() diff --git a/src/meshcore_hub/common/__init__.py b/src/meshcore_hub/common/__init__.py new file mode 100644 index 0000000..a4a1777 --- /dev/null +++ b/src/meshcore_hub/common/__init__.py @@ -0,0 +1 @@ +"""Common utilities, models and configurations used by all components.""" diff --git a/src/meshcore_hub/common/config.py b/src/meshcore_hub/common/config.py new file mode 100644 index 0000000..8b04806 --- /dev/null +++ b/src/meshcore_hub/common/config.py @@ -0,0 +1,192 @@ +"""Pydantic Settings for MeshCore Hub configuration.""" + +from enum import Enum +from typing import Optional + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class LogLevel(str, Enum): + """Log level enumeration.""" + + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class InterfaceMode(str, Enum): + """Interface component mode.""" + + RECEIVER = "RECEIVER" + SENDER = "SENDER" + + +class CommonSettings(BaseSettings): + """Common settings shared by all components.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Logging + log_level: LogLevel = Field(default=LogLevel.INFO, description="Logging level") + + # MQTT Broker + mqtt_host: str = Field(default="localhost", description="MQTT broker host") + mqtt_port: int = Field(default=1883, description="MQTT broker port") + mqtt_username: Optional[str] = Field( + default=None, description="MQTT username (optional)" + ) + mqtt_password: Optional[str] = Field( + default=None, description="MQTT password (optional)" + ) + mqtt_prefix: str = Field( + default="meshcore", description="MQTT topic prefix" + ) + + +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" + ) + + +class CollectorSettings(CommonSettings): + """Settings for the Collector component.""" + + # Database + database_url: str = Field( + default="sqlite:///./meshcore.db", + description="SQLAlchemy database URL", + ) + + @field_validator("database_url") + @classmethod + def validate_database_url(cls, v: str) -> str: + """Validate database URL format.""" + if not v: + raise ValueError("Database URL cannot be empty") + return v + + +class APISettings(CommonSettings): + """Settings for the API component.""" + + # Server binding + api_host: str = Field(default="0.0.0.0", description="API server host") + api_port: int = Field(default=8000, description="API server port") + + # Database + database_url: str = Field( + default="sqlite:///./meshcore.db", + description="SQLAlchemy database URL", + ) + + # Authentication + api_read_key: Optional[str] = Field( + default=None, description="Read-only API key" + ) + api_admin_key: Optional[str] = Field( + default=None, description="Admin API key (full access)" + ) + + @field_validator("database_url") + @classmethod + def validate_database_url(cls, v: str) -> str: + """Validate database URL format.""" + if not v: + raise ValueError("Database URL cannot be empty") + return v + + +class WebSettings(CommonSettings): + """Settings for the Web Dashboard component.""" + + # Server binding + web_host: str = Field(default="0.0.0.0", description="Web server host") + web_port: int = Field(default=8080, description="Web server port") + + # API connection + api_base_url: str = Field( + default="http://localhost:8000", + description="API server base URL", + ) + api_key: Optional[str] = Field( + default=None, description="API key for queries" + ) + + # Network information + network_domain: Optional[str] = Field( + default=None, description="Network domain name" + ) + network_name: str = Field( + default="MeshCore Network", description="Network display name" + ) + network_city: Optional[str] = Field( + default=None, description="Network city location" + ) + network_country: Optional[str] = Field( + default=None, description="Network country (ISO 3166-1 alpha-2)" + ) + network_location: Optional[str] = Field( + default=None, description="Network location (lat,lon)" + ) + network_radio_config: Optional[str] = Field( + default=None, description="Radio configuration details" + ) + network_contact_email: Optional[str] = Field( + default=None, description="Contact email address" + ) + network_contact_discord: Optional[str] = Field( + default=None, description="Discord server link" + ) + + # Members file + members_file: str = Field( + default="members.json", description="Path to members JSON file" + ) + + +def get_common_settings() -> CommonSettings: + """Get common settings instance.""" + return CommonSettings() + + +def get_interface_settings() -> InterfaceSettings: + """Get interface settings instance.""" + return InterfaceSettings() + + +def get_collector_settings() -> CollectorSettings: + """Get collector settings instance.""" + return CollectorSettings() + + +def get_api_settings() -> APISettings: + """Get API settings instance.""" + return APISettings() + + +def get_web_settings() -> WebSettings: + """Get web settings instance.""" + return WebSettings() diff --git a/src/meshcore_hub/common/database.py b/src/meshcore_hub/common/database.py new file mode 100644 index 0000000..10671b5 --- /dev/null +++ b/src/meshcore_hub/common/database.py @@ -0,0 +1,186 @@ +"""Database connection and session management.""" + +from contextlib import contextmanager +from typing import Generator + +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +from meshcore_hub.common.models.base import Base + + +def create_database_engine( + database_url: str, + echo: bool = False, +) -> Engine: + """Create a SQLAlchemy database engine. + + Args: + database_url: SQLAlchemy database URL + echo: Enable SQL query logging + + Returns: + SQLAlchemy Engine instance + """ + connect_args = {} + + # SQLite-specific configuration + if database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + + engine = create_engine( + database_url, + echo=echo, + connect_args=connect_args, + pool_pre_ping=True, + ) + + # Enable foreign keys for SQLite + if database_url.startswith("sqlite"): + @event.listens_for(engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): # type: ignore + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + return engine + + +def create_session_factory(engine: Engine) -> sessionmaker[Session]: + """Create a session factory for the given engine. + + Args: + engine: SQLAlchemy Engine instance + + Returns: + Session factory + """ + return sessionmaker( + bind=engine, + autocommit=False, + autoflush=False, + expire_on_commit=False, + ) + + +def create_tables(engine: Engine) -> None: + """Create all database tables. + + Args: + engine: SQLAlchemy Engine instance + """ + Base.metadata.create_all(bind=engine) + + +def drop_tables(engine: Engine) -> None: + """Drop all database tables. + + Args: + engine: SQLAlchemy Engine instance + """ + Base.metadata.drop_all(bind=engine) + + +class DatabaseManager: + """Database connection manager. + + Manages database engine and session creation for a component. + """ + + def __init__(self, database_url: str, echo: bool = False): + """Initialize the database manager. + + Args: + database_url: SQLAlchemy database URL + echo: Enable SQL query logging + """ + self.database_url = database_url + self.engine = create_database_engine(database_url, echo=echo) + self.session_factory = create_session_factory(self.engine) + + def create_tables(self) -> None: + """Create all database tables.""" + create_tables(self.engine) + + def drop_tables(self) -> None: + """Drop all database tables.""" + drop_tables(self.engine) + + def get_session(self) -> Session: + """Get a new database session. + + Returns: + New Session instance + """ + return self.session_factory() + + @contextmanager + def session_scope(self) -> Generator[Session, None, None]: + """Provide a transactional scope around a series of operations. + + Yields: + Session instance + + Example: + with db.session_scope() as session: + session.add(node) + session.commit() + """ + session = self.get_session() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + def dispose(self) -> None: + """Dispose of the database engine and connection pool.""" + self.engine.dispose() + + +# Global database manager instance (initialized at runtime) +_db_manager: DatabaseManager | None = None + + +def init_database(database_url: str, echo: bool = False) -> DatabaseManager: + """Initialize the global database manager. + + Args: + database_url: SQLAlchemy database URL + echo: Enable SQL query logging + + Returns: + DatabaseManager instance + """ + global _db_manager + _db_manager = DatabaseManager(database_url, echo=echo) + return _db_manager + + +def get_database() -> DatabaseManager: + """Get the global database manager. + + Returns: + DatabaseManager instance + + Raises: + RuntimeError: If database not initialized + """ + if _db_manager is None: + raise RuntimeError( + "Database not initialized. Call init_database() first." + ) + return _db_manager + + +def get_session() -> Session: + """Get a database session from the global manager. + + Returns: + Session instance + """ + return get_database().get_session() diff --git a/src/meshcore_hub/common/logging.py b/src/meshcore_hub/common/logging.py new file mode 100644 index 0000000..edb13f8 --- /dev/null +++ b/src/meshcore_hub/common/logging.py @@ -0,0 +1,126 @@ +"""Logging configuration for MeshCore Hub.""" + +import logging +import sys +from typing import Optional + +from meshcore_hub.common.config import LogLevel + + +# Default log format +DEFAULT_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +# Structured log format (more suitable for production/parsing) +STRUCTURED_FORMAT = ( + "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s" +) + + +def configure_logging( + level: LogLevel | str = LogLevel.INFO, + format_string: Optional[str] = None, + structured: bool = False, +) -> None: + """Configure logging for the application. + + Args: + level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) + format_string: Custom log format string (optional) + structured: Use structured logging format + """ + # Convert LogLevel enum to string if necessary + if isinstance(level, LogLevel): + level_str = level.value + else: + level_str = level.upper() + + # Get numeric log level + numeric_level = getattr(logging, level_str, logging.INFO) + + # Determine format + if format_string: + log_format = format_string + elif structured: + log_format = STRUCTURED_FORMAT + else: + log_format = DEFAULT_FORMAT + + # Configure root logger + logging.basicConfig( + level=numeric_level, + format=log_format, + handlers=[ + logging.StreamHandler(sys.stdout), + ], + ) + + # Set levels for noisy third-party loggers + logging.getLogger("paho").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + + # Set our loggers to the configured level + logging.getLogger("meshcore_hub").setLevel(numeric_level) + + +def get_logger(name: str) -> logging.Logger: + """Get a logger with the given name. + + Args: + name: Logger name (typically __name__) + + Returns: + Logger instance + """ + return logging.getLogger(name) + + +class ComponentLogger: + """Logger wrapper for a specific component.""" + + def __init__(self, component: str): + """Initialize component logger. + + Args: + component: Component name (e.g., 'interface', 'collector') + """ + self.component = component + self._logger = logging.getLogger(f"meshcore_hub.{component}") + + def debug(self, message: str, **kwargs: object) -> None: + """Log a debug message.""" + self._logger.debug(message, extra=kwargs) + + def info(self, message: str, **kwargs: object) -> None: + """Log an info message.""" + self._logger.info(message, extra=kwargs) + + def warning(self, message: str, **kwargs: object) -> None: + """Log a warning message.""" + self._logger.warning(message, extra=kwargs) + + def error(self, message: str, **kwargs: object) -> None: + """Log an error message.""" + self._logger.error(message, extra=kwargs) + + def critical(self, message: str, **kwargs: object) -> None: + """Log a critical message.""" + self._logger.critical(message, extra=kwargs) + + def exception(self, message: str, **kwargs: object) -> None: + """Log an exception with traceback.""" + self._logger.exception(message, extra=kwargs) + + +def get_component_logger(component: str) -> ComponentLogger: + """Get a component-specific logger. + + Args: + component: Component name + + Returns: + ComponentLogger instance + """ + return ComponentLogger(component) diff --git a/src/meshcore_hub/common/models/__init__.py b/src/meshcore_hub/common/models/__init__.py new file mode 100644 index 0000000..ce64158 --- /dev/null +++ b/src/meshcore_hub/common/models/__init__.py @@ -0,0 +1,22 @@ +"""SQLAlchemy database models.""" + +from meshcore_hub.common.models.base import Base, TimestampMixin +from meshcore_hub.common.models.node import Node +from meshcore_hub.common.models.node_tag import NodeTag +from meshcore_hub.common.models.message import Message +from meshcore_hub.common.models.advertisement import Advertisement +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 + +__all__ = [ + "Base", + "TimestampMixin", + "Node", + "NodeTag", + "Message", + "Advertisement", + "TracePath", + "Telemetry", + "EventLog", +] diff --git a/src/meshcore_hub/common/models/advertisement.py b/src/meshcore_hub/common/models/advertisement.py new file mode 100644 index 0000000..dbd9994 --- /dev/null +++ b/src/meshcore_hub/common/models/advertisement.py @@ -0,0 +1,67 @@ +"""Advertisement model for storing node advertisements.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now + + +class Advertisement(Base, UUIDMixin, TimestampMixin): + """Advertisement model for storing node advertisements. + + Attributes: + id: UUID primary key + receiver_node_id: FK to nodes (receiving interface) + node_id: FK to nodes (advertised node) + public_key: Advertised public key + name: Advertised name + adv_type: Node type (chat, repeater, room, none) + flags: Capability flags + received_at: When received by interface + created_at: Record creation timestamp + """ + + __tablename__ = "advertisements" + + receiver_node_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("nodes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + node_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("nodes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + public_key: Mapped[str] = mapped_column( + String(64), + nullable=False, + index=True, + ) + name: Mapped[Optional[str]] = mapped_column( + String(255), + nullable=True, + ) + adv_type: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + ) + flags: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + nullable=False, + ) + + __table_args__ = ( + Index("ix_advertisements_received_at", "received_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/models/base.py b/src/meshcore_hub/common/models/base.py new file mode 100644 index 0000000..4be6840 --- /dev/null +++ b/src/meshcore_hub/common/models/base.py @@ -0,0 +1,71 @@ +"""Base model with common fields and mixins.""" + +import uuid +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import DateTime, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +def generate_uuid() -> str: + """Generate a new UUID string.""" + return str(uuid.uuid4()) + + +def utc_now() -> datetime: + """Get current UTC datetime.""" + return datetime.now(timezone.utc) + + +class Base(DeclarativeBase): + """Base class for all SQLAlchemy models.""" + + pass + + +class TimestampMixin: + """Mixin that adds created_at and updated_at timestamp columns.""" + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + server_default=func.now(), + onupdate=utc_now, + nullable=False, + ) + + +class UUIDMixin: + """Mixin that adds a UUID primary key.""" + + id: Mapped[str] = mapped_column( + primary_key=True, + default=generate_uuid, + nullable=False, + ) + + +def model_to_dict(model: Any) -> dict[str, Any]: + """Convert a SQLAlchemy model instance to a dictionary. + + Args: + model: SQLAlchemy model instance + + Returns: + Dictionary representation of the model + """ + result = {} + for column in model.__table__.columns: + value = getattr(model, column.name) + if isinstance(value, datetime): + result[column.name] = value.isoformat() + else: + result[column.name] = value + return result diff --git a/src/meshcore_hub/common/models/event_log.py b/src/meshcore_hub/common/models/event_log.py new file mode 100644 index 0000000..4e03629 --- /dev/null +++ b/src/meshcore_hub/common/models/event_log.py @@ -0,0 +1,52 @@ +"""EventLog model for storing all event payloads.""" + +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import DateTime, ForeignKey, Index, String +from sqlalchemy.dialects.sqlite import JSON +from sqlalchemy.orm import Mapped, mapped_column + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now + + +class EventLog(Base, UUIDMixin, TimestampMixin): + """EventLog model for storing all event payloads for audit/debugging. + + Attributes: + id: UUID primary key + receiver_node_id: FK to nodes (receiving interface) + event_type: Event type name + payload: Full event payload as JSON + received_at: When received by interface + created_at: Record creation timestamp + """ + + __tablename__ = "events_log" + + receiver_node_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("nodes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + event_type: Mapped[str] = mapped_column( + String(50), + nullable=False, + ) + payload: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSON, + nullable=True, + ) + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + nullable=False, + ) + + __table_args__ = ( + Index("ix_events_log_event_type", "event_type"), + Index("ix_events_log_received_at", "received_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/models/message.py b/src/meshcore_hub/common/models/message.py new file mode 100644 index 0000000..ad50630 --- /dev/null +++ b/src/meshcore_hub/common/models/message.py @@ -0,0 +1,88 @@ +"""Message model for storing received messages.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now + + +class Message(Base, UUIDMixin, TimestampMixin): + """Message model for storing contact and channel messages. + + Attributes: + id: UUID primary key + receiver_node_id: FK to nodes (receiving interface) + message_type: Message type (contact, channel) + pubkey_prefix: Sender's public key prefix (12 chars, contact msgs) + channel_idx: Channel index (channel msgs) + text: Message content + path_len: Number of hops + txt_type: Message type indicator + signature: Message signature (8 hex chars) + snr: Signal-to-noise ratio + sender_timestamp: Sender's timestamp + received_at: When received by interface + created_at: Record creation timestamp + """ + + __tablename__ = "messages" + + receiver_node_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("nodes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + message_type: Mapped[str] = mapped_column( + String(20), + nullable=False, + ) + pubkey_prefix: Mapped[Optional[str]] = mapped_column( + String(12), + nullable=True, + ) + channel_idx: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + text: Mapped[str] = mapped_column( + Text, + nullable=False, + ) + path_len: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + txt_type: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + signature: Mapped[Optional[str]] = mapped_column( + String(8), + nullable=True, + ) + snr: Mapped[Optional[float]] = mapped_column( + Float, + nullable=True, + ) + sender_timestamp: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + nullable=False, + ) + + __table_args__ = ( + Index("ix_messages_message_type", "message_type"), + Index("ix_messages_pubkey_prefix", "pubkey_prefix"), + Index("ix_messages_channel_idx", "channel_idx"), + Index("ix_messages_received_at", "received_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/models/node.py b/src/meshcore_hub/common/models/node.py new file mode 100644 index 0000000..b8d933f --- /dev/null +++ b/src/meshcore_hub/common/models/node.py @@ -0,0 +1,75 @@ +"""Node model for tracking MeshCore network nodes.""" + +from datetime import datetime +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import DateTime, Index, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now + +if TYPE_CHECKING: + from meshcore_hub.common.models.node_tag import NodeTag + + +class Node(Base, UUIDMixin, TimestampMixin): + """Node model representing a MeshCore network node. + + Attributes: + id: UUID primary key + public_key: Node's 64-character hex public key (unique) + name: Node display name + adv_type: Advertisement type (chat, repeater, room, none) + flags: Capability/status flags bitmask + first_seen: Timestamp of first advertisement + last_seen: Timestamp of most recent activity + created_at: Record creation timestamp + updated_at: Record update timestamp + """ + + __tablename__ = "nodes" + + public_key: Mapped[str] = mapped_column( + String(64), + unique=True, + nullable=False, + index=True, + ) + name: Mapped[Optional[str]] = mapped_column( + String(255), + nullable=True, + ) + adv_type: Mapped[Optional[str]] = mapped_column( + String(20), + nullable=True, + ) + flags: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + first_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + nullable=False, + ) + last_seen: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + nullable=False, + ) + + # Relationships + tags: Mapped[list["NodeTag"]] = relationship( + "NodeTag", + back_populates="node", + cascade="all, delete-orphan", + lazy="selectin", + ) + + __table_args__ = ( + Index("ix_nodes_last_seen", "last_seen"), + Index("ix_nodes_adv_type", "adv_type"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/models/node_tag.py b/src/meshcore_hub/common/models/node_tag.py new file mode 100644 index 0000000..969013d --- /dev/null +++ b/src/meshcore_hub/common/models/node_tag.py @@ -0,0 +1,62 @@ +"""NodeTag model for custom node metadata.""" + +from typing import TYPE_CHECKING, Optional + +from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin + +if TYPE_CHECKING: + from meshcore_hub.common.models.node import Node + + +class NodeTag(Base, UUIDMixin, TimestampMixin): + """NodeTag model for custom node metadata. + + Allows users to assign arbitrary key-value tags to nodes. + + Attributes: + id: UUID primary key + node_id: Foreign key to nodes table + key: Tag name/key + value: Tag value (stored as text, can be JSON for typed values) + value_type: Type hint (string, number, boolean, coordinate) + created_at: Record creation timestamp + updated_at: Record update timestamp + """ + + __tablename__ = "node_tags" + + node_id: Mapped[str] = mapped_column( + ForeignKey("nodes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + key: Mapped[str] = mapped_column( + String(100), + nullable=False, + ) + value: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + ) + value_type: Mapped[str] = mapped_column( + String(20), + default="string", + nullable=False, + ) + + # Relationships + node: Mapped["Node"] = relationship( + "Node", + back_populates="tags", + ) + + __table_args__ = ( + UniqueConstraint("node_id", "key", name="uq_node_tags_node_key"), + Index("ix_node_tags_key", "key"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/models/telemetry.py b/src/meshcore_hub/common/models/telemetry.py new file mode 100644 index 0000000..7bf1341 --- /dev/null +++ b/src/meshcore_hub/common/models/telemetry.py @@ -0,0 +1,63 @@ +"""Telemetry model for storing sensor data.""" + +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import DateTime, ForeignKey, Index, LargeBinary, String +from sqlalchemy.dialects.sqlite import JSON +from sqlalchemy.orm import Mapped, mapped_column + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now + + +class Telemetry(Base, UUIDMixin, TimestampMixin): + """Telemetry model for storing sensor data from network nodes. + + Attributes: + id: UUID primary key + receiver_node_id: FK to nodes (receiving interface) + node_id: FK to nodes (reporting node) + node_public_key: Reporting node's public key + lpp_data: Raw LPP-encoded sensor data + parsed_data: Decoded sensor readings as JSON + received_at: When received by interface + created_at: Record creation timestamp + """ + + __tablename__ = "telemetry" + + receiver_node_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("nodes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + node_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("nodes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + node_public_key: Mapped[str] = mapped_column( + String(64), + nullable=False, + index=True, + ) + lpp_data: Mapped[Optional[bytes]] = mapped_column( + LargeBinary, + nullable=True, + ) + parsed_data: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSON, + nullable=True, + ) + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + nullable=False, + ) + + __table_args__ = ( + Index("ix_telemetry_received_at", "received_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/models/trace_path.py b/src/meshcore_hub/common/models/trace_path.py new file mode 100644 index 0000000..11630dc --- /dev/null +++ b/src/meshcore_hub/common/models/trace_path.py @@ -0,0 +1,77 @@ +"""TracePath model for storing network trace data.""" + +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String +from sqlalchemy.dialects.sqlite import JSON +from sqlalchemy.orm import Mapped, mapped_column + +from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin, utc_now + + +class TracePath(Base, UUIDMixin, TimestampMixin): + """TracePath model for storing network trace path results. + + Attributes: + id: UUID primary key + receiver_node_id: FK to nodes (receiving interface) + initiator_tag: Unique trace identifier + path_len: Path length + flags: Trace flags + auth: Authentication data + path_hashes: JSON array of node hash identifiers + snr_values: JSON array of SNR values per hop + hop_count: Total number of hops + received_at: When received by interface + created_at: Record creation timestamp + """ + + __tablename__ = "trace_paths" + + receiver_node_id: Mapped[Optional[str]] = mapped_column( + ForeignKey("nodes.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + initiator_tag: Mapped[int] = mapped_column( + BigInteger, + nullable=False, + ) + path_len: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + flags: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + auth: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + path_hashes: Mapped[Optional[list[str]]] = mapped_column( + JSON, + nullable=True, + ) + snr_values: Mapped[Optional[list[float]]] = mapped_column( + JSON, + nullable=True, + ) + hop_count: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=utc_now, + nullable=False, + ) + + __table_args__ = ( + Index("ix_trace_paths_initiator_tag", "initiator_tag"), + Index("ix_trace_paths_received_at", "received_at"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/src/meshcore_hub/common/mqtt.py b/src/meshcore_hub/common/mqtt.py new file mode 100644 index 0000000..9ae7752 --- /dev/null +++ b/src/meshcore_hub/common/mqtt.py @@ -0,0 +1,365 @@ +"""MQTT client utilities for MeshCore Hub.""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Callable, Optional + +import paho.mqtt.client as mqtt +from paho.mqtt.enums import CallbackAPIVersion + +logger = logging.getLogger(__name__) + + +@dataclass +class MQTTConfig: + """MQTT connection configuration.""" + + host: str = "localhost" + port: int = 1883 + username: Optional[str] = None + password: Optional[str] = None + prefix: str = "meshcore" + client_id: Optional[str] = None + keepalive: int = 60 + clean_session: bool = True + + +class TopicBuilder: + """Helper class for building MQTT topics.""" + + def __init__(self, prefix: str = "meshcore"): + """Initialize topic builder. + + Args: + prefix: MQTT topic prefix + """ + self.prefix = prefix + + def event_topic(self, public_key: str, event_name: str) -> str: + """Build an event topic. + + Args: + public_key: Node's public key + event_name: Event name + + Returns: + Full MQTT topic string + """ + return f"{self.prefix}/{public_key}/event/{event_name}" + + def command_topic(self, public_key: str, command_name: str) -> str: + """Build a command topic. + + Args: + public_key: Node's public key (or '+' for wildcard) + command_name: Command name + + Returns: + Full MQTT topic string + """ + return f"{self.prefix}/{public_key}/command/{command_name}" + + def all_events_topic(self) -> str: + """Build a topic pattern to subscribe to all events. + + Returns: + MQTT topic pattern with wildcards + """ + return f"{self.prefix}/+/event/#" + + def all_commands_topic(self) -> str: + """Build a topic pattern to subscribe to all commands. + + Returns: + MQTT topic pattern with wildcards + """ + return f"{self.prefix}/+/command/#" + + def parse_event_topic(self, topic: str) -> tuple[str, str] | None: + """Parse an event topic to extract public key and event name. + + Args: + topic: Full MQTT topic string + + Returns: + Tuple of (public_key, event_name) or None if invalid + """ + parts = topic.split("/") + if len(parts) >= 4 and parts[0] == self.prefix and parts[2] == "event": + public_key = parts[1] + event_name = "/".join(parts[3:]) + return (public_key, event_name) + return None + + def parse_command_topic(self, topic: str) -> tuple[str, str] | None: + """Parse a command topic to extract public key and command name. + + Args: + topic: Full MQTT topic string + + Returns: + Tuple of (public_key, command_name) or None if invalid + """ + parts = topic.split("/") + if len(parts) >= 4 and parts[0] == self.prefix and parts[2] == "command": + public_key = parts[1] + command_name = "/".join(parts[3:]) + return (public_key, command_name) + return None + + +MessageHandler = Callable[[str, str, dict[str, Any]], None] + + +class MQTTClient: + """Wrapper for paho-mqtt client with helper methods.""" + + def __init__(self, config: MQTTConfig): + """Initialize MQTT client. + + Args: + config: MQTT configuration + """ + self.config = config + self.topic_builder = TopicBuilder(config.prefix) + self._client = mqtt.Client( + callback_api_version=CallbackAPIVersion.VERSION2, + client_id=config.client_id, + clean_session=config.clean_session, + ) + self._connected = False + self._message_handlers: dict[str, list[MessageHandler]] = {} + + # Set up authentication if provided + if config.username: + self._client.username_pw_set(config.username, config.password) + + # Set up callbacks + self._client.on_connect = self._on_connect + self._client.on_disconnect = self._on_disconnect + self._client.on_message = self._on_message + + def _on_connect( + self, + client: mqtt.Client, + userdata: Any, + flags: Any, + reason_code: Any, + properties: Any = None, + ) -> None: + """Handle connection callback.""" + if reason_code == 0: + self._connected = True + logger.info(f"Connected to MQTT broker at {self.config.host}:{self.config.port}") + # Resubscribe to topics on reconnect + for topic in self._message_handlers.keys(): + self._client.subscribe(topic) + logger.debug(f"Resubscribed to topic: {topic}") + else: + logger.error(f"Failed to connect to MQTT broker: {reason_code}") + + def _on_disconnect( + self, + client: mqtt.Client, + userdata: Any, + disconnect_flags: Any, + reason_code: Any, + properties: Any = None, + ) -> None: + """Handle disconnection callback.""" + self._connected = False + logger.warning(f"Disconnected from MQTT broker: {reason_code}") + + def _on_message( + self, + client: mqtt.Client, + userdata: Any, + message: mqtt.MQTTMessage, + ) -> None: + """Handle incoming message callback.""" + topic = message.topic + try: + payload = json.loads(message.payload.decode("utf-8")) + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.error(f"Failed to decode message payload: {e}") + return + + logger.debug(f"Received message on topic {topic}: {payload}") + + # Call registered handlers + for pattern, handlers in self._message_handlers.items(): + if self._topic_matches(pattern, topic): + for handler in handlers: + try: + handler(topic, pattern, payload) + except Exception as e: + logger.error(f"Error in message handler: {e}") + + def _topic_matches(self, pattern: str, topic: str) -> bool: + """Check if a topic matches a subscription pattern. + + Args: + pattern: MQTT subscription pattern (may contain + and #) + topic: Actual topic string + + Returns: + True if topic matches pattern + """ + pattern_parts = pattern.split("/") + topic_parts = topic.split("/") + + for i, (p, t) in enumerate(zip(pattern_parts, topic_parts)): + if p == "#": + return True + if p != "+" and p != t: + return False + + return len(pattern_parts) == len(topic_parts) or ( + len(pattern_parts) > 0 and pattern_parts[-1] == "#" + ) + + def connect(self) -> None: + """Connect to the MQTT broker.""" + logger.info(f"Connecting to MQTT broker at {self.config.host}:{self.config.port}") + self._client.connect( + self.config.host, + self.config.port, + self.config.keepalive, + ) + + def disconnect(self) -> None: + """Disconnect from the MQTT broker.""" + self._client.disconnect() + + def start(self) -> None: + """Start the MQTT client loop (blocking).""" + self._client.loop_forever() + + def start_background(self) -> None: + """Start the MQTT client loop in background thread.""" + self._client.loop_start() + + def stop(self) -> None: + """Stop the MQTT client loop.""" + self._client.loop_stop() + + def subscribe( + self, + topic: str, + handler: MessageHandler, + qos: int = 1, + ) -> None: + """Subscribe to a topic with a handler. + + Args: + topic: MQTT topic pattern + handler: Message handler function + qos: Quality of service level + """ + if topic not in self._message_handlers: + self._message_handlers[topic] = [] + if self._connected: + self._client.subscribe(topic, qos) + logger.debug(f"Subscribed to topic: {topic}") + + self._message_handlers[topic].append(handler) + + def unsubscribe(self, topic: str) -> None: + """Unsubscribe from a topic. + + Args: + topic: MQTT topic pattern + """ + if topic in self._message_handlers: + del self._message_handlers[topic] + self._client.unsubscribe(topic) + logger.debug(f"Unsubscribed from topic: {topic}") + + def publish( + self, + topic: str, + payload: dict[str, Any], + qos: int = 1, + retain: bool = False, + ) -> None: + """Publish a message to a topic. + + Args: + topic: MQTT topic + payload: Message payload (will be JSON encoded) + qos: Quality of service level + retain: Whether to retain the message + """ + message = json.dumps(payload) + self._client.publish(topic, message, qos=qos, retain=retain) + logger.debug(f"Published message to topic {topic}: {payload}") + + def publish_event( + self, + public_key: str, + event_name: str, + payload: dict[str, Any], + ) -> None: + """Publish an event message. + + Args: + public_key: Node's public key + event_name: Event name + payload: Event payload + """ + topic = self.topic_builder.event_topic(public_key, event_name) + self.publish(topic, payload) + + def publish_command( + self, + public_key: str, + command_name: str, + payload: dict[str, Any], + ) -> None: + """Publish a command message. + + Args: + public_key: Target node's public key (or '+' for all) + command_name: Command name + payload: Command payload + """ + topic = self.topic_builder.command_topic(public_key, command_name) + self.publish(topic, payload) + + @property + def is_connected(self) -> bool: + """Check if client is connected to broker.""" + return self._connected + + +def create_mqtt_client( + host: str = "localhost", + port: int = 1883, + username: Optional[str] = None, + password: Optional[str] = None, + prefix: str = "meshcore", + client_id: Optional[str] = None, +) -> MQTTClient: + """Create and configure an MQTT client. + + Args: + host: MQTT broker host + port: MQTT broker port + username: MQTT username (optional) + password: MQTT password (optional) + prefix: Topic prefix + client_id: Client identifier (optional) + + Returns: + Configured MQTTClient instance + """ + config = MQTTConfig( + host=host, + port=port, + username=username, + password=password, + prefix=prefix, + client_id=client_id, + ) + return MQTTClient(config) diff --git a/src/meshcore_hub/common/schemas/__init__.py b/src/meshcore_hub/common/schemas/__init__.py new file mode 100644 index 0000000..ce7bf89 --- /dev/null +++ b/src/meshcore_hub/common/schemas/__init__.py @@ -0,0 +1,59 @@ +"""Pydantic schemas for API request/response validation.""" + +from meshcore_hub.common.schemas.events import ( + AdvertisementEvent, + ContactMessageEvent, + ChannelMessageEvent, + TraceDataEvent, + TelemetryResponseEvent, + ContactsEvent, + SendConfirmedEvent, + StatusResponseEvent, + BatteryEvent, + PathUpdatedEvent, +) +from meshcore_hub.common.schemas.nodes import ( + NodeRead, + NodeList, + NodeTagCreate, + NodeTagUpdate, + NodeTagRead, +) +from meshcore_hub.common.schemas.messages import ( + MessageRead, + MessageList, + MessageFilters, +) +from meshcore_hub.common.schemas.commands import ( + SendMessageCommand, + SendChannelMessageCommand, + SendAdvertCommand, +) + +__all__ = [ + # Events + "AdvertisementEvent", + "ContactMessageEvent", + "ChannelMessageEvent", + "TraceDataEvent", + "TelemetryResponseEvent", + "ContactsEvent", + "SendConfirmedEvent", + "StatusResponseEvent", + "BatteryEvent", + "PathUpdatedEvent", + # Nodes + "NodeRead", + "NodeList", + "NodeTagCreate", + "NodeTagUpdate", + "NodeTagRead", + # Messages + "MessageRead", + "MessageList", + "MessageFilters", + # Commands + "SendMessageCommand", + "SendChannelMessageCommand", + "SendAdvertCommand", +] diff --git a/src/meshcore_hub/common/schemas/commands.py b/src/meshcore_hub/common/schemas/commands.py new file mode 100644 index 0000000..ca61694 --- /dev/null +++ b/src/meshcore_hub/common/schemas/commands.py @@ -0,0 +1,89 @@ +"""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/events.py b/src/meshcore_hub/common/schemas/events.py new file mode 100644 index 0000000..a72fcd1 --- /dev/null +++ b/src/meshcore_hub/common/schemas/events.py @@ -0,0 +1,261 @@ +"""Pydantic schemas for MeshCore events.""" + +from datetime import datetime +from typing import Any, Optional + +from pydantic import BaseModel, Field + + +class AdvertisementEvent(BaseModel): + """Schema for ADVERTISEMENT / NEW_ADVERT events.""" + + public_key: str = Field( + ..., + min_length=64, + max_length=64, + description="Node's 64-character hex public key", + ) + name: Optional[str] = Field( + default=None, + max_length=255, + description="Node name/alias", + ) + adv_type: Optional[str] = Field( + default=None, + description="Node type: chat, repeater, room, none", + ) + flags: Optional[int] = Field( + default=None, + description="Capability/status flags bitmask", + ) + + +class ContactMessageEvent(BaseModel): + """Schema for CONTACT_MSG_RECV events.""" + + pubkey_prefix: str = Field( + ..., + min_length=12, + max_length=12, + description="First 12 characters of sender's public key", + ) + text: str = Field(..., description="Message content") + path_len: Optional[int] = Field( + default=None, + description="Number of hops message traveled", + ) + txt_type: Optional[int] = Field( + default=None, + description="Message type indicator (0=plain, 2=signed, etc.)", + ) + signature: Optional[str] = Field( + default=None, + max_length=8, + description="Message signature (8 hex chars)", + ) + SNR: Optional[float] = Field( + default=None, + alias="snr", + description="Signal-to-Noise Ratio in dB", + ) + sender_timestamp: Optional[int] = Field( + default=None, + description="Unix timestamp when message was sent", + ) + + class Config: + populate_by_name = True + + +class ChannelMessageEvent(BaseModel): + """Schema for CHANNEL_MSG_RECV events.""" + + channel_idx: int = Field( + ..., + ge=0, + le=255, + description="Channel number (0-255)", + ) + text: str = Field(..., description="Message content") + path_len: Optional[int] = Field( + default=None, + description="Number of hops message traveled", + ) + txt_type: Optional[int] = Field( + default=None, + description="Message type indicator", + ) + signature: Optional[str] = Field( + default=None, + max_length=8, + description="Message signature (8 hex chars)", + ) + SNR: Optional[float] = Field( + default=None, + alias="snr", + description="Signal-to-Noise Ratio in dB", + ) + sender_timestamp: Optional[int] = Field( + default=None, + description="Unix timestamp when message was sent", + ) + + class Config: + populate_by_name = True + + +class TraceDataEvent(BaseModel): + """Schema for TRACE_DATA events.""" + + initiator_tag: int = Field( + ..., + description="Unique trace identifier", + ) + path_len: Optional[int] = Field( + default=None, + description="Length of the path", + ) + flags: Optional[int] = Field( + default=None, + description="Trace flags/options", + ) + auth: Optional[int] = Field( + default=None, + description="Authentication/validation data", + ) + path_hashes: Optional[list[str]] = Field( + default=None, + description="Array of 2-character node hash identifiers", + ) + snr_values: Optional[list[float]] = Field( + default=None, + description="Array of SNR values per hop", + ) + hop_count: Optional[int] = Field( + default=None, + description="Total number of hops", + ) + + +class TelemetryResponseEvent(BaseModel): + """Schema for TELEMETRY_RESPONSE events.""" + + node_public_key: str = Field( + ..., + min_length=64, + max_length=64, + description="Full public key of reporting node", + ) + lpp_data: Optional[bytes] = Field( + default=None, + description="Raw LPP-encoded sensor data", + ) + parsed_data: Optional[dict[str, Any]] = Field( + default=None, + description="Decoded sensor readings", + ) + + +class ContactInfo(BaseModel): + """Schema for a single contact in CONTACTS event.""" + + public_key: str = Field( + ..., + min_length=64, + max_length=64, + description="Node's full public key", + ) + name: Optional[str] = Field( + default=None, + max_length=255, + description="Node name/alias", + ) + node_type: Optional[str] = Field( + default=None, + description="Node type: chat, repeater, room, none", + ) + + +class ContactsEvent(BaseModel): + """Schema for CONTACTS sync events.""" + + contacts: list[ContactInfo] = Field( + ..., + description="Array of contact objects", + ) + + +class SendConfirmedEvent(BaseModel): + """Schema for SEND_CONFIRMED events.""" + + destination_public_key: str = Field( + ..., + min_length=64, + max_length=64, + description="Recipient's full public key", + ) + round_trip_ms: int = Field( + ..., + description="Round-trip time in milliseconds", + ) + + +class StatusResponseEvent(BaseModel): + """Schema for STATUS_RESPONSE events.""" + + node_public_key: str = Field( + ..., + min_length=64, + max_length=64, + description="Node's full public key", + ) + status: Optional[str] = Field( + default=None, + description="Status description", + ) + uptime: Optional[int] = Field( + default=None, + description="Uptime in seconds", + ) + message_count: Optional[int] = Field( + default=None, + description="Total messages processed", + ) + + +class BatteryEvent(BaseModel): + """Schema for BATTERY events.""" + + battery_voltage: float = Field( + ..., + description="Battery voltage (e.g., 3.7V)", + ) + battery_percentage: int = Field( + ..., + ge=0, + le=100, + description="Battery level 0-100%", + ) + + +class PathUpdatedEvent(BaseModel): + """Schema for PATH_UPDATED events.""" + + node_public_key: str = Field( + ..., + min_length=64, + max_length=64, + description="Target node's full public key", + ) + hop_count: int = Field( + ..., + description="Number of hops in new path", + ) + + +class WebhookPayload(BaseModel): + """Schema for webhook payload envelope.""" + + event_type: str = Field(..., description="Event type name") + timestamp: datetime = Field(..., description="Event timestamp (ISO 8601)") + data: dict[str, Any] = Field(..., description="Event-specific payload") diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py new file mode 100644 index 0000000..2df842e --- /dev/null +++ b/src/meshcore_hub/common/schemas/messages.py @@ -0,0 +1,189 @@ +"""Pydantic schemas for message API endpoints.""" + +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class MessageRead(BaseModel): + """Schema for reading a message.""" + + id: str = Field(..., description="Message UUID") + receiver_node_id: Optional[str] = Field( + default=None, description="Receiving interface node UUID" + ) + message_type: str = Field(..., description="Message type (contact, channel)") + pubkey_prefix: Optional[str] = Field( + default=None, description="Sender's public key prefix (12 chars)" + ) + channel_idx: Optional[int] = Field( + default=None, description="Channel index" + ) + text: str = Field(..., description="Message content") + path_len: Optional[int] = Field(default=None, description="Number of hops") + txt_type: Optional[int] = Field( + default=None, description="Message type indicator" + ) + signature: Optional[str] = Field( + default=None, description="Message signature" + ) + snr: Optional[float] = Field( + default=None, description="Signal-to-noise ratio" + ) + sender_timestamp: Optional[datetime] = Field( + default=None, description="Sender's timestamp" + ) + received_at: datetime = Field(..., description="When received by interface") + created_at: datetime = Field(..., description="Record creation timestamp") + + class Config: + from_attributes = True + + +class MessageList(BaseModel): + """Schema for paginated message list response.""" + + items: list[MessageRead] = Field(..., description="List of messages") + total: int = Field(..., description="Total number of messages") + limit: int = Field(..., description="Page size limit") + offset: int = Field(..., description="Page offset") + + +class MessageFilters(BaseModel): + """Schema for message query filters.""" + + type: Optional[Literal["contact", "channel"]] = Field( + default=None, + description="Filter by message type", + ) + pubkey_prefix: Optional[str] = Field( + default=None, + description="Filter by sender public key prefix", + ) + channel_idx: Optional[int] = Field( + default=None, + description="Filter by channel index", + ) + since: Optional[datetime] = Field( + default=None, + description="Start timestamp filter", + ) + until: Optional[datetime] = Field( + default=None, + description="End timestamp filter", + ) + search: Optional[str] = Field( + default=None, + description="Search in message text", + ) + limit: int = Field(default=50, ge=1, le=100, description="Page size limit") + offset: int = Field(default=0, ge=0, description="Page offset") + + +class AdvertisementRead(BaseModel): + """Schema for reading an advertisement.""" + + id: str = Field(..., description="Advertisement UUID") + receiver_node_id: Optional[str] = Field( + default=None, description="Receiving interface node UUID" + ) + node_id: Optional[str] = Field( + default=None, description="Advertised node UUID" + ) + public_key: str = Field(..., description="Advertised public key") + name: Optional[str] = Field(default=None, description="Advertised name") + adv_type: Optional[str] = Field(default=None, description="Node type") + flags: Optional[int] = Field(default=None, description="Capability flags") + received_at: datetime = Field(..., description="When received") + created_at: datetime = Field(..., description="Record creation timestamp") + + class Config: + from_attributes = True + + +class AdvertisementList(BaseModel): + """Schema for paginated advertisement list response.""" + + items: list[AdvertisementRead] = Field(..., description="List of advertisements") + total: int = Field(..., description="Total number of advertisements") + limit: int = Field(..., description="Page size limit") + offset: int = Field(..., description="Page offset") + + +class TracePathRead(BaseModel): + """Schema for reading a trace path.""" + + id: str = Field(..., description="Trace path UUID") + receiver_node_id: Optional[str] = Field( + default=None, description="Receiving interface node UUID" + ) + initiator_tag: int = Field(..., description="Trace identifier") + path_len: Optional[int] = Field(default=None, description="Path length") + flags: Optional[int] = Field(default=None, description="Trace flags") + auth: Optional[int] = Field(default=None, description="Auth data") + path_hashes: Optional[list[str]] = Field( + default=None, description="Node hash identifiers" + ) + snr_values: Optional[list[float]] = Field( + default=None, description="SNR values per hop" + ) + 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") + + class Config: + from_attributes = True + + +class TracePathList(BaseModel): + """Schema for paginated trace path list response.""" + + items: list[TracePathRead] = Field(..., description="List of trace paths") + total: int = Field(..., description="Total number of trace paths") + limit: int = Field(..., description="Page size limit") + offset: int = Field(..., description="Page offset") + + +class TelemetryRead(BaseModel): + """Schema for reading a telemetry record.""" + + id: str = Field(..., description="Telemetry UUID") + receiver_node_id: Optional[str] = Field( + default=None, description="Receiving interface node UUID" + ) + node_id: Optional[str] = Field( + default=None, description="Reporting node UUID" + ) + node_public_key: str = Field(..., description="Reporting node public key") + parsed_data: Optional[dict] = Field( + default=None, description="Decoded sensor readings" + ) + received_at: datetime = Field(..., description="When received") + created_at: datetime = Field(..., description="Record creation timestamp") + + class Config: + from_attributes = True + + +class TelemetryList(BaseModel): + """Schema for paginated telemetry list response.""" + + items: list[TelemetryRead] = Field(..., description="List of telemetry records") + total: int = Field(..., description="Total number of records") + limit: int = Field(..., description="Page size limit") + offset: int = Field(..., description="Page offset") + + +class DashboardStats(BaseModel): + """Schema for dashboard statistics.""" + + total_nodes: int = Field(..., description="Total number of nodes") + active_nodes: int = Field(..., description="Nodes active in last 24h") + total_messages: int = Field(..., description="Total number of messages") + messages_today: int = Field(..., description="Messages received today") + total_advertisements: int = Field(..., description="Total advertisements") + channel_message_counts: dict[int, int] = Field( + default_factory=dict, + description="Message count per channel", + ) diff --git a/src/meshcore_hub/common/schemas/nodes.py b/src/meshcore_hub/common/schemas/nodes.py new file mode 100644 index 0000000..f696a13 --- /dev/null +++ b/src/meshcore_hub/common/schemas/nodes.py @@ -0,0 +1,101 @@ +"""Pydantic schemas for node API endpoints.""" + +from datetime import datetime +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +class NodeTagCreate(BaseModel): + """Schema for creating a node tag.""" + + key: str = Field( + ..., + min_length=1, + max_length=100, + description="Tag name/key", + ) + value: Optional[str] = Field( + default=None, + description="Tag value", + ) + value_type: Literal["string", "number", "boolean", "coordinate"] = Field( + default="string", + description="Value type hint", + ) + + +class NodeTagUpdate(BaseModel): + """Schema for updating a node tag.""" + + value: Optional[str] = Field( + default=None, + description="Tag value", + ) + value_type: Optional[Literal["string", "number", "boolean", "coordinate"]] = Field( + default=None, + description="Value type hint", + ) + + +class NodeTagRead(BaseModel): + """Schema for reading a node tag.""" + + id: str = Field(..., description="Tag UUID") + node_id: str = Field(..., description="Parent node UUID") + key: str = Field(..., description="Tag name/key") + value: Optional[str] = Field(default=None, description="Tag value") + value_type: str = Field(..., description="Value type hint") + created_at: datetime = Field(..., description="Creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + class Config: + from_attributes = True + + +class NodeRead(BaseModel): + """Schema for reading a node.""" + + id: str = Field(..., description="Node UUID") + public_key: str = Field(..., description="Node's 64-character hex public key") + name: Optional[str] = Field(default=None, description="Node display name") + adv_type: Optional[str] = Field(default=None, description="Advertisement type") + flags: Optional[int] = Field(default=None, description="Capability flags") + first_seen: datetime = Field(..., description="First advertisement timestamp") + last_seen: datetime = Field(..., description="Last activity timestamp") + created_at: datetime = Field(..., description="Record creation timestamp") + updated_at: datetime = Field(..., description="Record update timestamp") + tags: list[NodeTagRead] = Field( + default_factory=list, description="Node tags" + ) + + class Config: + from_attributes = True + + +class NodeList(BaseModel): + """Schema for paginated node list response.""" + + items: list[NodeRead] = Field(..., description="List of nodes") + total: int = Field(..., description="Total number of nodes") + limit: int = Field(..., description="Page size limit") + offset: int = Field(..., description="Page offset") + + +class NodeFilters(BaseModel): + """Schema for node query filters.""" + + search: Optional[str] = Field( + default=None, + description="Search in name or public key", + ) + adv_type: Optional[str] = Field( + default=None, + description="Filter by advertisement type", + ) + has_tag: Optional[str] = Field( + default=None, + description="Filter by tag key", + ) + limit: int = Field(default=50, ge=1, le=100, description="Page size limit") + offset: int = Field(default=0, ge=0, description="Page offset") diff --git a/src/meshcore_hub/interface/__init__.py b/src/meshcore_hub/interface/__init__.py new file mode 100644 index 0000000..9b0b3d2 --- /dev/null +++ b/src/meshcore_hub/interface/__init__.py @@ -0,0 +1 @@ +"""Interface component for MeshCore device communication.""" diff --git a/src/meshcore_hub/interface/cli.py b/src/meshcore_hub/interface/cli.py new file mode 100644 index 0000000..8563b1b --- /dev/null +++ b/src/meshcore_hub/interface/cli.py @@ -0,0 +1,367 @@ +"""CLI for the Interface component.""" + +import click + +from meshcore_hub.common.config import InterfaceMode +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( + "--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( + "--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, + mqtt_host: str, + mqtt_port: int, + mqtt_username: str | None, + mqtt_password: str | None, + prefix: str, + 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, + mqtt_host=mqtt_host, + mqtt_port=mqtt_port, + mqtt_username=mqtt_username, + mqtt_password=mqtt_password, + mqtt_prefix=prefix, + ) + elif mode_upper == "SENDER": + from meshcore_hub.interface.sender import run_sender + + 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, + ) + 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( + "--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", +) +def receiver( + port: str, + baud: int, + mock: bool, + node_address: str | None, + mqtt_host: str, + mqtt_port: int, + mqtt_username: str | None, + mqtt_password: str | None, + prefix: str, +) -> 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, + mqtt_host=mqtt_host, + mqtt_port=mqtt_port, + mqtt_username=mqtt_username, + mqtt_password=mqtt_password, + mqtt_prefix=prefix, + ) + + +@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( + "--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", +) +def sender( + port: str, + baud: int, + mock: bool, + node_address: str | None, + mqtt_host: str, + mqtt_port: int, + mqtt_username: str | None, + mqtt_password: str | None, + prefix: str, +) -> 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, + ) diff --git a/src/meshcore_hub/interface/device.py b/src/meshcore_hub/interface/device.py new file mode 100644 index 0000000..4efc58d --- /dev/null +++ b/src/meshcore_hub/interface/device.py @@ -0,0 +1,565 @@ +"""MeshCore device wrapper for serial communication.""" + +import asyncio +import logging +import time +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 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 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 = None + self._loop = None + self._subscriptions = [] + + 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(f"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}") + 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, et=our_event_type): + # 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(): + 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(): + 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(): + 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(): + 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(): + 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(): + 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 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(): + 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 run(self) -> None: + """Run the device event loop.""" + self._running = True + logger.info("Starting device event loop") + + # Set up event subscriptions + self._setup_event_subscriptions() + + # Run the async event loop + async def _run_loop(): + 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 new file mode 100644 index 0000000..cb1c46d --- /dev/null +++ b/src/meshcore_hub/interface/mock_device.py @@ -0,0 +1,424 @@ +"""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 + + ts = timestamp or int(time.time()) + 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 + + ts = timestamp or int(time.time()) + 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 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 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 new file mode 100644 index 0000000..78d2281 --- /dev/null +++ b/src/meshcore_hub/interface/receiver.py @@ -0,0 +1,257 @@ +"""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.mqtt import MQTTClient, MQTTConfig +from meshcore_hub.interface.device import ( + BaseMeshCoreDevice, + DeviceConfig, + EventType, + create_device, +) + +logger = logging.getLogger(__name__) + + +class Receiver: + """RECEIVER mode implementation. + + Bridges MeshCore device events to MQTT broker. + """ + + def __init__( + self, + device: BaseMeshCoreDevice, + mqtt_client: MQTTClient, + ): + """Initialize receiver. + + 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() + + def _initialize_device(self) -> None: + """Initialize device after connection. + + Sets the hardware clock, sends a local advertisement, and starts message fetching. + """ + # 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") + + # Send a local (non-flood) advertisement to announce presence + if self.device.send_advertisement(flood=False): + logger.info("Sent local advertisement") + else: + logger.warning("Failed to send local 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") + + 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 + + # Publish to MQTT + self.mqtt.publish_event( + self.device.public_key, + event_name, + payload, + ) + + logger.debug(f"Published {event_name} event to MQTT") + + except Exception as e: + logger.error(f"Failed to publish event to MQTT: {e}") + + 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() + logger.info("Connected to MQTT broker") + except Exception as e: + logger.error(f"Failed to connect to MQTT broker: {e}") + raise + + # Connect to device + if not self.device.connect(): + logger.error("Failed to connect to MeshCore device") + self.mqtt.stop() + self.mqtt.disconnect() + raise RuntimeError("Failed to connect to MeshCore device") + + logger.info(f"Connected to MeshCore device: {self.device.public_key}") + + # Initialize device: set time and send local advertisement + self._initialize_device() + + self._running = True + + 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 device + self.device.stop() + self.device.disconnect() + + # Stop MQTT + self.mqtt.stop() + self.mqtt.disconnect() + + logger.info("Receiver stopped") + + +def create_receiver( + port: str = "/dev/ttyUSB0", + baud: int = 115200, + mock: bool = False, + node_address: 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", +) -> 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 + mqtt_host: MQTT broker host + mqtt_port: MQTT broker port + mqtt_username: MQTT username + mqtt_password: MQTT password + mqtt_prefix: MQTT topic prefix + + Returns: + Configured Receiver instance + """ + # Create device + device = create_device(port=port, baud=baud, mock=mock, node_address=node_address) + + # Create MQTT client + 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[:8] if device.public_key else 'unknown'}", + ) + mqtt_client = MQTTClient(mqtt_config) + + return Receiver(device, mqtt_client) + + +def run_receiver( + port: str = "/dev/ttyUSB0", + baud: int = 115200, + mock: bool = False, + node_address: 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", +) -> 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 + mqtt_host: MQTT broker host + mqtt_port: MQTT broker port + mqtt_username: MQTT username + mqtt_password: MQTT password + mqtt_prefix: MQTT topic prefix + """ + receiver = create_receiver( + 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=mqtt_prefix, + ) + + # 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 new file mode 100644 index 0000000..5c08a1e --- /dev/null +++ b/src/meshcore_hub/interface/sender.py @@ -0,0 +1,329 @@ +"""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 json +import logging +import signal +import threading +import time +from typing import Any, Optional + +from meshcore_hub.common.mqtt import MQTTClient, MQTTConfig +from meshcore_hub.interface.device import ( + BaseMeshCoreDevice, + DeviceConfig, + 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() + + 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") + + # Connect to device first + if not self.device.connect(): + 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}") + + # Connect to MQTT broker + try: + self.mqtt.connect() + self.mqtt.start_background() + logger.info("Connected to MQTT broker") + except Exception as e: + logger.error(f"Failed to connect to MQTT broker: {e}") + self.device.disconnect() + 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 + + 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 MQTT + self.mqtt.stop() + self.mqtt.disconnect() + + # Stop device + self.device.stop() + self.device.disconnect() + + logger.info("Sender stopped") + + +def create_sender( + port: str = "/dev/ttyUSB0", + baud: int = 115200, + mock: bool = False, + node_address: 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", +) -> 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 + mqtt_host: MQTT broker host + mqtt_port: MQTT broker port + mqtt_username: MQTT username + mqtt_password: MQTT password + mqtt_prefix: MQTT topic prefix + + Returns: + Configured Sender instance + """ + # Create device + device = create_device(port=port, baud=baud, mock=mock, node_address=node_address) + + # Create MQTT client + 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[:8] if device.public_key else 'unknown'}", + ) + 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, + mqtt_host: str = "localhost", + mqtt_port: int = 1883, + mqtt_username: Optional[str] = None, + mqtt_password: Optional[str] = None, + mqtt_prefix: str = "meshcore", +) -> 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 + mqtt_host: MQTT broker host + mqtt_port: MQTT broker port + mqtt_username: MQTT username + mqtt_password: MQTT password + mqtt_prefix: MQTT topic prefix + """ + sender = create_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=mqtt_prefix, + ) + + # 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/py.typed b/src/meshcore_hub/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/meshcore_hub/web/__init__.py b/src/meshcore_hub/web/__init__.py new file mode 100644 index 0000000..f28e599 --- /dev/null +++ b/src/meshcore_hub/web/__init__.py @@ -0,0 +1 @@ +"""Web dashboard component for visualizing MeshCore network.""" diff --git a/src/meshcore_hub/web/app.py b/src/meshcore_hub/web/app.py new file mode 100644 index 0000000..db9a1b9 --- /dev/null +++ b/src/meshcore_hub/web/app.py @@ -0,0 +1,149 @@ +"""FastAPI application for MeshCore Hub Web Dashboard.""" + +import logging +from contextlib import asynccontextmanager +from pathlib import Path +from typing import AsyncGenerator + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from meshcore_hub import __version__ + +logger = logging.getLogger(__name__) + +# Directory paths +PACKAGE_DIR = Path(__file__).parent +TEMPLATES_DIR = PACKAGE_DIR / "templates" +STATIC_DIR = PACKAGE_DIR / "static" + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + """Application lifespan handler.""" + # Create HTTP client for API calls + api_url = getattr(app.state, "api_url", "http://localhost:8000") + api_key = getattr(app.state, "api_key", None) + + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + app.state.http_client = httpx.AsyncClient( + base_url=api_url, + headers=headers, + timeout=30.0, + ) + + logger.info(f"Web dashboard started, API URL: {api_url}") + + yield + + # Cleanup + await app.state.http_client.aclose() + logger.info("Web dashboard stopped") + + +def create_app( + api_url: str = "http://localhost:8000", + api_key: str | None = None, + network_name: str = "MeshCore Network", + network_city: str | None = None, + network_country: str | None = None, + network_location: tuple[float, float] | None = None, + network_radio_config: str | None = None, + network_contact_email: str | None = None, + network_contact_discord: str | None = None, + members_file: str | None = None, +) -> FastAPI: + """Create and configure the web dashboard application. + + Args: + api_url: Base URL of the MeshCore Hub API + api_key: API key for authentication + network_name: Display name for the network + network_city: City where the network is located + network_country: Country where the network is located + network_location: (lat, lon) tuple for map centering + network_radio_config: Radio configuration description + network_contact_email: Contact email address + network_contact_discord: Discord invite/server info + members_file: Path to members JSON file + + Returns: + Configured FastAPI application + """ + app = FastAPI( + title="MeshCore Hub Dashboard", + description="Web dashboard for MeshCore network visualization", + version=__version__, + lifespan=lifespan, + docs_url=None, # Disable docs for web app + redoc_url=None, + ) + + # Store configuration in app state + app.state.api_url = api_url + app.state.api_key = api_key + app.state.network_name = network_name + app.state.network_city = network_city + app.state.network_country = network_country + app.state.network_location = network_location or (0.0, 0.0) + app.state.network_radio_config = network_radio_config + app.state.network_contact_email = network_contact_email + app.state.network_contact_discord = network_contact_discord + app.state.members_file = members_file + + # Set up templates + templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + app.state.templates = templates + + # Mount static files + if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + # Include routers + from meshcore_hub.web.routes import web_router + + app.include_router(web_router) + + # Health check endpoint + @app.get("/health", tags=["Health"]) + async def health() -> dict: + """Basic health check.""" + return {"status": "healthy", "version": __version__} + + @app.get("/health/ready", tags=["Health"]) + async def health_ready(request: Request) -> dict: + """Readiness check including API connectivity.""" + try: + response = await request.app.state.http_client.get("/health") + if response.status_code == 200: + return {"status": "ready", "api": "connected"} + return {"status": "not_ready", "api": f"status {response.status_code}"} + except Exception as e: + return {"status": "not_ready", "api": str(e)} + + return app + + +def get_templates(request: Request) -> Jinja2Templates: + """Get templates from app state.""" + return request.app.state.templates + + +def get_network_context(request: Request) -> dict: + """Get network configuration context for templates.""" + return { + "network_name": request.app.state.network_name, + "network_city": request.app.state.network_city, + "network_country": request.app.state.network_country, + "network_location": request.app.state.network_location, + "network_radio_config": request.app.state.network_radio_config, + "network_contact_email": request.app.state.network_contact_email, + "network_contact_discord": request.app.state.network_contact_discord, + "version": __version__, + } diff --git a/src/meshcore_hub/web/cli.py b/src/meshcore_hub/web/cli.py new file mode 100644 index 0000000..eb8a196 --- /dev/null +++ b/src/meshcore_hub/web/cli.py @@ -0,0 +1,195 @@ +"""Web dashboard CLI commands.""" + +import click + + +@click.command() +@click.option( + "--host", + type=str, + default="0.0.0.0", + envvar="WEB_HOST", + help="Web server host", +) +@click.option( + "--port", + type=int, + default=8080, + envvar="WEB_PORT", + help="Web server port", +) +@click.option( + "--api-url", + type=str, + default="http://localhost:8000", + envvar="API_BASE_URL", + help="API server base URL", +) +@click.option( + "--api-key", + type=str, + default=None, + envvar="API_KEY", + help="API key for queries", +) +@click.option( + "--network-name", + type=str, + default="MeshCore Network", + envvar="NETWORK_NAME", + help="Network display name", +) +@click.option( + "--network-city", + type=str, + default=None, + envvar="NETWORK_CITY", + help="Network city location", +) +@click.option( + "--network-country", + type=str, + default=None, + envvar="NETWORK_COUNTRY", + help="Network country", +) +@click.option( + "--network-lat", + type=float, + default=0.0, + envvar="NETWORK_LAT", + help="Network center latitude", +) +@click.option( + "--network-lon", + type=float, + default=0.0, + envvar="NETWORK_LON", + help="Network center longitude", +) +@click.option( + "--network-radio-config", + type=str, + default=None, + envvar="NETWORK_RADIO_CONFIG", + help="Radio configuration description", +) +@click.option( + "--network-contact-email", + type=str, + default=None, + envvar="NETWORK_CONTACT_EMAIL", + help="Contact email address", +) +@click.option( + "--network-contact-discord", + type=str, + default=None, + envvar="NETWORK_CONTACT_DISCORD", + help="Discord server info", +) +@click.option( + "--members-file", + type=str, + default=None, + envvar="MEMBERS_FILE", + help="Path to members JSON file", +) +@click.option( + "--reload", + is_flag=True, + default=False, + help="Enable auto-reload for development", +) +@click.pass_context +def web( + ctx: click.Context, + host: str, + port: int, + api_url: str, + api_key: str | None, + network_name: str, + network_city: str | None, + network_country: str | None, + network_lat: float, + network_lon: float, + network_radio_config: str | None, + network_contact_email: str | None, + network_contact_discord: str | None, + members_file: str | None, + reload: bool, +) -> None: + """Run the web dashboard. + + Provides a web interface for visualizing network status, browsing nodes, + viewing messages, and displaying a node map. + + Examples: + + # Run with defaults + meshcore-hub web + + # Run with custom network name and location + meshcore-hub web --network-name "My Mesh" --network-city "New York" --network-country "USA" + + # Run with API authentication + meshcore-hub web --api-url http://api.example.com --api-key secret + + # Run with members file + meshcore-hub web --members-file /path/to/members.json + + # Development mode with auto-reload + meshcore-hub web --reload + """ + import uvicorn + + from meshcore_hub.web.app import create_app + + click.echo("=" * 50) + click.echo("MeshCore Hub Web Dashboard") + click.echo("=" * 50) + click.echo(f"Host: {host}") + click.echo(f"Port: {port}") + click.echo(f"API URL: {api_url}") + click.echo(f"API key configured: {api_key is not None}") + click.echo(f"Network: {network_name}") + if network_city and network_country: + click.echo(f"Location: {network_city}, {network_country}") + if network_lat != 0.0 or network_lon != 0.0: + click.echo(f"Map center: {network_lat}, {network_lon}") + if members_file: + click.echo(f"Members file: {members_file}") + click.echo(f"Reload mode: {reload}") + click.echo("=" * 50) + + network_location = (network_lat, network_lon) + + if reload: + # For development, use uvicorn's reload feature + click.echo("\nStarting in development mode with auto-reload...") + click.echo("Note: Using default settings for reload mode.") + + uvicorn.run( + "meshcore_hub.web.app:create_app", + host=host, + port=port, + reload=True, + factory=True, + ) + else: + # For production, create app directly + app = create_app( + api_url=api_url, + api_key=api_key, + network_name=network_name, + network_city=network_city, + network_country=network_country, + network_location=network_location, + network_radio_config=network_radio_config, + network_contact_email=network_contact_email, + network_contact_discord=network_contact_discord, + members_file=members_file, + ) + + click.echo("\nStarting web dashboard...") + uvicorn.run(app, host=host, port=port) diff --git a/src/meshcore_hub/web/routes/__init__.py b/src/meshcore_hub/web/routes/__init__.py new file mode 100644 index 0000000..4d35f07 --- /dev/null +++ b/src/meshcore_hub/web/routes/__init__.py @@ -0,0 +1,23 @@ +"""Web routes for MeshCore Hub Dashboard.""" + +from fastapi import APIRouter + +from meshcore_hub.web.routes.home import router as home_router +from meshcore_hub.web.routes.network import router as network_router +from meshcore_hub.web.routes.nodes import router as nodes_router +from meshcore_hub.web.routes.messages import router as messages_router +from meshcore_hub.web.routes.map import router as map_router +from meshcore_hub.web.routes.members import router as members_router + +# Create main web router +web_router = APIRouter() + +# Include all sub-routers +web_router.include_router(home_router) +web_router.include_router(network_router) +web_router.include_router(nodes_router) +web_router.include_router(messages_router) +web_router.include_router(map_router) +web_router.include_router(members_router) + +__all__ = ["web_router"] diff --git a/src/meshcore_hub/web/routes/home.py b/src/meshcore_hub/web/routes/home.py new file mode 100644 index 0000000..03a37cf --- /dev/null +++ b/src/meshcore_hub/web/routes/home.py @@ -0,0 +1,18 @@ +"""Home page route.""" + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +router = APIRouter() + + +@router.get("/", response_class=HTMLResponse) +async def home(request: Request) -> HTMLResponse: + """Render the home page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + return templates.TemplateResponse("home.html", context) diff --git a/src/meshcore_hub/web/routes/map.py b/src/meshcore_hub/web/routes/map.py new file mode 100644 index 0000000..f3af646 --- /dev/null +++ b/src/meshcore_hub/web/routes/map.py @@ -0,0 +1,77 @@ +"""Map page route.""" + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/map", response_class=HTMLResponse) +async def map_page(request: Request) -> HTMLResponse: + """Render the map page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + return templates.TemplateResponse("map.html", context) + + +@router.get("/map/data") +async def map_data(request: Request) -> JSONResponse: + """Return node location data as JSON for the map.""" + nodes_with_location = [] + + try: + # Fetch all nodes from API + response = await request.app.state.http_client.get( + "/api/v1/nodes", params={"limit": 500} + ) + if response.status_code == 200: + data = response.json() + nodes = data.get("items", []) + + # Filter nodes with location tags + for node in nodes: + tags = node.get("tags", []) + lat = None + lon = None + for tag in tags: + if tag.get("key") == "lat": + try: + lat = float(tag.get("value")) + except (ValueError, TypeError): + pass + elif tag.get("key") == "lon": + try: + lon = float(tag.get("value")) + except (ValueError, TypeError): + pass + + if lat is not None and lon is not None: + nodes_with_location.append({ + "public_key": node.get("public_key"), + "name": node.get("name") or node.get("public_key", "")[:12], + "adv_type": node.get("adv_type"), + "lat": lat, + "lon": lon, + "last_seen": node.get("last_seen"), + }) + + except Exception as e: + logger.warning(f"Failed to fetch nodes for map: {e}") + + # Get network center location + network_location = request.app.state.network_location + + return JSONResponse({ + "nodes": nodes_with_location, + "center": { + "lat": network_location[0], + "lon": network_location[1], + }, + }) diff --git a/src/meshcore_hub/web/routes/members.py b/src/meshcore_hub/web/routes/members.py new file mode 100644 index 0000000..f730a05 --- /dev/null +++ b/src/meshcore_hub/web/routes/members.py @@ -0,0 +1,59 @@ +"""Members page route.""" + +import json +import logging +from pathlib import Path + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +def load_members(members_file: str | None) -> list[dict]: + """Load members from JSON file. + + Args: + members_file: Path to members JSON file + + Returns: + List of member dictionaries + """ + if not members_file: + return [] + + try: + path = Path(members_file) + if path.exists(): + with open(path, "r") as f: + data = json.load(f) + # Handle both list and dict with "members" key + if isinstance(data, list): + return data + elif isinstance(data, dict) and "members" in data: + return data["members"] + else: + logger.warning(f"Members file not found: {members_file}") + except Exception as e: + logger.error(f"Failed to load members file: {e}") + + return [] + + +@router.get("/members", response_class=HTMLResponse) +async def members_page(request: Request) -> HTMLResponse: + """Render the members page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Load members from file + members_file = request.app.state.members_file + members = load_members(members_file) + + context["members"] = members + + return templates.TemplateResponse("members.html", context) diff --git a/src/meshcore_hub/web/routes/messages.py b/src/meshcore_hub/web/routes/messages.py new file mode 100644 index 0000000..8befb89 --- /dev/null +++ b/src/meshcore_hub/web/routes/messages.py @@ -0,0 +1,68 @@ +"""Messages page route.""" + +import logging + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/messages", response_class=HTMLResponse) +async def messages_list( + request: Request, + message_type: str | None = Query(None, description="Filter by message type"), + channel_idx: int | None = Query(None, description="Filter by channel"), + search: str | None = Query(None, description="Search in message text"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(50, ge=1, le=100, description="Items per page"), +) -> HTMLResponse: + """Render the messages list page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Calculate offset + offset = (page - 1) * limit + + # Build query params + params = {"limit": limit, "offset": offset} + if message_type: + params["message_type"] = message_type + if channel_idx is not None: + params["channel_idx"] = channel_idx + + # Fetch messages from API + messages = [] + total = 0 + + try: + response = await request.app.state.http_client.get( + "/api/v1/messages", params=params + ) + if response.status_code == 200: + data = response.json() + messages = data.get("items", []) + total = data.get("total", 0) + except Exception as e: + logger.warning(f"Failed to fetch messages from API: {e}") + context["api_error"] = str(e) + + # Calculate pagination + total_pages = (total + limit - 1) // limit if total > 0 else 1 + + context.update({ + "messages": messages, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + "message_type": message_type or "", + "channel_idx": channel_idx, + "search": search or "", + }) + + return templates.TemplateResponse("messages.html", context) diff --git a/src/meshcore_hub/web/routes/network.py b/src/meshcore_hub/web/routes/network.py new file mode 100644 index 0000000..74f1c18 --- /dev/null +++ b/src/meshcore_hub/web/routes/network.py @@ -0,0 +1,41 @@ +"""Network overview page route.""" + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/network", response_class=HTMLResponse) +async def network_overview(request: Request) -> HTMLResponse: + """Render the network overview page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Fetch stats from API + stats = { + "total_nodes": 0, + "active_nodes": 0, + "total_messages": 0, + "messages_today": 0, + "total_advertisements": 0, + "channel_message_counts": {}, + } + + try: + response = await request.app.state.http_client.get("/api/v1/dashboard/stats") + if response.status_code == 200: + stats = response.json() + except Exception as e: + logger.warning(f"Failed to fetch stats from API: {e}") + context["api_error"] = str(e) + + context["stats"] = stats + + return templates.TemplateResponse("network.html", context) diff --git a/src/meshcore_hub/web/routes/nodes.py b/src/meshcore_hub/web/routes/nodes.py new file mode 100644 index 0000000..e85870d --- /dev/null +++ b/src/meshcore_hub/web/routes/nodes.py @@ -0,0 +1,113 @@ +"""Nodes page routes.""" + +import logging + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse + +from meshcore_hub.web.app import get_network_context, get_templates + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/nodes", response_class=HTMLResponse) +async def nodes_list( + request: Request, + search: str | None = Query(None, description="Search term"), + adv_type: str | None = Query(None, description="Filter by node type"), + page: int = Query(1, ge=1, description="Page number"), + limit: int = Query(20, ge=1, le=100, description="Items per page"), +) -> HTMLResponse: + """Render the nodes list page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + # Calculate offset + offset = (page - 1) * limit + + # Build query params + params = {"limit": limit, "offset": offset} + if search: + params["search"] = search + if adv_type: + params["adv_type"] = adv_type + + # Fetch nodes from API + nodes = [] + total = 0 + + try: + response = await request.app.state.http_client.get( + "/api/v1/nodes", params=params + ) + if response.status_code == 200: + data = response.json() + nodes = data.get("items", []) + total = data.get("total", 0) + except Exception as e: + logger.warning(f"Failed to fetch nodes from API: {e}") + context["api_error"] = str(e) + + # Calculate pagination + total_pages = (total + limit - 1) // limit if total > 0 else 1 + + context.update({ + "nodes": nodes, + "total": total, + "page": page, + "limit": limit, + "total_pages": total_pages, + "search": search or "", + "adv_type": adv_type or "", + }) + + return templates.TemplateResponse("nodes.html", context) + + +@router.get("/nodes/{public_key}", response_class=HTMLResponse) +async def node_detail(request: Request, public_key: str) -> HTMLResponse: + """Render the node detail page.""" + templates = get_templates(request) + context = get_network_context(request) + context["request"] = request + + node = None + advertisements = [] + telemetry = [] + + try: + # Fetch node details + response = await request.app.state.http_client.get(f"/api/v1/nodes/{public_key}") + if response.status_code == 200: + node = response.json() + + # Fetch recent advertisements for this node + response = await request.app.state.http_client.get( + "/api/v1/advertisements", + params={"public_key": public_key, "limit": 10} + ) + if response.status_code == 200: + advertisements = response.json().get("items", []) + + # Fetch recent telemetry for this node + response = await request.app.state.http_client.get( + "/api/v1/telemetry", + params={"node_public_key": public_key, "limit": 10} + ) + if response.status_code == 200: + telemetry = response.json().get("items", []) + + except Exception as e: + logger.warning(f"Failed to fetch node details from API: {e}") + context["api_error"] = str(e) + + context.update({ + "node": node, + "advertisements": advertisements, + "telemetry": telemetry, + "public_key": public_key, + }) + + return templates.TemplateResponse("node_detail.html", context) diff --git a/src/meshcore_hub/web/templates/base.html b/src/meshcore_hub/web/templates/base.html new file mode 100644 index 0000000..816cc3e --- /dev/null +++ b/src/meshcore_hub/web/templates/base.html @@ -0,0 +1,120 @@ + + + + + + {% block title %}{{ network_name }}{% endblock %} + + + + + + + + + + + {% block extra_head %}{% endblock %} + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+ +
+ + + + + {% block extra_scripts %}{% endblock %} + + diff --git a/src/meshcore_hub/web/templates/home.html b/src/meshcore_hub/web/templates/home.html new file mode 100644 index 0000000..d52b8f2 --- /dev/null +++ b/src/meshcore_hub/web/templates/home.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Home{% endblock %} + +{% block content %} +
+
+
+

{{ network_name }}

+ {% if network_city and network_country %} +

{{ network_city }}, {{ network_country }}

+ {% endif %} +

+ Welcome to the {{ network_name }} mesh network dashboard. + Monitor network activity, view connected nodes, and explore message history. +

+ +
+
+
+ +
+ +
+
+

+ + + + Network Info +

+
+ {% if network_radio_config %} +
+ Radio Config: + {{ network_radio_config }} +
+ {% endif %} + {% if network_location and network_location != (0.0, 0.0) %} +
+ Location: + {{ "%.4f"|format(network_location[0]) }}, {{ "%.4f"|format(network_location[1]) }} +
+ {% endif %} +
+
+
+ + +
+
+

+ + + + Quick Links +

+ +
+
+ + +
+
+

+ + + + Contact +

+
+ {% if network_contact_email %} + + + + + {{ network_contact_email }} + + {% endif %} + {% if network_contact_discord %} +
+ + + + {{ network_contact_discord }} +
+ {% endif %} + {% if not network_contact_email and not network_contact_discord %} +

No contact information configured.

+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/src/meshcore_hub/web/templates/map.html b/src/meshcore_hub/web/templates/map.html new file mode 100644 index 0000000..0480b14 --- /dev/null +++ b/src/meshcore_hub/web/templates/map.html @@ -0,0 +1,103 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Node Map{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+

Node Map

+ Loading... +
+ +
+
+
+
+
+ +
+

Nodes are placed on the map based on their lat and lon tags.

+

To add a node to the map, set its location tags via the API.

+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} diff --git a/src/meshcore_hub/web/templates/members.html b/src/meshcore_hub/web/templates/members.html new file mode 100644 index 0000000..6518583 --- /dev/null +++ b/src/meshcore_hub/web/templates/members.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Members{% endblock %} + +{% block content %} +
+

Network Members

+ {{ members|length }} members +
+ +{% if members %} +
+ {% for member in members %} +
+
+

+ {{ member.name }} + {% if member.callsign %} + {{ member.callsign }} + {% endif %} +

+ + {% if member.role %} +

{{ member.role }}

+ {% endif %} + + {% if member.description %} +

{{ member.description }}

+ {% endif %} + + {% if member.email or member.discord or member.website %} +
+ {% if member.email %} + + + + + Email + + {% endif %} + {% if member.website %} + + + + + Website + + {% endif %} +
+ {% endif %} +
+
+ {% endfor %} +
+{% else %} +
+ + + +
+

No members configured

+

To display network members, provide a members JSON file using the --members-file option.

+
+
+ +
+
+

Members File Format

+

Create a JSON file with the following structure:

+
{
+  "members": [
+    {
+      "name": "John Doe",
+      "callsign": "AB1CD",
+      "role": "Network Admin",
+      "description": "Manages the main repeater node.",
+      "email": "john@example.com",
+      "website": "https://example.com"
+    },
+    {
+      "name": "Jane Smith",
+      "role": "Member",
+      "description": "Regular user in the downtown area."
+    }
+  ]
+}
+
+
+{% endif %} +{% endblock %} diff --git a/src/meshcore_hub/web/templates/messages.html b/src/meshcore_hub/web/templates/messages.html new file mode 100644 index 0000000..93f478b --- /dev/null +++ b/src/meshcore_hub/web/templates/messages.html @@ -0,0 +1,139 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Messages{% endblock %} + +{% block content %} +
+

Messages

+ {{ total }} total +
+ +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + + +
+
+
+
+ + +
+
+ + +
+ + Clear +
+
+
+ + +
+ + + + + + + + + + + + + {% for msg in messages %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
TimeTypeFrom/ChannelMessageSNRHops
+ {{ msg.received_at[:19].replace('T', ' ') if msg.received_at else '-' }} + + {% if msg.message_type == 'channel' %} + Channel + {% else %} + Direct + {% endif %} + + {% if msg.message_type == 'channel' %} + CH{{ msg.channel_idx }} + {% else %} + {{ (msg.pubkey_prefix or '-')[:12] }} + {% endif %} + + {{ msg.text or '-' }} + + {% if msg.snr is not none %} + {{ "%.1f"|format(msg.snr) }} + {% else %} + - + {% endif %} + + {% if msg.hops is not none %} + {{ msg.hops }} + {% else %} + - + {% endif %} +
No messages found.
+
+ + +{% if total_pages > 1 %} +
+
+ {% if page > 1 %} + Previous + {% else %} + + {% endif %} + + {% for p in range(1, total_pages + 1) %} + {% if p == page %} + + {% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %} + {{ p }} + {% elif p == 2 or p == total_pages - 1 %} + + {% endif %} + {% endfor %} + + {% if page < total_pages %} + Next + {% else %} + + {% endif %} +
+
+{% endif %} +{% endblock %} diff --git a/src/meshcore_hub/web/templates/network.html b/src/meshcore_hub/web/templates/network.html new file mode 100644 index 0000000..44bebd1 --- /dev/null +++ b/src/meshcore_hub/web/templates/network.html @@ -0,0 +1,148 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Network Overview{% endblock %} + +{% block content %} +
+

Network Overview

+ +
+ +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + + +
+ +
+
+ + + +
+
Total Nodes
+
{{ stats.total_nodes }}
+
All discovered nodes
+
+ + +
+
+ + + +
+
Active Nodes
+
{{ stats.active_nodes }}
+
Active in last 24 hours
+
+ + +
+
+ + + +
+
Total Messages
+
{{ stats.total_messages }}
+
All time
+
+ + +
+
+ + + +
+
Messages Today
+
{{ stats.messages_today }}
+
Last 24 hours
+
+
+ + +
+ +
+
+

+ + + + Advertisements +

+
{{ stats.total_advertisements }}
+

Total advertisements received

+
+
+ + +
+
+

+ + + + Channel Messages +

+ {% if stats.channel_message_counts %} +
+ + + + + + + + + {% for channel, count in stats.channel_message_counts.items() %} + + + + + {% endfor %} + +
ChannelCount
Channel {{ channel }}{{ count }}
+
+ {% else %} +

No channel messages recorded yet.

+ {% endif %} +
+
+
+ + + +{% endblock %} diff --git a/src/meshcore_hub/web/templates/node_detail.html b/src/meshcore_hub/web/templates/node_detail.html new file mode 100644 index 0000000..defacff --- /dev/null +++ b/src/meshcore_hub/web/templates/node_detail.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Node Details{% endblock %} + +{% block content %} + + +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + +{% if node %} + +
+
+

+ {{ node.name or 'Unnamed Node' }} + {% if node.adv_type %} + {{ node.adv_type }} + {% endif %} +

+ +
+
+

Public Key

+ {{ node.public_key }} +
+
+

Activity

+
+

First seen: {{ node.first_seen[:19].replace('T', ' ') if node.first_seen else '-' }}

+

Last seen: {{ node.last_seen[:19].replace('T', ' ') if node.last_seen else '-' }}

+
+
+
+ + + {% if node.tags %} +
+

Tags

+
+ + + + + + + + + + {% for tag in node.tags %} + + + + + + {% endfor %} + +
KeyValueType
{{ tag.key }}{{ tag.value }}{{ tag.value_type or 'string' }}
+
+
+ {% endif %} +
+
+ +
+ +
+
+

Recent Advertisements

+ {% if advertisements %} +
+ + + + + + + + + + {% for adv in advertisements %} + + + + + + {% endfor %} + +
TimeTypeName
{{ adv.received_at[:19].replace('T', ' ') if adv.received_at else '-' }}{{ adv.adv_type or '-' }}{{ adv.name or '-' }}
+
+ {% else %} +

No advertisements recorded.

+ {% endif %} +
+
+ + +
+
+

Recent Telemetry

+ {% if telemetry %} +
+ + + + + + + + + {% for tel in telemetry %} + + + + + {% endfor %} + +
TimeData
{{ tel.received_at[:19].replace('T', ' ') if tel.received_at else '-' }} + {% if tel.parsed_data %} + {{ tel.parsed_data | tojson }} + {% else %} + - + {% endif %} +
+
+ {% else %} +

No telemetry recorded.

+ {% endif %} +
+
+
+ +{% else %} +
+ + + + Node not found: {{ public_key }} +
+Back to Nodes +{% endif %} +{% endblock %} diff --git a/src/meshcore_hub/web/templates/nodes.html b/src/meshcore_hub/web/templates/nodes.html new file mode 100644 index 0000000..e47fa1f --- /dev/null +++ b/src/meshcore_hub/web/templates/nodes.html @@ -0,0 +1,138 @@ +{% extends "base.html" %} + +{% block title %}{{ network_name }} - Nodes{% endblock %} + +{% block content %} +
+

Nodes

+ {{ total }} total +
+ +{% if api_error %} +
+ + + + Could not fetch data from API: {{ api_error }} +
+{% endif %} + + +
+
+
+
+ + +
+
+ + +
+ + Clear +
+
+
+ + +
+ + + + + + + + + + + + + {% for node in nodes %} + + + + + + + + + {% else %} + + + + {% endfor %} + +
NamePublic KeyTypeLast SeenTags
{{ node.name or '-' }} + {{ node.public_key[:16] }}... + + {% if node.adv_type %} + {{ node.adv_type }} + {% else %} + - + {% endif %} + + {% if node.last_seen %} + {{ node.last_seen[:19].replace('T', ' ') }} + {% else %} + - + {% endif %} + + {% if node.tags %} +
+ {% for tag in node.tags[:3] %} + {{ tag.key }} + {% endfor %} + {% if node.tags|length > 3 %} + +{{ node.tags|length - 3 }} + {% endif %} +
+ {% else %} + - + {% endif %} +
+ + View + +
No nodes found.
+
+ + +{% if total_pages > 1 %} +
+
+ {% if page > 1 %} + Previous + {% else %} + + {% endif %} + + {% for p in range(1, total_pages + 1) %} + {% if p == page %} + + {% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %} + {{ p }} + {% elif p == 2 or p == total_pages - 1 %} + + {% endif %} + {% endfor %} + + {% if page < total_pages %} + Next + {% else %} + + {% endif %} +
+
+{% endif %} +{% endblock %} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..fb749e0 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""MeshCore Hub test suite.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..206605a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,29 @@ +"""Shared pytest fixtures for all tests.""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from meshcore_hub.common.models import Base + + +@pytest.fixture +def db_engine(): + """Create an in-memory SQLite database engine for testing.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(engine) + yield engine + Base.metadata.drop_all(engine) + engine.dispose() + + +@pytest.fixture +def db_session(db_engine): + """Create a database session for testing.""" + Session = sessionmaker(bind=db_engine) + session = Session() + yield session + session.close() diff --git a/tests/test_api/__init__.py b/tests/test_api/__init__.py new file mode 100644 index 0000000..e1e732a --- /dev/null +++ b/tests/test_api/__init__.py @@ -0,0 +1 @@ +"""API component tests.""" diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py new file mode 100644 index 0000000..55e5ccf --- /dev/null +++ b/tests/test_api/conftest.py @@ -0,0 +1,262 @@ +"""API test fixtures.""" + +import os +import tempfile +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from meshcore_hub.api.app import create_app +from meshcore_hub.api.dependencies import get_db_session, get_mqtt_client, get_db_manager +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import ( + Advertisement, + Base, + Message, + Node, + NodeTag, + Telemetry, + TracePath, +) + + +@pytest.fixture +def test_db_path(): + """Create a temporary database file path.""" + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + yield path + # Cleanup + if os.path.exists(path): + os.unlink(path) + + +@pytest.fixture +def api_db_engine(test_db_path): + """Create a SQLite database engine for API testing.""" + db_url = f"sqlite:///{test_db_path}" + engine = create_engine( + db_url, + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(engine) + yield engine + Base.metadata.drop_all(engine) + engine.dispose() + + +@pytest.fixture +def api_db_session(api_db_engine): + """Create a database session for API testing.""" + Session = sessionmaker(bind=api_db_engine) + session = Session() + yield session + session.close() + + +@pytest.fixture +def mock_mqtt(): + """Create a mock MQTT client.""" + mock = MagicMock() + mock.connect.return_value = None + mock.start_background.return_value = None + mock.stop.return_value = None + mock.disconnect.return_value = None + mock.publish_command.return_value = None + return mock + + +@pytest.fixture +def mock_db_manager(api_db_engine): + """Create a mock database manager using the test engine.""" + manager = MagicMock(spec=DatabaseManager) + Session = sessionmaker(bind=api_db_engine) + manager.get_session = lambda: Session() + return manager + + +@pytest.fixture +def app_no_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager): + """Create a FastAPI app with no authentication required.""" + db_url = f"sqlite:///{test_db_path}" + + # Patch the global db_manager to avoid lifespan issues + with patch("meshcore_hub.api.app._db_manager", mock_db_manager): + app = create_app( + database_url=db_url, + read_key=None, + admin_key=None, + ) + + # Create session maker for this test engine + Session = sessionmaker(bind=api_db_engine) + + def override_get_db_manager(request=None): + return mock_db_manager + + def override_get_db_session(): + session = Session() + try: + yield session + finally: + session.close() + + def override_get_mqtt_client(request=None): + return mock_mqtt + + app.dependency_overrides[get_db_manager] = override_get_db_manager + app.dependency_overrides[get_db_session] = override_get_db_session + app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client + + yield app + + +@pytest.fixture +def app_with_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager): + """Create a FastAPI app with authentication enabled.""" + db_url = f"sqlite:///{test_db_path}" + + with patch("meshcore_hub.api.app._db_manager", mock_db_manager): + app = create_app( + database_url=db_url, + read_key="test-read-key", + admin_key="test-admin-key", + ) + + Session = sessionmaker(bind=api_db_engine) + + def override_get_db_manager(request=None): + return mock_db_manager + + def override_get_db_session(): + session = Session() + try: + yield session + finally: + session.close() + + def override_get_mqtt_client(request=None): + return mock_mqtt + + app.dependency_overrides[get_db_manager] = override_get_db_manager + app.dependency_overrides[get_db_session] = override_get_db_session + app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client + + yield app + + +@pytest.fixture +def client_no_auth(app_no_auth, mock_db_manager): + """Create a test client with no authentication. + + Uses raise_server_exceptions=False to skip lifespan events. + """ + # Don't use context manager to skip lifespan + client = TestClient(app_no_auth, raise_server_exceptions=True) + yield client + + +@pytest.fixture +def client_with_auth(app_with_auth, mock_db_manager): + """Create a test client with authentication enabled. + + Uses raise_server_exceptions=False to skip lifespan events. + """ + client = TestClient(app_with_auth, raise_server_exceptions=True) + yield client + + +@pytest.fixture +def sample_node(api_db_session): + """Create a sample node in the database.""" + node = Node( + public_key="abc123def456abc123def456abc123de", + name="Test Node", + adv_type="REPEATER", + first_seen=datetime.now(timezone.utc), + last_seen=datetime.now(timezone.utc), + ) + api_db_session.add(node) + api_db_session.commit() + api_db_session.refresh(node) + return node + + +@pytest.fixture +def sample_node_tag(api_db_session, sample_node): + """Create a sample node tag in the database.""" + tag = NodeTag( + node_id=sample_node.id, + key="environment", + value="production", + ) + api_db_session.add(tag) + api_db_session.commit() + api_db_session.refresh(tag) + return tag + + +@pytest.fixture +def sample_message(api_db_session): + """Create a sample message in the database.""" + message = Message( + message_type="direct", + pubkey_prefix="abc123", + text="Hello World", + received_at=datetime.now(timezone.utc), + ) + api_db_session.add(message) + api_db_session.commit() + api_db_session.refresh(message) + return message + + +@pytest.fixture +def sample_advertisement(api_db_session): + """Create a sample advertisement in the database.""" + advert = Advertisement( + public_key="abc123def456abc123def456abc123de", + name="TestNode", + adv_type="REPEATER", + received_at=datetime.now(timezone.utc), + ) + api_db_session.add(advert) + api_db_session.commit() + api_db_session.refresh(advert) + return advert + + +@pytest.fixture +def sample_telemetry(api_db_session): + """Create a sample telemetry record in the database.""" + telemetry = Telemetry( + node_public_key="abc123def456abc123def456abc123de", + parsed_data={ + "battery_level": 85.5, + "temperature": 25.3, + }, + received_at=datetime.now(timezone.utc), + ) + api_db_session.add(telemetry) + api_db_session.commit() + api_db_session.refresh(telemetry) + return telemetry + + +@pytest.fixture +def sample_trace_path(api_db_session): + """Create a sample trace path in the database.""" + trace = TracePath( + initiator_tag=12345, + path_hashes=["abc123", "def456", "ghi789"], + hop_count=3, + received_at=datetime.now(timezone.utc), + ) + api_db_session.add(trace) + api_db_session.commit() + api_db_session.refresh(trace) + return trace diff --git a/tests/test_api/test_advertisements.py b/tests/test_api/test_advertisements.py new file mode 100644 index 0000000..803a13f --- /dev/null +++ b/tests/test_api/test_advertisements.py @@ -0,0 +1,62 @@ +"""Tests for advertisement API routes.""" + +import pytest + + +class TestListAdvertisements: + """Tests for GET /advertisements endpoint.""" + + def test_list_advertisements_empty(self, client_no_auth): + """Test listing advertisements when database is empty.""" + response = client_no_auth.get("/api/v1/advertisements") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_list_advertisements_with_data(self, client_no_auth, sample_advertisement): + """Test listing advertisements with data in database.""" + response = client_no_auth.get("/api/v1/advertisements") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["total"] == 1 + assert data["items"][0]["public_key"] == sample_advertisement.public_key + assert data["items"][0]["adv_type"] == sample_advertisement.adv_type + + def test_list_advertisements_filter_by_public_key( + self, client_no_auth, sample_advertisement + ): + """Test filtering advertisements by public key.""" + response = client_no_auth.get( + f"/api/v1/advertisements?public_key={sample_advertisement.public_key}" + ) + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + + response = client_no_auth.get( + "/api/v1/advertisements?public_key=nonexistent" + ) + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 0 + + +class TestGetAdvertisement: + """Tests for GET /advertisements/{id} endpoint.""" + + def test_get_advertisement_success(self, client_no_auth, sample_advertisement): + """Test getting a specific advertisement.""" + response = client_no_auth.get( + f"/api/v1/advertisements/{sample_advertisement.id}" + ) + assert response.status_code == 200 + data = response.json() + assert data["id"] == sample_advertisement.id + assert data["public_key"] == sample_advertisement.public_key + + def test_get_advertisement_not_found(self, client_no_auth): + """Test getting a non-existent advertisement.""" + response = client_no_auth.get("/api/v1/advertisements/nonexistent-id") + assert response.status_code == 404 diff --git a/tests/test_api/test_auth.py b/tests/test_api/test_auth.py new file mode 100644 index 0000000..b084090 --- /dev/null +++ b/tests/test_api/test_auth.py @@ -0,0 +1,106 @@ +"""Tests for API authentication.""" + +import pytest + + +class TestAuthenticationFlow: + """Tests for authentication behavior.""" + + def test_no_auth_when_keys_not_configured(self, client_no_auth): + """Test that no auth is required when keys are not configured.""" + # All endpoints should work without auth + response = client_no_auth.get("/api/v1/nodes") + assert response.status_code == 200 + + 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( + "/api/v1/nodes", + headers={"Authorization": "Bearer test-read-key"}, + ) + assert response.status_code == 200 + + def test_read_endpoints_accept_admin_key(self, client_with_auth): + """Test that read endpoints accept admin key.""" + response = client_with_auth.get( + "/api/v1/nodes", + headers={"Authorization": "Bearer test-admin-key"}, + ) + assert response.status_code == 200 + + def test_admin_endpoints_reject_read_key(self, client_with_auth): + """Test that admin endpoints reject read key.""" + response = client_with_auth.post( + "/api/v1/commands/send-message", + json={ + "destination": "abc123def456abc123def456abc123de", + "text": "Test", + }, + headers={"Authorization": "Bearer test-read-key"}, + ) + assert response.status_code == 403 + + 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", + json={ + "destination": "abc123def456abc123def456abc123de", + "text": "Test", + }, + headers={"Authorization": "Bearer test-admin-key"}, + ) + assert response.status_code == 200 + + def test_invalid_key_rejected(self, client_with_auth): + """Test that invalid keys are rejected.""" + response = client_with_auth.get( + "/api/v1/nodes", + headers={"Authorization": "Bearer invalid-key"}, + ) + assert response.status_code == 401 + + def test_missing_bearer_prefix_rejected(self, client_with_auth): + """Test that tokens without Bearer prefix are rejected.""" + response = client_with_auth.get( + "/api/v1/nodes", + headers={"Authorization": "test-read-key"}, + ) + assert response.status_code == 401 + + def test_empty_auth_header_rejected(self, client_with_auth): + """Test that empty auth headers are rejected.""" + response = client_with_auth.get( + "/api/v1/nodes", + headers={"Authorization": ""}, + ) + assert response.status_code == 401 + + +class TestHealthEndpoint: + """Tests for health check endpoint.""" + + def test_health_no_auth(self, client_no_auth): + """Test health endpoint without auth.""" + response = client_no_auth.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + + def test_health_with_auth_configured(self, client_with_auth): + """Test health endpoint works even when auth is configured.""" + # Health endpoint should always be accessible + response = client_with_auth.get("/health") + assert response.status_code == 200 diff --git a/tests/test_api/test_commands.py b/tests/test_api/test_commands.py new file mode 100644 index 0000000..ecfd0c1 --- /dev/null +++ b/tests/test_api/test_commands.py @@ -0,0 +1,118 @@ +"""Tests for command API routes.""" + +import pytest + + +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_dashboard.py b/tests/test_api/test_dashboard.py new file mode 100644 index 0000000..7d04408 --- /dev/null +++ b/tests/test_api/test_dashboard.py @@ -0,0 +1,62 @@ +"""Tests for dashboard API routes.""" + +import pytest + + +class TestDashboardStats: + """Tests for GET /dashboard/stats endpoint.""" + + def test_get_stats_empty(self, client_no_auth): + """Test getting stats with empty database.""" + response = client_no_auth.get("/api/v1/dashboard/stats") + assert response.status_code == 200 + data = response.json() + assert data["total_nodes"] == 0 + assert data["active_nodes"] == 0 + assert data["total_messages"] == 0 + assert data["messages_today"] == 0 + assert data["total_advertisements"] == 0 + assert data["channel_message_counts"] == {} + + def test_get_stats_with_data( + self, client_no_auth, sample_node, sample_message, sample_advertisement + ): + """Test getting stats with data in database.""" + response = client_no_auth.get("/api/v1/dashboard/stats") + assert response.status_code == 200 + data = response.json() + assert data["total_nodes"] == 1 + assert data["active_nodes"] == 1 # Node was just created + assert data["total_messages"] == 1 + assert data["total_advertisements"] == 1 + + +class TestDashboardHtml: + """Tests for GET /dashboard/dashboard endpoint.""" + + def test_dashboard_html_response(self, client_no_auth): + """Test dashboard returns HTML.""" + response = client_no_auth.get("/api/v1/dashboard/dashboard") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "" in response.text + assert "MeshCore Hub Dashboard" in response.text + + def test_dashboard_contains_stats( + self, client_no_auth, sample_node, sample_message + ): + """Test dashboard HTML contains stat values.""" + response = client_no_auth.get("/api/v1/dashboard/dashboard") + assert response.status_code == 200 + # Check that stats are present + assert "Total Nodes" in response.text + assert "Active Nodes" in response.text + assert "Total Messages" in response.text + + def test_dashboard_contains_recent_data(self, client_no_auth, sample_node): + """Test dashboard HTML contains recent nodes.""" + response = client_no_auth.get("/api/v1/dashboard/dashboard") + assert response.status_code == 200 + assert "Recent Nodes" in response.text + # The node name should appear in the table + assert sample_node.name in response.text diff --git a/tests/test_api/test_messages.py b/tests/test_api/test_messages.py new file mode 100644 index 0000000..3d3b878 --- /dev/null +++ b/tests/test_api/test_messages.py @@ -0,0 +1,62 @@ +"""Tests for message API routes.""" + +import pytest + + +class TestListMessages: + """Tests for GET /messages endpoint.""" + + def test_list_messages_empty(self, client_no_auth): + """Test listing messages when database is empty.""" + response = client_no_auth.get("/api/v1/messages") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_list_messages_with_data(self, client_no_auth, sample_message): + """Test listing messages with data in database.""" + response = client_no_auth.get("/api/v1/messages") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["total"] == 1 + assert data["items"][0]["text"] == sample_message.text + assert data["items"][0]["message_type"] == sample_message.message_type + + def test_list_messages_filter_by_type(self, client_no_auth, sample_message): + """Test filtering messages by type.""" + response = client_no_auth.get("/api/v1/messages?message_type=direct") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + + response = client_no_auth.get("/api/v1/messages?message_type=channel") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 0 + + def test_list_messages_pagination(self, client_no_auth): + """Test message list pagination parameters.""" + response = client_no_auth.get("/api/v1/messages?limit=25&offset=10") + assert response.status_code == 200 + data = response.json() + assert data["limit"] == 25 + assert data["offset"] == 10 + + +class TestGetMessage: + """Tests for GET /messages/{id} endpoint.""" + + def test_get_message_success(self, client_no_auth, sample_message): + """Test getting a specific message.""" + response = client_no_auth.get(f"/api/v1/messages/{sample_message.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == sample_message.id + assert data["text"] == sample_message.text + + def test_get_message_not_found(self, client_no_auth): + """Test getting a non-existent message.""" + response = client_no_auth.get("/api/v1/messages/nonexistent-id") + assert response.status_code == 404 diff --git a/tests/test_api/test_nodes.py b/tests/test_api/test_nodes.py new file mode 100644 index 0000000..1804f90 --- /dev/null +++ b/tests/test_api/test_nodes.py @@ -0,0 +1,136 @@ +"""Tests for node API routes.""" + +import pytest + + +class TestListNodes: + """Tests for GET /nodes endpoint.""" + + def test_list_nodes_empty(self, client_no_auth): + """Test listing nodes when database is empty.""" + response = client_no_auth.get("/api/v1/nodes") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_list_nodes_with_data(self, client_no_auth, sample_node): + """Test listing nodes with data in database.""" + response = client_no_auth.get("/api/v1/nodes") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["total"] == 1 + assert data["items"][0]["public_key"] == sample_node.public_key + assert data["items"][0]["name"] == sample_node.name + + def test_list_nodes_pagination(self, client_no_auth, sample_node): + """Test node list pagination parameters.""" + response = client_no_auth.get("/api/v1/nodes?limit=10&offset=0") + assert response.status_code == 200 + data = response.json() + assert data["limit"] == 10 + assert data["offset"] == 0 + + def test_list_nodes_with_auth_required(self, client_with_auth): + """Test listing nodes requires auth when configured.""" + # Without auth header + response = client_with_auth.get("/api/v1/nodes") + assert response.status_code == 401 + + # With read key + response = client_with_auth.get( + "/api/v1/nodes", + headers={"Authorization": "Bearer test-read-key"}, + ) + assert response.status_code == 200 + + +class TestGetNode: + """Tests for GET /nodes/{public_key} endpoint.""" + + def test_get_node_success(self, client_no_auth, sample_node): + """Test getting a specific node.""" + response = client_no_auth.get(f"/api/v1/nodes/{sample_node.public_key}") + assert response.status_code == 200 + data = response.json() + assert data["public_key"] == sample_node.public_key + assert data["name"] == sample_node.name + + def test_get_node_not_found(self, client_no_auth): + """Test getting a non-existent node.""" + response = client_no_auth.get("/api/v1/nodes/nonexistent123") + assert response.status_code == 404 + + +class TestNodeTags: + """Tests for node tag endpoints.""" + + def test_create_node_tag(self, client_no_auth, sample_node): + """Test creating a node tag.""" + response = client_no_auth.post( + f"/api/v1/nodes/{sample_node.public_key}/tags", + json={"key": "location", "value": "building-a"}, + ) + assert response.status_code == 201 # Created + data = response.json() + assert data["key"] == "location" + assert data["value"] == "building-a" + + def test_get_node_tag(self, client_no_auth, sample_node, sample_node_tag): + """Test getting a specific node tag.""" + response = client_no_auth.get( + f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}" + ) + assert response.status_code == 200 + data = response.json() + assert data["key"] == sample_node_tag.key + assert data["value"] == sample_node_tag.value + + def test_update_node_tag(self, client_no_auth, sample_node, sample_node_tag): + """Test updating a node tag.""" + response = client_no_auth.put( + f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}", + json={"value": "staging"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["value"] == "staging" + + def test_delete_node_tag(self, client_no_auth, sample_node, sample_node_tag): + """Test deleting a node tag.""" + response = client_no_auth.delete( + f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}" + ) + assert response.status_code == 204 # No Content + + # Verify it's deleted + response = client_no_auth.get( + f"/api/v1/nodes/{sample_node.public_key}/tags/{sample_node_tag.key}" + ) + assert response.status_code == 404 + + def test_tag_crud_requires_admin(self, client_with_auth, sample_node): + """Test that tag CRUD operations require admin auth.""" + # Without auth + response = client_with_auth.post( + f"/api/v1/nodes/{sample_node.public_key}/tags", + json={"key": "test", "value": "test"}, + ) + assert response.status_code == 401 + + # With read key (not admin) + response = client_with_auth.post( + f"/api/v1/nodes/{sample_node.public_key}/tags", + json={"key": "test", "value": "test"}, + headers={"Authorization": "Bearer test-read-key"}, + ) + assert response.status_code == 403 + + # With admin key + response = client_with_auth.post( + f"/api/v1/nodes/{sample_node.public_key}/tags", + json={"key": "test", "value": "test"}, + headers={"Authorization": "Bearer test-admin-key"}, + ) + assert response.status_code == 201 # Created diff --git a/tests/test_api/test_telemetry.py b/tests/test_api/test_telemetry.py new file mode 100644 index 0000000..115e7ad --- /dev/null +++ b/tests/test_api/test_telemetry.py @@ -0,0 +1,58 @@ +"""Tests for telemetry API routes.""" + +import pytest + + +class TestListTelemetry: + """Tests for GET /telemetry endpoint.""" + + def test_list_telemetry_empty(self, client_no_auth): + """Test listing telemetry when database is empty.""" + response = client_no_auth.get("/api/v1/telemetry") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_list_telemetry_with_data(self, client_no_auth, sample_telemetry): + """Test listing telemetry with data in database.""" + response = client_no_auth.get("/api/v1/telemetry") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["total"] == 1 + assert data["items"][0]["node_public_key"] == sample_telemetry.node_public_key + assert data["items"][0]["parsed_data"] == sample_telemetry.parsed_data + + def test_list_telemetry_filter_by_node(self, client_no_auth, sample_telemetry): + """Test filtering telemetry by node public key.""" + response = client_no_auth.get( + f"/api/v1/telemetry?node_public_key={sample_telemetry.node_public_key}" + ) + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + + response = client_no_auth.get( + "/api/v1/telemetry?node_public_key=nonexistent" + ) + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 0 + + +class TestGetTelemetry: + """Tests for GET /telemetry/{id} endpoint.""" + + def test_get_telemetry_success(self, client_no_auth, sample_telemetry): + """Test getting a specific telemetry record.""" + response = client_no_auth.get(f"/api/v1/telemetry/{sample_telemetry.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == sample_telemetry.id + assert data["node_public_key"] == sample_telemetry.node_public_key + + def test_get_telemetry_not_found(self, client_no_auth): + """Test getting a non-existent telemetry record.""" + response = client_no_auth.get("/api/v1/telemetry/nonexistent-id") + assert response.status_code == 404 diff --git a/tests/test_api/test_trace_paths.py b/tests/test_api/test_trace_paths.py new file mode 100644 index 0000000..7ceaa2e --- /dev/null +++ b/tests/test_api/test_trace_paths.py @@ -0,0 +1,42 @@ +"""Tests for trace path API routes.""" + +import pytest + + +class TestListTracePaths: + """Tests for GET /trace-paths endpoint.""" + + def test_list_trace_paths_empty(self, client_no_auth): + """Test listing trace paths when database is empty.""" + response = client_no_auth.get("/api/v1/trace-paths") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_list_trace_paths_with_data(self, client_no_auth, sample_trace_path): + """Test listing trace paths with data in database.""" + response = client_no_auth.get("/api/v1/trace-paths") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["total"] == 1 + assert data["items"][0]["path_hashes"] == sample_trace_path.path_hashes + assert data["items"][0]["hop_count"] == sample_trace_path.hop_count + + +class TestGetTracePath: + """Tests for GET /trace-paths/{id} endpoint.""" + + def test_get_trace_path_success(self, client_no_auth, sample_trace_path): + """Test getting a specific trace path.""" + response = client_no_auth.get(f"/api/v1/trace-paths/{sample_trace_path.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == sample_trace_path.id + assert data["path_hashes"] == sample_trace_path.path_hashes + + def test_get_trace_path_not_found(self, client_no_auth): + """Test getting a non-existent trace path.""" + response = client_no_auth.get("/api/v1/trace-paths/nonexistent-id") + assert response.status_code == 404 diff --git a/tests/test_collector/__init__.py b/tests/test_collector/__init__.py new file mode 100644 index 0000000..359a862 --- /dev/null +++ b/tests/test_collector/__init__.py @@ -0,0 +1 @@ +"""Collector component tests.""" diff --git a/tests/test_collector/conftest.py b/tests/test_collector/conftest.py new file mode 100644 index 0000000..c8c7592 --- /dev/null +++ b/tests/test_collector/conftest.py @@ -0,0 +1,25 @@ +"""Fixtures for collector component tests.""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from meshcore_hub.common.database import DatabaseManager +from meshcore_hub.common.models import Base + + +@pytest.fixture +def db_manager(): + """Create an in-memory database manager for testing.""" + manager = DatabaseManager("sqlite:///:memory:") + manager.create_tables() + yield manager + manager.dispose() + + +@pytest.fixture +def db_session(db_manager): + """Create a database session for testing.""" + session = db_manager.get_session() + yield session + session.close() diff --git a/tests/test_collector/test_handlers/__init__.py b/tests/test_collector/test_handlers/__init__.py new file mode 100644 index 0000000..7236067 --- /dev/null +++ b/tests/test_collector/test_handlers/__init__.py @@ -0,0 +1 @@ +"""Event handler tests.""" diff --git a/tests/test_collector/test_handlers/test_advertisement.py b/tests/test_collector/test_handlers/test_advertisement.py new file mode 100644 index 0000000..c4a7d50 --- /dev/null +++ b/tests/test_collector/test_handlers/test_advertisement.py @@ -0,0 +1,87 @@ +"""Tests for advertisement handler.""" + +import pytest +from sqlalchemy import select + +from meshcore_hub.common.models import Advertisement, Node +from meshcore_hub.collector.handlers.advertisement import handle_advertisement + + +class TestHandleAdvertisement: + """Tests for handle_advertisement.""" + + def test_creates_new_node(self, db_manager, db_session): + """Test that new nodes are created.""" + payload = { + "public_key": "a" * 64, + "name": "TestNode", + "adv_type": "chat", + "flags": 218, + } + + handle_advertisement("b" * 64, "advertisement", payload, db_manager) + + # Check node was created + node = db_session.execute( + select(Node).where(Node.public_key == "a" * 64) + ).scalar_one_or_none() + + assert node is not None + assert node.name == "TestNode" + assert node.adv_type == "chat" + assert node.flags == 218 + + def test_updates_existing_node(self, db_manager, db_session): + """Test that existing nodes are updated.""" + # Create initial node + node = Node(public_key="a" * 64, name="OldName", adv_type="repeater") + db_session.add(node) + db_session.commit() + + # Handle advertisement with new data + payload = { + "public_key": "a" * 64, + "name": "NewName", + "adv_type": "chat", + "flags": 100, + } + + handle_advertisement("b" * 64, "advertisement", payload, db_manager) + + # Refresh node + db_session.refresh(node) + + assert node.name == "NewName" + assert node.adv_type == "chat" + assert node.flags == 100 + + def test_creates_advertisement_record(self, db_manager, db_session): + """Test that advertisement records are created.""" + payload = { + "public_key": "a" * 64, + "name": "TestNode", + "adv_type": "chat", + } + + handle_advertisement("b" * 64, "advertisement", payload, db_manager) + + # Check advertisement was created + ad = db_session.execute(select(Advertisement)).scalar_one_or_none() + + assert ad is not None + assert ad.public_key == "a" * 64 + assert ad.name == "TestNode" + + def test_handles_missing_public_key(self, db_manager, db_session): + """Test that missing public_key is handled gracefully.""" + payload = { + "name": "TestNode", + "adv_type": "chat", + } + + # Should not raise + handle_advertisement("b" * 64, "advertisement", payload, db_manager) + + # No advertisement should be created + ads = db_session.execute(select(Advertisement)).scalars().all() + assert len(ads) == 0 diff --git a/tests/test_collector/test_handlers/test_message.py b/tests/test_collector/test_handlers/test_message.py new file mode 100644 index 0000000..fdf32d7 --- /dev/null +++ b/tests/test_collector/test_handlers/test_message.py @@ -0,0 +1,89 @@ +"""Tests for message handlers.""" + +import pytest +from sqlalchemy import select + +from meshcore_hub.common.models import Message, Node +from meshcore_hub.collector.handlers.message import ( + handle_contact_message, + handle_channel_message, +) + + +class TestHandleContactMessage: + """Tests for handle_contact_message.""" + + def test_creates_contact_message(self, db_manager, db_session): + """Test that contact messages are stored.""" + payload = { + "pubkey_prefix": "01ab2186c4d5", + "text": "Hello World!", + "path_len": 3, + "SNR": 15.5, + } + + handle_contact_message("a" * 64, "contact_msg_recv", payload, db_manager) + + # Check message was created + msg = db_session.execute(select(Message)).scalar_one_or_none() + + assert msg is not None + assert msg.message_type == "contact" + assert msg.pubkey_prefix == "01ab2186c4d5" + assert msg.text == "Hello World!" + assert msg.path_len == 3 + assert msg.snr == 15.5 + + def test_handles_missing_text(self, db_manager, db_session): + """Test that missing text is handled gracefully.""" + payload = { + "pubkey_prefix": "01ab2186c4d5", + "path_len": 3, + } + + handle_contact_message("a" * 64, "contact_msg_recv", payload, db_manager) + + # No message should be created + msgs = db_session.execute(select(Message)).scalars().all() + assert len(msgs) == 0 + + +class TestHandleChannelMessage: + """Tests for handle_channel_message.""" + + def test_creates_channel_message(self, db_manager, db_session): + """Test that channel messages are stored.""" + payload = { + "channel_idx": 4, + "text": "Channel broadcast", + "path_len": 10, + "SNR": 8.5, + } + + handle_channel_message("a" * 64, "channel_msg_recv", payload, db_manager) + + # Check message was created + msg = db_session.execute(select(Message)).scalar_one_or_none() + + assert msg is not None + assert msg.message_type == "channel" + assert msg.channel_idx == 4 + assert msg.text == "Channel broadcast" + assert msg.path_len == 10 + assert msg.snr == 8.5 + + def test_creates_receiver_node_if_needed(self, db_manager, db_session): + """Test that receiver node is created if it doesn't exist.""" + payload = { + "channel_idx": 4, + "text": "Test message", + } + + handle_channel_message("a" * 64, "channel_msg_recv", payload, db_manager) + + # Check receiver node was created + node = db_session.execute( + select(Node).where(Node.public_key == "a" * 64) + ).scalar_one_or_none() + + assert node is not None diff --git a/tests/test_collector/test_handlers/test_telemetry.py b/tests/test_collector/test_handlers/test_telemetry.py new file mode 100644 index 0000000..17f89bd --- /dev/null +++ b/tests/test_collector/test_handlers/test_telemetry.py @@ -0,0 +1,61 @@ +"""Tests for telemetry handler.""" + +import pytest +from sqlalchemy import select + +from meshcore_hub.common.models import Node, Telemetry +from meshcore_hub.collector.handlers.telemetry import handle_telemetry + + +class TestHandleTelemetry: + """Tests for handle_telemetry.""" + + def test_creates_telemetry_record(self, db_manager, db_session): + """Test that telemetry records are stored.""" + payload = { + "node_public_key": "b" * 64, + "parsed_data": { + "temperature": 22.5, + "humidity": 65, + "battery": 3.8, + }, + } + + handle_telemetry("a" * 64, "telemetry_response", payload, db_manager) + + # Check telemetry was created + telemetry = db_session.execute(select(Telemetry)).scalar_one_or_none() + + assert telemetry is not None + assert telemetry.node_public_key == "b" * 64 + assert telemetry.parsed_data["temperature"] == 22.5 + assert telemetry.parsed_data["humidity"] == 65 + assert telemetry.parsed_data["battery"] == 3.8 + + def test_creates_reporting_node(self, db_manager, db_session): + """Test that reporting node is created if needed.""" + payload = { + "node_public_key": "b" * 64, + "parsed_data": {"temperature": 20.0}, + } + + handle_telemetry("a" * 64, "telemetry_response", payload, db_manager) + + # Check node was created + node = db_session.execute( + select(Node).where(Node.public_key == "b" * 64) + ).scalar_one_or_none() + + assert node is not None + + def test_handles_missing_node_public_key(self, db_manager, db_session): + """Test that missing node_public_key is handled gracefully.""" + payload = { + "parsed_data": {"temperature": 20.0}, + } + + handle_telemetry("a" * 64, "telemetry_response", payload, db_manager) + + # No telemetry should be created + records = db_session.execute(select(Telemetry)).scalars().all() + assert len(records) == 0 diff --git a/tests/test_collector/test_subscriber.py b/tests/test_collector/test_subscriber.py new file mode 100644 index 0000000..722161e --- /dev/null +++ b/tests/test_collector/test_subscriber.py @@ -0,0 +1,78 @@ +"""Tests for the collector subscriber.""" + +import pytest +from unittest.mock import MagicMock, patch + +from meshcore_hub.collector.subscriber import Subscriber, create_subscriber + + +class TestSubscriber: + """Tests for Subscriber class.""" + + @pytest.fixture + def mock_mqtt_client(self): + """Create a mock MQTT client.""" + client = MagicMock() + client.topic_builder = MagicMock() + client.topic_builder.all_events_topic.return_value = "meshcore/+/event/#" + client.topic_builder.parse_event_topic.return_value = ("a" * 64, "advertisement") + return client + + @pytest.fixture + def subscriber(self, mock_mqtt_client, db_manager): + """Create a subscriber instance.""" + return Subscriber(mock_mqtt_client, db_manager) + + def test_register_handler(self, subscriber): + """Test handler registration.""" + handler = MagicMock() + + subscriber.register_handler("advertisement", handler) + + assert "advertisement" in subscriber._handlers + + def test_start_connects_mqtt(self, subscriber, mock_mqtt_client): + """Test that start connects to MQTT.""" + subscriber.start() + + 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_mqtt(self, subscriber, mock_mqtt_client): + """Test that stop disconnects MQTT.""" + subscriber.start() + subscriber.stop() + + mock_mqtt_client.stop.assert_called_once() + mock_mqtt_client.disconnect.assert_called_once() + + def test_handle_mqtt_message_calls_handler(self, subscriber, mock_mqtt_client, db_manager): + """Test that MQTT messages are routed to handlers.""" + handler = MagicMock() + subscriber.register_handler("advertisement", handler) + subscriber.start() + + subscriber._handle_mqtt_message( + topic="meshcore/abc/event/advertisement", + pattern="meshcore/+/event/#", + payload={"public_key": "b" * 64, "name": "Test"}, + ) + + handler.assert_called_once() + + +class TestCreateSubscriber: + """Tests for create_subscriber factory function.""" + + def test_creates_subscriber(self): + """Test creating a subscriber.""" + with patch("meshcore_hub.collector.subscriber.MQTTClient") as MockMQTT: + subscriber = create_subscriber( + mqtt_host="localhost", + mqtt_port=1883, + database_url="sqlite:///:memory:", + ) + + assert subscriber is not None + MockMQTT.assert_called_once() diff --git a/tests/test_common/__init__.py b/tests/test_common/__init__.py new file mode 100644 index 0000000..ef7a82e --- /dev/null +++ b/tests/test_common/__init__.py @@ -0,0 +1 @@ +"""Common package tests.""" diff --git a/tests/test_common/test_config.py b/tests/test_common/test_config.py new file mode 100644 index 0000000..bc5f228 --- /dev/null +++ b/tests/test_common/test_config.py @@ -0,0 +1,84 @@ +"""Tests for configuration settings.""" + +import pytest + +from meshcore_hub.common.config import ( + CommonSettings, + InterfaceSettings, + CollectorSettings, + APISettings, + WebSettings, + LogLevel, + InterfaceMode, +) + + +class TestCommonSettings: + """Tests for CommonSettings.""" + + def test_default_values(self) -> None: + """Test default setting values.""" + settings = CommonSettings() + + assert settings.log_level == LogLevel.INFO + assert settings.mqtt_host == "localhost" + assert settings.mqtt_port == 1883 + assert settings.mqtt_username is None + assert settings.mqtt_password is None + assert settings.mqtt_prefix == "meshcore" + + +class TestInterfaceSettings: + """Tests for InterfaceSettings.""" + + def test_default_values(self) -> None: + """Test default setting values.""" + settings = InterfaceSettings() + + assert settings.interface_mode == InterfaceMode.RECEIVER + assert settings.serial_port == "/dev/ttyUSB0" + assert settings.serial_baud == 115200 + assert settings.mock_device is False + + +class TestCollectorSettings: + """Tests for CollectorSettings.""" + + def test_default_values(self) -> None: + """Test default setting values.""" + settings = CollectorSettings() + + assert settings.database_url == "sqlite:///./meshcore.db" + + def test_database_url_validation(self) -> None: + """Test database URL validation.""" + with pytest.raises(ValueError): + CollectorSettings(database_url="") + + +class TestAPISettings: + """Tests for APISettings.""" + + def test_default_values(self) -> None: + """Test default setting values.""" + settings = APISettings() + + assert settings.api_host == "0.0.0.0" + assert settings.api_port == 8000 + assert settings.database_url == "sqlite:///./meshcore.db" + assert settings.api_read_key is None + assert settings.api_admin_key is None + + +class TestWebSettings: + """Tests for WebSettings.""" + + def test_default_values(self) -> None: + """Test default setting values.""" + settings = WebSettings() + + assert settings.web_host == "0.0.0.0" + assert settings.web_port == 8080 + assert settings.api_base_url == "http://localhost:8000" + assert settings.network_name == "MeshCore Network" + assert settings.members_file == "members.json" diff --git a/tests/test_common/test_models.py b/tests/test_common/test_models.py new file mode 100644 index 0000000..a79d504 --- /dev/null +++ b/tests/test_common/test_models.py @@ -0,0 +1,178 @@ +"""Tests for database models.""" + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from meshcore_hub.common.models import ( + Base, + Node, + NodeTag, + Message, + Advertisement, + TracePath, + Telemetry, + EventLog, +) + + +@pytest.fixture +def db_session(): + """Create an in-memory SQLite database session.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + yield session + session.close() + Base.metadata.drop_all(engine) + engine.dispose() + + +class TestNodeModel: + """Tests for Node model.""" + + def test_create_node(self, db_session) -> None: + """Test creating a node.""" + node = Node( + public_key="a" * 64, + name="Test Node", + adv_type="chat", + flags=218, + ) + db_session.add(node) + db_session.commit() + + assert node.id is not None + assert node.public_key == "a" * 64 + assert node.name == "Test Node" + assert node.adv_type == "chat" + assert node.flags == 218 + + def test_node_tags_relationship(self, db_session) -> None: + """Test node-tag relationship.""" + node = Node(public_key="b" * 64, name="Tagged Node") + tag = NodeTag(key="location", value="51.5,-0.1", value_type="coordinate") + node.tags.append(tag) + + db_session.add(node) + db_session.commit() + + assert len(node.tags) == 1 + assert node.tags[0].key == "location" + + +class TestMessageModel: + """Tests for Message model.""" + + def test_create_contact_message(self, db_session) -> None: + """Test creating a contact message.""" + message = Message( + message_type="contact", + pubkey_prefix="01ab2186c4d5", + text="Hello World!", + path_len=3, + snr=15.5, + ) + db_session.add(message) + db_session.commit() + + assert message.id is not None + assert message.message_type == "contact" + assert message.text == "Hello World!" + + def test_create_channel_message(self, db_session) -> None: + """Test creating a channel message.""" + message = Message( + message_type="channel", + channel_idx=4, + text="Channel broadcast", + path_len=10, + ) + db_session.add(message) + db_session.commit() + + assert message.channel_idx == 4 + assert message.message_type == "channel" + + +class TestAdvertisementModel: + """Tests for Advertisement model.""" + + def test_create_advertisement(self, db_session) -> None: + """Test creating an advertisement.""" + ad = Advertisement( + public_key="c" * 64, + name="Repeater-01", + adv_type="repeater", + flags=128, + ) + db_session.add(ad) + db_session.commit() + + assert ad.id is not None + assert ad.public_key == "c" * 64 + assert ad.adv_type == "repeater" + + +class TestTracePathModel: + """Tests for TracePath model.""" + + def test_create_trace_path(self, db_session) -> None: + """Test creating a trace path.""" + trace = TracePath( + initiator_tag=123456789, + path_len=3, + path_hashes=["4a", "b3", "fa"], + snr_values=[25.3, 18.7, 12.4], + hop_count=3, + ) + db_session.add(trace) + db_session.commit() + + assert trace.id is not None + assert trace.initiator_tag == 123456789 + assert trace.path_hashes == ["4a", "b3", "fa"] + + +class TestTelemetryModel: + """Tests for Telemetry model.""" + + def test_create_telemetry(self, db_session) -> None: + """Test creating a telemetry record.""" + telemetry = Telemetry( + node_public_key="d" * 64, + parsed_data={ + "temperature": 22.5, + "humidity": 65, + "battery": 3.8, + }, + ) + db_session.add(telemetry) + db_session.commit() + + assert telemetry.id is not None + assert telemetry.parsed_data["temperature"] == 22.5 + + +class TestEventLogModel: + """Tests for EventLog model.""" + + def test_create_event_log(self, db_session) -> None: + """Test creating an event log entry.""" + event = EventLog( + event_type="BATTERY", + payload={ + "battery_voltage": 3.8, + "battery_percentage": 75, + }, + ) + db_session.add(event) + db_session.commit() + + assert event.id is not None + assert event.event_type == "BATTERY" + assert event.payload["battery_percentage"] == 75 diff --git a/tests/test_interface/__init__.py b/tests/test_interface/__init__.py new file mode 100644 index 0000000..aca483f --- /dev/null +++ b/tests/test_interface/__init__.py @@ -0,0 +1 @@ +"""Interface component tests.""" diff --git a/tests/test_interface/conftest.py b/tests/test_interface/conftest.py new file mode 100644 index 0000000..96b4532 --- /dev/null +++ b/tests/test_interface/conftest.py @@ -0,0 +1,35 @@ +"""Fixtures for interface component tests.""" + +import pytest + +from meshcore_hub.interface.device import DeviceConfig, EventType +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, mock_device_config) -> MockMeshCoreDevice: + """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 new file mode 100644 index 0000000..73e0894 --- /dev/null +++ b/tests/test_interface/test_device.py @@ -0,0 +1,67 @@ +"""Tests for device abstraction.""" + +import pytest + +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 new file mode 100644 index 0000000..5c05361 --- /dev/null +++ b/tests/test_interface/test_mock_device.py @@ -0,0 +1,206 @@ +"""Tests for mock device implementation.""" + +import pytest +import time +import threading + +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 new file mode 100644 index 0000000..1ca2fb4 --- /dev/null +++ b/tests/test_interface/test_receiver.py @@ -0,0 +1,89 @@ +"""Tests for receiver mode implementation.""" + +import pytest +from unittest.mock import MagicMock, patch + +from meshcore_hub.interface.device import DeviceConfig, EventType +from meshcore_hub.interface.mock_device import MockDeviceConfig, MockMeshCoreDevice +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() + + +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") as MockMQTT: + 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 MockMQTT: + receiver = create_receiver( + mock=True, + mqtt_host="mqtt.example.com", + mqtt_port=8883, + mqtt_prefix="custom", + ) + + # Verify MQTT client was created with correct config + MockMQTT.assert_called_once() + config = MockMQTT.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 new file mode 100644 index 0000000..8ed614f --- /dev/null +++ b/tests/test_interface/test_sender.py @@ -0,0 +1,125 @@ +"""Tests for sender mode implementation.""" + +import pytest +from unittest.mock import MagicMock, patch + +from meshcore_hub.interface.device import DeviceConfig, EventType +from meshcore_hub.interface.mock_device import MockDeviceConfig, MockMeshCoreDevice +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") as MockMQTT: + 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 MockMQTT: + sender = create_sender( + mock=True, + mqtt_host="mqtt.example.com", + mqtt_port=8883, + mqtt_prefix="custom", + ) + + MockMQTT.assert_called_once() + config = MockMQTT.call_args[0][0] + assert config.host == "mqtt.example.com" + assert config.port == 8883 + assert config.prefix == "custom" diff --git a/tests/test_web/__init__.py b/tests/test_web/__init__.py new file mode 100644 index 0000000..43153b8 --- /dev/null +++ b/tests/test_web/__init__.py @@ -0,0 +1 @@ +"""Web dashboard component tests."""