Add MQTT removal migration and fix tests + docs

This commit is contained in:
Jack Kingsman
2026-03-05 21:21:08 -08:00
parent e99fed2e76
commit adfb4addb7
30 changed files with 352 additions and 1630 deletions
+12 -34
View File
@@ -27,10 +27,7 @@ app/
├── packet_processor.py # Raw packet pipeline, dedup, path handling
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
├── websocket.py # WS manager + broadcast helpers
├── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
├── mqtt.py # Private MQTT publisher (fire-and-forget forwarding)
├── community_mqtt.py # Community MQTT publisher (raw packet sharing)
├── bot.py # Bot execution and outbound bot sends
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── keystore.py # Ephemeral private/public key storage for DM decryption
├── frontend_static.py # Mount/serve built frontend (production)
@@ -43,6 +40,7 @@ app/
├── packets.py
├── read_state.py
├── settings.py
├── fanout.py
├── repeaters.py
├── statistics.py
└── ws.py
@@ -103,33 +101,13 @@ app/
- `0` means disabled.
- Last send time tracked in `app_settings.last_advert_time`.
### MQTT publishing
### Fanout bus
- Optional forwarding of mesh events to an external MQTT broker.
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
- Disabled when `mqtt_broker_host` is empty, or when both publish toggles are off (`mqtt_publish_messages=false` and `mqtt_publish_raw_packets=false`).
- `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — single hook covers all message and raw_packet events.
- `MqttPublisher` (`app/mqtt.py`) runs a background connection loop with auto-reconnect and exponential backoff (5s → 30s).
- Publishes are fire-and-forget; individual publish failures logged but not surfaced to users.
- Connection state changes surface via `broadcast_error`/`broadcast_success` toasts.
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`), where `disabled` covers both "no broker host configured" and "nothing enabled to publish".
- Settings changes trigger `mqtt_publisher.restart()` — no server restart needed.
- Topics: `{prefix}/dm:{key}`, `{prefix}/gm:{key}`, `{prefix}/raw/dm:{key}`, `{prefix}/raw/gm:{key}`, `{prefix}/raw/unrouted`.
### Community MQTT
- Separate publisher (`app/community_mqtt.py`) for sharing raw packets with the MeshCore community aggregator.
- Implementation intent: keep functional parity with the reference implementation at `https://github.com/agessaman/meshcore-packet-capture` unless this repository explicitly documents a deliberate deviation.
- Independent from the private `MqttPublisher` — different broker, authentication, and topic structure.
- Connects to the community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
- Authentication: Ed25519 JWT tokens signed with the radio's expanded "orlp" private key. Tokens expire after 24 hours; proactive renewal at 23 hours.
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
- JWT claims include `publicKey`, `owner` (radio pubkey), `client` (app identifier), and optional `email` (for node claiming on the community aggregator).
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code (required to enable; no default).
- Only raw packets are published — never decrypted messages.
- Publishes are fire-and-forget. The connection loop detects publish failures via `connected` flag and reconnects within 60 seconds.
- Health endpoint includes `community_mqtt_status` field.
- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`.
- All external integrations (MQTT, bots, webhooks, Apprise) are managed through the fanout bus (`app/fanout/`).
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
- Each integration is a `FanoutModule` with scope-based filtering.
- See `app/fanout/AGENTS_fanout.md` for full architecture details.
## API Surface (all under `/api`)
@@ -242,13 +220,11 @@ Main tables:
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `bots`
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
- `flood_scope`
- `blocked_keys`, `blocked_names`
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
## Security Posture (intentional)
- No authn/authz.
@@ -279,6 +255,8 @@ tests/
├── test_decoder.py # Packet parsing/decryption
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
├── test_fanout_integration.py # Fanout integration tests
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving
├── test_health_mqtt_status.py # Health endpoint MQTT status field
-377
View File
@@ -1,377 +0,0 @@
# MQTT Architecture
RemoteTerm implements two independent MQTT publishing systems that share a common base class:
1. **Private MQTT** — forwards mesh events to a user-configured broker (home automation, logging, alerting)
2. **Community MQTT** — shares raw RF packets with the MeshCore community aggregator for coverage mapping
Both are optional, configured entirely through the Settings UI, and require no server restart.
## File Map
```
app/
├── mqtt_base.py # BaseMqttPublisher — shared lifecycle, connection loop, reconnect
├── mqtt.py # MqttPublisher — private broker forwarding
├── community_mqtt.py # CommunityMqttPublisher — community aggregator integration
├── keystore.py # In-memory Ed25519 key storage (community auth)
├── models.py # AppSettings — all MQTT fields (14 total)
├── repository/settings.py # Database CRUD for MQTT settings
├── routers/settings.py # PATCH /api/settings — validates + restarts publishers
├── routers/health.py # GET /api/health — mqtt_status, community_mqtt_status
├── websocket.py # broadcast_event() — fans out to WS + both MQTT publishers
└── migrations.py # Migration 031 (private fields), 032 (community fields)
frontend/src/
├── components/settings/SettingsMqttSection.tsx # Dual collapsible settings UI
└── types.ts # AppSettings, AppSettingsUpdate, HealthStatus
tests/
├── test_mqtt.py # Topic routing, lifecycle
├── test_community_mqtt.py # JWT generation, packet format, hash, broadcast
└── test_health_mqtt_status.py # Health endpoint status reporting
```
## Base Publisher (`app/mqtt_base.py`)
`BaseMqttPublisher` is an abstract class that manages the full MQTT client lifecycle for both publishers. Subclasses implement hooks; the base class owns the connection loop.
### Connection Loop
The `_connection_loop()` runs as a background `asyncio.Task` and never exits unless cancelled:
```
loop:
├─ _is_configured()? No → call _on_not_configured(), wait for settings change, loop
├─ _pre_connect()? False → wait and retry
├─ Build client via _build_client_kwargs()
├─ Connect with aiomqtt.Client
├─ Set connected=True, broadcast success toast via _on_connected()
├─ Wait in 60s intervals:
│ ├─ _on_periodic_wake(elapsed) → subclass hook (e.g., periodic status republish)
│ ├─ Settings version changed? → break, reconnect with new settings
│ ├─ _should_break_wait()? → break (e.g., JWT expiry)
│ └─ Otherwise keep waiting (paho-mqtt handles keepalive internally)
├─ On error: set connected=False, broadcast error toast, exponential backoff
└─ On cancel: cleanup and exit
```
### Abstract Hooks
| Hook | Returns | Purpose |
|------|---------|---------|
| `_is_configured()` | `bool` | Should the publisher attempt to connect? |
| `_build_client_kwargs(settings)` | `dict` | Arguments for `aiomqtt.Client(...)` |
| `_on_connected(settings)` | `(title, detail)` | Success toast content |
| `_on_error()` | `(title, detail)` | Error toast content |
### Optional Hooks
| Hook | Default | Purpose |
|------|---------|---------|
| `_pre_connect(settings)` | `return True` | Async setup before connect; return `False` to retry |
| `_should_break_wait(elapsed)` | `return False` | Force reconnect while connected (e.g., token renewal) |
| `_on_not_configured()` | no-op | Called repeatedly while waiting for configuration |
| `_on_periodic_wake(elapsed)` | no-op | Called every ~60s while connected (e.g., periodic status republish) |
### Lifecycle Methods
- `start(settings)` — stores settings, starts the background loop task
- `stop()` — cancels the task, disconnects the client
- `restart(settings)``stop()` then `start()` (called when settings change)
- `publish(topic, payload)` — JSON-serializes and publishes; silently drops if disconnected
### Backoff
Reconnect delay: 5 seconds minimum, exponential growth, capped at `_backoff_max` (30s for private, 60s for community). Resets on successful connect.
### QoS
All publishing uses QoS 0 (at-most-once delivery), the aiomqtt default.
## Private MQTT (`app/mqtt.py`)
### When It Connects
`_is_configured()` returns `True` when all of:
- `mqtt_broker_host` is non-empty
- At least one of `mqtt_publish_messages` or `mqtt_publish_raw_packets` is enabled
If the user unchecks both publish toggles and saves, the publisher disconnects and the health status shows "Disabled".
### Client Configuration
```python
hostname: settings.mqtt_broker_host
port: settings.mqtt_broker_port (default 1883)
username: settings.mqtt_username or None
password: settings.mqtt_password or None
tls_context: ssl.create_default_context() if mqtt_use_tls, else None
# mqtt_tls_insecure=True disables hostname check + cert verification
```
TLS is opt-in. When enabled with `mqtt_tls_insecure`, both `check_hostname` and `verify_mode` are relaxed for self-signed certificates.
### Topic Structure
Default prefix: `meshcore` (configurable via `mqtt_topic_prefix`).
**Decrypted messages** (when `mqtt_publish_messages` is on):
- `{prefix}/dm:{contact_key}` — private DM
- `{prefix}/gm:{channel_key}` — channel message
- `{prefix}/message:{conversation_key}` — fallback for unknown type
**Raw packets** (when `mqtt_publish_raw_packets` is on):
- `{prefix}/raw/dm:{contact_key}` — attributed to a DM contact
- `{prefix}/raw/gm:{channel_key}` — attributed to a channel
- `{prefix}/raw/unrouted` — unattributed
Topic routing uses `decrypted_info.contact_key` and `decrypted_info.channel_key` from the raw packet data.
### Fire-and-Forget Pattern
`mqtt_broadcast(event_type, data)` is called synchronously from `broadcast_event()` in `websocket.py`. It filters to only `"message"` and `"raw_packet"` events, then creates an `asyncio.Task` for the actual publish. No awaiting — failures are logged at WARNING level and silently dropped.
## Community MQTT (`app/community_mqtt.py`)
Implements the [meshcore-packet-capture](https://github.com/agessaman/meshcore-packet-capture) protocol for sharing raw RF packets with the MeshCore community aggregator.
### When It Connects
`_is_configured()` returns `True` when all of:
- `community_mqtt_enabled` is `True`
- The radio's private key is available in the keystore (`has_private_key()`)
The private key is exported from the radio firmware on startup via `export_and_store_private_key()` in `app/keystore.py`. This requires `ENABLE_PRIVATE_KEY_EXPORT` to be enabled in the radio firmware. If unavailable, the publisher broadcasts a warning and waits.
### Client Configuration
```python
hostname: community_mqtt_broker_host or "mqtt-us-v1.letsmesh.net"
port: community_mqtt_broker_port or 443
transport: "websockets"
tls_context: ssl.create_default_context() # always enforced, not user-configurable
websocket_path: "/"
username: "v1_{pubkey_hex}"
password: {jwt_token}
```
TLS is always on — the community connection uses WebSocket Secure (WSS) with full certificate verification. There is no option to disable it.
### JWT Authentication
The community broker authenticates via Ed25519-signed JWT tokens.
**Token format:** `header_b64url.payload_b64url.signature_hex`
**Header:**
```json
{"alg": "Ed25519", "typ": "JWT"}
```
**Payload:**
```json
{
"publicKey": "{PUBKEY_HEX_UPPER}",
"iat": 1234567890,
"exp": 1234654290,
"aud": "{broker_host}",
"owner": "{PUBKEY_HEX_UPPER}",
"client": "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)",
"email": "user@example.com" // optional, only if configured
}
```
**Signing:** MeshCore uses an "expanded" 64-byte Ed25519 key format (`scalar[32] || prefix[32]`, the "orlp" format). Standard Ed25519 libraries expect seed format and would re-hash the key. The `_ed25519_sign_expanded()` function performs signing manually using `nacl.bindings.crypto_scalarmult_ed25519_base_noclamp()` — a direct port of meshcore-packet-capture's `ed25519_sign_with_expanded_key()`.
**Token lifetime:** 24 hours. The `_should_break_wait()` hook forces a reconnect at the 23-hour mark to renew before expiry.
### Status Messages
On connect and every 5 minutes thereafter, the community publisher sends a retained status message to `meshcore/{IATA}/{PUBKEY}/status` with device info and radio telemetry:
```json
{
"status": "online",
"timestamp": "2024-01-15T10:30:00.000000",
"origin": "NodeName",
"origin_id": "PUBKEY_HEX_UPPER",
"model": "T-Deck",
"firmware_version": "v2.2.2 (Build: 2025-01-15)",
"radio": "915.0,250.0,10,8",
"client_version": "RemoteTerm 2.4.0",
"stats": {
"battery_mv": 4200,
"uptime_secs": 3600,
"errors": 0,
"queue_len": 0,
"noise_floor": -120,
"last_rssi": -85,
"last_snr": 10.5,
"tx_air_secs": 42,
"rx_air_secs": 150
}
}
```
- `model` and `firmware_version` are fetched once per connection via `send_device_query()` (requires firmware version >= 3)
- `radio` is comma-separated raw values from `self_info` (freq, BW, SF, CR) matching the reference format
- `client_version` is read from Python package metadata (`remoteterm-meshcore`)
- `stats` is fetched from `get_stats_core()` + `get_stats_radio()` every 5 minutes; omitted if firmware doesn't support stats commands
- All radio queries use `blocking=False` — if the radio is busy, cached values are used. No user-facing operations are ever blocked.
- LWT (Last Will and Testament) publishes `{"status": "offline", ...}` on the same topic with retain
### Packet Formatting
`_format_raw_packet()` converts raw packet broadcast data into the meshcore-packet-capture JSON format:
```json
{
"origin": "NodeName",
"origin_id": "PUBKEY_HEX_UPPER",
"timestamp": "2024-01-15T10:30:00.000000",
"type": "PACKET",
"direction": "rx",
"time": "10:30:00",
"date": "15/01/2024",
"len": "42",
"packet_type": "5",
"route": "F",
"payload_len": "30",
"raw": "AABBCCDD...",
"SNR": "10.5",
"RSSI": "-85",
"hash": "A1B2C3D4E5F6G7H8",
"path": "ab,cd,ef"
}
```
- `origin` is the radio's device name from `meshcore.self_info`
- `route` is derived from the header's bottom 2 bits: `0,1→"F"` (Flood), `2→"D"` (Direct), `3→"T"` (Trace)
- `path` is only present when `route=="D"`
- `hash` matches MeshCore's C++ `Packet::calculatePacketHash()`: SHA-256 of `payload_type[1 byte] + [path_len as uint16 LE, TRACE only] + payload_data`, truncated to first 16 hex characters
### Topic Structure
```
meshcore/{IATA}/{PUBKEY_HEX}/packets
```
IATA must be exactly 3 uppercase letters (e.g., `DEN`, `LAX`). Validated both client-side (input maxLength + uppercase conversion) and server-side (regex `^[A-Z]{3}$`, returns HTTP 400 on failure).
### Only Raw Packets
The community publisher only handles `"raw_packet"` events. Decrypted messages are never shared with the community — `community_mqtt_broadcast()` explicitly filters `event_type != "raw_packet"`.
## Event Flow
```
Radio RF event
meshcore_py library callback
app/event_handlers.py (on_contact_message, on_rx_log_data, etc.)
Store to SQLite database
broadcast_event(event_type, data) ← app/websocket.py
├─ WebSocket → browser clients
├─ mqtt_broadcast() ← app/mqtt.py (messages + raw packets)
│ └─ asyncio.create_task(_mqtt_maybe_publish())
└─ community_mqtt_broadcast() ← app/community_mqtt.py (raw packets only)
└─ asyncio.create_task(_community_maybe_publish())
```
## Settings & Persistence
### Database Fields (`app_settings` table)
**Private MQTT** (Migration 031):
| Column | Type | Default |
|--------|------|---------|
| `mqtt_broker_host` | TEXT | `''` |
| `mqtt_broker_port` | INTEGER | `1883` |
| `mqtt_username` | TEXT | `''` |
| `mqtt_password` | TEXT | `''` |
| `mqtt_use_tls` | INTEGER | `0` |
| `mqtt_tls_insecure` | INTEGER | `0` |
| `mqtt_topic_prefix` | TEXT | `'meshcore'` |
| `mqtt_publish_messages` | INTEGER | `0` |
| `mqtt_publish_raw_packets` | INTEGER | `0` |
**Community MQTT** (Migration 032):
| Column | Type | Default |
|--------|------|---------|
| `community_mqtt_enabled` | INTEGER | `0` |
| `community_mqtt_iata` | TEXT | `''` |
| `community_mqtt_broker_host` | TEXT | `'mqtt-us-v1.letsmesh.net'` |
| `community_mqtt_broker_port` | INTEGER | `443` |
| `community_mqtt_email` | TEXT | `''` |
### Settings API
`PATCH /api/settings` accepts any subset of MQTT fields. The router tracks whether private or community fields changed independently:
- If any private MQTT field changed → `await mqtt_publisher.restart(result)`
- If any community MQTT field changed → `await community_publisher.restart(result)`
This means toggling a publish checkbox triggers a full disconnect/reconnect cycle.
### Health API
`GET /api/health` reports both statuses:
```json
{
"mqtt_status": "connected | disconnected | disabled",
"community_mqtt_status": "connected | disconnected | disabled"
}
```
Status logic for each publisher:
- `_is_configured()` returns `True` → report `"connected"` or `"disconnected"` based on `publisher.connected`
- `_is_configured()` returns `False` → report `"disabled"`
## App Lifecycle
**Startup** (in `app/main.py` lifespan):
1. Database connects, radio connects
2. `export_and_store_private_key()` — export Ed25519 key from radio (needed for community auth)
3. Load `AppSettings` from database
4. `mqtt_publisher.start(settings)` — spawns background connection loop
5. `community_publisher.start(settings)` — spawns background connection loop
**Shutdown:**
1. `community_publisher.stop()`
2. `mqtt_publisher.stop()`
3. Radio and database cleanup
## Frontend (`SettingsMqttSection.tsx`)
The MQTT settings UI is a single React component with two collapsible sections (both collapsed by default):
### Private MQTT Broker Section
- Header shows connection status indicator (green/red/gray dot + label)
- Always visible when expanded: Publish Messages and Publish Raw Packets checkboxes
- Broker configuration (host, port, username, password, TLS, topic prefix) only revealed when at least one publish checkbox is checked
- Responsive grid layout (`grid-cols-1 sm:grid-cols-2`) for host+port and username+password pairs
### Community Analytics Section
- Header shows connection status indicator
- Enable Community Analytics checkbox
- When enabled: broker host/port, IATA code input (3 chars, auto-uppercase), owner email
- Broker host shows "MQTT over TLS (WebSocket Secure) only" note
### Shared
- Beta warning banner at the top (links to GitHub issues)
- Single "Save MQTT Settings" button outside both collapsibles
- Save constructs an `AppSettingsUpdate` and calls `PATCH /api/settings`
- Success/error feedback via toast notifications
## Security Notes
- **Private MQTT password** is stored in plaintext in SQLite, consistent with the project's trusted-network design.
- **Community MQTT** always uses TLS with full certificate verification. The Ed25519 private key is held in memory only (never persisted to disk) and is used solely for JWT signing.
- **Community data** is limited to raw RF packets — decrypted message content is never shared.
+32 -6
View File
@@ -46,15 +46,31 @@ Setting `realtime=False` (used during historical decryption) skips fanout dispat
## Current Module Types
### mqtt_private (mqtt_private.py)
Wraps `MqttPublisher` from `app/mqtt.py`. Config blob:
Wraps `MqttPublisher` from `app/fanout/mqtt.py`. Config blob:
- `broker_host`, `broker_port`, `username`, `password`
- `use_tls`, `tls_insecure`, `topic_prefix`
### mqtt_community (mqtt_community.py)
Wraps `CommunityMqttPublisher` from `app/community_mqtt.py`. Config blob:
Wraps `CommunityMqttPublisher` from `app/fanout/community_mqtt.py`. Config blob:
- `broker_host`, `broker_port`, `iata`, `email`
- Only publishes raw packets (on_message is a no-op)
### bot (bot.py)
Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
- `code` — Python bot function source code
- Executes in a thread pool with timeout and semaphore concurrency control
- Rate-limits outgoing messages for repeater compatibility
### webhook (webhook.py)
HTTP POST webhook delivery. Config blob:
- `url`, `secret` (optional HMAC signing key)
- Delivers messages and raw packets as JSON payloads
### apprise (apprise_mod.py)
Push notifications via Apprise library. Config blob:
- `urls` — list of Apprise notification service URLs
- Formats messages for human-readable notification delivery
## Adding a New Integration Type
1. Create `app/fanout/my_type.py` with a class extending `FanoutModule`
@@ -73,19 +89,29 @@ Wraps `CommunityMqttPublisher` from `app/community_mqtt.py`. Config blob:
## Database
`fanout_configs` table (created in migration 36):
`fanout_configs` table:
- `id` TEXT PRIMARY KEY
- `type`, `name`, `enabled`, `config` (JSON), `scope` (JSON)
- `sort_order`, `created_at`
Migration 36 also migrates existing `app_settings` MQTT columns into fanout rows.
Migrations:
- **36**: Creates `fanout_configs` table, migrates existing MQTT settings from `app_settings`
- **37**: Migrates bot configs from `app_settings.bots` JSON column into fanout rows
- **38**: Drops legacy `mqtt_*`, `community_mqtt_*`, and `bots` columns from `app_settings`
## Key Files
- `app/fanout/base.py` — FanoutModule ABC
- `app/fanout/manager.py` — FanoutManager singleton
- `app/fanout/mqtt_private.py`Private MQTT module
- `app/fanout/mqtt_community.py` — Community MQTT module
- `app/fanout/mqtt_base.py`BaseMqttPublisher ABC (shared MQTT connection loop)
- `app/fanout/mqtt.py` — MqttPublisher (private MQTT publishing)
- `app/fanout/community_mqtt.py` — CommunityMqttPublisher (community MQTT with JWT auth)
- `app/fanout/mqtt_private.py` — Private MQTT fanout module
- `app/fanout/mqtt_community.py` — Community MQTT fanout module
- `app/fanout/bot.py` — Bot fanout module
- `app/fanout/bot_exec.py` — Bot code execution, response processing, rate limiting
- `app/fanout/webhook.py` — Webhook fanout module
- `app/fanout/apprise_mod.py` — Apprise fanout module
- `app/repository/fanout.py` — Database CRUD
- `app/routers/fanout.py` — REST API
- `app/websocket.py``broadcast_event()` dispatches to fanout
+6 -2
View File
@@ -28,7 +28,11 @@ class BotModule(FanoutModule):
asyncio.create_task(self._run_for_message(data))
async def _run_for_message(self, data: dict) -> None:
from app.bot import BOT_EXECUTION_TIMEOUT, execute_bot_code, process_bot_response
from app.fanout.bot_exec import (
BOT_EXECUTION_TIMEOUT,
execute_bot_code,
process_bot_response,
)
code = self.config.get("code", "")
if not code or not code.strip():
@@ -83,7 +87,7 @@ class BotModule(FanoutModule):
await asyncio.sleep(2)
# Execute bot code in thread pool with timeout
from app.bot import _bot_executor, _bot_semaphore
from app.fanout.bot_exec import _bot_executor, _bot_semaphore
async with _bot_semaphore:
loop = asyncio.get_event_loop()
-96
View File
@@ -19,8 +19,6 @@ from typing import Any
from fastapi import HTTPException
from app.config import settings as server_settings
logger = logging.getLogger(__name__)
# Limit concurrent bot executions to prevent resource exhaustion
@@ -259,97 +257,3 @@ async def _send_single_bot_message(
# Update last send time after successful send
_last_bot_send_time = time.monotonic()
async def run_bot_for_message(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None = None,
sender_timestamp: int | None = None,
path: str | None = None,
is_outgoing: bool = False,
) -> None:
"""
Run all enabled bots for a message (incoming or outgoing).
This is the main entry point called by message handlers after
a message is successfully decrypted and stored. Bots run serially,
and errors in one bot don't prevent others from running.
Args:
sender_name: Display name of the sender
sender_key: 64-char hex public key of sender (DMs only, None for channels)
message_text: The message content
is_dm: True for direct messages, False for channel messages
channel_key: Channel key for channel messages
channel_name: Channel name (e.g. "#general"), None for DMs
sender_timestamp: Sender's timestamp from the message
path: Hex-encoded routing path
is_outgoing: Whether this is our own outgoing message
"""
if server_settings.disable_bots:
return
# Early check if any bots are enabled (will re-check after sleep)
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
return
async with _bot_semaphore:
logger.debug(
"Running %d bot(s) for message from %s (is_dm=%s)",
len(enabled_bots),
sender_name or (sender_key[:12] if sender_key else "unknown"),
is_dm,
)
# Wait for the initiating message's retransmissions to propagate through the mesh
await asyncio.sleep(2)
# Re-check settings after sleep (user may have changed bot config)
settings = await AppSettingsRepository.get()
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
logger.debug("All bots disabled during wait, skipping")
return
# Run each enabled bot serially
loop = asyncio.get_event_loop()
for bot in enabled_bots:
logger.debug("Executing bot '%s'", bot.name)
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
bot.code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning(
"Bot '%s' execution timed out after %ds", bot.name, BOT_EXECUTION_TIMEOUT
)
continue # Continue to next bot
except Exception as e:
logger.warning("Bot '%s' execution error: %s", bot.name, e)
continue # Continue to next bot
# Send response if any
if response:
await process_bot_response(response, is_dm, sender_key or "", channel_key)
@@ -19,13 +19,12 @@ import re
import ssl
import time
from datetime import datetime
from typing import Any
from typing import Any, Protocol
import aiomqtt
import nacl.bindings
from app.models import AppSettings
from app.mqtt_base import BaseMqttPublisher
from app.fanout.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
@@ -49,6 +48,16 @@ _IATA_RE = re.compile(r"^[A-Z]{3}$")
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
class CommunityMqttSettings(Protocol):
"""Attributes expected on the settings object for the community MQTT publisher."""
community_mqtt_enabled: bool
community_mqtt_broker_host: str
community_mqtt_broker_port: int
community_mqtt_iata: str
community_mqtt_email: str
def _base64url_encode(data: bytes) -> str:
"""Base64url encode without padding."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
@@ -258,7 +267,7 @@ def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: s
return packet
def _build_status_topic(settings: AppSettings, pubkey_hex: str) -> str:
def _build_status_topic(settings: CommunityMqttSettings, pubkey_hex: str) -> str:
"""Build the ``meshcore/{IATA}/{PUBKEY}/status`` topic string."""
iata = settings.community_mqtt_iata.upper().strip()
return f"meshcore/{iata}/{pubkey_hex}/status"
@@ -310,7 +319,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
self._last_stats_fetch: float = 0.0
self._last_status_publish: float = 0.0
async def start(self, settings: AppSettings) -> None:
async def start(self, settings: object) -> None:
self._key_unavailable_warned = False
self._cached_device_info = None
self._cached_stats = None
@@ -323,9 +332,10 @@ class CommunityMqttPublisher(BaseMqttPublisher):
from app.keystore import has_private_key
from app.websocket import broadcast_error
s: CommunityMqttSettings | None = self._settings
if (
self._settings
and self._settings.community_mqtt_enabled
s
and s.community_mqtt_enabled
and not has_private_key()
and not self._key_unavailable_warned
):
@@ -339,9 +349,11 @@ class CommunityMqttPublisher(BaseMqttPublisher):
"""Check if community MQTT is enabled and keys are available."""
from app.keystore import has_private_key
return bool(self._settings and self._settings.community_mqtt_enabled and has_private_key())
s: CommunityMqttSettings | None = self._settings
return bool(s and s.community_mqtt_enabled and has_private_key())
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
s: CommunityMqttSettings = settings # type: ignore[assignment]
from app.keystore import get_private_key, get_public_key
from app.radio import radio_manager
@@ -350,13 +362,13 @@ class CommunityMqttPublisher(BaseMqttPublisher):
assert private_key is not None and public_key is not None # guaranteed by _pre_connect
pubkey_hex = public_key.hex().upper()
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
jwt_token = _generate_jwt_token(
private_key,
public_key,
audience=broker_host,
email=settings.community_mqtt_email or "",
email=s.community_mqtt_email or "",
)
tls_context = ssl.create_default_context()
@@ -365,7 +377,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
status_topic = _build_status_topic(settings, pubkey_hex)
status_topic = _build_status_topic(s, pubkey_hex)
offline_payload = json.dumps(
{
"status": "offline",
@@ -386,9 +398,10 @@ class CommunityMqttPublisher(BaseMqttPublisher):
"will": aiomqtt.Will(status_topic, offline_payload, retain=True),
}
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
def _on_connected(self, settings: object) -> tuple[str, str]:
s: CommunityMqttSettings = settings # type: ignore[assignment]
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
async def _fetch_device_info(self) -> dict[str, str]:
@@ -479,7 +492,9 @@ class CommunityMqttPublisher(BaseMqttPublisher):
return self._cached_stats
async def _publish_status(self, settings: AppSettings, *, refresh_stats: bool = True) -> None:
async def _publish_status(
self, settings: CommunityMqttSettings, *, refresh_stats: bool = True
) -> None:
"""Build and publish the enriched retained status message."""
from app.keystore import get_public_key
from app.radio import radio_manager
@@ -514,9 +529,9 @@ class CommunityMqttPublisher(BaseMqttPublisher):
await self.publish(status_topic, payload, retain=True)
self._last_status_publish = time.monotonic()
async def _on_connected_async(self, settings: AppSettings) -> None:
async def _on_connected_async(self, settings: object) -> None:
"""Publish a retained online status message after connecting."""
await self._publish_status(settings)
await self._publish_status(settings) # type: ignore[arg-type]
async def _on_periodic_wake(self, elapsed: float) -> None:
if not self._settings:
@@ -540,7 +555,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
return True
return False
async def _pre_connect(self, settings: AppSettings) -> bool:
async def _pre_connect(self, settings: object) -> bool:
from app.keystore import get_private_key, get_public_key
private_key = get_private_key()
+28 -15
View File
@@ -4,14 +4,26 @@ from __future__ import annotations
import logging
import ssl
from typing import Any
from typing import Any, Protocol
from app.models import AppSettings
from app.mqtt_base import BaseMqttPublisher
from app.fanout.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
class PrivateMqttSettings(Protocol):
"""Attributes expected on the settings object for the private MQTT publisher."""
mqtt_broker_host: str
mqtt_broker_port: int
mqtt_username: str
mqtt_password: str
mqtt_use_tls: bool
mqtt_tls_insecure: bool
mqtt_publish_messages: bool
mqtt_publish_raw_packets: bool
class MqttPublisher(BaseMqttPublisher):
"""Manages an MQTT connection and publishes mesh network events."""
@@ -20,29 +32,30 @@ class MqttPublisher(BaseMqttPublisher):
def _is_configured(self) -> bool:
"""Check if MQTT is configured and has something to publish."""
s: PrivateMqttSettings | None = self._settings
return bool(
self._settings
and self._settings.mqtt_broker_host
and (self._settings.mqtt_publish_messages or self._settings.mqtt_publish_raw_packets)
s and s.mqtt_broker_host and (s.mqtt_publish_messages or s.mqtt_publish_raw_packets)
)
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
s: PrivateMqttSettings = settings # type: ignore[assignment]
return {
"hostname": settings.mqtt_broker_host,
"port": settings.mqtt_broker_port,
"username": settings.mqtt_username or None,
"password": settings.mqtt_password or None,
"tls_context": self._build_tls_context(settings),
"hostname": s.mqtt_broker_host,
"port": s.mqtt_broker_port,
"username": s.mqtt_username or None,
"password": s.mqtt_password or None,
"tls_context": self._build_tls_context(s),
}
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
return ("MQTT connected", f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}")
def _on_connected(self, settings: object) -> tuple[str, str]:
s: PrivateMqttSettings = settings # type: ignore[assignment]
return ("MQTT connected", f"{s.mqtt_broker_host}:{s.mqtt_broker_port}")
def _on_error(self) -> tuple[str, str]:
return ("MQTT connection failure", "Please correct the settings or disable.")
@staticmethod
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
def _build_tls_context(settings: PrivateMqttSettings) -> ssl.SSLContext | None:
"""Build TLS context from settings, or None if TLS is disabled."""
if not settings.mqtt_use_tls:
return None
+12 -9
View File
@@ -18,8 +18,6 @@ from typing import Any
import aiomqtt
from app.models import AppSettings
logger = logging.getLogger(__name__)
_BACKOFF_MIN = 5
@@ -38,6 +36,11 @@ class BaseMqttPublisher(ABC):
Subclasses implement the abstract hooks to control configuration checks,
client construction, toast messages, and optional wait-loop behavior.
The settings type is duck-typed each subclass defines a Protocol
describing the attributes it expects (e.g. ``PrivateMqttSettings``,
``CommunityMqttSettings``). Callers pass ``SimpleNamespace`` instances
that satisfy the protocol.
"""
_backoff_max: int = 30
@@ -47,14 +50,14 @@ class BaseMqttPublisher(ABC):
def __init__(self) -> None:
self._client: aiomqtt.Client | None = None
self._task: asyncio.Task[None] | None = None
self._settings: AppSettings | None = None
self._settings: Any = None
self._settings_version: int = 0
self._version_event: asyncio.Event = asyncio.Event()
self.connected: bool = False
# ── Lifecycle ──────────────────────────────────────────────────────
async def start(self, settings: AppSettings) -> None:
async def start(self, settings: object) -> None:
"""Start the background connection loop."""
self._settings = settings
self._settings_version += 1
@@ -74,7 +77,7 @@ class BaseMqttPublisher(ABC):
self._client = None
self.connected = False
async def restart(self, settings: AppSettings) -> None:
async def restart(self, settings: object) -> None:
"""Called when settings change — stop + start."""
await self.stop()
await self.start(settings)
@@ -99,11 +102,11 @@ class BaseMqttPublisher(ABC):
"""Return True when this publisher should attempt to connect."""
@abstractmethod
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
"""Return the keyword arguments for ``aiomqtt.Client(...)``."""
@abstractmethod
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
def _on_connected(self, settings: object) -> tuple[str, str]:
"""Return ``(title, detail)`` for the success toast on connect."""
@abstractmethod
@@ -116,7 +119,7 @@ class BaseMqttPublisher(ABC):
"""Return True to break the inner wait (e.g. token expiry)."""
return False
async def _pre_connect(self, settings: AppSettings) -> bool:
async def _pre_connect(self, settings: object) -> bool:
"""Called before connecting. Return True to proceed, False to retry."""
return True
@@ -124,7 +127,7 @@ class BaseMqttPublisher(ABC):
"""Called each time the loop finds the publisher not configured."""
return # no-op by default; subclasses may override
async def _on_connected_async(self, settings: AppSettings) -> None:
async def _on_connected_async(self, settings: object) -> None:
"""Async hook called after connection succeeds (before health broadcast).
Subclasses can override to publish messages immediately after connecting.
+5 -5
View File
@@ -4,20 +4,20 @@ from __future__ import annotations
import logging
import re
from types import SimpleNamespace
from typing import Any
from app.community_mqtt import CommunityMqttPublisher, _format_raw_packet
from app.fanout.base import FanoutModule
from app.models import AppSettings
from app.fanout.community_mqtt import CommunityMqttPublisher, _format_raw_packet
logger = logging.getLogger(__name__)
_IATA_RE = re.compile(r"^[A-Z]{3}$")
def _config_to_settings(config: dict) -> AppSettings:
"""Map a fanout config blob to AppSettings for the CommunityMqttPublisher."""
return AppSettings(
def _config_to_settings(config: dict) -> SimpleNamespace:
"""Map a fanout config blob to a settings namespace for the CommunityMqttPublisher."""
return SimpleNamespace(
community_mqtt_enabled=True,
community_mqtt_broker_host=config.get("broker_host", "mqtt-us-v1.letsmesh.net"),
community_mqtt_broker_port=config.get("broker_port", 443),
+5 -6
View File
@@ -3,17 +3,17 @@
from __future__ import annotations
import logging
from types import SimpleNamespace
from app.fanout.base import FanoutModule
from app.models import AppSettings
from app.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
from app.fanout.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
logger = logging.getLogger(__name__)
def _config_to_settings(config: dict) -> AppSettings:
"""Map a fanout config blob to AppSettings for the MqttPublisher."""
return AppSettings(
def _config_to_settings(config: dict) -> SimpleNamespace:
"""Map a fanout config blob to a settings namespace for the MqttPublisher."""
return SimpleNamespace(
mqtt_broker_host=config.get("broker_host", ""),
mqtt_broker_port=config.get("broker_port", 1883),
mqtt_username=config.get("username", ""),
@@ -21,7 +21,6 @@ def _config_to_settings(config: dict) -> AppSettings:
mqtt_use_tls=config.get("use_tls", False),
mqtt_tls_insecure=config.get("tls_insecure", False),
mqtt_topic_prefix=config.get("topic_prefix", "meshcore"),
# Always enable both publish flags; the fanout scope controls delivery.
mqtt_publish_messages=True,
mqtt_publish_raw_packets=True,
)
+56
View File
@@ -296,6 +296,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 37)
applied += 1
# Migration 38: Drop legacy MQTT, community MQTT, and bots columns from app_settings
if version < 38:
logger.info("Applying migration 38: drop legacy MQTT/bot columns from app_settings")
await _migrate_038_drop_legacy_columns(conn)
await set_version(conn, 38)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2214,3 +2221,52 @@ async def _migrate_037_bots_to_fanout(conn: aiosqlite.Connection) -> None:
logger.info("Migrated bot '%s' to fanout_configs (enabled=%s)", bot_name, bot_enabled)
await conn.commit()
async def _migrate_038_drop_legacy_columns(conn: aiosqlite.Connection) -> None:
"""Drop legacy MQTT, community MQTT, and bots columns from app_settings.
These columns were migrated to fanout_configs in migrations 36 and 37.
SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN. For older versions,
the columns remain but are harmless (no longer read or written).
"""
# Check if app_settings table exists (some test DBs may not have it)
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
columns_to_drop = [
"bots",
"mqtt_broker_host",
"mqtt_broker_port",
"mqtt_username",
"mqtt_password",
"mqtt_use_tls",
"mqtt_tls_insecure",
"mqtt_topic_prefix",
"mqtt_publish_messages",
"mqtt_publish_raw_packets",
"community_mqtt_enabled",
"community_mqtt_iata",
"community_mqtt_broker_host",
"community_mqtt_broker_port",
"community_mqtt_email",
]
for column in columns_to_drop:
try:
await conn.execute(f"ALTER TABLE app_settings DROP COLUMN {column}")
logger.debug("Dropped %s from app_settings", column)
except aiosqlite.OperationalError as e:
error_msg = str(e).lower()
if "no such column" in error_msg:
logger.debug("app_settings.%s already dropped, skipping", column)
elif "syntax error" in error_msg or "drop column" in error_msg:
logger.debug("SQLite doesn't support DROP COLUMN, %s column will remain", column)
else:
raise
await conn.commit()
+1 -70
View File
@@ -399,15 +399,6 @@ class Favorite(BaseModel):
id: str = Field(description="Channel key or contact public key")
class BotConfig(BaseModel):
"""Configuration for a single bot."""
id: str = Field(description="UUID for stable identity across renames/reorders")
name: str = Field(description="User-editable name")
enabled: bool = Field(default=False, description="Whether this bot is enabled")
code: str = Field(default="", description="Python code for this bot")
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
@@ -459,66 +450,6 @@ class AppSettings(BaseModel):
default=0,
description="Unix timestamp of last advertisement sent (0 = never)",
)
bots: list[BotConfig] = Field(
default_factory=list,
description="List of bot configurations",
)
mqtt_broker_host: str = Field(
default="",
description="MQTT broker hostname (empty = disabled)",
)
mqtt_broker_port: int = Field(
default=1883,
description="MQTT broker port",
)
mqtt_username: str = Field(
default="",
description="MQTT username (optional)",
)
mqtt_password: str = Field(
default="",
description="MQTT password (optional)",
)
mqtt_use_tls: bool = Field(
default=False,
description="Whether to use TLS for MQTT connection",
)
mqtt_tls_insecure: bool = Field(
default=False,
description="Skip TLS certificate verification (for self-signed certs)",
)
mqtt_topic_prefix: str = Field(
default="meshcore",
description="MQTT topic prefix",
)
mqtt_publish_messages: bool = Field(
default=False,
description="Whether to publish decrypted messages to MQTT",
)
mqtt_publish_raw_packets: bool = Field(
default=False,
description="Whether to publish raw packets to MQTT",
)
community_mqtt_enabled: bool = Field(
default=False,
description="Whether to publish raw packets to the community MQTT broker (letsmesh.net)",
)
community_mqtt_iata: str = Field(
default="",
description="IATA region code for community MQTT topic routing (3 alpha chars)",
)
community_mqtt_broker_host: str = Field(
default="mqtt-us-v1.letsmesh.net",
description="Community MQTT broker hostname",
)
community_mqtt_broker_port: int = Field(
default=443,
description="Community MQTT broker port",
)
community_mqtt_email: str = Field(
default="",
description="Email address for node claiming on the community aggregator (optional)",
)
flood_scope: str = Field(
default="",
description="Outbound flood scope / region name (empty = disabled, no tagging)",
@@ -537,7 +468,7 @@ class FanoutConfig(BaseModel):
"""Configuration for a single fanout integration."""
id: str
type: str # 'mqtt_private' | 'mqtt_community'
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise'
name: str
enabled: bool
config: dict
+2 -114
View File
@@ -4,7 +4,7 @@ import time
from typing import Any, Literal
from app.database import db
from app.models import AppSettings, BotConfig, Favorite
from app.models import AppSettings, Favorite
logger = logging.getLogger(__name__)
@@ -26,13 +26,7 @@ class AppSettingsRepository:
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, bots,
mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
mqtt_publish_messages, mqtt_publish_raw_packets,
community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port,
community_mqtt_email, flood_scope,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names
FROM app_settings WHERE id = 1
"""
@@ -69,20 +63,6 @@ class AppSettingsRepository:
)
last_message_times = {}
# Parse bots JSON
bots: list[BotConfig] = []
if row["bots"]:
try:
bots_data = json.loads(row["bots"])
bots = [BotConfig(**b) for b in bots_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse bots JSON, using empty list: %s (data=%r)",
e,
row["bots"][:100] if row["bots"] else None,
)
bots = []
# Parse blocked_keys JSON
blocked_keys: list[str] = []
if row["blocked_keys"]:
@@ -113,22 +93,6 @@ class AppSettingsRepository:
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0,
bots=bots,
mqtt_broker_host=row["mqtt_broker_host"] or "",
mqtt_broker_port=row["mqtt_broker_port"] or 1883,
mqtt_username=row["mqtt_username"] or "",
mqtt_password=row["mqtt_password"] or "",
mqtt_use_tls=bool(row["mqtt_use_tls"]),
mqtt_tls_insecure=bool(row["mqtt_tls_insecure"]),
mqtt_topic_prefix=row["mqtt_topic_prefix"] or "meshcore",
mqtt_publish_messages=bool(row["mqtt_publish_messages"]),
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
community_mqtt_enabled=bool(row["community_mqtt_enabled"]),
community_mqtt_iata=row["community_mqtt_iata"] or "",
community_mqtt_broker_host=row["community_mqtt_broker_host"]
or "mqtt-us-v1.letsmesh.net",
community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443,
community_mqtt_email=row["community_mqtt_email"] or "",
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
@@ -144,21 +108,6 @@ class AppSettingsRepository:
preferences_migrated: bool | None = None,
advert_interval: int | None = None,
last_advert_time: int | None = None,
bots: list[BotConfig] | None = None,
mqtt_broker_host: str | None = None,
mqtt_broker_port: int | None = None,
mqtt_username: str | None = None,
mqtt_password: str | None = None,
mqtt_use_tls: bool | None = None,
mqtt_tls_insecure: bool | None = None,
mqtt_topic_prefix: str | None = None,
mqtt_publish_messages: bool | None = None,
mqtt_publish_raw_packets: bool | None = None,
community_mqtt_enabled: bool | None = None,
community_mqtt_iata: str | None = None,
community_mqtt_broker_host: str | None = None,
community_mqtt_broker_port: int | None = None,
community_mqtt_email: str | None = None,
flood_scope: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
@@ -200,67 +149,6 @@ class AppSettingsRepository:
updates.append("last_advert_time = ?")
params.append(last_advert_time)
if bots is not None:
updates.append("bots = ?")
bots_json = json.dumps([b.model_dump() for b in bots])
params.append(bots_json)
if mqtt_broker_host is not None:
updates.append("mqtt_broker_host = ?")
params.append(mqtt_broker_host)
if mqtt_broker_port is not None:
updates.append("mqtt_broker_port = ?")
params.append(mqtt_broker_port)
if mqtt_username is not None:
updates.append("mqtt_username = ?")
params.append(mqtt_username)
if mqtt_password is not None:
updates.append("mqtt_password = ?")
params.append(mqtt_password)
if mqtt_use_tls is not None:
updates.append("mqtt_use_tls = ?")
params.append(1 if mqtt_use_tls else 0)
if mqtt_tls_insecure is not None:
updates.append("mqtt_tls_insecure = ?")
params.append(1 if mqtt_tls_insecure else 0)
if mqtt_topic_prefix is not None:
updates.append("mqtt_topic_prefix = ?")
params.append(mqtt_topic_prefix)
if mqtt_publish_messages is not None:
updates.append("mqtt_publish_messages = ?")
params.append(1 if mqtt_publish_messages else 0)
if mqtt_publish_raw_packets is not None:
updates.append("mqtt_publish_raw_packets = ?")
params.append(1 if mqtt_publish_raw_packets else 0)
if community_mqtt_enabled is not None:
updates.append("community_mqtt_enabled = ?")
params.append(1 if community_mqtt_enabled else 0)
if community_mqtt_iata is not None:
updates.append("community_mqtt_iata = ?")
params.append(community_mqtt_iata)
if community_mqtt_broker_host is not None:
updates.append("community_mqtt_broker_host = ?")
params.append(community_mqtt_broker_host)
if community_mqtt_broker_port is not None:
updates.append("community_mqtt_broker_port = ?")
params.append(community_mqtt_broker_port)
if community_mqtt_email is not None:
updates.append("community_mqtt_email = ?")
params.append(community_mqtt_email)
if flood_scope is not None:
updates.append("flood_scope = ?")
params.append(flood_scope)