diff --git a/README.md b/README.md index 3ec06e60..a0c2310b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ A Python bot that connects to MeshCore mesh networks via serial port, BLE, or TC 1. Clone the repository: ```bash -git clone +git clone https://github.com/agessaman/meshcore-bot cd meshcore-bot ``` diff --git a/config.ini.example b/config.ini.example index 0dfbcf50..5303dfec 100644 --- a/config.ini.example +++ b/config.ini.example @@ -1723,9 +1723,11 @@ enabled = false # flood_scope = #west # Daily weather forecast time -# Format: HH:MM (24-hour format, e.g., "6:00" for 6 AM) +# Single time: HH:MM (24-hour format, e.g., "6:00" for 6 AM) +# Multiple times: comma-separated (e.g., "6:00, 12:00, 18:00") +# Interval: "every hour", "every 2 hours", "every 30 minutes", or "@hourly" # Or use "sunrise" or "sunset" for dynamic times based on your location -# Bot will send daily weather forecast at this time +# Bot will send weather forecast at the configured schedule weather_alarm = 6:00 # NOTE: Temperature, wind speed, and precipitation units are inherited from [Weather] section diff --git a/docs/command-reference.md b/docs/command-reference.md index 7f87b7d3..c5520303 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -86,27 +86,34 @@ greetings ### `cmd` -List available commands in compact format. +List available commands in compact format, or return a configured reference URL. **Usage:** ``` cmd ``` -**Response:** Compact list of all available commands. +**Response:** Compact list of all available commands, unless `[Cmd_Command]` sets `cmd_reference_url` — then the bot replies with `Full command reference: ` instead of the inline list. + +**Configuration:** `[Cmd_Command]` — `enabled`, `cmd_reference_url` (optional). --- -### `version` +### `version` or `ver` Show the bot's current software version. +**Aliases:** `ver` + **Usage:** ``` version +ver ``` -**Response:** Version string for the running MeshCore bot build. +**Response:** `@[User] Bot version: v0.9.x` (or branch-commit on non-release builds). + +**Configuration:** `[Version_Command]` — `enabled` (default true). --- @@ -170,9 +177,11 @@ wxa 10001 **Response:** Current weather conditions, forecast for tonight/tomorrow, and active weather alerts. Includes: - Current conditions (temperature, humidity, wind, etc.) -- Short-term forecast (tonight, tomorrow) +- Short-term forecast (tonight, tomorrow) with **high/low** temperatures when available - Weather alerts if any are active +**Configuration:** `[Weather]` — `weather_provider` (`noaa` or `openmeteo`), `temperature_high_low_format`, `use_bot_location_when_no_location`, and optional **`custom.mqtt_weather.*`** topics for MQTT-sourced weather. + **Note:** Weather alerts are automatically included when available. --- @@ -203,11 +212,11 @@ gwx 35.6762,139.6503 - Current temperature and conditions - Feels-like temperature - Wind speed and direction -- Humidity -- Dew point -- Visibility -- Pressure -- Forecast for today/tonight and tomorrow +- Humidity, dew point, visibility, pressure +- Forecast with **high/low** temperatures +- **Multi-day** forecasts when requested (e.g. `gwx Tokyo 7` or `7d` suffix — see `config.ini.example`) + +**Configuration:** `[Weather]` — `openmeteo_model` (model selection), `use_bot_location_when_no_location` (fallback when no location in message), `temperature_high_low_format`, and optional **`custom.mqtt_weather.`** MQTT topics. --- @@ -313,7 +322,7 @@ overhead , - `ladd` - Show only LADD aircraft - `pia` - Show only PIA aircraft - `squawk=` - Filter by transponder squawk code -- `limit=` - Limit number of results (default: 10, max: 50) +- `limit=` - Limit API fetch size (overrides `[Airplanes_Command]` `max_results` for this request) - `closest` - Sort by distance (closest first) - `highest` - Sort by altitude (highest first) - `fastest` - Sort by speed (fastest first) @@ -332,15 +341,14 @@ airplanes 47.6,-122.3 radius=25 closest ``` **Response:** -- **Single aircraft**: Detailed format with callsign, type, altitude, speed, track, distance, bearing, vertical rate, and registration -- **Multiple aircraft**: Compact list format with callsign, altitude, speed, distance, and bearing +- **Single aircraft** (`overhead`): Detailed format with callsign, type, altitude, speed, track, distance, bearing, vertical rate, and registration +- **Multiple aircraft**: All matches are packed into **one mesh message** (RF length limited). If not everything fits, the reply ends with `...+N more` for the remainder count -**Configuration:** -The command can be configured in `config.ini` under `[Airplanes_Command]`: +**Configuration:** `[Airplanes_Command]`: - `enabled` - Enable/disable the command - `api_url` - API endpoint URL (default: `http://api.airplanes.live/v2/`) - `default_radius` - Default search radius in nautical miles -- `max_results` - Maximum number of results to return +- `max_results` - Default API result cap (`0` = no configured cap; output is still bounded by single-message RF limits) - `url_timeout` - API request timeout in seconds **Note:** Uses companion location from database if available, otherwise falls back to bot location from config. The API is rate-limited to 1 request per second. @@ -663,6 +671,32 @@ catfact --- +### RandomLine (configurable triggers) + +Not a single fixed command name — **`[RandomLine]`** defines trigger words that return a random line from a text file. Useful for fortunes, facts, or custom responses. + +**Example config** (see `config.ini.example`): + +```ini +[RandomLine] +triggers.fortune = fortune,fortunes +file.fortune = data/randomlines/fortunes.txt +prefix.fortune = 🥠 +``` + +**Usage:** Send an exact trigger word (e.g. `fortune`) as the message or command stem. + +**Options per entry:** +- `triggers.` — Comma-separated trigger words (exact match after parsing) +- `file.` — Path to line-delimited text file (BSD fortune format supported) +- `prefix.` — String prepended to the chosen line +- `channel.` / `channels.` — Restrict to specific channels +- `category.` — Website command-reference category + +There is no separate built-in `fortune` command — use RandomLine with a fortunes file. + +--- + ## Sports Commands ### `sports` @@ -690,7 +724,9 @@ sports mlb ### `path` or `decode` or `route` -Decode and display the routing path of a message. +Decode and display the routing path of a message. Supports **multi-byte** hop encodings (1-, 2-, and 3-byte prefixes) when present in the path. + +**Aliases:** `decode`, `route` **Usage:** ``` @@ -699,7 +735,13 @@ decode route ``` -**Response:** Shows the complete routing path the message took through the mesh network, including all intermediate nodes. +**Response:** Routing path through the mesh, including intermediate nodes. Repeater selection may use graph validation, proximity scoring, and configured presets. + +**Configuration:** `[Path_Command]` — see [Path Command](path-command-config.md) for presets, graph settings, and tuning. Key option: + +- **`geographic_scoring_enabled`** (default `true`) — When `false`, geographic proximity guessing is disabled for path decode. This is a **config** toggle, not a chat subcommand. + +Optional **`enable_p_shortcut`** (default true) allows abbreviated path selection flows documented in the Path Command guide. --- @@ -1054,9 +1096,11 @@ View configured scheduled messages and advert interval. schedule ``` -**Response:** Lists configured scheduled posts (each line shows the cron or preset schedule, or legacy `HH:MM` if you still use deprecated HHMM keys) plus current advert timing. +**Response:** Lists scheduled posts from `[Scheduled_Messages]` (cron or preset schedule, or legacy `HH:MM` keys) plus current advert timing. Scoped entries show regional flood scope when configured (`channel:#scope:message`). + +**Configuration:** `[Schedule_Command]` — `enabled`, `dm_only` (default true: schedule is visible in DMs only unless changed). -**Note:** DM-only command by default. +**Note:** Admin-level visibility; configure `dm_only = false` to allow channel use. --- diff --git a/docs/config-validation.md b/docs/config-validation.md index b3a10b75..dd7df8ce 100644 --- a/docs/config-validation.md +++ b/docs/config-validation.md @@ -47,6 +47,7 @@ The bot will not start without these sections. The validator reports them as **e - `[WebViewer]` → use `[Web_Viewer]` - `[FeedManager]` → use `[Feed_Manager]` - `[Jokes]` → use `[Joke_Command]` / `[DadJoke_Command]` (see [Configuration](configuration.md) and [Upgrade](upgrade.md) for legacy support). + - **`[Aliases]`** — Deprecated in v0.9. Move entries to per-command `aliases =` under each `*_Command` section. The validator may report this as **info**; see [Upgrade guide](upgrade.md#upgrading-from-v08--v09). - **Unknown sections** (not in the canonical list and not a `*_Command` section) are reported as **info**; the validator may suggest a similar section name if it looks like a command. ### Optional sections (info only) @@ -56,6 +57,9 @@ If these are absent, the validator reports **info** (no error): - **`[Admin_ACL]`** – Absent means admin commands (repeater, webviewer, reload, channelpause) are disabled. - **`[Banned_Users]`** – Absent means no users are banned. - **`[Localization]`** – Absent means defaults (e.g. `language=en`, `translation_path=translations/`) are used. +- **`[Rate_Limits]`**, **`[Webhook]`** – Optional; no error if absent. + +The validator does not deeply validate `[Rate_Limits]` key shapes or `[Webhook]` API settings — refer to `config.ini.example` and [Configuration](configuration.md). ### Public channel guard diff --git a/docs/configuration.md b/docs/configuration.md index b5c8b0e4..e9d3116d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,12 @@ The main sections include: | `[Keywords]` | Keyword → response pairs | | `[Weather]` | Units and settings shared by `wx` / `gwx` and Weather Service | | `[Logging]` | Log file path and level | +| `[Web_Viewer]` | Web dashboard (host, port, password, auto_start) | +| `[Data_Retention]` | Database table retention periods — see [Data retention](data-retention.md) | +| `[Rate_Limits]` | Per-channel minimum seconds between bot messages | +| `[Webhook]` | Inbound HTTP POST relay to channels or DMs | +| `[Version_Command]` | `version` / `ver` command | +| `[Schedule_Command]` | `schedule` command visibility | ### Connection: transport reconnect @@ -128,6 +134,35 @@ Common per-command options (when supported by that command): - Comma list: only those channels - **`aliases`** – Extra trigger words for that command, comma-separated **stems only** (e.g. `aliases = weather, w`). Do not put the bot's **`command_prefix`** or punctuation in this value (no `!` or `.`) +### Per-command aliases (v0.9) + +The global **`[Aliases]`** section is **deprecated**. Define aliases in each command’s own section: + +```ini +[Wx_Command] +enabled = true +aliases = weather, w +``` + +Remove any legacy `[Aliases]` block when upgrading. See [Upgrade guide](upgrade.md#upgrading-from-v08--v09). + +### Rate limiting + +**`[Rate_Limits]`** sets per-channel minimum seconds between bot messages: + +```ini +[Rate_Limits] +channel.BotCmds_seconds = 15 +``` + +Channels without an entry are unrestricted. Global and per-user rate limits remain under `[Bot]`. + +### Inbound webhook + +**`[Webhook]`** runs an HTTP server that accepts POST requests and relays JSON payloads to MeshCore channels or DMs. See `config.ini.example` for `enabled`, `host`, `port`, `secret_token`, `allowed_channels`, and `rate_limit_per_minute`. Use bearer token or `X-Webhook-Token` when `secret_token` is set. + +Bind to `127.0.0.1` unless your firewall restricts access. Default port **8765** (must not conflict with the web viewer on **8080**). + Full reference: see `config.ini.example` in the repository for every section and option, with inline comments. ## Data retention @@ -140,6 +175,8 @@ The Path command has many options (presets, proximity, graph validation, etc.). **[Path Command](path-command-config.md)** – Presets, geographic and graph settings, and tuning. +Key option: **`geographic_scoring_enabled`** (default `true`) in `[Path_Command]` — when `false`, geographic proximity guessing is disabled for path decode. + ## Service plugin configuration Service plugins (Discord Bridge, Telegram Bridge, Packet Capture, Map Uploader, Weather Service, Earthquake Service, Repeater Prefix Collision Service, and Webhook Service) each have their own section and are documented under [Service Plugins](service-plugins.md). The MQTT weather relay uses the `MqttWeather` section plus custom topic keys under `[Weather]`. diff --git a/docs/data-retention.md b/docs/data-retention.md index 0fbe3dea..4e35c679 100644 --- a/docs/data-retention.md +++ b/docs/data-retention.md @@ -1,6 +1,6 @@ # Data retention -The bot stores data in a SQLite database for the web viewer, stats, repeater management, and path routing. To limit database size, **data retention** controls how long rows are kept. Cleanup runs **daily** from the bot’s scheduler, so retention is enforced even when the standalone web viewer is not running. +The bot stores data in a SQLite database for the web viewer, stats, repeater management, and path routing. To limit database size, **data retention** controls how long rows are kept. Cleanup runs **daily** from the bot’s APScheduler-based maintenance loop, so retention is enforced even when the standalone web viewer is not running. ## Configuration diff --git a/docs/discord-bridge.md b/docs/discord-bridge.md index c636b623..df277580 100644 --- a/docs/discord-bridge.md +++ b/docs/discord-bridge.md @@ -5,6 +5,7 @@ The Discord Bridge service posts MeshCore channel messages to Discord channels v **Features:** - One-way message flow (MeshCore → Discord only) - Multi-channel mapping (map multiple MeshCore channels to different Discord channels) +- **Multi-webhook fan-out** — comma-separated webhook URLs per MeshCore channel - Webhook-based (simple, secure, no bot permissions needed) - **DMs are NEVER bridged** (hardcoded for privacy) - Rate limit monitoring (warns when approaching Discord's limits) @@ -29,8 +30,9 @@ Edit `config.ini`: [DiscordBridge] enabled = true -# Map MeshCore channels to Discord webhooks +# Map MeshCore channels to Discord webhooks (comma-separated for multiple destinations) bridge.general = https://discord.com/api/webhooks/YOUR_WEBHOOK_URL_HERE +# bridge.alerts = https://discord.com/api/webhooks/URL1,https://discord.com/api/webhooks/URL2 ``` ### 3. Restart Bot diff --git a/docs/docker.md b/docs/docker.md index 76bbb785..2a398902 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -204,13 +204,13 @@ docker-compose up -d --build ## Using Pre-built Images -If you're using GitHub Container Registry images: +Official images are published to **GitHub Container Registry** (`ghcr.io/agessaman/meshcore-bot`) on tagged releases and `main`. 1. **Update docker-compose.yml** to use the image: ```yaml services: meshcore-bot: - image: ghcr.io/your-username/meshcore-bot:latest + image: ghcr.io/agessaman/meshcore-bot:latest # Remove or comment out the 'build' section ``` @@ -220,6 +220,26 @@ If you're using GitHub Container Registry images: docker-compose up -d ``` +## Multi-architecture images + +CI builds multi-platform images with SBOM and provenance attestations: + +| Platform | Typical hardware | +|----------|------------------| +| `linux/amd64` | x86-64 servers and desktops | +| `linux/arm64` | Raspberry Pi 4/5 (64-bit OS), Apple Silicon via emulation | +| `linux/arm/v7` | Raspberry Pi 3 and older (32-bit Raspberry Pi OS) | + +On ARM devices, pull the matching platform (Docker usually selects automatically): + +```bash +docker pull --platform linux/arm64 ghcr.io/agessaman/meshcore-bot:latest +``` + +Or build locally for your architecture with `docker compose build`. + +**Non-Docker installs:** Debian packages are available via `make deb` in the repository. See the [Upgrade guide](upgrade.md). + ## Troubleshooting ### Permission Issues @@ -468,7 +488,7 @@ docker-compose up -d 4. **Secrets**: Never commit API keys or sensitive data to version control. Use environment variables or secrets management -5. **Web viewer**: If enabled, ensure it's only accessible on trusted networks or use a reverse proxy with authentication +5. **Web viewer**: Set `web_viewer_password` in `[Web_Viewer]` when `host = 0.0.0.0`. Use a reverse proxy with TLS on untrusted networks. See [Web Viewer](web-viewer.md). ## Connecting COM Ports to Docker on Windows 11 {#connecting-com-ports-to-docker-on-windows-11} @@ -536,4 +556,6 @@ serial_port = /dev/ttyUSB0 - [Docker Documentation](https://docs.docker.com/) - [Docker Compose Documentation](https://docs.docker.com/compose/) -- [Main README](https://github.com/agessaman/meshcore-bot/blob/main/README.md) for general bot configuration. +- [Main README](https://github.com/agessaman/meshcore-bot/blob/main/README.md) for general bot configuration +- [Upgrade guide](upgrade.md) for v0.9 migration notes +- [Web Viewer](web-viewer.md) for dashboard configuration diff --git a/docs/faq.md b/docs/faq.md index 8c6fbe69..bdea73e3 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -22,11 +22,21 @@ Without `--upgrade`, the script does *not* update the service file (systemd/laun **Recommendation:** Use `./install-service.sh --upgrade` after `git pull` when you want to upgrade; that updates files, dependencies, and the service, and reloads the service, while keeping your `config.ini` intact. +### I'm upgrading to v0.9. What do I need to change? + +v0.9 requires **Python 3.10+** and **`meshcore >= 2.3.6`**. Your `config.ini` is preserved by the install script, but you should review: + +- Remove global **`[Aliases]`** and use per-command `aliases =` instead +- Set **`web_viewer_password`** if the web viewer is exposed beyond localhost +- Start the bot once so **database migrations** run + +See the full checklist in the [Upgrade guide](upgrade.md). + ### I moved a previous database into a new install; the bot runs but I see "Error processing message queue" or "Error processing channel operations". What should I do? Moving an old database into a new install can cause those errors when: -1. **Schema mismatch** — The old DB may be missing tables or columns (e.g. `feed_message_queue`, `channel_operations`, or `message_send_interval_seconds` on `feed_subscriptions`). The bot runs `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE` on startup, so missing tables/columns are usually added. If the exception in the log is `no such column`, the schema in the copied DB is older than expected; ensure the bot has started at least once so migrations run. +1. **Schema mismatch** — The old DB may be missing tables or columns. v0.9 uses versioned migrations (`MigrationRunner`) at startup. Ensure you are on the latest code and start the bot at least once so migrations complete. If the log shows `no such column`, the copied DB may be from a much older release — see the [Upgrade guide](upgrade.md). 2. **Stale queue/ops** — Pending rows in `feed_message_queue` or `channel_operations` from the old install may reference channels or feeds that don’t exist or differ on the new install. You can clear them so the scheduler stops hitting errors (with the bot stopped). If `sqlite3` is not installed, use Python instead: - Clear unsent queue and pending channel ops (Python; no extra packages): ```bash diff --git a/docs/getting-started.md b/docs/getting-started.md index 02355bef..c19a7323 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,7 +20,9 @@ Get meshcore-bot running on your machine in a few minutes. 2. **Configure** - Copy an example config and edit with your connection and bot settings: + **Interactive (recommended):** `make config` — ncurses editor for `config.ini`. + + Or copy an example config and edit with your connection and bot settings: - **Full config** (all commands and options): ```bash @@ -89,5 +91,8 @@ meshcore-bot.url = "github:agessaman/meshcore-bot/"; ## Next steps - **[Command Reference](command-reference.md)** — Full command reference (wx, aqi, sun, path, prefix, etc.) +- **[Upgrade guide](upgrade.md)** — Migrating to v0.9 from older releases +- **[Config validation](config-validation.md)** — Validate `config.ini` before first run +- **[Data retention](data-retention.md)** — Database cleanup defaults - **[README](https://github.com/agessaman/meshcore-bot/blob/main/README.md)** — Features, keywords, configuration overview - **Guides** (sidebar) — Path command, repeater commands, feeds, weather service, Discord bridge, map uploader, packet capture diff --git a/docs/index.md b/docs/index.md index 9e7bfa65..5b68653e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,15 @@ Documentation for the MeshCore bot: setup, configuration, commands, and services - [Command Reference](command-reference.md) – Full command reference - [Docker deployment](docker.md) – Docker deployment - [Service installation](service-installation.md) – Systemd service setup -- [Web Viewer](web-viewer.md) – Web viewer module +- [Web Viewer](web-viewer.md) – Authenticated dashboard with live streams + +## Operations + +| Document | Description | +|----------|-------------| +| [Upgrade guide](upgrade.md) | Migrating from v0.7, v0.8, or earlier to v0.9 | +| [Data retention](data-retention.md) | Database cleanup schedules and defaults | +| [FAQ](faq.md) | Common installation and upgrade questions | ## Configuration @@ -26,7 +34,7 @@ Documentation for the MeshCore bot: setup, configuration, commands, and services |----------|-------------| | [Repeater Commands](repeater-commands.md) | Repeater management DM commands | | [Feed Management](FEEDS.md) | RSS/REST feeds and posting to channels | -| [Web Viewer](web-viewer.md) | Web-based data viewer and API | +| [Web Viewer](web-viewer.md) | Web dashboard, real-time streams, and API | ## Service Plugins diff --git a/docs/installation.md b/docs/installation.md index 0b346b89..d4d783d0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,5 +6,17 @@ Choose how to run the bot: |--------|----------| | **[Docker](docker.md)** | Containers, consistent environments, easy updates | | **[Service (systemd)](service-installation.md)** | Linux servers, run at boot, no containers | +| **Debian package** | `make deb` in the repo — see [README](https://github.com/agessaman/meshcore-bot/blob/main/README.md) | + +## Requirements + +- **Python 3.10+** +- MeshCore-compatible device (USB, BLE, or TCP) + +## Development setup See [Getting started](getting-started.md) for a quick development setup (run from the repo with `python meshcore_bot.py`). + +## Upgrading + +If you are upgrading from an older release, read the [Upgrade guide](upgrade.md) before restarting the bot. diff --git a/docs/path-command-config.md b/docs/path-command-config.md index 460aeb1e..240c34c2 100644 --- a/docs/path-command-config.md +++ b/docs/path-command-config.md @@ -262,6 +262,17 @@ These settings control how graph edges are stored in the database. - Final hop proximity: enabled with lower weight - Good for: Well-connected networks with strong graph evidence +## Geographic scoring toggle + +**`geographic_scoring_enabled`** in `[Path_Command]` (default `true`): + +- When **`true`**, geographic proximity scoring is used during path decode (subject to other preset and graph settings). +- When **`false`**, geographic proximity guessing is disabled entirely for path decode. + +This is a **configuration** option only — there is no chat subcommand to toggle it at runtime. Restart the bot (or reload config if supported) after changing it. + +See also the [`path` command](command-reference.md#path-or-decode-or-route) in the command reference. + ## Typical LoRa Transmission Ranges - **Typical transmission**: < 30km diff --git a/docs/service-installation.md b/docs/service-installation.md index 5b14f175..9f1846d7 100644 --- a/docs/service-installation.md +++ b/docs/service-installation.md @@ -5,10 +5,12 @@ This guide explains how to install the MeshCore Bot as a systemd service on Linu ## Prerequisites - Linux system with systemd -- Python 3.7+ (Python 3.12+ recommended; on 3.11 the meshcore dependency has an f-string bug — the install script patches it automatically) +- **Python 3.10+** (3.11–3.13 supported in CI; Python 3.9 is not supported in v0.9) - Root/sudo access - MeshCore-compatible device +**Alternative:** Install from a Debian package (`make deb` in the repo) instead of the install script. See the [README](https://github.com/agessaman/meshcore-bot/blob/main/README.md) for build instructions. + ## Quick Installation 1. **Clone and navigate to the bot directory:** @@ -37,6 +39,16 @@ This guide explains how to install the MeshCore Bot as a systemd service on Linu sudo systemctl status meshcore-bot ``` +## Upgrading + +After `git pull` in the repository (or copying new files), run: + +```bash +sudo ./install-service.sh --upgrade +``` + +This updates installed files and dependencies, refreshes the systemd unit, and reloads the service **without overwriting** your existing `config.ini`. See [Upgrade guide](upgrade.md) for v0.9 migration notes (Python 3.10+, config aliases, database migrations, etc.). + ## Manual Installation If you prefer to install manually: @@ -111,13 +123,21 @@ The bot configuration is located at `/opt/meshcore-bot/config.ini`. Edit it with sudo nano /opt/meshcore-bot/config.ini ``` -After changing configuration, you can reload in place (no process restart): +After changing configuration, you can reload many settings without a full restart: ```bash sudo systemctl reload meshcore-bot ``` -Use restart when connection/radio settings changed (serial port, BLE target, TCP host/port, timeout): +Or use the admin reload API (requires `[Admin] enabled = true` in `config.ini`): + +```bash +./scripts/reload_config.sh /opt/meshcore-bot/config.ini +``` + +This is equivalent to the admin DM command `reload`. It reloads in-process config; it does **not** reconnect the radio. + +Use **restart** when connection/radio settings change (serial port, BLE target, TCP host/port, timeout): ```bash sudo systemctl restart meshcore-bot @@ -150,15 +170,14 @@ sudo systemctl restart meshcore-bot 3. Check configuration: `sudo nano /opt/meshcore-bot/config.ini` 4. Verify dependencies: `sudo pip3 list | grep meshcore` -### SyntaxError: f-string: unmatched '[' (Python 3.11) -If the bot fails on import with this error in `meshcore/commands/contact.py`, you are on Python 3.11 and the **meshcore** dependency uses an f-string that only works on Python 3.12+. +### SyntaxError: f-string: unmatched '[' (Python 3.11, older meshcore) + +If the bot fails on import with this error in `meshcore/commands/contact.py`, you may be on an older **meshcore** package. v0.9 requires **`meshcore >= 2.3.6`**. **Options:** -- **Recommended:** Use Python 3.12+ (create the venv with `python3.12` if available, then re-run `./install-service.sh --upgrade`). -- **Or:** Re-run the install script so it can patch the installed package: - `sudo ./install-service.sh --upgrade` - The script detects Python 3.11 and patches the meshcore file in the venv. -- **Manual patch:** Edit `/opt/meshcore-bot/venv/lib/python3.11/site-packages/meshcore/commands/contact.py`, find the line containing `contact["adv_name"]` inside the f-string, and change it to `contact['adv_name']` (single quotes around `adv_name`). +- Re-run `./install-service.sh --upgrade` to refresh the venv with current requirements. +- Or use Python 3.12+ for the venv: `python3.12 -m venv ...` then re-run the install script. +- On Python 3.11 only, the install script can patch legacy meshcore f-string issues in the venv automatically. ### Permission Issues 1. Check file ownership: `ls -la /opt/meshcore-bot/` diff --git a/docs/upgrade.md b/docs/upgrade.md index 3ff5ed5b..2c1a6f7c 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -1,21 +1,115 @@ # Upgrade Guide -This document describes changes that may affect users upgrading from previous versions. +This document describes changes that may affect users upgrading from previous versions. Read the section that matches the version you are upgrading **from** (not the version you are installing). + +For a full list of v0.9 changes, see [CHANGELOG.md](https://github.com/agessaman/meshcore-bot/blob/main/CHANGELOG.md). + +## Upgrading from v0.8 → v0.9 + +v0.9 is a large release focused on operational reliability, observability, and deployment ergonomics. Your existing `config.ini` continues to work; review the items below after pulling the new code. + +### Python and dependencies + +- **Python 3.10+** is required (Python 3.9 is no longer supported). Rebuild your virtual environment or re-run `./install-service.sh --upgrade` with a system Python 3.10 or newer. +- **`meshcore >= 2.3.6`** is required. This fixes negative `out_path_len` encoding (#126) and `KeyError('msg_hash')` parser spam (#83). + +### Config changes + +- **Command aliases** — The global **`[Aliases]`** section is removed. Move each alias list to the corresponding command section as `aliases = stem1, stem2` (stems only; no command prefix). See [Configuration](configuration.md#per-command-aliases-v09). +- **`max_response_hops`** — Shipped config templates now default to **7** (was 10). The code fallback when unset is still 64. Review this if you relied on the old template default. +- **New optional sections** (safe to omit): + - **`[Rate_Limits]`** — Per-channel minimum seconds between bot messages. See [Configuration](configuration.md#rate-limiting). + - **`[Webhook]`** — Inbound HTTP POST relay to channels or DMs. See [Configuration](configuration.md#inbound-webhook). + - Radio reliability options under **`[Bot]`** (zombie-radio detection, send suppression during outages, etc.) — see `config.ini.example`. + +### Database + +- Schema upgrades are handled automatically via versioned migrations (`MigrationRunner` / `AsyncDBManager`). Start the bot once after upgrading; migrations run at startup. +- If you see migration errors, ensure you are on the latest code and restart once. See [FAQ](faq.md) for database troubleshooting. + +### Web viewer + +- Set **`web_viewer_password`** when exposing the viewer beyond localhost (`host = 0.0.0.0`). Password is optional on localhost but strongly recommended on a LAN or the internet. +- Mutating routes use **CSRF** protection when authenticated. +- New pages and real-time streams (packets, commands, messages, logs, mesh graph). See [Web Viewer](web-viewer.md). + +### Scheduler and config reload + +- The scheduler uses **APScheduler**; maintenance tasks live in a separate module. +- Some configuration can be reloaded without a full restart via **`reload_config.sh`**, the admin HTTP server, or the admin **`reload`** command. Radio/connection settings still require a full bot restart. + +### Packaging + +- **Debian package:** `make deb` (see README). +- **Docker:** Multi-architecture images (amd64, arm64, armv7) on GHCR. See [Docker deployment](docker.md). + +### Security review + +- Outbound HTTP uses SSRF hardening; review integrations that fetch URLs. +- SMTP: use **`allow_local_smtp`** only if you intentionally relay to local mail servers. +- User-supplied strings in logs are sanitized to reduce log-injection risk. + +### New commands and behavior + +- **`version`** / **`ver`** — Reports bot software version. +- **`schedule`** — Lists scheduled messages and advert interval (admin). +- **`path`** — Multi-byte path support; **`geographic_scoring_enabled`** in `[Path_Command]` toggles proximity guessing (config only, not a chat subcommand). +- **Weather** — High/low temperatures, Open-Meteo model selection, MQTT weather, location fallback, multi-day forecasts. +- **Airplanes** — Sends all matching aircraft in one RF-bounded message (see [Command Reference](command-reference.md)). +- **RandomLine** — Trigger-based random lines (including fortunes via `[RandomLine]`); no separate `fortune` command. +- **Discord bridge** — Multiple webhook URLs per channel (comma-separated). + +--- + +## Upgrading from v0.7 → v0.8 + +If you are coming from v0.7 and skipping v0.8, also read [Upgrading from v0.8 → v0.9](#upgrading-from-v08--v09) above. + +### Path command and mesh graph + +- **Multi-byte paths** — Path decoding supports 1-, 2-, and 3-byte hop encodings. Configure **`prefix_bytes`** and graph options under **`[Path_Command]`**. See [Path Command](path-command-config.md). + +### Flood scopes and regional messaging + +- **`flood_scopes`** — Allowlist of regional TC_FLOOD scopes the bot accepts. +- **`outgoing_flood_scope_override`** — Optional fixed outbound scope for proactive sends. +- **Scheduled messages** — Support scoped channel posts (`channel:#scope:message` syntax). + +### Local plugins + +- Drop custom command modules in **`modules/local/`** and enable via **`local_plugins`** in config. See [Local plugins](local-plugins.md). + +### Web viewer and database + +- The web viewer can share the bot’s SQLite database (`[Bot] db_path`) so contacts, mesh graph, and packet stream appear in one place. +- Optional **`collect_stats = true`** under `[Stats_Command]` populates dashboard stats when the `stats` chat command is disabled. + +### Bridges and services + +- Discord and Telegram bridges gained bot-response bridging and additional options. See [Discord Bridge](discord-bridge.md) and [Telegram Bridge](telegram-bridge.md). + +### Service installation + +- Chunked message sends for long responses; improved shutdown and scheduler hardening when running under systemd. + +--- ## Upgrading from v0.7 -### Config Compatibility +If you upgraded through v0.8, see the sections above for v0.8 and v0.9 changes. The notes below apply specifically to configs that have not been updated since v0.7. + +### Config compatibility Previous config files continue to work. The following legacy config formats are supported: - **`[Jokes]`** with `joke_enabled` / `dadjoke_enabled` — Migrated to `[Joke_Command]` and `[DadJoke_Command]` with `enabled`. Both formats work; consider updating to the new format. - **`[Stats]` / `stats_enabled`**, **`[Sports]` / `sports_enabled`**, **`[Hacker]` / `hacker_enabled`**, **`[Alert_Command]` / `alert_enabled`** — All support the legacy `*_enabled` key; the new `enabled` key is preferred. -### Banned Users: Prefix Matching +### Banned users: prefix matching `[Banned_Users]` uses **prefix (starts-with) matching** for `banned_users` entries. A banned entry `"Awful Username"` matches both `"Awful Username"` and `"Awful Username 🍆"`. If you rely on exact matching, ensure your banned entries are specific enough. -### New Optional Sections +### New optional sections - **`[Feed_Manager]`** — If you use RSS/API feeds, add this section. If absent, the feed manager is disabled. New installs and minimal configs include `[Feed_Manager]` with `feed_manager_enabled = false`. -- **`[Path_Command]`** — New options like `path_selection_preset`, `enable_p_shortcut` (default: true), and graph-related settings. Omitted options use sensible defaults. +- **`[Path_Command]`** — Options like `path_selection_preset`, `enable_p_shortcut` (default: true), and graph-related settings. Omitted options use sensible defaults. See [Path Command](path-command-config.md). diff --git a/docs/weather-service.md b/docs/weather-service.md index 01154987..6dd7aeab 100644 --- a/docs/weather-service.md +++ b/docs/weather-service.md @@ -18,6 +18,8 @@ my_position_lon = -122.3321 # Daily forecast time weather_alarm = 6:00 # Or "sunrise" / "sunset" +# weather_alarm = 6:00, 12:00, 18:00 # Multiple times per day +# weather_alarm = every hour # Or every 2 hours, every 30 minutes, @hourly # Channels weather_channel = #weather @@ -37,7 +39,7 @@ alerts_channel = #weather enabled = true my_position_lat = 47.6062 # Your latitude (required) my_position_lon = -122.3321 # Your longitude (required) -weather_alarm = 6:00 # Time for daily forecast (HH:MM or sunrise/sunset) +weather_alarm = 6:00 # Time(s) for forecast (see Scheduling Options below) weather_channel = #weather # Channel for forecasts alerts_channel = #weather # Channel for weather alerts ``` @@ -108,6 +110,8 @@ Sends forecast to `weather_channel` at configured time: **Scheduling Options:** - Fixed time: `weather_alarm = 6:00` (24-hour format) +- Multiple times: `weather_alarm = 6:00, 12:00, 18:00` +- Interval: `weather_alarm = every hour` (also `every 2 hours`, `every 30 minutes`, `@hourly`) - Sunrise: `weather_alarm = sunrise` - Sunset: `weather_alarm = sunset` diff --git a/docs/web-viewer.md b/docs/web-viewer.md index 0a61800c..bf644340 100644 --- a/docs/web-viewer.md +++ b/docs/web-viewer.md @@ -1,250 +1,206 @@ -# MeshCore Bot Data Viewer +# Web Viewer -A web-based interface for viewing and analyzing data from your MeshCore Bot. +A browser-based dashboard for monitoring and managing your MeshCore bot. The viewer shares the bot’s SQLite database by default and provides real-time streams, contact management, mesh graph visualization, radio control, and in-browser configuration. ## Features -- **Dashboard**: Overview of database statistics and bot status -- **Repeater Contacts**: View active repeater contacts with location and status information -- **Contact Tracking**: Complete history of all heard contacts with signal strength and routing data -- **Config panel**: Structured settings with categorized topics and database tools -- **Purging Log**: Audit trail of contact purging operations -- **Real-time Updates**: Auto-refreshes every 30 seconds -- **API Endpoints**: JSON API for programmatic access +- **Contacts** — Live contact list with signal, path, and location; star contacts; purge inactive contacts; export CSV/JSON +- **Mesh graph** — Interactive node graph at `/mesh` +- **Radio** — Channel management, reboot, connect/disconnect radio +- **Feeds** — RSS/API feed subscriptions per channel +- **Packets** — Raw packet monitor +- **Live activity** — Real-time packet/command/message feed at `/realtime` (pause and clear) +- **Logs** — Real-time log viewer at `/logs` with level filtering +- **Config** — SMTP, log rotation, backups, maintenance status at `/config` +- **Admin config** — Read-only effective config with secrets redacted at `/admin/config` +- **API Explorer** — Interactive API documentation at `/api-explorer` +- **Operational banners** — Initializing, zombie-radio, and radio-offline alerts when applicable +- **Version footer** — Displays resolved bot version -## Quick Start +For SMTP and nightly maintenance email details, see the [README Web Viewer section](https://github.com/agessaman/meshcore-bot/blob/main/README.md#web-viewer). -### Option 1: Standalone Mode -```bash -# Install Flask if not already installed -pip3 install flask - -# Start the web viewer (reads config from config.ini) -python3 -m modules.web_viewer.app +## Quick start -# Or use the restart script for standalone mode -./restart_viewer.sh +### Integrated with the bot (recommended) -# Override configuration with command line arguments -python3 -m modules.web_viewer.app --port 8080 --host 0.0.0.0 -``` +1. Edit `config.ini`: -### Option 2: Integrated with Bot -1. Edit `config.ini` and set: ```ini [Web_Viewer] enabled = true auto_start = true host = 127.0.0.1 - port = 5000 + port = 8080 + web_viewer_password = yourpassword ``` -2. The web viewer will start automatically with the bot +2. Start the bot. The viewer starts automatically when `auto_start = true`. + +3. Open `http://localhost:8080` (or `http://:8080` if `host = 0.0.0.0`). + +### Standalone mode + +Use standalone mode only for debugging or when the bot is not running: + +```bash +pip install flask flask-socketio +python3 -m modules.web_viewer.app --config config.ini --host 127.0.0.1 --port 8080 +``` + +The viewer reads `[Web_Viewer]` from the config file. Override host/port on the command line if needed. ## Configuration -The web viewer can be configured in the `[Web_Viewer]` section of `config.ini`: +All options are in the **`[Web_Viewer]`** section of `config.ini`: ```ini [Web_Viewer] -# Enable or disable the web data viewer -enabled = true - -# Web viewer host address -# 127.0.0.1: Only accessible from localhost -# 0.0.0.0: Accessible from any network interface +enabled = false +# web_viewer_password = changeme host = 127.0.0.1 - -# Web viewer port -port = 5000 - -# Enable debug mode for the web viewer +port = 8080 debug = false - -# Auto-start web viewer with bot auto_start = false # Optional: enable the multibyte monitor page and API multibyte_monitor_enabled = false ``` -## Accessing the Viewer +| Option | Description | +|--------|-------------| +| `enabled` | Enable the web viewer | +| `web_viewer_password` | If set, login is required for all routes and Socket.IO. If empty, auth is disabled (not recommended when `host = 0.0.0.0`) | +| `host` | `127.0.0.1` (localhost only) or `0.0.0.0` (all interfaces) | +| `port` | HTTP port (default **8080**, range 1024–65535) | +| `debug` | Flask debug mode (development only) | +| `auto_start` | Start viewer when the bot starts | +| `decode_hashtag_channels` | Comma-separated hashtag channels to decrypt in the packet stream without adding them to the radio | +| `db_path` | Optional separate DB path; if unset, uses `[Bot] db_path` (recommended) | + +**Security:** Using `host = 0.0.0.0` without `web_viewer_password` logs an error at startup. Always set a password when exposing the viewer on a LAN or the internet. + +## Authentication and security + +When `web_viewer_password` is set: + +- A **login page** protects all HTML routes and Socket.IO connections. +- **CSRF protection** applies to mutating HTTP POST requests. +- **Security headers** are set on responses. -Once started, open your web browser and navigate to: -- **Local access**: http://localhost:5005 (or your configured port) -- **Network access**: http://YOUR_BOT_IP:5005 (if host is set to 0.0.0.0) +When the password is empty, the UI is reachable without login. Use `host = 127.0.0.1` for localhost-only access, or set a password before binding to `0.0.0.0`. -## Pages Overview +For production deployments on untrusted networks, also use a reverse proxy with TLS and restrict access by firewall. -### Dashboard -- Database status and statistics -- Contact counts and cache information -- Quick navigation to other sections +## Pages overview -### Repeater Contacts -- Active repeater contacts -- Location information (city/coordinates) -- Device types and status -- First/last seen timestamps -- Purge count tracking +| Path | Purpose | +|------|---------| +| `/` | Dashboard — database stats, quick navigation | +| `/contacts` | Repeater contacts and contact tracking | +| `/mesh` | Interactive mesh network graph | +| `/radio` | Radio settings and control | +| `/feeds` | Feed manager subscriptions | +| `/realtime` | Live packet, command, and message activity | +| `/logs` | Real-time bot log stream | +| `/config` | Notifications, log rotation, backups, maintenance | +| `/admin/config` | Effective config (secrets redacted) | +| `/api-explorer` | API documentation and try-it UI | +| `/stats` | Message/command/path statistics | +| `/greeter` | Greeter configuration | +| `/cache` | Cache inspection | -### Contact Tracking -- Complete history of all heard contacts -- Signal strength indicators -- Hop count and routing information -- Advertisement data -- Currently tracked status +Legacy routes such as `/cache` remain for compatibility; primary navigation is from the dashboard navbar. -### Config -- Categorized configuration topics in a left navigation column -- Core settings such as notifications, log rotation, backup, and maintenance status -- Database operations and database information views in the same tab +## Real-time streams -### Purging Log -- Audit trail of contact purging operations -- Timestamps and reasons -- Contact names and public keys +The viewer uses **Socket.IO** for live data. After connecting, the client emits subscription events; the navbar indicator reflects connection state (subscriptions are silent—no per-subscribe toast). -## API Endpoints +| Client event | Data | +|--------------|------| +| `subscribe_packets` | Raw packet stream | +| `subscribe_commands` | Command invocations | +| `subscribe_messages` | Channel messages | +| `subscribe_logs` | Log lines | +| `subscribe_mesh` | Mesh graph updates | -The viewer also provides JSON API endpoints: +Use **Live activity** (`/realtime`) for a combined color-coded feed, or subscribe from custom clients via the same events. -- `GET /api/stats` - Database statistics -- `GET /api/contacts` - Repeater contacts data -- `GET /api/tracking` - Contact tracking data +## API endpoints + +JSON APIs are available for automation (authentication required when `web_viewer_password` is set). Examples: -Example usage: ```bash -curl http://localhost:5000/api/stats +curl http://localhost:8080/api/stats +curl http://localhost:8080/api/contacts +curl http://localhost:8080/api/mesh/nodes ``` -## Database Requirements +See **API Explorer** (`/api-explorer`) for the full list of routes and request formats. -The viewer uses the same database as the bot by default (`[Bot] db_path`, typically `meshcore_bot.db`). That single file holds repeater contacts, mesh graph, packet stream, and other data so the viewer can show everything. +## Database -**Dashboard stats** (message/command counts, top users, etc.) come from the stats tables (`message_stats`, `command_stats`, `path_stats`). To populate these when the `stats` chat command is disabled, you can set the optional config under `[Stats_Command]`: `collect_stats = true`. +The viewer uses the same database as the bot by default (`[Bot] db_path`, typically `meshcore_bot.db`). That file holds repeater contacts, mesh graph, packet stream, and related tables. -## Migrating from a separate web viewer database +**Dashboard stats** (message/command/path counts) come from `message_stats`, `command_stats`, and `path_stats`. To populate these when the `stats` chat command is disabled, set under `[Stats_Command]`: -If you previously had the web viewer using a **separate** database (e.g. `[Web_Viewer] db_path = bot_data.db`), you can switch to the shared database so the viewer shows repeater/graph data and uses one file. +```ini +collect_stats = true +``` -1. **Stop the bot and web viewer** so neither has the databases open. +**Packet stream retention** is controlled by `[Data_Retention] packet_stream_retention_days`. See [Data retention](data-retention.md). -2. **Optionally preserve packet stream history** from the old viewer DB into the main DB: - - From the project root, run: - ```bash - python3 migrate_webviewer_db.py bot_data.db meshcore_bot.db - ``` - Use your actual paths if they differ (e.g. full paths or different filenames). The script copies the `packet_stream` table from the first file into the second and skips rows that would duplicate IDs. - - If you don’t care about old packet stream data, skip this step; the viewer will create a new `packet_stream` table in the main DB. +## Migrating from a separate web viewer database -3. **Point the viewer at the main database** in `config.ini`: - ```ini - [Web_Viewer] - db_path = meshcore_bot.db +If you previously used a separate viewer database (e.g. `[Web_Viewer] db_path = bot_data.db`): + +1. **Stop the bot and viewer** so neither has the databases open. + +2. **Optionally migrate packet stream history:** + ```bash + python3 migrate_webviewer_db.py bot_data.db meshcore_bot.db ``` - (Or the same value as `[Bot] db_path` if you use a different path.) + Adjust paths as needed. The script copies `packet_stream` rows and skips duplicates. -4. **Start the bot (and viewer as usual)**. The viewer will now read and write to the same database as the bot. +3. **Remove or comment out** `[Web_Viewer] db_path` so the viewer uses `[Bot] db_path`. -You can keep or remove the old `bot_data.db` file after verifying the viewer works with the shared DB. +4. **Start the bot** and verify the viewer shows contacts and mesh data. ## Troubleshooting ### Web viewer not accessible (e.g. Orange Pi / SBC) -If the viewer does not load from another device (e.g. from your phone or PC while the bot runs on an Orange Pi), work through these steps on the Pi. - -1. **Confirm config** - - In `config.ini` under `[Web_Viewer]`: - - `enabled = true` - - `auto_start = true` (if you want it to start with the bot) - - `host = 0.0.0.0` (required for access from other devices; `127.0.0.1` is localhost only) - - `port = 8080` (or another port 1024–65535) - - Restart the bot after changing config. - -2. **Check that the viewer process is running** - ```bash - # From project root on the Pi - ss -tlnp | grep 8080 - # or - netstat -tlnp | grep 8080 - ``` - If nothing listens on your port, the viewer did not start or has exited. - -3. **Inspect viewer logs** - - When run by the bot, the viewer writes to: - - `logs/web_viewer_stdout.log` - - `logs/web_viewer_stderr.log` - - Look for Python tracebacks, "Address already in use", or missing dependencies (e.g. Flask, flask-socketio). - - Optional: run the viewer manually to see errors in the terminal: - ```bash - cd /path/to/meshcore-bot - python3 modules/web_viewer/app.py --config config.ini --host 0.0.0.0 --port 8080 - ``` - -4. **Check integration startup** - - Bot logs may show: `Web viewer integration failed: ...` or `Web viewer integration initialized`. - - If integration failed, the viewer subprocess is never started; fix the error shown (e.g. invalid `host` or `port` in config). - -5. **Firewall** - - Many SBC images (e.g. Orange Pi, Armbian minimal) do **not** ship with a firewall; if `curl` to localhost works and `host = 0.0.0.0`, the blocker may be network (Wi‑Fi client isolation, different subnet, or router). Check from a device on the same LAN using `http://:8080`. - - If your system uses **ufw**: - ```bash - sudo ufw status - sudo ufw allow 8080/tcp - sudo ufw reload - ``` - - If `ufw` is not installed (e.g. `sudo: ufw: command not found`), you may have no host firewall—that’s common on embedded images. To allow the port with **iptables** (often available when ufw is not): - ```bash - sudo iptables -I INPUT -p tcp --dport 8080 -j ACCEPT - ``` - (Rules may not persist across reboots unless you use a persistence method for your distro.) - - If you prefer ufw, install it (e.g. `sudo apt install ufw`) and use the ufw commands above. - -6. **Test from the Pi first** +1. **Confirm config** under `[Web_Viewer]`: + - `enabled = true` + - `auto_start = true` (if starting with the bot) + - `host = 0.0.0.0` for access from other devices + - `port = 8080` (or another port 1024–65535) + - `web_viewer_password` set when using `0.0.0.0` +2. **Check the port is listening:** `ss -tlnp | grep 8080` +3. **Inspect logs:** `logs/web_viewer_stdout.log`, `logs/web_viewer_stderr.log` +4. **Bot integration:** Look for `Web viewer integration initialized` or `Web viewer integration failed` in bot logs. +5. **Firewall:** Allow TCP on your viewer port (`ufw allow 8080/tcp` or equivalent). +6. **Test locally:** `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/` +7. **Standalone debug:** ```bash - curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/ + python3 -m modules.web_viewer.app --config config.ini --host 0.0.0.0 --port 8080 ``` - If this returns `200`, the viewer is running and the issue is binding or firewall. If you use `host = 0.0.0.0`, then try from another device: `http://:8080`. -7. **Standalone run (no bot)** - - To rule out bot integration issues, start the viewer by itself (same config path so it finds the DB): - ```bash - python3 modules/web_viewer/app.py --config config.ini --host 0.0.0.0 --port 8080 - ``` - - If `restart_viewer.sh` is used, note it binds to `127.0.0.1` by default; for network access run the command above with `--host 0.0.0.0` or edit the script. +### Flask not found -### Flask Not Found ```bash -pip3 install flask flask-socketio +pip install flask flask-socketio ``` -### Database Not Found -- Ensure the bot has been run at least once to create the databases -- Check file permissions on database files - -### Port Already in Use -- Change the port in `config.ini` or stop the conflicting service -- Use `ss -tlnp | grep 8080` or `lsof -i :8080` (if available) to find what's using the port +### Database not found -### Permission Denied -```bash -chmod +x restart_viewer.sh -``` +- Run the bot at least once to create the database. +- Check file permissions on the DB path. -## Security Notes +### Port already in use -- The web viewer is designed for local network use -- Set `host = 127.0.0.1` for localhost-only access -- Set `host = 0.0.0.0` for network access (use with caution) -- No authentication is implemented - consider firewall rules for production use +- Change `port` in `config.ini` or stop the conflicting service. +- `ss -tlnp | grep 8080` or `lsof -i :8080` to find the process. -## Future Enhancements +### Login required but password forgotten -- Live packet streaming -- Real-time message monitoring -- Interactive contact management -- Export functionality -- Authentication system -- Mobile-responsive design improvements +- Set a new `web_viewer_password` in `config.ini` and restart the bot (or viewer process). diff --git a/modules/service_plugins/weather_alarm_schedule.py b/modules/service_plugins/weather_alarm_schedule.py new file mode 100644 index 00000000..7e5c5412 --- /dev/null +++ b/modules/service_plugins/weather_alarm_schedule.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Parse ``[Weather_Service] weather_alarm`` schedule expressions.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.base import BaseTrigger + + +@dataclass(frozen=True) +class WeatherAlarmSchedule: + """Parsed weather forecast schedule.""" + + mode: str + """``fixed``, ``interval``, or ``sun_event``.""" + + fixed_times: list[tuple[int, int]] = field(default_factory=list) + interval_hours: int | None = None + interval_minutes: int | None = None + sun_event: str | None = None + display: str = "" + + +def parse_clock_time(time_str: str) -> tuple[int, int]: + """Parse ``HH:MM``, ``H:MM``, or legacy ``HHMM`` into hour and minute. + + Raises: + ValueError: If the time string is invalid. + """ + raw = (time_str or "").strip() + if not raw: + raise ValueError("empty time") + + if ":" in raw: + parts = raw.split(":", 1) + hour = int(parts[0]) + minute = int(parts[1]) + elif len(raw) == 4 and raw.isdigit(): + hour = int(raw[:2]) + minute = int(raw[2:]) + else: + raise ValueError(f"invalid time format: {time_str!r}") + + if not (0 <= hour <= 23 and 0 <= minute <= 59): + raise ValueError(f"invalid time value: {time_str!r}") + + return hour, minute + + +def parse_weather_alarm_schedule(raw: str) -> WeatherAlarmSchedule: + """Parse ``weather_alarm`` config value. + + Supported forms: + - Fixed clock time: ``6:00``, ``0600`` + - Multiple times: ``6:00, 12:00, 18:00`` + - Interval: ``every hour``, ``every 2 hours``, ``every 30 minutes``, ``@hourly`` + - Sun events: ``sunrise``, ``sunset`` + """ + value = (raw or "6:00").strip() + lowered = value.lower() + + if lowered in ("sunrise", "sunset"): + return WeatherAlarmSchedule(mode="sun_event", sun_event=lowered, display=lowered) + + if lowered == "@hourly": + return WeatherAlarmSchedule( + mode="interval", + interval_hours=1, + display="every hour", + ) + + hour_match = re.fullmatch(r"every\s+(?:(\d+)\s+)?hours?", lowered) + if hour_match: + hours = int(hour_match.group(1) or "1") + if not (1 <= hours <= 24): + raise ValueError(f"invalid hourly interval: {hours}") + label = "every hour" if hours == 1 else f"every {hours} hours" + return WeatherAlarmSchedule( + mode="interval", + interval_hours=hours, + display=label, + ) + + minute_match = re.fullmatch(r"every\s+(\d+)\s+minutes?", lowered) + if minute_match: + minutes = int(minute_match.group(1)) + if not (1 <= minutes <= 59): + raise ValueError(f"invalid minute interval: {minutes}") + label = "every minute" if minutes == 1 else f"every {minutes} minutes" + return WeatherAlarmSchedule( + mode="interval", + interval_minutes=minutes, + display=label, + ) + + if "," in value: + parts = [part.strip() for part in value.split(",") if part.strip()] + if not parts: + raise ValueError("empty schedule list") + fixed_times = [parse_clock_time(part) for part in parts] + display = ", ".join(f"{hour:02d}:{minute:02d}" for hour, minute in fixed_times) + return WeatherAlarmSchedule(mode="fixed", fixed_times=fixed_times, display=display) + + hour, minute = parse_clock_time(value) + return WeatherAlarmSchedule( + mode="fixed", + fixed_times=[(hour, minute)], + display=f"{hour:02d}:{minute:02d}", + ) + + +def build_forecast_cron_triggers( + schedule: WeatherAlarmSchedule, + timezone, +) -> list[tuple[str, BaseTrigger, str]]: + """Build APScheduler triggers for a parsed schedule. + + Fixed times use ``CronTrigger``; intervals use ``IntervalTrigger`` so that + the period is counted from the moment the scheduler starts rather than + being pinned to clock boundaries (e.g. 00:00, 02:00, 04:00 …). + + Returns: + List of ``(job_id, trigger, label)`` tuples. + """ + if schedule.mode == "fixed": + triggers: list[tuple[str, BaseTrigger, str]] = [] + for hour, minute in schedule.fixed_times: + label = f"{hour:02d}:{minute:02d}" + triggers.append( + ( + f"weather_forecast_{hour:02d}{minute:02d}", + CronTrigger(hour=hour, minute=minute, timezone=timezone), + label, + ) + ) + return triggers + + if schedule.mode == "interval": + if schedule.interval_hours is not None: + trigger = IntervalTrigger(hours=schedule.interval_hours, timezone=timezone) + return [("weather_forecast_interval", trigger, schedule.display)] + + if schedule.interval_minutes is not None: + trigger = IntervalTrigger(minutes=schedule.interval_minutes, timezone=timezone) + return [("weather_forecast_interval", trigger, schedule.display)] + + raise ValueError(f"unsupported schedule mode: {schedule.mode}") diff --git a/modules/service_plugins/weather_service.py b/modules/service_plugins/weather_service.py index a9d8b24f..98dccded 100644 --- a/modules/service_plugins/weather_service.py +++ b/modules/service_plugins/weather_service.py @@ -16,7 +16,6 @@ import ephem import requests from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -44,6 +43,11 @@ from ..url_shortener import shorten_url from ..utils import format_temperature_high_low, get_config_timezone from .base_service import BaseServicePlugin +from .weather_alarm_schedule import ( + WeatherAlarmSchedule, + build_forecast_cron_triggers, + parse_weather_alarm_schedule, +) class WeatherService(BaseServicePlugin): @@ -65,7 +69,8 @@ def __init__(self, bot: Any): super().__init__(bot) # Configuration - self.weather_alarm_time = self.bot.config.get('Weather_Service', 'weather_alarm', fallback='6:00') + self.weather_alarm_raw = self.bot.config.get('Weather_Service', 'weather_alarm', fallback='6:00') + self.weather_schedule = self._load_weather_schedule(self.weather_alarm_raw) self.my_position_lat = self.bot.config.getfloat('Weather_Service', 'my_position_lat', fallback=None) self.my_position_lon = self.bot.config.getfloat('Weather_Service', 'my_position_lon', fallback=None) self.weather_channel = self.bot.config.get('Weather_Service', 'weather_channel', fallback='general') @@ -152,12 +157,29 @@ def __init__(self, bot: Any): self.mqtt_task: Optional[asyncio.Task] = None # Check if using sunrise/sunset - self.use_sunrise_sunset = self.weather_alarm_time.lower() in ['sunrise', 'sunset'] + self.use_sunrise_sunset = self.weather_schedule.mode == 'sun_event' # Cache for location name (to avoid repeated reverse geocoding) self._cached_location_name: Optional[str] = None - self.logger.info(f"Weather service initialized: position=({self.my_position_lat}, {self.my_position_lon}), alarm={self.weather_alarm_time}") + self.logger.info( + "Weather service initialized: position=(%s, %s), alarm=%s", + self.my_position_lat, + self.my_position_lon, + self.weather_schedule.display, + ) + + def _load_weather_schedule(self, raw: str) -> WeatherAlarmSchedule: + """Load and validate weather forecast schedule from config.""" + try: + return parse_weather_alarm_schedule(raw) + except ValueError as exc: + self.logger.warning( + "Invalid weather_alarm %r (%s), falling back to 6:00", + raw, + exc, + ) + return parse_weather_alarm_schedule('6:00') def _load_weather_model(self) -> Optional[str]: """Load and normalize Open-Meteo model selection from config. @@ -250,8 +272,8 @@ async def start(self) -> None: # For sunrise/sunset, use a background task that reschedules daily self._forecast_task = asyncio.create_task(self._sunrise_sunset_forecast_loop()) else: - # For fixed times, use APScheduler (BackgroundScheduler + daily cron) - self._setup_daily_forecast() + # For fixed times and intervals, use APScheduler cron jobs + self._setup_forecast_schedule() # Start background tasks self._alerts_task = asyncio.create_task(self._poll_weather_alerts_loop()) @@ -324,17 +346,9 @@ async def stop(self) -> None: self.logger.info("Weather service stopped") - def _setup_daily_forecast(self) -> None: - """Setup daily weather forecast schedule for fixed times (APScheduler cron, bot timezone).""" + def _setup_forecast_schedule(self) -> None: + """Setup weather forecast schedule (fixed times or intervals, bot timezone).""" try: - # Parse time (format: "HH:MM" or "H:MM") - if ':' in self.weather_alarm_time: - hour, minute = map(int, self.weather_alarm_time.split(':')) - else: - # Assume format "HHMM" - hour = int(self.weather_alarm_time[:2]) - minute = int(self.weather_alarm_time[2:]) - if self._forecast_scheduler is not None: try: self._forecast_scheduler.shutdown(wait=False) @@ -343,29 +357,31 @@ def _setup_daily_forecast(self) -> None: self._forecast_scheduler = None tz, _ = get_config_timezone(self.bot.config, self.logger) + triggers = build_forecast_cron_triggers(self.weather_schedule, tz) self._forecast_scheduler = BackgroundScheduler(timezone=tz) - self._forecast_scheduler.add_job( - self._send_daily_forecast, - CronTrigger(hour=hour, minute=minute), - id="weather_daily_forecast", - replace_existing=True, - ) + for job_id, trigger, label in triggers: + self._forecast_scheduler.add_job( + self._send_daily_forecast, + trigger, + id=job_id, + replace_existing=True, + ) self._forecast_scheduler.start() + labels = ", ".join(label for _, _, label in triggers) self.logger.info( - "Scheduled daily weather forecast at %02d:%02d (%s)", - hour, - minute, + "Scheduled weather forecast (%s) in %s", + labels, getattr(tz, "zone", tz), ) except Exception as e: - self.logger.error(f"Error setting up daily forecast schedule: {e}") + self.logger.error(f"Error setting up forecast schedule: {e}") async def _sunrise_sunset_forecast_loop(self) -> None: """Background task for sunrise/sunset-based forecasts. Calculates daily sunrise/sunset times and schedules the forecast accordingly. """ - event_type = self.weather_alarm_time.lower() + event_type = self.weather_schedule.sun_event or 'sunrise' self.logger.info(f"Starting {event_type}-based forecast loop") while self._running: diff --git a/tests/unit/test_weather_alarm_schedule.py b/tests/unit/test_weather_alarm_schedule.py new file mode 100644 index 00000000..69c7afbe --- /dev/null +++ b/tests/unit/test_weather_alarm_schedule.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Unit tests for weather_alarm schedule parsing.""" + +import pytest + +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger + +from modules.service_plugins.weather_alarm_schedule import ( + build_forecast_cron_triggers, + parse_clock_time, + parse_weather_alarm_schedule, +) + + +def test_parse_clock_time_hhmm_colon(): + assert parse_clock_time("6:00") == (6, 0) + assert parse_clock_time("06:30") == (6, 30) + + +def test_parse_clock_time_legacy_hhmm(): + assert parse_clock_time("0630") == (6, 30) + + +def test_parse_weather_alarm_single_fixed_time(): + schedule = parse_weather_alarm_schedule("6:00") + assert schedule.mode == "fixed" + assert schedule.fixed_times == [(6, 0)] + assert schedule.display == "06:00" + + +def test_parse_weather_alarm_multiple_fixed_times(): + schedule = parse_weather_alarm_schedule("6:00, 12:00, 18:00") + assert schedule.mode == "fixed" + assert schedule.fixed_times == [(6, 0), (12, 0), (18, 0)] + assert schedule.display == "06:00, 12:00, 18:00" + + +def test_parse_weather_alarm_every_hour_variants(): + assert parse_weather_alarm_schedule("every hour").interval_hours == 1 + assert parse_weather_alarm_schedule("every 1 hour").interval_hours == 1 + assert parse_weather_alarm_schedule("every 2 hours").interval_hours == 2 + assert parse_weather_alarm_schedule("@hourly").interval_hours == 1 + + +def test_parse_weather_alarm_every_minutes(): + schedule = parse_weather_alarm_schedule("every 30 minutes") + assert schedule.mode == "interval" + assert schedule.interval_minutes == 30 + + +def test_parse_weather_alarm_sun_events(): + sunrise = parse_weather_alarm_schedule("sunrise") + assert sunrise.mode == "sun_event" + assert sunrise.sun_event == "sunrise" + + sunset = parse_weather_alarm_schedule("Sunset") + assert sunset.mode == "sun_event" + assert sunset.sun_event == "sunset" + + +def test_parse_weather_alarm_invalid_time(): + with pytest.raises(ValueError): + parse_clock_time("25:00") + + +def test_build_forecast_cron_triggers_fixed_times(): + schedule = parse_weather_alarm_schedule("6:00, 18:00") + triggers = build_forecast_cron_triggers(schedule, "UTC") + assert len(triggers) == 2 + assert triggers[0][0] == "weather_forecast_0600" + assert triggers[1][0] == "weather_forecast_1800" + # Fixed times must use CronTrigger + assert isinstance(triggers[0][1], CronTrigger) + assert isinstance(triggers[1][1], CronTrigger) + + +def test_build_forecast_cron_triggers_hourly(): + schedule = parse_weather_alarm_schedule("every hour") + triggers = build_forecast_cron_triggers(schedule, "UTC") + assert len(triggers) == 1 + assert triggers[0][0] == "weather_forecast_interval" + # Intervals must use IntervalTrigger, not CronTrigger + assert isinstance(triggers[0][1], IntervalTrigger) + + +def test_build_forecast_cron_triggers_every_n_hours(): + schedule = parse_weather_alarm_schedule("every 2 hours") + triggers = build_forecast_cron_triggers(schedule, "UTC") + assert len(triggers) == 1 + assert triggers[0][0] == "weather_forecast_interval" + assert isinstance(triggers[0][1], IntervalTrigger) + + +def test_build_forecast_cron_triggers_every_minutes(): + schedule = parse_weather_alarm_schedule("every 30 minutes") + triggers = build_forecast_cron_triggers(schedule, "UTC") + assert len(triggers) == 1 + assert triggers[0][0] == "weather_forecast_interval" + assert isinstance(triggers[0][1], IntervalTrigger) diff --git a/weather_alarm_schedule.py b/weather_alarm_schedule.py new file mode 100644 index 00000000..e69de29b