mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 14:22:22 +00:00
merge(dev): resolve scheduler conflict in PR #163
Merge latest dev into the PR branch and keep both DateTrigger and EventType imports so scheduler changes remain compatible and conflict-free. Made-with: Cursor
This commit is contained in:
commit
ad4de524f8
34 files changed
+2758
-196
No files matched your search
@@ -25,6 +25,7 @@ wheels/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
node_modules/
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
@@ -41,6 +42,7 @@ htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage.json
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
A Python bot that connects to MeshCore mesh networks via serial port, BLE, or TCP/IP. The bot responds to messages containing configured keywords, executes commands, and provides various data services including weather, solar conditions, and satellite pass information. A web viewer provides a browser-based dashboard for monitoring and managing the bot.
|
||||
|
||||
|
||||
> [!CAUTION]
|
||||
> Before installing this bot, please take a moment to _truly_ consider if your mesh needs another bot. If there are already several bots on your mesh, it is likely that you are adding congestion without adding value.
|
||||
>
|
||||
> It is not recommended to run more than one bot on a single channel. Adding another bot to a channel already in use on your mesh may result in users unnecessarily receiving double responses, depleting mesh airtime for little additional value.
|
||||
>
|
||||
> If you decide to run the bot on your mesh, please take advantage of regions or hop limits to ensure that your bot is helping your neighbors not flooding the entire mesh.
|
||||
|
||||
## Features
|
||||
|
||||
- **Connection Methods**: Serial port, BLE (Bluetooth Low Energy), or TCP/IP
|
||||
@@ -28,6 +36,9 @@ A Python bot that connects to MeshCore mesh networks via serial port, BLE, or TC
|
||||
- **Packet Capture**: Capture and publish packets to MQTT brokers ([docs](docs/packet-capture.md))
|
||||
- **Map Uploader**: Upload node adverts to map.meshcore.dev ([docs](docs/map-uploader.md))
|
||||
- **Weather Service**: Scheduled forecasts, alerts, and lightning detection ([docs](docs/weather-service.md))
|
||||
- **Earthquake Service**: Scheduled USGS earthquake alerts for a configured region ([docs](docs/earthquake-service.md))
|
||||
- **Repeater Prefix Collision Service**: Detect and alert on repeater prefix collisions ([docs](docs/repeater-prefix-collision-service.md))
|
||||
- **MQTT Weather Relay**: Publish weather data from custom MQTT topics (configured via `MqttWeather` + `[Weather]`)
|
||||
- **Webhook Service**: Accept inbound HTTP POST payloads and relay to channels or DMs
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -178,6 +178,15 @@ startup_advert = false
|
||||
# false: Manual mode - no automatic actions, use !repeater commands to manage contacts (default)
|
||||
auto_manage_contacts = bot
|
||||
|
||||
# When auto_manage_contacts = device, the bot schedules one-shot jobs after connect:
|
||||
# - Firmware: manual per-type contact adds + overwrite oldest non-favourite on full + auto-add chat (companions) only (bitmask 0x03)
|
||||
# - Favourite hygiene: Admin_ACL and announcements ACL pubkeys are favourited on the radio; other favourites are cleared so eviction can recycle slots
|
||||
# Delays (seconds) before each step so the radio is not flooded at boot (defaults shown)
|
||||
#device_mode_firmware_delay_seconds = 30
|
||||
#device_mode_favourite_pass1_delay_seconds = 90
|
||||
#device_mode_favourite_pass2_delay_seconds = 180
|
||||
#contact_flag_update_spacing_ms = 200
|
||||
|
||||
# Database path for main bot database
|
||||
# Default: meshcore_bot.db
|
||||
db_path = meshcore_bot.db
|
||||
@@ -313,6 +322,7 @@ admin_commands = repeater,webviewer,reload,channelpause
|
||||
# Enable companion contact purging
|
||||
# true: Purge inactive companions when contact list is full
|
||||
# false: Never purge companions (default: false for safety)
|
||||
# When [Bot] auto_manage_contacts=device, companion auto-purge from the radio is always skipped (firmware slot policy).
|
||||
companion_purge_enabled = false
|
||||
|
||||
# Days since last DM to consider companion inactive
|
||||
@@ -841,6 +851,15 @@ mesh_connections_retention_days = 7
|
||||
# Enable or disable the path command
|
||||
enabled = true
|
||||
|
||||
# Require minimum path byte length before responding to this command
|
||||
# 0 = allow all (default)
|
||||
# 1 = allow all (compatibility alias for 0)
|
||||
# 2 = only respond when path has 2 bytes or more (e.g. 0101...)
|
||||
# 3 = only respond when path is exactly 3 bytes (e.g. 010101)
|
||||
require_path_bytes_greater_or_equal_to = 0
|
||||
# Optional response when rejected by path-byte requirement; leave empty for silent reject
|
||||
require_path_bytes_failure_response =
|
||||
|
||||
# Enable "p" shortcut for path command (similar to "t" for test command)
|
||||
# true: Respond to just "p" or "p <path_data>" as a shortcut for "path" (default)
|
||||
# false: Only respond to "path", "decode", or "route" keywords
|
||||
@@ -1085,6 +1104,14 @@ enabled = false
|
||||
response_format = @[{sender}] found {path_count} unique path(s):\n{paths}
|
||||
# condense_paths: false = one full path per line; true = flat condensed tree (default); nested = indented ├/└ under ┐ sub-blocks (uses ASCII spaces, not U+2502, to save bytes vs old │).
|
||||
condense_paths = true
|
||||
# Require minimum path byte length for triggering and collected packets
|
||||
# 0 = allow all (default)
|
||||
# 1 = allow all (compatibility alias for 0)
|
||||
# 2 = only include paths with 2 bytes or more (e.g. 0101...)
|
||||
# 3 = only include paths with exactly 3 bytes (e.g. 010101)
|
||||
require_path_bytes_greater_or_equal_to = 0
|
||||
# Optional response when rejected by path-byte requirement; leave empty for silent reject
|
||||
require_path_bytes_failure_response =
|
||||
|
||||
[Greeter_Command]
|
||||
# Enable greeter to greet users on first channel message (true/false)
|
||||
@@ -1314,6 +1341,14 @@ enabled = true
|
||||
|
||||
[Test_Command]
|
||||
enabled = true
|
||||
# Require minimum path byte length before responding to test/t
|
||||
# 0 = allow all (default)
|
||||
# 1 = allow all (compatibility alias for 0)
|
||||
# 2 = only respond when path has 2 bytes or more (e.g. 0101...)
|
||||
# 3 = only respond when path is exactly 3 bytes (e.g. 010101)
|
||||
require_path_bytes_greater_or_equal_to = 0
|
||||
# Optional response when rejected by path-byte requirement; leave empty for silent reject
|
||||
require_path_bytes_failure_response =
|
||||
# channels =
|
||||
|
||||
[Trace_Command]
|
||||
@@ -1448,6 +1483,12 @@ verbose = false
|
||||
# false: Standard logging
|
||||
debug = false
|
||||
|
||||
# Skip RAW_DATA publish when the same hex was just handled from RX_LOG_DATA (seconds).
|
||||
raw_duplicate_window = 2.0
|
||||
|
||||
# Keep RX_LOG SNR/RSSI keyed by packet prefix this long for RAW_DATA correlation (seconds).
|
||||
rf_data_cache_timeout = 15.0
|
||||
|
||||
# Owner information (for packet analyzer registration)
|
||||
# Owner public key (64-character hex string)
|
||||
owner_public_key =
|
||||
@@ -1872,6 +1913,31 @@ enabled = false
|
||||
# bridge.Public = @YourChannelName
|
||||
# bridge.emergency = -1001234567890
|
||||
|
||||
[DARC_MoWaS_Service]
|
||||
# Enable DARC MoWaS (Mobile Warning System) integration for receiving and posting warnings
|
||||
# For legal requirements and backend configuration, please read https://www.darc.de/index.php?id=58435 before operating.
|
||||
# Please also ensure to limit the scope of the messages to the revelant region (via Channels.flood_scope).
|
||||
enabled = false
|
||||
|
||||
# Listen address and port of webserver that receives the webhook POSTs from DARC MoWaS.
|
||||
# Must be accessible from the internet (or HAMNET) for MoWaS to reach it.
|
||||
# host = localhost
|
||||
# port = 8081
|
||||
|
||||
# Language specific channel for redistribution of warnings received from MoWaS.
|
||||
# The non german channels are only served if a warning is available in that language
|
||||
# Only "de" and "en" are currently supported as language codes.
|
||||
# The channels are automatically created if they don't exist.
|
||||
# channel_de = #mowas
|
||||
# channel_en = #mowas-en
|
||||
|
||||
# Download warning messages from MoWaS in HAMNET instead of internet.
|
||||
# hamnet = false
|
||||
|
||||
# Retry messages that are not acked by at least a single repeater after retry_timeout (in seconds)
|
||||
# retry_max = 2
|
||||
# retry_timeout = 15
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Check-in service (local plugin: local/service_plugins/checkin_service.py)
|
||||
# Put [CheckIn] in local/config.ini to keep main config clean. See docs/checkin-api.md for API contract.
|
||||
|
||||
+149
-5
@@ -97,6 +97,32 @@ cmd
|
||||
|
||||
---
|
||||
|
||||
### `version`
|
||||
|
||||
Show the bot's current software version.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
version
|
||||
```
|
||||
|
||||
**Response:** Version string for the running MeshCore bot build.
|
||||
|
||||
---
|
||||
|
||||
### `status`
|
||||
|
||||
Show current bot and radio status details.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
status
|
||||
```
|
||||
|
||||
**Response:** Runtime status summary (connection/health information).
|
||||
|
||||
---
|
||||
|
||||
## Information Commands
|
||||
|
||||
### `channels`
|
||||
@@ -302,6 +328,20 @@ sun
|
||||
|
||||
---
|
||||
|
||||
### `aurora`
|
||||
|
||||
Get aurora visibility/forecast conditions for configured or provided coordinates.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
aurora
|
||||
aurora <lat>,<lon>
|
||||
```
|
||||
|
||||
**Response:** Aurora activity and visibility guidance for the requested location.
|
||||
|
||||
---
|
||||
|
||||
### `moon`
|
||||
|
||||
Get moon phase information and moonrise/moonset times for the bot's configured location.
|
||||
@@ -498,6 +538,25 @@ roll 1000
|
||||
|
||||
---
|
||||
|
||||
### `magic8`
|
||||
|
||||
Ask the Magic 8-Ball a yes/no question.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
magic8 <question>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
magic8 will the mesh be busy tonight?
|
||||
magic8 should I deploy another node?
|
||||
```
|
||||
|
||||
**Response:** A randomized Magic 8-Ball style answer.
|
||||
|
||||
---
|
||||
|
||||
## Entertainment Commands
|
||||
|
||||
### `joke`
|
||||
@@ -557,6 +616,19 @@ ls -l /secret/base
|
||||
|
||||
---
|
||||
|
||||
### `catfact`
|
||||
|
||||
Get a random cat fact.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
catfact
|
||||
```
|
||||
|
||||
**Response:** A short random cat fact.
|
||||
|
||||
---
|
||||
|
||||
## Sports Commands
|
||||
|
||||
### `sports`
|
||||
@@ -683,8 +755,6 @@ mt
|
||||
|
||||
**Response:** List of all unique routing paths discovered during the 6-second listening period.
|
||||
|
||||
---
|
||||
|
||||
## Command Syntax
|
||||
|
||||
### Prefix
|
||||
@@ -823,9 +893,9 @@ repeater stats
|
||||
- NEW_CONTACT events are automatically monitored
|
||||
- Repeaters are automatically cataloged when discovered
|
||||
- Contact list capacity is monitored in real-time
|
||||
- `auto_manage_contacts = device`: Device handles auto-addition, bot manages capacity
|
||||
- `auto_manage_contacts = bot`: Bot automatically adds companion contacts and manages capacity
|
||||
- `auto_manage_contacts = false`: Manual mode - use `!repeater` commands to manage contacts
|
||||
- `auto_manage_contacts = device`: Firmware auto-adds **chat (companion)** peers only, with **overwrite oldest non-favourite** when the contact table is full; the bot schedules delayed jobs to set that firmware policy and to **favourite** keys in `Admin_ACL` plus the effective announcements ACL (same rules as the announcements command), then clear **favourite** on other contacts. The bot still runs capacity management on NEW_CONTACT (near-limit `manage_contact_list`) and does **not** call `add_contact` for new companions itself. **Contact limit** for logging and capacity is taken from the radio’s `max_contacts` and, if the live table is larger (under-reported max), raised to match the mesh so counts are not shown as over-capacity. **Companion auto-purge** never runs on the radio in this mode. Count-based **repeater** auto-purge only runs if the table grows **strictly above** that synced limit (normally off while the firmware manages slots).
|
||||
- `auto_manage_contacts = bot`: Bot adds new companions via `add_contact` (full NEW_CONTACT payload), runs **manage-before-add** when the list is near limit, and **retries once** after `manage_contact_list` if the radio returns `TABLE_FULL`.
|
||||
- `auto_manage_contacts = false`: Manual mode - NEW_CONTACT companions are tracked in the database only; use `!repeater` commands to manage the device list.
|
||||
|
||||
---
|
||||
|
||||
@@ -847,6 +917,50 @@ advert
|
||||
|
||||
---
|
||||
|
||||
### `reload`
|
||||
|
||||
Reload supported runtime configuration without restarting the bot.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
reload
|
||||
```
|
||||
|
||||
**Response:** Confirms reload success or reports validation/loading errors.
|
||||
|
||||
**Note:** Admin DM command. Connection/radio settings still require a process restart.
|
||||
|
||||
---
|
||||
|
||||
### `channelpause` / `channelresume`
|
||||
|
||||
Temporarily pause or resume bot reactions on public channels.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
channelpause
|
||||
channelresume
|
||||
```
|
||||
|
||||
**Response:** Confirms whether channel handling is paused or resumed.
|
||||
|
||||
**Note:** Admin DM command. DMs continue to work while channel responses are paused.
|
||||
|
||||
---
|
||||
|
||||
### `greeter`
|
||||
|
||||
Show greeter behavior and configuration guidance.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
greeter
|
||||
```
|
||||
|
||||
**Response:** Describes greeter mode and points to `[Greeter_Command]` settings in config.
|
||||
|
||||
---
|
||||
|
||||
### `feed`
|
||||
|
||||
Manage RSS feed and API feed subscriptions (Admin only).
|
||||
@@ -882,5 +996,35 @@ feed test https://example.com/feed.xml
|
||||
|
||||
---
|
||||
|
||||
### `announcements`
|
||||
|
||||
Manage announcement ACL and related announcement settings.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
announcements <subcommand> [args]
|
||||
```
|
||||
|
||||
**Response:** Shows current announcement ACL or confirms changes.
|
||||
|
||||
**Note:** Admin access required.
|
||||
|
||||
---
|
||||
|
||||
### `schedule`
|
||||
|
||||
View configured scheduled messages and advert interval.
|
||||
|
||||
**Usage:**
|
||||
```
|
||||
schedule
|
||||
```
|
||||
|
||||
**Response:** Lists upcoming scheduled posts and current advert timing.
|
||||
|
||||
**Note:** DM-only command by default.
|
||||
|
||||
---
|
||||
|
||||
For more information about configuring the bot, see the main [README](https://github.com/agessaman/meshcore-bot/blob/main/README.md) file.
|
||||
|
||||
@@ -132,7 +132,7 @@ The Path command has many options (presets, proximity, graph validation, etc.).
|
||||
|
||||
## Service plugin configuration
|
||||
|
||||
Service plugins (Discord Bridge, Packet Capture, Map Uploader, Weather Service) each have their own section and are documented under [Service Plugins](service-plugins.md).
|
||||
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]`.
|
||||
|
||||
## Config validation
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Get meshcore-bot running on your machine in a few minutes.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Python 3.7+**
|
||||
- **Python 3.10+**
|
||||
- **MeshCore-compatible device** (Heltec V3, RAK Wireless, etc.)
|
||||
- **Connection**: USB cable, BLE, or TCP/IP to the device
|
||||
|
||||
@@ -15,7 +15,7 @@ Get meshcore-bot running on your machine in a few minutes.
|
||||
```bash
|
||||
git clone https://github.com/agessaman/meshcore-bot.git
|
||||
cd meshcore-bot
|
||||
pip install -r requirements.txt
|
||||
make dev
|
||||
```
|
||||
|
||||
2. **Configure**
|
||||
@@ -36,7 +36,7 @@ Get meshcore-bot running on your machine in a few minutes.
|
||||
3. **Run**
|
||||
|
||||
```bash
|
||||
python3 meshcore_bot.py
|
||||
.venv/bin/python meshcore_bot.py
|
||||
```
|
||||
|
||||
## Inspect effective config safely
|
||||
@@ -44,8 +44,8 @@ Get meshcore-bot running on your machine in a few minutes.
|
||||
Use these commands to inspect the resolved config with sensitive keys redacted:
|
||||
|
||||
```bash
|
||||
python3 meshcore_bot.py --show-config --config config.ini
|
||||
python3 meshcore_bot.py --show-config-json --config config.ini
|
||||
.venv/bin/python meshcore_bot.py --show-config --config config.ini
|
||||
.venv/bin/python meshcore_bot.py --show-config-json --config config.ini
|
||||
```
|
||||
|
||||
Also available in the web UI at `/admin/config`.
|
||||
|
||||
+4
-1
@@ -25,7 +25,7 @@ Documentation for the MeshCore bot: setup, configuration, commands, and services
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Repeater Commands](repeater-commands.md) | Repeater management DM commands |
|
||||
| [Feed Management](feeds.md) | RSS/REST feeds and posting to channels |
|
||||
| [Feed Management](FEEDS.md) | RSS/REST feeds and posting to channels |
|
||||
| [Web Viewer](web-viewer.md) | Web-based data viewer and API |
|
||||
|
||||
## Service Plugins
|
||||
@@ -34,6 +34,9 @@ Documentation for the MeshCore bot: setup, configuration, commands, and services
|
||||
|----------|-------------|
|
||||
| [Service Plugins overview](service-plugins.md) | Enable and configure background services |
|
||||
| [Discord Bridge](discord-bridge.md) | One-way bridge to Discord |
|
||||
| [Telegram Bridge](telegram-bridge.md) | One-way bridge to Telegram |
|
||||
| [Earthquake Service](earthquake-service.md) | Scheduled earthquake alerts from USGS |
|
||||
| [Packet Capture](packet-capture.md) | Packet capture and MQTT |
|
||||
| [Map Uploader](map-uploader.md) | Uploading to map.meshcore.dev |
|
||||
| [Weather Service](weather-service.md) | Scheduled weather and alerts |
|
||||
| [Repeater Prefix Collision Service](repeater-prefix-collision-service.md) | Detect repeater prefix collisions |
|
||||
@@ -5,9 +5,12 @@ Service plugins extend the bot with background services that run alongside the m
|
||||
| Plugin | Description |
|
||||
|--------|-------------|
|
||||
| [Discord Bridge](discord-bridge.md) | One-way webhook bridge to post mesh messages to Discord channels |
|
||||
| [Telegram Bridge](telegram-bridge.md) | One-way bridge to post mesh messages to Telegram chats/channels |
|
||||
| [Packet Capture](packet-capture.md) | Capture packets from the mesh and publish them to MQTT brokers |
|
||||
| [Map Uploader](map-uploader.md) | Upload node advertisements to [map.meshcore.dev](https://map.meshcore.dev) for network visualization |
|
||||
| [Weather Service](weather-service.md) | Scheduled weather forecasts, weather alerts, and lightning detection |
|
||||
| MQTT Weather Relay (`MqttWeather`) | Relay weather data to custom MQTT topics configured under `[Weather]` |
|
||||
| Webhook Service (`Webhook`) | Accept inbound HTTP POST payloads and relay to mesh channels or DMs |
|
||||
| [Earthquake Service](earthquake-service.md) | Earthquake alerts for a configured region (USGS API, defaults: California) |
|
||||
| [Repeater Prefix Collision Service](repeater-prefix-collision-service.md) | Alerts when a newly heard repeater prefix collides with an existing repeater prefix |
|
||||
|
||||
|
||||
@@ -957,15 +957,42 @@ class CommandManager:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Find the contact by name (since recipient_id is the contact name)
|
||||
# Name lookup first (backward compatible), then fallback to pubkey/prefix.
|
||||
contact = self.bot.meshcore.get_contact_by_name(recipient_id)
|
||||
lookup_type = "name"
|
||||
if not contact and hasattr(self.bot.meshcore, "contacts"):
|
||||
recipient_key = (recipient_id or "").strip()
|
||||
contacts = self.bot.meshcore.contacts or {}
|
||||
for contact_data in contacts.values():
|
||||
public_key = (contact_data.get("public_key", "") or "").strip()
|
||||
if not public_key:
|
||||
continue
|
||||
if public_key == recipient_key or public_key.startswith(recipient_key):
|
||||
contact = contact_data
|
||||
lookup_type = "pubkey_prefix"
|
||||
self.logger.debug(
|
||||
"Resolved DM recipient '%s' via public key prefix lookup",
|
||||
sanitize_name(recipient_key),
|
||||
)
|
||||
break
|
||||
|
||||
if not contact:
|
||||
self.logger.error(f"Contact not found for name: {recipient_id}")
|
||||
self.logger.error(
|
||||
"Contact not found for DM recipient identifier: %s",
|
||||
sanitize_name(recipient_id),
|
||||
)
|
||||
return False
|
||||
|
||||
# Use the contact name for logging
|
||||
contact_name = contact.get('name', contact.get('adv_name', recipient_id))
|
||||
self.logger.info("Sending DM to %s", sanitize_name(contact_name))
|
||||
if lookup_type != "name":
|
||||
self.logger.info(
|
||||
"Sending DM to %s (resolved via %s)",
|
||||
sanitize_name(contact_name),
|
||||
lookup_type,
|
||||
)
|
||||
else:
|
||||
self.logger.info("Sending DM to %s", sanitize_name(contact_name))
|
||||
|
||||
# Record transmission for repeat tracking (don't let this block sending)
|
||||
try:
|
||||
|
||||
@@ -919,6 +919,106 @@ class BaseCommand(ABC):
|
||||
"""Check if this command can execute right now (permissions, cooldown, etc.)"""
|
||||
return self.can_execute(message)
|
||||
|
||||
def _get_required_path_bytes_setting(self, section: str) -> int:
|
||||
"""Return normalized path-byte requirement for a command section.
|
||||
|
||||
Supported values:
|
||||
- 0/1: allow all
|
||||
- 2: require >= 2 bytes
|
||||
- 3: require exactly 3 bytes
|
||||
"""
|
||||
required = self.get_config_value(
|
||||
section,
|
||||
'require_path_bytes_greater_or_equal_to',
|
||||
fallback=0,
|
||||
value_type='int',
|
||||
)
|
||||
if required in (0, 1, 2, 3):
|
||||
return required
|
||||
self.logger.warning(
|
||||
f"Invalid {section}.require_path_bytes_greater_or_equal_to={required}; defaulting to 0"
|
||||
)
|
||||
return 0
|
||||
|
||||
def _path_bytes_match_requirement(self, path_byte_length: int, required_path_bytes: int) -> bool:
|
||||
"""Check whether path byte length satisfies configured requirement."""
|
||||
if required_path_bytes in (0, 1):
|
||||
return True
|
||||
if required_path_bytes == 2:
|
||||
return path_byte_length >= 2
|
||||
if required_path_bytes == 3:
|
||||
return path_byte_length == 3
|
||||
return True
|
||||
|
||||
def _get_message_path_byte_length(self, message: MeshMessage) -> int:
|
||||
"""Best-effort extraction of message path byte length from routing/path data."""
|
||||
routing_info = getattr(message, 'routing_info', None)
|
||||
if routing_info:
|
||||
raw_path_byte_length = routing_info.get('path_byte_length')
|
||||
if isinstance(raw_path_byte_length, int) and raw_path_byte_length >= 0:
|
||||
return raw_path_byte_length
|
||||
|
||||
bytes_per_hop = routing_info.get('bytes_per_hop')
|
||||
path_length = routing_info.get('path_length')
|
||||
if (
|
||||
isinstance(bytes_per_hop, int)
|
||||
and bytes_per_hop >= 0
|
||||
and isinstance(path_length, int)
|
||||
and path_length >= 0
|
||||
):
|
||||
return bytes_per_hop * path_length
|
||||
|
||||
path_nodes = routing_info.get('path_nodes') or []
|
||||
if path_nodes:
|
||||
total = 0
|
||||
for node in path_nodes:
|
||||
node_str = str(node).strip()
|
||||
if not node_str:
|
||||
continue
|
||||
total += len(node_str) // 2
|
||||
return total
|
||||
|
||||
path_string = (getattr(message, 'path', None) or '').strip()
|
||||
if not path_string:
|
||||
return 0
|
||||
if " via ROUTE_TYPE_" in path_string:
|
||||
path_string = path_string.split(" via ROUTE_TYPE_")[0]
|
||||
if "Direct" in path_string or "0 hops" in path_string:
|
||||
return 0
|
||||
path_string = re.sub(r'\s*\([^)]*hops?[^)]*\)', '', path_string, flags=re.IGNORECASE).strip()
|
||||
if not path_string:
|
||||
return 0
|
||||
if ',' in path_string:
|
||||
tokens = [t.strip() for t in path_string.split(',') if t.strip()]
|
||||
if tokens:
|
||||
return sum(len(t) // 2 for t in tokens)
|
||||
return len(path_string) // 2
|
||||
|
||||
def _get_required_path_bytes_failure_response(self, section: str) -> Optional[str]:
|
||||
"""Get optional response text used when path-byte requirement fails."""
|
||||
failure_response = self.get_config_value(
|
||||
section,
|
||||
'require_path_bytes_failure_response',
|
||||
fallback='',
|
||||
value_type='str',
|
||||
)
|
||||
if not failure_response:
|
||||
return None
|
||||
cleaned = self._strip_quotes_from_config(failure_response).strip()
|
||||
return cleaned or None
|
||||
|
||||
async def enforce_path_byte_requirement(self, message: MeshMessage, section: str) -> bool:
|
||||
"""Apply path-byte requirement; optionally send configured failure response."""
|
||||
required = self._get_required_path_bytes_setting(section)
|
||||
path_byte_length = self._get_message_path_byte_length(message)
|
||||
if self._path_bytes_match_requirement(path_byte_length, required):
|
||||
return True
|
||||
|
||||
failure_response = self._get_required_path_bytes_failure_response(section)
|
||||
if failure_response:
|
||||
await self.send_response(message, failure_response)
|
||||
return False
|
||||
|
||||
def get_path_display_string(self, message: MeshMessage) -> str:
|
||||
"""Get path string for display (test/ack placeholders). Prefers message.routing_info for multi-byte and direct."""
|
||||
routing_info = getattr(message, 'routing_info', None)
|
||||
|
||||
@@ -542,6 +542,7 @@ class MultitestSession:
|
||||
listening_duration: float
|
||||
collected_paths: set[str]
|
||||
initial_path: Optional[str] = None
|
||||
required_path_bytes_mode: int = 0
|
||||
|
||||
|
||||
class MultitestCommand(BaseCommand):
|
||||
@@ -741,6 +742,34 @@ class MultitestCommand(BaseCommand):
|
||||
return ','.join(n.lower() for n in node_ids)
|
||||
return None
|
||||
|
||||
def _get_routing_info_path_byte_length(self, routing_info: dict) -> int:
|
||||
"""Best-effort extraction of path byte length from routing_info."""
|
||||
if not routing_info:
|
||||
return 0
|
||||
|
||||
raw_path_byte_length = routing_info.get('path_byte_length')
|
||||
if isinstance(raw_path_byte_length, int) and raw_path_byte_length >= 0:
|
||||
return raw_path_byte_length
|
||||
|
||||
bytes_per_hop = routing_info.get('bytes_per_hop')
|
||||
path_length = routing_info.get('path_length')
|
||||
if (
|
||||
isinstance(bytes_per_hop, int)
|
||||
and bytes_per_hop >= 0
|
||||
and isinstance(path_length, int)
|
||||
and path_length >= 0
|
||||
):
|
||||
return bytes_per_hop * path_length
|
||||
|
||||
path_nodes = routing_info.get('path_nodes') or []
|
||||
total = 0
|
||||
for node in path_nodes:
|
||||
node_str = str(node).strip()
|
||||
if not node_str:
|
||||
continue
|
||||
total += len(node_str) // 2
|
||||
return total
|
||||
|
||||
def get_rf_data_for_message(self, message: MeshMessage) -> Optional[dict]:
|
||||
"""Get RF data for a message by looking it up in recent RF data"""
|
||||
try:
|
||||
@@ -831,6 +860,11 @@ class MultitestCommand(BaseCommand):
|
||||
# CRITICAL: Only collect paths if this message has the same hash as the target
|
||||
# This ensures we only track variations of the same original message
|
||||
if message_hash == session.target_packet_hash:
|
||||
routing_info = rf_data.get('routing_info', {})
|
||||
path_byte_length = self._get_routing_info_path_byte_length(routing_info)
|
||||
if not self._path_bytes_match_requirement(path_byte_length, session.required_path_bytes_mode):
|
||||
continue
|
||||
|
||||
# Try to extract path from RF data first (more reliable)
|
||||
path = self.extract_path_from_rf_data(rf_data)
|
||||
|
||||
@@ -843,7 +877,6 @@ class MultitestCommand(BaseCommand):
|
||||
self.logger.info(f"✓ Collected path for user {user_id}: {path} (hash: {message_hash[:8]}...)")
|
||||
else:
|
||||
# Log when we have a matching hash but can't extract path
|
||||
routing_info = rf_data.get('routing_info', {})
|
||||
path_length = routing_info.get('path_length', 0)
|
||||
if path_length == 0:
|
||||
self.logger.debug(f"Matched hash {message_hash[:8]}... but path is direct (0 hops) for user {user_id}")
|
||||
@@ -880,6 +913,11 @@ class MultitestCommand(BaseCommand):
|
||||
|
||||
# CRITICAL: Only process if hash matches exactly and is not None/empty
|
||||
if packet_hash and packet_hash == session.target_packet_hash:
|
||||
routing_info = rf_data.get('routing_info', {})
|
||||
path_byte_length = self._get_routing_info_path_byte_length(routing_info)
|
||||
if not self._path_bytes_match_requirement(path_byte_length, session.required_path_bytes_mode):
|
||||
continue
|
||||
|
||||
matching_count += 1
|
||||
# Extract path from this RF data
|
||||
path = self.extract_path_from_rf_data(rf_data)
|
||||
@@ -902,6 +940,9 @@ class MultitestCommand(BaseCommand):
|
||||
async def execute(self, message: MeshMessage) -> bool:
|
||||
"""Execute the multitest command"""
|
||||
user_id = message.sender_id or "unknown"
|
||||
required_path_bytes_mode = self._get_required_path_bytes_setting('Multitest_Command')
|
||||
if not await self.enforce_path_byte_requirement(message, 'Multitest_Command'):
|
||||
return True
|
||||
|
||||
# Use lock to prevent concurrent execution from interfering
|
||||
async with self._get_execution_lock():
|
||||
@@ -985,7 +1026,8 @@ class MultitestCommand(BaseCommand):
|
||||
listening_start_time=time.time(),
|
||||
listening_duration=listening_duration,
|
||||
collected_paths=set(),
|
||||
initial_path=initial_path
|
||||
initial_path=initial_path,
|
||||
required_path_bytes_mode=required_path_bytes_mode,
|
||||
)
|
||||
|
||||
# Add initial path if available
|
||||
|
||||
@@ -197,6 +197,9 @@ class PathCommand(BaseCommand):
|
||||
"""Execute path decode command"""
|
||||
self.logger.info(f"Path command executed with content: {message.content}")
|
||||
|
||||
if not await self.enforce_path_byte_requirement(message, 'Path_Command'):
|
||||
return True
|
||||
|
||||
# Store the current message for use in _extract_path_from_recent_messages
|
||||
self._current_message = message
|
||||
|
||||
|
||||
@@ -710,6 +710,9 @@ class TestCommand(BaseCommand):
|
||||
Returns:
|
||||
bool: True if execution was successful.
|
||||
"""
|
||||
if not await self.enforce_path_byte_requirement(message, 'Test_Command'):
|
||||
return True
|
||||
|
||||
# Store the current message for use in location lookups
|
||||
self._current_message = message
|
||||
return await self.handle_keyword_match(message)
|
||||
@@ -26,7 +26,7 @@ class TraceCommand(BaseCommand):
|
||||
|
||||
short_description = "Run link trace (manual or reciprocal path)"
|
||||
usage = "trace [path] or tracer [path]"
|
||||
examples = ["trace 01,7a,55", "tracer", "tracer 01,7a,55"]
|
||||
examples = ["trace 01,7a,55", "trace feed,6ddf,feed", "tracer", "tracer 01,7a,55"]
|
||||
|
||||
def __init__(self, bot):
|
||||
super().__init__(bot)
|
||||
@@ -55,7 +55,8 @@ class TraceCommand(BaseCommand):
|
||||
def get_help_text(self) -> str:
|
||||
return (
|
||||
"trace [path] — run trace along path (return may not be heard). No path = round-trip like tracer. "
|
||||
"tracer [path] — round-trip so bot hears return. Path: comma 2-char hex (e.g. 01,7a,55). "
|
||||
"tracer [path] — round-trip so bot hears return. Path: comma-separated hex nodes "
|
||||
"(2-char 1-byte e.g. 01,7a,55; 4-char 2-byte e.g. feed,6ddf,feed). "
|
||||
"No path = use your message path (round-trip)."
|
||||
)
|
||||
|
||||
@@ -66,7 +67,7 @@ class TraceCommand(BaseCommand):
|
||||
return bool(content_lower.startswith("trace ") or content_lower.startswith("tracer "))
|
||||
|
||||
def _extract_path_from_message(self, message: MeshMessage) -> list[str]:
|
||||
"""Extract path node IDs from message.path (supports 1-hop and multi-hop)."""
|
||||
"""Extract path node IDs from message.path (supports 1-byte, 2-byte, and 3-byte hashes)."""
|
||||
if not message.path:
|
||||
return []
|
||||
if "Direct" in message.path or "0 hops" in message.path:
|
||||
@@ -79,21 +80,32 @@ class TraceCommand(BaseCommand):
|
||||
path_string = path_string.strip()
|
||||
# Single node (e.g. "01 (1 hop)") has no comma
|
||||
if "," not in path_string:
|
||||
if len(path_string) == 2 and all(c in "0123456789abcdefABCDEF" for c in path_string):
|
||||
if len(path_string) in (2, 4, 6) and all(c in "0123456789abcdefABCDEF" for c in path_string):
|
||||
return [path_string.lower()]
|
||||
return []
|
||||
parts = path_string.split(",")
|
||||
valid = []
|
||||
expected_len = None
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if len(part) == 2 and all(c in "0123456789abcdefABCDEF" for c in part):
|
||||
valid.append(part.lower())
|
||||
if not part:
|
||||
continue
|
||||
if len(part) not in (2, 4, 6) or not all(c in "0123456789abcdefABCDEF" for c in part):
|
||||
continue
|
||||
if expected_len is None:
|
||||
expected_len = len(part)
|
||||
if len(part) != expected_len:
|
||||
continue
|
||||
valid.append(part.lower())
|
||||
return valid
|
||||
|
||||
def _parse_path_arg(self, content: str) -> Optional[list[str]]:
|
||||
"""Parse path from command content after 'trace ' or 'tracer '.
|
||||
Accepts: comma-separated 2-char hex (01,7a,55), contiguous hex (01e07a), or mixed (01,e001).
|
||||
Returns list of 2-char hex bytes, or None if no path args / invalid.
|
||||
Accepts comma-separated hex nodes where each segment is the same length:
|
||||
2-char = 1-byte (e.g. 01,7a,55), 4-char = 2-byte (e.g. feed,6ddf),
|
||||
6-char = 3-byte (e.g. feedca,6ddf01).
|
||||
Without commas, treats contiguous hex as 2-char (1-byte) nodes.
|
||||
Returns list of hex node IDs, or None if no path args / invalid.
|
||||
"""
|
||||
content = content.strip()
|
||||
if content.startswith("!"):
|
||||
@@ -105,13 +117,24 @@ class TraceCommand(BaseCommand):
|
||||
break
|
||||
if not rest:
|
||||
return None
|
||||
# Normalize: drop commas and spaces, single string of hex chars
|
||||
hex_chars = re.sub(r"[\s,]+", "", rest).lower()
|
||||
# Comma-separated: each segment is one node; preserves multibyte groupings
|
||||
if "," in rest:
|
||||
parts = [p.strip().lower() for p in rest.split(",") if p.strip()]
|
||||
if not parts:
|
||||
return None
|
||||
first_len = len(parts[0])
|
||||
if first_len not in (2, 4, 6) or not all(
|
||||
len(p) == first_len and all(c in "0123456789abcdef" for c in p)
|
||||
for p in parts
|
||||
):
|
||||
return None
|
||||
return parts
|
||||
# No commas: treat as contiguous hex, split into 2-char (1-byte) nodes
|
||||
hex_chars = re.sub(r"\s+", "", rest).lower()
|
||||
if not hex_chars or len(hex_chars) % 2 != 0:
|
||||
return None
|
||||
if not all(c in "0123456789abcdef" for c in hex_chars):
|
||||
return None
|
||||
# Split into 2-char (1-byte) nodes
|
||||
return [hex_chars[i : i + 2] for i in range(0, len(hex_chars), 2)]
|
||||
|
||||
def _build_reciprocal_path(self, nodes: list[str]) -> list[str]:
|
||||
@@ -203,9 +226,9 @@ class TraceCommand(BaseCommand):
|
||||
await self.send_response(message, "Trace not available (firmware/connection).")
|
||||
return True
|
||||
|
||||
flags = 0
|
||||
if self.trace_mode == "two_byte":
|
||||
flags = 1 # Reserve flag for 2-byte when supported
|
||||
# Auto-detect flags from path element length: 2-char=1-byte→0, 4-char=2-byte→1
|
||||
node_len = len(path_nodes[0]) if path_nodes else 2
|
||||
flags = {2: 0, 4: 1}.get(node_len, 0)
|
||||
|
||||
result = await run_trace(
|
||||
self.bot,
|
||||
|
||||
@@ -484,6 +484,12 @@ def _m0011_repeater_and_graph_indexes(cursor: sqlite3.Cursor) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _m0012_purging_log_details_column(cursor: sqlite3.Cursor) -> None:
|
||||
"""Add details column for newer purging log entries."""
|
||||
if _table_exists(cursor, "purging_log"):
|
||||
_add_column(cursor, "purging_log", "details", "TEXT")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration registry — append new entries here, never remove or reorder.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -502,6 +508,7 @@ MIGRATIONS: list[MigrationEntry] = [
|
||||
(9, "optional repeater/graph indexes", _m0009_repeater_optional_indexes),
|
||||
(10, "create repeater/graph tables", _m0010_create_repeater_and_graph_tables),
|
||||
(11, "repeater/graph indexes", _m0011_repeater_and_graph_indexes),
|
||||
(12, "purging_log: add details column", _m0012_purging_log_details_column),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+79
-22
@@ -373,9 +373,8 @@ class MessageHandler:
|
||||
break
|
||||
|
||||
# Get the full public key from contacts if available
|
||||
sender_pubkey = payload.get('pubkey_prefix', '')
|
||||
sender_pubkey = sender_id # Default to sender_id
|
||||
if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts:
|
||||
sender_pubkey = sender_id # Default to pubkey prefix (same value as sender_id at this point)
|
||||
if sender_id and hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts:
|
||||
for _contact_key, contact_data in self.bot.meshcore.contacts.items():
|
||||
if contact_data.get('public_key', '').startswith(sender_id):
|
||||
# Use the full public key from the contact
|
||||
@@ -1975,7 +1974,7 @@ class MessageHandler:
|
||||
|
||||
# Get the full public key from contacts if available
|
||||
sender_pubkey = payload.get('pubkey_prefix', '')
|
||||
if hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts:
|
||||
if sender_pubkey and hasattr(self.bot.meshcore, 'contacts') and self.bot.meshcore.contacts:
|
||||
for _contact_key, contact_data in self.bot.meshcore.contacts.items():
|
||||
if contact_data.get('public_key', '').startswith(sender_pubkey):
|
||||
# Use the full public key from the contact
|
||||
@@ -3273,28 +3272,86 @@ class MessageHandler:
|
||||
self.logger.info(f"✅ Repeater {contact_name} tracked in database - not added to device contacts")
|
||||
return
|
||||
else:
|
||||
# COMPANION: Track in database AND add to device contact list
|
||||
self.logger.info(f"👤 New companion discovered: {contact_name} - will be added to device contacts")
|
||||
# COMPANION: track in DB; device add behaviour depends on auto_manage_contacts
|
||||
auto_manage_setting = self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower()
|
||||
self.logger.info(
|
||||
"👤 New companion discovered: %s — auto_manage_contacts=%s",
|
||||
contact_name,
|
||||
auto_manage_setting,
|
||||
)
|
||||
|
||||
# Track companion in complete database with signal info
|
||||
await self.bot.repeater_manager.track_contact_advertisement(contact_data, signal_info, packet_hash=packet_hash)
|
||||
await self.bot.repeater_manager.track_contact_advertisement(
|
||||
contact_data, signal_info, packet_hash=packet_hash
|
||||
)
|
||||
|
||||
# Add companion to device contact list
|
||||
try:
|
||||
self._ensure_contact_meshcore_path_encoding(contact_data)
|
||||
result = await self.bot.meshcore.commands.add_contact(contact_data)
|
||||
if hasattr(result, 'type') and result.type.name == 'OK':
|
||||
self.logger.info(f"✅ Companion {contact_name} added to device contacts")
|
||||
if auto_manage_setting == 'false':
|
||||
self.logger.info(
|
||||
"Manual mode — companion %s tracked in database only (not added to device)",
|
||||
contact_name,
|
||||
)
|
||||
elif auto_manage_setting == 'device':
|
||||
self.logger.info(
|
||||
"Device mode — companion %s tracked; firmware handles addition; bot may manage capacity",
|
||||
contact_name,
|
||||
)
|
||||
status = await self.bot.repeater_manager.get_contact_list_status()
|
||||
if status and status.get('is_near_limit', False):
|
||||
self.logger.warning(
|
||||
"Contact list near limit (%.1f%%) — managing capacity",
|
||||
status['usage_percentage'],
|
||||
)
|
||||
await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True)
|
||||
else:
|
||||
self.logger.warning(f"❌ Failed to add companion {contact_name} to device: {result}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"❌ Error adding companion {contact_name} to device: {e}")
|
||||
self.logger.info(
|
||||
"New companion %s — contact list has adequate space",
|
||||
contact_name,
|
||||
)
|
||||
elif auto_manage_setting == 'bot':
|
||||
self.logger.info(
|
||||
"Bot mode — adding companion %s to device with capacity management",
|
||||
contact_name,
|
||||
)
|
||||
try:
|
||||
self._ensure_contact_meshcore_path_encoding(contact_data)
|
||||
ok = await self.bot.repeater_manager.add_companion_from_contact_data(
|
||||
contact_data, contact_name, public_key
|
||||
)
|
||||
if not ok:
|
||||
self.logger.warning(
|
||||
"Failed to add companion contact %s to device after managed add/retry",
|
||||
contact_name,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error("Error adding companion %s to device: %s", contact_name, e)
|
||||
|
||||
status = await self.bot.repeater_manager.get_contact_list_status()
|
||||
if status and status.get('is_near_limit', False):
|
||||
self.logger.warning(
|
||||
"Contact list near limit (%.1f%%) — managing capacity after add",
|
||||
status['usage_percentage'],
|
||||
)
|
||||
await self.bot.repeater_manager.manage_contact_list(auto_cleanup=True)
|
||||
else:
|
||||
self.logger.info(
|
||||
"Companion %s — contact list has adequate space after add attempt",
|
||||
contact_name,
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Unknown auto_manage_contacts value %r — treating as manual for %s",
|
||||
auto_manage_setting,
|
||||
contact_name,
|
||||
)
|
||||
|
||||
# Check if auto-purge is needed
|
||||
await self.bot.repeater_manager.check_and_auto_purge()
|
||||
|
||||
self.bot.repeater_manager.log_purging_action(
|
||||
"new_contact_discovered",
|
||||
f"New contact discovered: {contact_name} (key: {public_key[:16]}...)",
|
||||
)
|
||||
return
|
||||
|
||||
# Fallback: Track in database for unknown contact types
|
||||
# Fallback: Track in database for unknown contact types (no repeater_manager)
|
||||
if hasattr(self.bot, 'repeater_manager'):
|
||||
await self.bot.repeater_manager.track_contact_advertisement(contact_data, packet_hash=packet_hash)
|
||||
await self.bot.repeater_manager.check_and_auto_purge()
|
||||
@@ -3347,9 +3404,9 @@ class MessageHandler:
|
||||
|
||||
# Log the new contact discovery
|
||||
if hasattr(self.bot, 'repeater_manager'):
|
||||
self.bot.repeater_manager.db_manager.execute_update(
|
||||
'INSERT INTO purging_log (action, details) VALUES (?, ?)',
|
||||
('new_contact_discovered', f'New contact discovered: {contact_name} (key: {public_key[:16]}...)')
|
||||
self.bot.repeater_manager.log_purging_action(
|
||||
"new_contact_discovered",
|
||||
f"New contact discovered: {contact_name} (key: {public_key[:16]}...)",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+381
-39
@@ -12,10 +12,47 @@ from typing import Any, Optional
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from .security_utils import sanitize_name
|
||||
from .security_utils import sanitize_name, validate_pubkey_format
|
||||
from .utils import rate_limited_nominatim_reverse_sync
|
||||
|
||||
|
||||
def collect_protected_pubkeys_for_device_mode(config: Any, logger: Any) -> set[str]:
|
||||
"""Public keys that must stay favourited on the radio (admin + announcements ACL semantics).
|
||||
|
||||
Matches announcements_command._load_announcements_acl: explicit announcements_acl keys plus
|
||||
all Admin_ACL admin_pubkeys (admins always included).
|
||||
"""
|
||||
keys: list[str] = []
|
||||
try:
|
||||
if config.has_section('Announcements_Command'):
|
||||
ann = config.get('Announcements_Command', 'announcements_acl', fallback='')
|
||||
if ann and ann.strip():
|
||||
for key in ann.split(','):
|
||||
key = key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if validate_pubkey_format(key, expected_length=64):
|
||||
keys.append(key.lower())
|
||||
else:
|
||||
logger.warning('Invalid pubkey in announcements_acl: %s...', key[:16])
|
||||
if config.has_section('Admin_ACL'):
|
||||
admin_pubkeys = config.get('Admin_ACL', 'admin_pubkeys', fallback='')
|
||||
if admin_pubkeys and admin_pubkeys.strip():
|
||||
for key in admin_pubkeys.split(','):
|
||||
key = key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if validate_pubkey_format(key, expected_length=64):
|
||||
nk = key.lower()
|
||||
if nk not in keys:
|
||||
keys.append(nk)
|
||||
else:
|
||||
logger.warning('Invalid pubkey in admin_pubkeys: %s...', key[:16])
|
||||
except Exception as e:
|
||||
logger.debug('collect_protected_pubkeys_for_device_mode: %s', e)
|
||||
return set(keys)
|
||||
|
||||
|
||||
class RepeaterManager:
|
||||
"""Manages repeater contacts database and purging operations"""
|
||||
|
||||
@@ -35,6 +72,7 @@ class RepeaterManager:
|
||||
self.auto_purge_threshold = 280 # Start purging when 280+ contacts
|
||||
# Respect auto_manage_contacts: manual mode (false) = no auto-purge; device/bot = auto-purge on
|
||||
auto_manage = bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower()
|
||||
self._auto_manage_contacts = auto_manage
|
||||
self.auto_purge_enabled = (auto_manage != 'false')
|
||||
|
||||
# Initialize companion purge settings
|
||||
@@ -50,6 +88,7 @@ class RepeaterManager:
|
||||
self._auto_purge_lock = asyncio.Lock()
|
||||
self._auto_purge_in_progress = False
|
||||
self._purge_inflight_keys = set()
|
||||
self._purging_log_has_details: Optional[bool] = None
|
||||
|
||||
def _start_purge_attempt(self, public_key: str, contact_type: str) -> bool:
|
||||
"""Mark a purge as in-flight; return False when another attempt is already active."""
|
||||
@@ -65,6 +104,43 @@ class RepeaterManager:
|
||||
"""Clear in-flight marker for a purge attempt."""
|
||||
self._purge_inflight_keys.discard(public_key)
|
||||
|
||||
def _refresh_purging_log_details_capability(self) -> bool:
|
||||
"""Return True if purging_log.details exists."""
|
||||
if self._purging_log_has_details is not None:
|
||||
return self._purging_log_has_details
|
||||
try:
|
||||
with self.db_manager.connection() as conn:
|
||||
rows = conn.execute("PRAGMA table_info(purging_log)").fetchall()
|
||||
self._purging_log_has_details = any(row[1] == "details" for row in rows)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Unable to inspect purging_log schema: {e}")
|
||||
self._purging_log_has_details = False
|
||||
return self._purging_log_has_details
|
||||
|
||||
def log_purging_action(self, action: str, details: str) -> None:
|
||||
"""Insert purging log rows across both new and legacy schemas."""
|
||||
try:
|
||||
if self._refresh_purging_log_details_capability():
|
||||
self.db_manager.execute_update(
|
||||
"INSERT INTO purging_log (action, details) VALUES (?, ?)",
|
||||
(action, details),
|
||||
)
|
||||
return
|
||||
self.db_manager.execute_update(
|
||||
"INSERT INTO purging_log (action, public_key, name, reason) VALUES (?, ?, ?, ?)",
|
||||
(action, "", action, details),
|
||||
)
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
if "no column named details" in err:
|
||||
self._purging_log_has_details = False
|
||||
self.db_manager.execute_update(
|
||||
"INSERT INTO purging_log (action, public_key, name, reason) VALUES (?, ?, ?, ?)",
|
||||
(action, "", action, details),
|
||||
)
|
||||
return
|
||||
self.logger.error(f"Failed to write purging log action '{action}': {e}")
|
||||
|
||||
def _init_repeater_tables(self):
|
||||
"""Ensure repeater-specific tables exist (created by migrations)."""
|
||||
try:
|
||||
@@ -648,11 +724,26 @@ class RepeaterManager:
|
||||
if not self.auto_purge_enabled:
|
||||
return False
|
||||
|
||||
if not getattr(self.bot, 'meshcore', None) or not hasattr(self.bot.meshcore, 'contacts'):
|
||||
return False
|
||||
|
||||
await self._update_contact_limit_from_device()
|
||||
|
||||
# Get current contact count
|
||||
current_count = len(self.bot.meshcore.contacts)
|
||||
|
||||
if current_count >= self.auto_purge_threshold:
|
||||
self.logger.info(f"🔄 Auto-purge triggered: {current_count}/{self.contact_limit} contacts (threshold: {self.auto_purge_threshold})")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.logger.info(
|
||||
"Repeater-only auto-purge check: %s/%s contacts (threshold %s)",
|
||||
current_count,
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
else:
|
||||
self.logger.info(
|
||||
f"🔄 Auto-purge triggered: {current_count}/{self.contact_limit} contacts (threshold: {self.auto_purge_threshold})"
|
||||
)
|
||||
|
||||
# Calculate how many to purge
|
||||
target_count = self.auto_purge_threshold - 20 # Leave some buffer
|
||||
@@ -663,19 +754,46 @@ class RepeaterManager:
|
||||
repeater_success = await self._auto_purge_repeaters(purge_count)
|
||||
remaining_count = len(self.bot.meshcore.contacts)
|
||||
|
||||
# If still above threshold and companion purging is enabled, purge companions
|
||||
if remaining_count >= self.auto_purge_threshold and self.companion_purge_enabled:
|
||||
companion_success = False
|
||||
# If still above threshold and companion purging is enabled, purge companions.
|
||||
# Device mode: never auto-purge companions from the radio — firmware overwrite + favourite
|
||||
# hygiene own the contact table; repeater purge above may still free slots.
|
||||
if (
|
||||
remaining_count >= self.auto_purge_threshold
|
||||
and self.companion_purge_enabled
|
||||
and self._auto_manage_contacts != 'device'
|
||||
):
|
||||
remaining_purge_count = remaining_count - target_count
|
||||
self.logger.info(f"Still above threshold after repeater purge, purging {remaining_purge_count} companions...")
|
||||
companion_success = await self._auto_purge_companions(remaining_purge_count)
|
||||
elif (
|
||||
remaining_count >= self.auto_purge_threshold
|
||||
and self.companion_purge_enabled
|
||||
and self._auto_manage_contacts == 'device'
|
||||
):
|
||||
self.logger.info(
|
||||
"Still above contact threshold after repeater purge; skipping companion auto-purge "
|
||||
"(auto_manage_contacts=device — firmware handles slot policy)"
|
||||
)
|
||||
|
||||
if repeater_success or companion_success:
|
||||
final_count = len(self.bot.meshcore.contacts)
|
||||
self.logger.info(f"✅ Auto-purge completed, now at {final_count}/{self.contact_limit} contacts")
|
||||
return True
|
||||
if repeater_success or companion_success:
|
||||
final_count = len(self.bot.meshcore.contacts)
|
||||
self.logger.info(f"✅ Auto-purge completed, now at {final_count}/{self.contact_limit} contacts")
|
||||
return True
|
||||
elif repeater_success:
|
||||
self.logger.info(f"✅ Auto-purged {purge_count} repeaters, now at {remaining_count}/{self.contact_limit} contacts")
|
||||
return True
|
||||
elif (
|
||||
self._auto_manage_contacts == 'device'
|
||||
and remaining_count >= self.auto_purge_threshold
|
||||
):
|
||||
# Not an error: we do not bulk-remove companions on the radio in device mode.
|
||||
self.logger.info(
|
||||
"Device mode: %s contacts still at/above threshold; no repeater slots freed. "
|
||||
"Companion list changes are left to firmware (overwrite non-favourite / favourites).",
|
||||
remaining_count,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self.logger.warning(f"❌ Auto-purge failed to remove {purge_count} contacts")
|
||||
return False
|
||||
@@ -695,7 +813,13 @@ class RepeaterManager:
|
||||
repeaters_to_purge = await self._get_repeaters_for_purging(count)
|
||||
|
||||
if not repeaters_to_purge:
|
||||
self.logger.warning("No repeaters available for auto-purge")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.logger.debug(
|
||||
"No repeaters on device for repeater-only auto-purge check (%s contacts)",
|
||||
len(self.bot.meshcore.contacts),
|
||||
)
|
||||
else:
|
||||
self.logger.warning("No repeaters available for auto-purge")
|
||||
# Log some debugging info
|
||||
total_contacts = len(self.bot.meshcore.contacts)
|
||||
repeater_count = sum(1 for contact_data in list(self.bot.meshcore.contacts.values()) if self._is_repeater_device(contact_data))
|
||||
@@ -2452,9 +2576,9 @@ class RepeaterManager:
|
||||
self.logger.error(f"Failed to discover companion contacts: {e}")
|
||||
|
||||
# Step 3: Log the post-purge management action
|
||||
self.db_manager.execute_update(
|
||||
'INSERT INTO purging_log (action, details) VALUES (?, ?)',
|
||||
('post_purge_management', 'Enabled manual contact addition and initiated companion contact discovery')
|
||||
self.log_purging_action(
|
||||
"post_purge_management",
|
||||
"Enabled manual contact addition and initiated companion contact discovery",
|
||||
)
|
||||
|
||||
self.logger.info("Post-purge contact management completed")
|
||||
@@ -2596,9 +2720,9 @@ class RepeaterManager:
|
||||
|
||||
# Log the management action
|
||||
if actions_taken:
|
||||
self.db_manager.execute_update(
|
||||
'INSERT INTO purging_log (action, details) VALUES (?, ?)',
|
||||
('contact_management', f'Contact list management: {"; ".join(actions_taken)}')
|
||||
self.log_purging_action(
|
||||
"contact_management",
|
||||
f'Contact list management: {"; ".join(actions_taken)}',
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -2639,9 +2763,9 @@ class RepeaterManager:
|
||||
self.logger.info(f"✅ Successfully removed stale contact: {contact_name}")
|
||||
|
||||
# Log the removal
|
||||
self.db_manager.execute_update(
|
||||
'INSERT INTO purging_log (action, details) VALUES (?, ?)',
|
||||
('stale_contact_removal', f'Removed stale contact: {contact_name} (last seen {contact["days_stale"]} days ago)')
|
||||
self.log_purging_action(
|
||||
"stale_contact_removal",
|
||||
f'Removed stale contact: {contact_name} (last seen {contact["days_stale"]} days ago)',
|
||||
)
|
||||
else:
|
||||
error_code = result.payload.get('error_code', 'unknown') if hasattr(result, 'payload') else 'unknown'
|
||||
@@ -2680,6 +2804,163 @@ class RepeaterManager:
|
||||
self.logger.error(f"Error in aggressive contact cleanup: {e}")
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _is_meshcore_table_full(result: Any) -> bool:
|
||||
if result is None or not hasattr(result, 'type'):
|
||||
return False
|
||||
if result.type != EventType.ERROR:
|
||||
return False
|
||||
payload = getattr(result, 'payload', None) or {}
|
||||
if payload.get('error_code') == 3:
|
||||
return True
|
||||
code_str = str(payload.get('code_string', '') or '')
|
||||
return 'TABLE_FULL' in code_str.upper()
|
||||
|
||||
async def add_companion_from_contact_data(
|
||||
self,
|
||||
contact_data: dict[str, Any],
|
||||
contact_name: str,
|
||||
public_key: str,
|
||||
) -> bool:
|
||||
"""Add companion using full contact_data (paths). Pre-manages when near limit; retries once on TABLE_FULL."""
|
||||
try:
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
self.logger.error('No meshcore commands — cannot add companion %s', contact_name)
|
||||
return False
|
||||
|
||||
status = await self.get_contact_list_status()
|
||||
if status and status.get('is_near_limit'):
|
||||
self.logger.warning(
|
||||
'Contact list near limit (%.1f%%) before add — managing capacity for %s',
|
||||
status.get('usage_percentage', 0.0),
|
||||
contact_name,
|
||||
)
|
||||
await self.manage_contact_list(auto_cleanup=True)
|
||||
|
||||
result = await self.bot.meshcore.commands.add_contact(contact_data)
|
||||
if hasattr(result, 'type') and result.type == EventType.OK:
|
||||
self.logger.info("Companion %s added to device contacts", contact_name)
|
||||
return True
|
||||
|
||||
if self._is_meshcore_table_full(result):
|
||||
self.logger.warning(
|
||||
'TABLE_FULL adding companion %s — running manage_contact_list and retrying once',
|
||||
contact_name,
|
||||
)
|
||||
await self.manage_contact_list(auto_cleanup=True)
|
||||
result = await self.bot.meshcore.commands.add_contact(contact_data)
|
||||
if hasattr(result, 'type') and result.type == EventType.OK:
|
||||
self.logger.info('Companion %s added after retry', contact_name)
|
||||
return True
|
||||
|
||||
self.logger.warning('Failed to add companion %s to device: %s', contact_name, result)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error('Error adding companion %s: %s', contact_name, e)
|
||||
return False
|
||||
|
||||
async def apply_device_mode_firmware_preferences(self) -> bool:
|
||||
"""Set companion-radio firmware: manual per-type adds + overwrite oldest non-favourite + chat-only (0x03)."""
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
return False
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
self.logger.info('Skipping firmware autoadd setup — auto_manage_contacts is not device')
|
||||
return False
|
||||
try:
|
||||
cmds = self.bot.meshcore.commands
|
||||
r_manual = await cmds.set_manual_add_contacts(True)
|
||||
if hasattr(r_manual, 'type') and r_manual.type != EventType.OK:
|
||||
self.logger.warning('set_manual_add_contacts(True) returned %s', r_manual)
|
||||
r_auto = await cmds.set_autoadd_config(0x03)
|
||||
if hasattr(r_auto, 'type') and r_auto.type != EventType.OK:
|
||||
self.logger.warning('set_autoadd_config(0x03) returned %s', r_auto)
|
||||
return False
|
||||
self.logger.info('Device mode: firmware autoadd_config=0x03 (overwrite oldest + chat only), manual add on')
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error('apply_device_mode_firmware_preferences failed: %s', e)
|
||||
return False
|
||||
|
||||
async def sync_device_mode_favourites_pass1(self) -> None:
|
||||
"""Favourite all on-device contacts whose pubkey is in the protected set (admin + announcements ACL)."""
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
return
|
||||
protected = collect_protected_pubkeys_for_device_mode(self.bot.config, self.logger)
|
||||
if not protected:
|
||||
self.logger.debug('sync_device_mode_favourites_pass1: no protected pubkeys configured')
|
||||
spacing_ms = max(0, self.bot.config.getint('Bot', 'contact_flag_update_spacing_ms', fallback=200))
|
||||
spacing = spacing_ms / 1000.0
|
||||
try:
|
||||
await self.bot.meshcore.commands.get_contacts()
|
||||
except Exception as e:
|
||||
self.logger.debug('get_contacts before favourite pass1: %s', e)
|
||||
contacts = getattr(self.bot.meshcore, 'contacts', None) or {}
|
||||
for pub_key, c in list(contacts.items()):
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
pk = (pub_key or '').lower()
|
||||
if pk not in protected:
|
||||
continue
|
||||
try:
|
||||
flags = int(c.get('flags', 0))
|
||||
except (TypeError, ValueError):
|
||||
flags = 0
|
||||
if flags & 1:
|
||||
continue
|
||||
contact_copy = dict(c)
|
||||
new_flags = flags | 1
|
||||
try:
|
||||
res = await self.bot.meshcore.commands.change_contact_flags(contact_copy, new_flags)
|
||||
if hasattr(res, 'type') and res.type == EventType.OK:
|
||||
self.logger.debug('Favourited protected contact %s', pk[:16])
|
||||
else:
|
||||
self.logger.warning('change_contact_flags (favourite on) failed for %s: %s', pk[:16], res)
|
||||
except Exception as e:
|
||||
self.logger.warning('change_contact_flags error for %s: %s', pk[:16], e)
|
||||
if spacing > 0:
|
||||
await asyncio.sleep(spacing)
|
||||
|
||||
async def sync_device_mode_favourites_pass2(self) -> None:
|
||||
"""Clear favourite bit for contacts not in the protected set."""
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
return
|
||||
protected = collect_protected_pubkeys_for_device_mode(self.bot.config, self.logger)
|
||||
spacing_ms = max(0, self.bot.config.getint('Bot', 'contact_flag_update_spacing_ms', fallback=200))
|
||||
spacing = spacing_ms / 1000.0
|
||||
try:
|
||||
await self.bot.meshcore.commands.get_contacts()
|
||||
except Exception as e:
|
||||
self.logger.debug('get_contacts before favourite pass2: %s', e)
|
||||
contacts = getattr(self.bot.meshcore, 'contacts', None) or {}
|
||||
for pub_key, c in list(contacts.items()):
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
pk = (pub_key or '').lower()
|
||||
if pk in protected:
|
||||
continue
|
||||
try:
|
||||
flags = int(c.get('flags', 0))
|
||||
except (TypeError, ValueError):
|
||||
flags = 0
|
||||
if not (flags & 1):
|
||||
continue
|
||||
contact_copy = dict(c)
|
||||
new_flags = flags & ~1
|
||||
try:
|
||||
res = await self.bot.meshcore.commands.change_contact_flags(contact_copy, new_flags)
|
||||
if hasattr(res, 'type') and res.type == EventType.OK:
|
||||
self.logger.debug('Cleared favourite for non-protected contact %s', pk[:16])
|
||||
else:
|
||||
self.logger.warning('change_contact_flags (favourite off) failed for %s: %s', pk[:16], res)
|
||||
except Exception as e:
|
||||
self.logger.warning('change_contact_flags error for %s: %s', pk[:16], e)
|
||||
if spacing > 0:
|
||||
await asyncio.sleep(spacing)
|
||||
|
||||
async def add_discovered_contact(self, contact_name: str, public_key: Optional[str] = None, reason: str = "Manual addition") -> bool:
|
||||
"""Add a discovered contact to the contact list using multiple methods"""
|
||||
try:
|
||||
@@ -2783,9 +3064,9 @@ class RepeaterManager:
|
||||
|
||||
# Log the addition if successful
|
||||
if contact_addition_successful:
|
||||
self.db_manager.execute_update(
|
||||
'INSERT INTO purging_log (action, details) VALUES (?, ?)',
|
||||
('contact_addition', f'Added discovered contact: {contact_name} - {reason}')
|
||||
self.log_purging_action(
|
||||
"contact_addition",
|
||||
f"Added discovered contact: {contact_name} - {reason}",
|
||||
)
|
||||
self.logger.info(f"Successfully added contact '{contact_name}': {reason}")
|
||||
return True
|
||||
@@ -2813,9 +3094,9 @@ class RepeaterManager:
|
||||
self.logger.debug(f"Manual contact addition toggle result: {result}")
|
||||
|
||||
# Log the action
|
||||
self.db_manager.execute_update(
|
||||
'INSERT INTO purging_log (action, details) VALUES (?, ?)',
|
||||
('manual_add_toggle', f'{"Enabled" if enabled else "Disabled"} manual contact addition - {reason}')
|
||||
self.log_purging_action(
|
||||
"manual_add_toggle",
|
||||
f'{"Enabled" if enabled else "Disabled"} manual contact addition - {reason}',
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -2843,9 +3124,9 @@ class RepeaterManager:
|
||||
self.logger.debug(f"Discovery result: {result}")
|
||||
|
||||
# Log the action
|
||||
self.db_manager.execute_update(
|
||||
'INSERT INTO purging_log (action, details) VALUES (?, ?)',
|
||||
('companion_discovery', f'Manual companion contact discovery - {reason}')
|
||||
self.log_purging_action(
|
||||
"companion_discovery",
|
||||
f"Manual companion contact discovery - {reason}",
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -3167,11 +3448,27 @@ class RepeaterManager:
|
||||
if not self.auto_purge_enabled:
|
||||
return
|
||||
|
||||
if not getattr(self.bot, 'meshcore', None) or not hasattr(self.bot.meshcore, 'contacts'):
|
||||
return
|
||||
|
||||
await self._update_contact_limit_from_device()
|
||||
|
||||
current_count = len(self.bot.meshcore.contacts)
|
||||
|
||||
# Log current status
|
||||
if current_count >= self.auto_purge_threshold:
|
||||
self.logger.warning(f"⚠️ Contact limit monitoring: {current_count}/{self.contact_limit} contacts (threshold: {self.auto_purge_threshold})")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.logger.info(
|
||||
"📊 Contact list high (%s/%s, threshold %s); device mode — running repeater-only auto-purge check "
|
||||
"(companions not bulk-removed by bot)",
|
||||
current_count,
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"⚠️ Contact limit monitoring: {current_count}/{self.contact_limit} contacts (threshold: {self.auto_purge_threshold})"
|
||||
)
|
||||
|
||||
# Trigger auto-purge
|
||||
await self.check_and_auto_purge()
|
||||
@@ -3266,31 +3563,76 @@ class RepeaterManager:
|
||||
self.logger.debug(f"Background geocoding error: {e}")
|
||||
|
||||
async def _update_contact_limit_from_device(self):
|
||||
"""Update contact limit from device using proper MeshCore API"""
|
||||
"""Update contact limit from device using proper MeshCore API.
|
||||
|
||||
In ``auto_manage_contacts=device`` mode, the effective limit is at least the
|
||||
number of contacts currently on the radio (firmware can report a lower
|
||||
``max_contacts`` than the live table). Count-based auto-purge uses
|
||||
``threshold = contact_limit + 1`` so routine companion-heavy meshes do not
|
||||
loop through repeater-only purge when the firmware manages the table.
|
||||
"""
|
||||
current_mesh = 0
|
||||
try:
|
||||
if self.bot.meshcore and hasattr(self.bot.meshcore, 'contacts'):
|
||||
current_mesh = len(self.bot.meshcore.contacts)
|
||||
except Exception:
|
||||
current_mesh = 0
|
||||
|
||||
try:
|
||||
if not self.bot.meshcore or not hasattr(self.bot.meshcore, 'commands'):
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.contact_limit = max(self.contact_limit, current_mesh)
|
||||
self.auto_purge_threshold = self.contact_limit + 1
|
||||
self.logger.debug(
|
||||
"Device mode contact limit from mesh only: %s (threshold %s)",
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
return False
|
||||
|
||||
# Use the correct MeshCore API to get device info
|
||||
device_info = await self.bot.meshcore.commands.send_device_query()
|
||||
|
||||
# Check if the query was successful
|
||||
if hasattr(device_info, 'type') and device_info.type.name == 'DEVICE_INFO':
|
||||
max_contacts = device_info.payload.get("max_contacts")
|
||||
if hasattr(device_info, 'type') and device_info.type == EventType.DEVICE_INFO:
|
||||
raw_max = device_info.payload.get('max_contacts')
|
||||
try:
|
||||
max_contacts = int(raw_max) if raw_max is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
max_contacts = 0
|
||||
|
||||
if max_contacts and max_contacts > 100:
|
||||
if max_contacts > 100:
|
||||
self.contact_limit = max_contacts
|
||||
# Update threshold to be 20 contacts below the limit
|
||||
self.auto_purge_threshold = max(200, max_contacts - 20)
|
||||
self.logger.debug(f"Updated contact limit from device query: {self.contact_limit} (threshold: {self.auto_purge_threshold})")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.contact_limit = max(self.contact_limit, current_mesh)
|
||||
self.auto_purge_threshold = self.contact_limit + 1
|
||||
else:
|
||||
# Update threshold to be 20 contacts below the limit
|
||||
self.auto_purge_threshold = max(200, self.contact_limit - 20)
|
||||
self.logger.debug(
|
||||
"Updated contact limit from device query: %s (threshold: %s)",
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self.logger.debug(f"Device returned invalid max_contacts: {max_contacts}")
|
||||
|
||||
self.logger.debug(f"Device returned invalid max_contacts: {raw_max}")
|
||||
else:
|
||||
self.logger.debug(f"Device query failed: {device_info}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not update contact limit from device: {e}")
|
||||
|
||||
# Keep default values if device query failed
|
||||
self.logger.debug(f"Using default contact limit: {self.contact_limit}")
|
||||
if self._auto_manage_contacts == 'device':
|
||||
self.contact_limit = max(self.contact_limit, current_mesh)
|
||||
self.auto_purge_threshold = self.contact_limit + 1
|
||||
self.logger.debug(
|
||||
"Device mode contact limit after failed query: %s (threshold %s)",
|
||||
self.contact_limit,
|
||||
self.auto_purge_threshold,
|
||||
)
|
||||
else:
|
||||
self.logger.debug(f"Using default contact limit: {self.contact_limit}")
|
||||
return False
|
||||
|
||||
async def get_auto_purge_status(self) -> dict:
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import Any, Optional
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from meshcore.events import EventType
|
||||
|
||||
from .maintenance import MaintenanceRunner
|
||||
@@ -103,6 +104,8 @@ class MessageScheduler:
|
||||
self._apscheduler.start()
|
||||
self.logger.info(f"APScheduler started with {len(self.scheduled_messages)} scheduled message(s)")
|
||||
|
||||
self._setup_device_mode_scheduler_jobs()
|
||||
|
||||
# Setup interval-based advertising
|
||||
self.setup_interval_advertising()
|
||||
|
||||
@@ -120,6 +123,84 @@ class MessageScheduler:
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error setting up interval advertising: {e}")
|
||||
|
||||
def _setup_device_mode_scheduler_jobs(self) -> None:
|
||||
"""One-shot jobs for auto_manage_contacts=device: firmware autoadd + favourite hygiene."""
|
||||
if self._apscheduler is None:
|
||||
return
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
try:
|
||||
delay_fw = max(0, self.bot.config.getint('Bot', 'device_mode_firmware_delay_seconds', fallback=30))
|
||||
delay_p1 = max(0, self.bot.config.getint('Bot', 'device_mode_favourite_pass1_delay_seconds', fallback=90))
|
||||
delay_p2 = max(0, self.bot.config.getint('Bot', 'device_mode_favourite_pass2_delay_seconds', fallback=180))
|
||||
base = self.get_current_time()
|
||||
self._apscheduler.add_job(
|
||||
self._device_mode_firmware_job_sync,
|
||||
trigger=DateTrigger(run_date=base + datetime.timedelta(seconds=delay_fw)),
|
||||
id='device_mode_firmware_autoadd',
|
||||
replace_existing=True,
|
||||
)
|
||||
self._apscheduler.add_job(
|
||||
self._device_mode_favourite_pass1_job_sync,
|
||||
trigger=DateTrigger(run_date=base + datetime.timedelta(seconds=delay_p1)),
|
||||
id='device_mode_favourite_pass1',
|
||||
replace_existing=True,
|
||||
)
|
||||
self._apscheduler.add_job(
|
||||
self._device_mode_favourite_pass2_job_sync,
|
||||
trigger=DateTrigger(run_date=base + datetime.timedelta(seconds=delay_p2)),
|
||||
id='device_mode_favourite_pass2',
|
||||
replace_existing=True,
|
||||
)
|
||||
self.logger.info(
|
||||
'Scheduled device-mode jobs: firmware +%ss, favourite pass1 +%ss, pass2 +%ss',
|
||||
delay_fw,
|
||||
delay_p1,
|
||||
delay_p2,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning('Could not schedule device-mode contact jobs: %s', e)
|
||||
|
||||
def _run_async_on_main_loop(self, coro: Any, timeout: float = 300.0) -> None:
|
||||
"""Run async coroutine on bot main loop from APScheduler thread (same pattern as send_scheduled_message)."""
|
||||
import asyncio
|
||||
|
||||
if hasattr(self.bot, 'main_event_loop') and self.bot.main_event_loop and self.bot.main_event_loop.is_running():
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self.bot.main_event_loop)
|
||||
try:
|
||||
future.result(timeout=timeout)
|
||||
except RuntimeError as e:
|
||||
self.logger.warning('Event loop gone during device-mode job: %s', e)
|
||||
except Exception as e:
|
||||
self.logger.error('Device-mode scheduled job failed: %s', e)
|
||||
else:
|
||||
self.logger.warning('No running main_event_loop — skipping device-mode scheduled job')
|
||||
|
||||
async def _device_mode_firmware_coro(self) -> None:
|
||||
await self.bot.repeater_manager.apply_device_mode_firmware_preferences()
|
||||
|
||||
async def _device_mode_favourite_pass1_coro(self) -> None:
|
||||
await self.bot.repeater_manager.sync_device_mode_favourites_pass1()
|
||||
|
||||
async def _device_mode_favourite_pass2_coro(self) -> None:
|
||||
await self.bot.repeater_manager.sync_device_mode_favourites_pass2()
|
||||
|
||||
def _device_mode_firmware_job_sync(self) -> None:
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
self.logger.debug('Skipping device_mode_firmware job — not device mode')
|
||||
return
|
||||
self._run_async_on_main_loop(self._device_mode_firmware_coro(), timeout=120.0)
|
||||
|
||||
def _device_mode_favourite_pass1_job_sync(self) -> None:
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
self._run_async_on_main_loop(self._device_mode_favourite_pass1_coro(), timeout=600.0)
|
||||
|
||||
def _device_mode_favourite_pass2_job_sync(self) -> None:
|
||||
if self.bot.config.get('Bot', 'auto_manage_contacts', fallback='false').lower() != 'device':
|
||||
return
|
||||
self._run_async_on_main_loop(self._device_mode_favourite_pass2_coro(), timeout=600.0)
|
||||
|
||||
def _is_valid_time_format(self, time_str: str) -> bool:
|
||||
"""Validate time format (HHMM)"""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Implementation of the DARC MoWaS receiver that is called via a
|
||||
webhook (http post request) and distributes the passed MoWaS alert
|
||||
to meshcore channels.
|
||||
|
||||
Alerts need to be sent to /api/alert, the status of the service
|
||||
is exposed as /api/health. To mitigate the risk of spam, the
|
||||
/api/alert endpoint should be operated behind a reverse proxy and
|
||||
basic auth (supported by DARC MoWaS gateway).
|
||||
|
||||
For details regarding the interface, see
|
||||
https://www.darc.de/index.php?id=58435
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
import xml.dom.minidom
|
||||
from asyncio import AbstractEventLoop
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, cast
|
||||
|
||||
import aiohttp
|
||||
from flask import (
|
||||
Flask,
|
||||
jsonify,
|
||||
redirect,
|
||||
request,
|
||||
)
|
||||
from werkzeug.serving import BaseWSGIServer, make_server
|
||||
|
||||
from modules import i18n
|
||||
from modules.service_plugins.base_service import BaseServicePlugin
|
||||
|
||||
|
||||
class DARC_MoWaS_Service(BaseServicePlugin):
|
||||
config_section = "DARC_MoWaS_Service"
|
||||
description = "Receives the MoWaS alerts from a DARC operated backend"
|
||||
|
||||
def __init__(self, bot: Any) -> None:
|
||||
super().__init__(bot)
|
||||
|
||||
self.host = self.bot.config.get(self.config_section, "host", fallback="0.0.0.0")
|
||||
self.port = self.bot.config.getint(self.config_section, "port", fallback=8080)
|
||||
self.channels = {
|
||||
"de": self.bot.config.get(
|
||||
self.config_section, "channel_de", fallback="mowas"
|
||||
),
|
||||
"en": self.bot.config.get(
|
||||
self.config_section, "channel_en", fallback="mowas-en"
|
||||
),
|
||||
}
|
||||
self.use_hamnet = self.bot.config.getboolean(
|
||||
self.config_section, "hamnet", fallback=False
|
||||
)
|
||||
self.retry_max = self.bot.config.getint(
|
||||
self.config_section, "retry_max", fallback=2
|
||||
)
|
||||
self.retry_timeout = self.bot.config.getint(
|
||||
self.config_section, "retry_timeout", fallback=15
|
||||
)
|
||||
|
||||
self.translators = {
|
||||
"de": i18n.Translator("de"),
|
||||
"en": i18n.Translator("en"),
|
||||
}
|
||||
|
||||
self.app = Flask(__name__)
|
||||
self._server: BaseWSGIServer | None = None
|
||||
self._server_future: asyncio.Future[None] | None = None
|
||||
self._loop: AbstractEventLoop | None = None
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
self._setup_routes()
|
||||
|
||||
async def start(self) -> None:
|
||||
self._running = True
|
||||
await self._ensure_channels()
|
||||
self.logger.info(
|
||||
"MoWaSAlert service starting on %s:%s",
|
||||
self.host,
|
||||
self.port,
|
||||
)
|
||||
self._server = make_server(self.host, self.port, self.app)
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._server_future = self._loop.run_in_executor(
|
||||
None, self._server.serve_forever
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
self._tasks.clear()
|
||||
if self._server:
|
||||
self._server.shutdown()
|
||||
if self._server_future:
|
||||
await self._server_future
|
||||
self.logger.info("MoWaSAlert service stopped")
|
||||
|
||||
async def _ensure_channels(self) -> None:
|
||||
"""Create configured channels on the node if they don't already exist."""
|
||||
cm = self.bot.channel_manager
|
||||
used = set(cm._channels_cache.keys())
|
||||
free = (i for i in range(1, cm.max_channels) if i not in used)
|
||||
for name in self.channels.values():
|
||||
if cm.get_channel_number(name) is None:
|
||||
idx = next(free, None)
|
||||
if idx is None:
|
||||
self.logger.error("No free channel slot for '%s'", name)
|
||||
return
|
||||
self.logger.info("Creating channel '%s' at index %d", name, idx)
|
||||
await cm.add_hashtag_channel(idx, name)
|
||||
|
||||
async def _process_mowas_notification(self, data: dict) -> None:
|
||||
self.logger.info("Processing alert '%s'", data["title"])
|
||||
urls = (
|
||||
data.get("url", {})
|
||||
.get("xml", {})
|
||||
.get("hamnet" if self.use_hamnet else "internet", [])
|
||||
)
|
||||
if not urls:
|
||||
self.logger.warning("No download URLs in alert '%s'", data["title"])
|
||||
return
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# for load-balancing, probe URLs in random order
|
||||
for url in sorted(urls, key=lambda _: random.random()):
|
||||
try:
|
||||
async with session.get(
|
||||
url, timeout=aiohttp.ClientTimeout(total=15)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
body = await resp.read()
|
||||
self.logger.info(
|
||||
"Downloaded %d bytes from %s", len(body), url
|
||||
)
|
||||
cap_xml = xml.dom.minidom.parseString(await resp.text())
|
||||
self._process_emergency_cap(cap_xml)
|
||||
return
|
||||
self.logger.warning(
|
||||
"HTTP %d from %s, trying next", resp.status, url
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.warning("Failed to download %s: %s", url, exc)
|
||||
self.logger.error("All download URLs failed for alert '%s'", data["title"])
|
||||
|
||||
def _process_emergency_cap(self, cap: xml.dom.minidom.Document) -> None:
|
||||
"""
|
||||
Implementation based on TR DE-Alert
|
||||
"""
|
||||
for alert in cap.getElementsByTagName("alert"):
|
||||
self._process_alert(alert)
|
||||
|
||||
def _process_alert(self, cap_alert: xml.dom.minidom.Element) -> None:
|
||||
alert = TRDECapAlert.from_xml(cap_alert)
|
||||
self.logger.info("process alert id '%s'", alert.identifier)
|
||||
if not alert.info:
|
||||
self.logger.warning("Alert '%s' has no info element", alert.identifier)
|
||||
return
|
||||
for info in alert.info:
|
||||
# on NINA test messages the lang is missing
|
||||
lang = info.language.lower()[:2] or "de"
|
||||
channel = self.channels.get(lang)
|
||||
if channel is None:
|
||||
self.logger.warning("No channel configured for language '%s'", lang)
|
||||
continue
|
||||
message = self.make_cb_message(alert, info)
|
||||
chunks = self.chunk_message(message)
|
||||
task = asyncio.create_task(self._send_chunks(channel, chunks))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
|
||||
async def _send_chunks(self, channel: str, chunks: list[str]) -> None:
|
||||
"""
|
||||
Send all chunks, each with async retry if configured.
|
||||
Note, that we cannot guarantee that the messages arrive in-order,
|
||||
but as the chunks have a (x/n) identifier at the end, the user still
|
||||
should be able to grasp the message correctly.
|
||||
|
||||
Ideally this chunking should be implemented at protocol level to
|
||||
guarantee the atomicity and order of the full message.
|
||||
"""
|
||||
for i, chunk in enumerate(chunks):
|
||||
asyncio.create_task(
|
||||
self._send_chunk_with_retry(
|
||||
channel,
|
||||
chunk,
|
||||
i,
|
||||
len(chunks),
|
||||
)
|
||||
)
|
||||
|
||||
async def _send_chunk_with_retry(
|
||||
self,
|
||||
channel: str,
|
||||
chunk: str,
|
||||
index: int,
|
||||
total: int,
|
||||
) -> None:
|
||||
"""Send a chunk and retry until acked or retries exhausted."""
|
||||
tracker = getattr(self.bot, "transmission_tracker", None)
|
||||
cmd_ids: list[str] = []
|
||||
retries_left = self.retry_max
|
||||
|
||||
while True:
|
||||
cmd_id = f"mowas_{hash((channel, index, retries_left, chunk)):x}"
|
||||
if not await self.bot.command_manager.send_channel_message(
|
||||
channel,
|
||||
chunk,
|
||||
command_id=cmd_id,
|
||||
skip_user_rate_limit=True,
|
||||
):
|
||||
self.logger.warning("Send failed for '%s'", channel)
|
||||
return
|
||||
cmd_ids.append(cmd_id)
|
||||
|
||||
if retries_left <= 0 or tracker is None:
|
||||
return
|
||||
|
||||
# As the TransmissionTracker does not provide a callback interface,
|
||||
# we just wait up to the max duration and check then. To avoid failures
|
||||
# due to synchronous patterns, we add some jitter.
|
||||
await asyncio.sleep(
|
||||
random.uniform(self.retry_timeout, 2 * self.retry_timeout)
|
||||
)
|
||||
if self._any_acked(tracker, cmd_ids):
|
||||
self.logger.info(
|
||||
"Chunk %d/%d on '%s' acked, skip resend",
|
||||
index + 1,
|
||||
total,
|
||||
channel,
|
||||
)
|
||||
return
|
||||
self.logger.warning(
|
||||
"No repeater ack for '%s' chunk %d/%d "
|
||||
"after %.0fs, resending (%d left)",
|
||||
channel,
|
||||
index + 1,
|
||||
total,
|
||||
self.retry_timeout,
|
||||
retries_left,
|
||||
)
|
||||
retries_left -= 1
|
||||
|
||||
@staticmethod
|
||||
def _any_acked(tracker: Any, cmd_ids: list[str]) -> bool:
|
||||
"""Return True if any of the command IDs was repeated."""
|
||||
return any(
|
||||
tracker.get_repeat_info(command_id=cid).get("repeat_count", 0) > 0
|
||||
for cid in cmd_ids
|
||||
)
|
||||
|
||||
def make_cb_message(self, alert: "TRDECapAlert", info: "TRDECapAlertInfo") -> str:
|
||||
"""
|
||||
Represent an alert info as cell broadcast message.
|
||||
If properties are matching the EU-Alert definition, represent as such.
|
||||
Otherwise present as generic alert with type info.event.
|
||||
"""
|
||||
lang = info.language.lower()[:2]
|
||||
translator = self.translators.get(lang, self.translators["de"])
|
||||
|
||||
status = (alert.status or "").lower()
|
||||
scope = (alert.scope or "").lower()
|
||||
severity = (info.severity or "").lower()
|
||||
urgency = (info.urgency or "").lower()
|
||||
certainty = (info.certainty or "").lower()
|
||||
# note, that the TR-DE does not have a mapping for level 3
|
||||
# other types like eu-test and eu-reserved, ... are not yet mapped in Germany
|
||||
eu_level: str | None
|
||||
match (status, scope, severity, urgency, certainty):
|
||||
case ("actual", "public", "extreme", "immediate", "observed"):
|
||||
eu_level = "eu-alert-level-1"
|
||||
case ("actual", "public", "extreme", "immediate", "likely"):
|
||||
eu_level = "eu-alert-level-2"
|
||||
case ("actual", "public", "minor", "expected", "likely"):
|
||||
eu_level = "eu-alert-level-4"
|
||||
# no official level, but used by MoWaS for test messages
|
||||
case ("actual", "public", "minor", "immediate", "observed"):
|
||||
eu_level = "eu-alert-level-4"
|
||||
case _:
|
||||
eu_level = None
|
||||
|
||||
if eu_level is not None:
|
||||
severity = translator.translate(
|
||||
f"services.darcmowas.messagetype.{eu_level}"
|
||||
)
|
||||
else:
|
||||
severity = info.event or ""
|
||||
|
||||
footer = []
|
||||
# ignore pure polygon areas
|
||||
area_texts = [
|
||||
area_desc
|
||||
for area_desc in (x.areaDesc for x in info.area)
|
||||
if area_desc and "polygonal" not in area_desc
|
||||
]
|
||||
if len(area_texts) == 1:
|
||||
headline = f"[{severity} {area_texts[0]}] {info.headline}"
|
||||
else:
|
||||
headline = f"[{severity}] {info.headline}"
|
||||
footer.append(
|
||||
translator.translate(
|
||||
"services.darcmowas.fields.area", areas=", ".join(area_texts)
|
||||
)
|
||||
)
|
||||
footer.append(
|
||||
translator.translate(
|
||||
"services.darcmowas.fields.sender", sender=alert.sender
|
||||
)
|
||||
)
|
||||
return "\n".join(part for part in [headline, info.description] + footer if part)
|
||||
|
||||
@staticmethod
|
||||
def chunk_message(text: str, max_length: int = 130) -> list[str]:
|
||||
"""
|
||||
Chunk the message in max_length pieces, add (x/n) at the end
|
||||
of each chunk if multiple chunks are created.
|
||||
"""
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
def _chunk_words(words: list[str], limit: int) -> list[str]:
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for word in words:
|
||||
candidate = f"{current} {word}" if current else word
|
||||
if len(candidate) <= limit:
|
||||
current = candidate
|
||||
else:
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = word
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks
|
||||
|
||||
words = text.split(" ")
|
||||
# Estimate chunk count, then re-chunk with suffix space reserved
|
||||
n = len(_chunk_words(words, max_length))
|
||||
suffix_len = len(f" ({n}/{n})")
|
||||
chunks = _chunk_words(words, max_length - suffix_len)
|
||||
return [f"{c.strip()} ({i+1}/{len(chunks)})" for i, c in enumerate(chunks)]
|
||||
|
||||
def _setup_routes(self):
|
||||
"""Setup webhook route"""
|
||||
|
||||
# Log full traceback for 500 errors so service logs show the real cause
|
||||
@self.app.errorhandler(500)
|
||||
def internal_error(e):
|
||||
self.logger.exception("Unhandled exception (500): %s", e)
|
||||
return jsonify({"error": "Internal Server Error"}), 500
|
||||
|
||||
@self.app.route("/")
|
||||
def main():
|
||||
return redirect("/api/health", code=302)
|
||||
|
||||
@self.app.route("/api/alert", methods=["POST"])
|
||||
def api_alert():
|
||||
"""
|
||||
MoWaS alert webhook. Caller uses basic auth, which should be handled
|
||||
by reverse proxy.
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid or missing JSON"}), 400
|
||||
self.logger.info("MoWaSAlert received")
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._process_mowas_notification(data), self._loop
|
||||
)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@self.app.route("/api/health")
|
||||
def api_health():
|
||||
"""Health check endpoint"""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "healthy",
|
||||
"channels": self.channels,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _child_text(
|
||||
node: xml.dom.minidom.Document | xml.dom.minidom.Element, tag: str
|
||||
) -> str | None:
|
||||
"""Return the text content of the first descendant element with the given tag name."""
|
||||
elements = node.getElementsByTagName(tag)
|
||||
if not elements or not elements[0].firstChild:
|
||||
return None
|
||||
text = elements[0].firstChild.nodeValue
|
||||
return text.strip() or None if text else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TRDECapAlert:
|
||||
"""Incomplete representation of a CAP 1.2 alert with TR-DE 1.1 semantics"""
|
||||
|
||||
identifier: str | None
|
||||
sender: str | None
|
||||
sent: datetime | None
|
||||
status: str | None
|
||||
msgType: str | None
|
||||
scope: str | None
|
||||
references: str | None
|
||||
info: list["TRDECapAlertInfo"]
|
||||
|
||||
@staticmethod
|
||||
def from_xml(alert: xml.dom.minidom.Element) -> "TRDECapAlert":
|
||||
sent_str = _child_text(alert, "sent")
|
||||
sent = datetime.fromisoformat(sent_str) if sent_str else None
|
||||
|
||||
infos = []
|
||||
for info_el in alert.getElementsByTagName("info"):
|
||||
infos.append(TRDECapAlertInfo.from_xml(cast(xml.dom.minidom.Element, info_el)))
|
||||
|
||||
return TRDECapAlert(
|
||||
identifier=_child_text(alert, "identifier"),
|
||||
sender=_child_text(alert, "sender"),
|
||||
sent=sent,
|
||||
status=_child_text(alert, "status"),
|
||||
msgType=_child_text(alert, "msgType"),
|
||||
scope=_child_text(alert, "scope"),
|
||||
references=_child_text(alert, "references"),
|
||||
info=infos,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TRDECapAlertInfo:
|
||||
"""Incomplete representation of the CAP 1.2 info field"""
|
||||
|
||||
language: str
|
||||
category: str | None
|
||||
event: str | None
|
||||
urgency: str | None
|
||||
severity: str | None
|
||||
certainty: str | None
|
||||
description: str
|
||||
parameter: list[tuple[str, str]]
|
||||
headline: str | None
|
||||
area: list["TRDECapAlertArea"]
|
||||
|
||||
@staticmethod
|
||||
def from_xml(info: xml.dom.minidom.Element) -> "TRDECapAlertInfo":
|
||||
parameters = []
|
||||
for param_el in info.getElementsByTagName("parameter"):
|
||||
param_el = cast(xml.dom.minidom.Element, param_el)
|
||||
name = _child_text(param_el, "valueName")
|
||||
value = _child_text(param_el, "value")
|
||||
if name is not None:
|
||||
parameters.append((name, value or ""))
|
||||
|
||||
area = []
|
||||
for area_el in info.getElementsByTagName("area"):
|
||||
area.append(TRDECapAlertArea.from_xml(cast(xml.dom.minidom.Element, area_el)))
|
||||
|
||||
return TRDECapAlertInfo(
|
||||
language=_child_text(info, "language") or "",
|
||||
category=_child_text(info, "category"),
|
||||
event=_child_text(info, "event"),
|
||||
urgency=_child_text(info, "urgency"),
|
||||
severity=_child_text(info, "severity"),
|
||||
certainty=_child_text(info, "certainty"),
|
||||
description=_child_text(info, "description") or "",
|
||||
parameter=parameters,
|
||||
headline=_child_text(info, "headline"),
|
||||
area=area,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TRDECapAlertArea:
|
||||
"""Incomplete representation of the CAP 1.2 area field"""
|
||||
|
||||
areaDesc: str | None
|
||||
geocode: list[tuple[str, str]]
|
||||
|
||||
@staticmethod
|
||||
def from_xml(area: xml.dom.minidom.Element) -> "TRDECapAlertArea":
|
||||
geocodes = []
|
||||
for geocode_el in area.getElementsByTagName("geocode"):
|
||||
geocode_el = cast(xml.dom.minidom.Element, geocode_el)
|
||||
name = _child_text(geocode_el, "valueName")
|
||||
value = _child_text(geocode_el, "value")
|
||||
if name is not None:
|
||||
geocodes.append((name, value or ""))
|
||||
|
||||
return TRDECapAlertArea(
|
||||
areaDesc=_child_text(area, "areaDesc"),
|
||||
geocode=geocodes,
|
||||
)
|
||||
@@ -12,7 +12,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, Optional
|
||||
|
||||
# Import meshcore
|
||||
from meshcore import EventType
|
||||
@@ -152,6 +152,12 @@ class PacketCaptureService(BaseServicePlugin):
|
||||
# Event subscriptions (track for cleanup)
|
||||
self.event_subscriptions = []
|
||||
|
||||
# RF / RAW deduplication (aligns with meshcore-packet-capture: avoid double publish
|
||||
# on packet topic when both RX_LOG_DATA and RAW_DATA fire for the same reception).
|
||||
# Timeouts/window come from _load_config (raw_duplicate_window, rf_data_cache_timeout).
|
||||
self.rf_data_cache: dict[str, dict[str, Any]] = {}
|
||||
self.recent_rf_packets: dict[str, float] = {}
|
||||
|
||||
self.logger.info("Packet capture service initialized")
|
||||
|
||||
def _load_config(self) -> None:
|
||||
@@ -196,10 +202,34 @@ class PacketCaptureService(BaseServicePlugin):
|
||||
# 'device' = try on-device signing first, fallback to Python
|
||||
# 'python' = use Python signing only
|
||||
|
||||
# RX_LOG vs RAW correlation (mirror meshcore-packet-capture RAW_DUPLICATE_WINDOW / RF_DATA_TIMEOUT)
|
||||
self.raw_duplicate_window = config.getfloat(
|
||||
'PacketCapture', 'raw_duplicate_window', fallback=2.0
|
||||
)
|
||||
self.rf_data_cache_timeout = config.getfloat(
|
||||
'PacketCapture', 'rf_data_cache_timeout', fallback=15.0
|
||||
)
|
||||
|
||||
# Note: Python signing can fetch private key from device if not provided via file
|
||||
# The create_auth_token_async function will automatically try to export the key
|
||||
# from the device if private_key_hex is None and meshcore_instance is available
|
||||
|
||||
def _prune_correlation_caches(self, current_time: Optional[float] = None) -> None:
|
||||
"""Drop stale rf_data_cache and recent_rf_packets entries.
|
||||
|
||||
Matches meshcore-packet-capture cleanup in handle_rf_log_data / handle_raw_data.
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = time.time()
|
||||
self.rf_data_cache = {
|
||||
k: v for k, v in self.rf_data_cache.items()
|
||||
if current_time - v['timestamp'] < self.rf_data_cache_timeout
|
||||
}
|
||||
self.recent_rf_packets = {
|
||||
k: v for k, v in self.recent_rf_packets.items()
|
||||
if current_time - v < self.raw_duplicate_window
|
||||
}
|
||||
|
||||
def _parse_mqtt_brokers(self, config) -> list[dict[str, Any]]:
|
||||
"""Parse MQTT broker configuration (mqttN_* format).
|
||||
|
||||
@@ -481,6 +511,19 @@ class PacketCaptureService(BaseServicePlugin):
|
||||
if self.debug:
|
||||
self.logger.debug(f"Received RX_LOG_DATA: {raw_hex[:50]}...")
|
||||
|
||||
# Correlate with RAW_DATA: cache SNR/RSSI for prefix; record hex for dedupe
|
||||
# (meshcore-packet-capture: recent_rf_packets + rf_data_cache)
|
||||
current_time = time.time()
|
||||
packet_prefix = raw_hex[:32] if len(raw_hex) >= 32 else raw_hex
|
||||
self.rf_data_cache[packet_prefix] = {
|
||||
'snr': payload.get('snr'),
|
||||
'rssi': payload.get('rssi'),
|
||||
'timestamp': current_time,
|
||||
'payload_length': payload.get('payload_length'),
|
||||
}
|
||||
self.recent_rf_packets[raw_hex.upper()] = current_time
|
||||
self._prune_correlation_caches(current_time)
|
||||
|
||||
# Process packet
|
||||
await self.process_packet(raw_hex, payload, metadata)
|
||||
else:
|
||||
@@ -492,6 +535,9 @@ class PacketCaptureService(BaseServicePlugin):
|
||||
async def handle_raw_data(self, event: Any, metadata: dict[str, Any] | None = None) -> None:
|
||||
"""Handle raw data events.
|
||||
|
||||
Aligns with meshcore-packet-capture: resolve data like reference, dedupe against
|
||||
RX_LOG_DATA when the same hex was just processed, merge cached RF metadata by prefix.
|
||||
|
||||
Args:
|
||||
event: The raw data event.
|
||||
metadata: Optional metadata dictionary.
|
||||
@@ -502,23 +548,61 @@ class PacketCaptureService(BaseServicePlugin):
|
||||
if payload is None:
|
||||
self.logger.warning("Raw data event has no payload")
|
||||
return
|
||||
raw_data = payload.get('data', '')
|
||||
|
||||
if not raw_data:
|
||||
raw_hex_src = None
|
||||
if hasattr(payload, 'data'):
|
||||
raw_hex_src = payload.data
|
||||
elif isinstance(payload, dict):
|
||||
if 'data' in payload:
|
||||
raw_hex_src = payload['data']
|
||||
elif 'raw_hex' in payload:
|
||||
raw_hex_src = payload['raw_hex']
|
||||
|
||||
if raw_hex_src is None:
|
||||
return
|
||||
|
||||
# Convert to hex string if needed
|
||||
if isinstance(raw_data, bytes):
|
||||
raw_hex = raw_data.hex()
|
||||
elif isinstance(raw_data, str):
|
||||
raw_hex = raw_data
|
||||
if isinstance(raw_hex_src, bytes):
|
||||
raw_hex = raw_hex_src.hex()
|
||||
elif isinstance(raw_hex_src, str):
|
||||
raw_hex = raw_hex_src
|
||||
if raw_hex.startswith('0x'):
|
||||
raw_hex = raw_hex[2:]
|
||||
else:
|
||||
return
|
||||
|
||||
# Process packet
|
||||
await self.process_packet(raw_hex, payload, metadata)
|
||||
raw_hex = raw_hex.upper()
|
||||
current_time = time.time()
|
||||
|
||||
recent_rf_time = self.recent_rf_packets.get(raw_hex)
|
||||
if recent_rf_time is not None and (current_time - recent_rf_time) < self.raw_duplicate_window:
|
||||
if self.debug:
|
||||
self.logger.debug(
|
||||
"Skipping RAW_DATA packet already processed from RX_LOG_DATA (duplicate raw hex)"
|
||||
)
|
||||
return
|
||||
|
||||
self.recent_rf_packets = {
|
||||
k: v for k, v in self.recent_rf_packets.items()
|
||||
if current_time - v < self.raw_duplicate_window
|
||||
}
|
||||
|
||||
packet_prefix = raw_hex[:32] if len(raw_hex) >= 32 else raw_hex
|
||||
rf_cached = self.rf_data_cache.get(packet_prefix)
|
||||
|
||||
merged_payload: dict[str, Any]
|
||||
if isinstance(payload, dict):
|
||||
merged_payload = dict(payload)
|
||||
else:
|
||||
merged_payload = {}
|
||||
|
||||
if rf_cached:
|
||||
merged_payload.setdefault('snr', rf_cached.get('snr'))
|
||||
merged_payload.setdefault('rssi', rf_cached.get('rssi'))
|
||||
pl = merged_payload.get('payload_length')
|
||||
if pl is None:
|
||||
merged_payload['payload_length'] = rf_cached.get('payload_length')
|
||||
|
||||
await self.process_packet(raw_hex, merged_payload, metadata)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error handling raw data: {e}")
|
||||
@@ -574,16 +658,21 @@ class PacketCaptureService(BaseServicePlugin):
|
||||
}
|
||||
packet_type = payload_type_map.get(packet_info.get('payload_type', 'UNKNOWN'), "0")
|
||||
|
||||
# Calculate payload length (matches original script logic)
|
||||
# MQTT payload_len: byte length of application payload after header/transport/path.
|
||||
# Subtract path *bytes*, not hop count (multi-byte path IDs); prefer decoded size.
|
||||
firmware_payload_len = payload.get('payload_length')
|
||||
if firmware_payload_len is not None:
|
||||
decoded_ok = packet_info.get('payload_type', 'UNKNOWN') != 'UNKNOWN'
|
||||
if decoded_ok and 'payload_bytes' in packet_info:
|
||||
payload_len = str(packet_info['payload_bytes'])
|
||||
elif firmware_payload_len is not None:
|
||||
payload_len = str(firmware_payload_len)
|
||||
else:
|
||||
# Calculate from packet structure
|
||||
path_len = packet_info.get('path_len', 0)
|
||||
path_bytes = packet_info.get('path_byte_length')
|
||||
if path_bytes is None:
|
||||
path_bytes = packet_info.get('path_len', 0)
|
||||
has_transport = packet_info.get('has_transport_codes', False)
|
||||
transport_bytes = 4 if has_transport else 0
|
||||
payload_len = str(max(0, packet_len - 1 - transport_bytes - 1 - path_len))
|
||||
payload_len = str(max(0, packet_len - 1 - transport_bytes - 1 - path_bytes))
|
||||
|
||||
# Get device name and public key
|
||||
device_name = self._get_bot_name()
|
||||
@@ -615,88 +704,15 @@ class PacketCaptureService(BaseServicePlugin):
|
||||
snr = str(payload.get('snr', 'Unknown'))
|
||||
rssi = str(payload.get('rssi', 'Unknown'))
|
||||
|
||||
# Get packet hash - check multiple sources in order of preference, then calculate if needed
|
||||
# This matches the original script's approach: use hash from routing_info if available, otherwise calculate
|
||||
packet_hash = '0000000000000000'
|
||||
# Get packet hash from decoded packet_info — same clean bytes as the upload's "raw" field,
|
||||
# so this is always the correct hash (matches what other observers compute).
|
||||
packet_hash = packet_info.get('packet_hash', '0000000000000000')
|
||||
|
||||
# 1. Check if payload has packet_hash directly (from bot's processing)
|
||||
if isinstance(payload, dict):
|
||||
if 'packet_hash' in payload:
|
||||
packet_hash = payload['packet_hash']
|
||||
# 2. Check if payload has routing_info with packet_hash
|
||||
elif 'routing_info' in payload:
|
||||
routing_info = payload.get('routing_info', {})
|
||||
if isinstance(routing_info, dict) and 'packet_hash' in routing_info:
|
||||
packet_hash = routing_info['packet_hash']
|
||||
|
||||
# 3. Check metadata if available
|
||||
if packet_hash == '0000000000000000' and metadata and isinstance(metadata, dict):
|
||||
if 'packet_hash' in metadata:
|
||||
packet_hash = metadata['packet_hash']
|
||||
elif 'routing_info' in metadata:
|
||||
routing_info = metadata.get('routing_info', {})
|
||||
if isinstance(routing_info, dict) and 'packet_hash' in routing_info:
|
||||
packet_hash = routing_info['packet_hash']
|
||||
|
||||
# 4. Try to get from bot's recent_rf_data cache (if message_handler has processed it)
|
||||
# Note: The bot stores full raw_hex (with framing bytes) for packet_prefix, but we use stripped raw_hex
|
||||
# So we need to use the full raw_hex from payload for prefix matching
|
||||
# Optimized: Use indexed rf_data_by_pubkey for O(1) lookup instead of linear search
|
||||
if packet_hash == '0000000000000000' and hasattr(self.bot, 'message_handler'):
|
||||
try:
|
||||
message_handler = self.bot.message_handler
|
||||
|
||||
# Get full raw_hex from payload for prefix matching (bot uses full raw_hex for packet_prefix)
|
||||
full_raw_hex = payload.get('raw_hex', '')
|
||||
if full_raw_hex:
|
||||
packet_prefix = full_raw_hex.replace('0x', '')[:32] if len(full_raw_hex.replace('0x', '')) >= 32 else full_raw_hex.replace('0x', '')
|
||||
else:
|
||||
# Fallback: use stripped raw_hex (might not match, but worth trying)
|
||||
clean_raw_hex_for_lookup = raw_hex.replace('0x', '')
|
||||
packet_prefix = clean_raw_hex_for_lookup[:32] if len(clean_raw_hex_for_lookup) >= 32 else clean_raw_hex_for_lookup
|
||||
|
||||
# Use indexed lookup (O(1)) instead of linear search (O(n))
|
||||
if hasattr(message_handler, 'rf_data_by_pubkey') and packet_prefix:
|
||||
rf_data_list = message_handler.rf_data_by_pubkey.get(packet_prefix, [])
|
||||
# Check most recent entries first (last in list, since they're appended in order)
|
||||
for rf_data in reversed(rf_data_list):
|
||||
if 'packet_hash' in rf_data:
|
||||
packet_hash = rf_data['packet_hash']
|
||||
break
|
||||
elif 'routing_info' in rf_data:
|
||||
routing_info = rf_data.get('routing_info', {})
|
||||
if isinstance(routing_info, dict) and 'packet_hash' in routing_info:
|
||||
packet_hash = routing_info['packet_hash']
|
||||
break
|
||||
|
||||
# Fallback to linear search only if indexed lookup not available (backward compatibility)
|
||||
if packet_hash == '0000000000000000' and hasattr(message_handler, 'recent_rf_data'):
|
||||
for rf_data in message_handler.recent_rf_data:
|
||||
# Match by packet_prefix (bot uses full raw_hex for this)
|
||||
if rf_data.get('packet_prefix') == packet_prefix:
|
||||
if 'packet_hash' in rf_data:
|
||||
packet_hash = rf_data['packet_hash']
|
||||
break
|
||||
elif 'routing_info' in rf_data:
|
||||
routing_info = rf_data.get('routing_info', {})
|
||||
if isinstance(routing_info, dict) and 'packet_hash' in routing_info:
|
||||
packet_hash = routing_info['packet_hash']
|
||||
break
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
self.logger.debug(f"Error checking recent_rf_data for hash: {e}")
|
||||
|
||||
# 5. Fall back to hash from decoded packet_info (should be calculated correctly)
|
||||
if packet_hash == '0000000000000000':
|
||||
packet_hash = packet_info.get('packet_hash', '0000000000000000')
|
||||
|
||||
# 6. If still no hash, calculate it from raw_hex (matches original script's format_packet_data)
|
||||
# Only fall back to direct calculation if decode_packet didn't produce a hash
|
||||
if packet_hash == '0000000000000000':
|
||||
try:
|
||||
# Use payload_type_value from packet_info if available, otherwise None (will be extracted from header)
|
||||
payload_type_value = packet_info.get('payload_type_value')
|
||||
if payload_type_value is not None:
|
||||
# Ensure it's an integer (handle enum.value if passed)
|
||||
if hasattr(payload_type_value, 'value'):
|
||||
payload_type_value = payload_type_value.value
|
||||
payload_type_value = int(payload_type_value) & 0x0F
|
||||
@@ -777,6 +793,7 @@ class PacketCaptureService(BaseServicePlugin):
|
||||
'payload_type_value': payload_type_value or 0,
|
||||
'payload_version': 0,
|
||||
'path_len': 0,
|
||||
'path_byte_length': 0,
|
||||
'path_hex': '',
|
||||
'path': [],
|
||||
'payload_hex': raw_hex.replace('0x', ''),
|
||||
|
||||
+4
-5
@@ -32,7 +32,7 @@ def is_valid_timezone(tz_str: str) -> bool:
|
||||
except ZoneInfoNotFoundError:
|
||||
return False
|
||||
try:
|
||||
import pytz
|
||||
pytz = __import__("pytz")
|
||||
pytz.timezone(tz_str.strip())
|
||||
return True
|
||||
except Exception:
|
||||
@@ -48,7 +48,7 @@ def get_config_timezone(config: Any, logger: Optional[Any] = None) -> tuple[Any,
|
||||
"""
|
||||
timezone_str = (config.get('Bot', 'timezone', fallback='') or '').strip()
|
||||
if timezone_str and is_valid_timezone(timezone_str):
|
||||
import pytz
|
||||
pytz = __import__("pytz")
|
||||
return (pytz.timezone(timezone_str), timezone_str)
|
||||
if timezone_str and logger:
|
||||
logger.warning("Invalid timezone '%s', using system timezone", timezone_str)
|
||||
@@ -529,9 +529,8 @@ def calculate_packet_hash(raw_hex: str, payload_type: Optional[int] = None) -> s
|
||||
|
||||
if payload_type == 9: # PAYLOAD_TYPE_TRACE
|
||||
# C++ does: sha.update(&path_len, sizeof(path_len))
|
||||
# path_len is uint16_t, so sizeof(path_len) = 2 bytes
|
||||
# Convert path_len to 2-byte little-endian uint16_t
|
||||
hash_obj.update(path_byte_length.to_bytes(2, byteorder='little'))
|
||||
# path_len is the raw wire byte (uint16_t in firmware), not the decoded byte count
|
||||
hash_obj.update(path_len_byte.to_bytes(2, byteorder='little'))
|
||||
|
||||
hash_obj.update(payload_data)
|
||||
|
||||
|
||||
@@ -378,6 +378,61 @@ class TestSendChannelMessageListeners:
|
||||
assert result is False
|
||||
cm_bot.meshcore.get_contact_by_name.assert_not_called()
|
||||
|
||||
|
||||
class TestSendDMRecipientResolution:
|
||||
"""Tests for recipient lookup in send_dm()."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_resolves_contact_by_pubkey_prefix(self, cm_bot):
|
||||
"""When name lookup fails, send_dm should resolve by public key prefix."""
|
||||
from meshcore import EventType
|
||||
|
||||
cm_bot.connected = True
|
||||
cm_bot.meshcore = Mock()
|
||||
cm_bot.meshcore.get_contact_by_name = Mock(return_value=None)
|
||||
cm_bot.meshcore.contacts = {
|
||||
"contact1": {
|
||||
"name": "Alice",
|
||||
"adv_name": "AliceAdv",
|
||||
"public_key": "ab12deadbeefcafebabe",
|
||||
}
|
||||
}
|
||||
cm_bot.meshcore.commands = Mock(spec=["send_msg"])
|
||||
cm_bot.meshcore.commands.send_msg = AsyncMock(return_value=Mock(type=EventType.MSG_SENT, payload=None))
|
||||
cm_bot.bot_tx_rate_limiter.wait_for_tx = AsyncMock(return_value=None)
|
||||
manager = make_manager(cm_bot)
|
||||
|
||||
result = await manager.send_dm("ab12", "Hello mesh")
|
||||
|
||||
assert result is True
|
||||
cm_bot.meshcore.get_contact_by_name.assert_called_once_with("ab12")
|
||||
cm_bot.meshcore.commands.send_msg.assert_awaited_once()
|
||||
sent_contact = cm_bot.meshcore.commands.send_msg.await_args.args[0]
|
||||
assert sent_contact["name"] == "Alice"
|
||||
assert sent_contact["public_key"].startswith("ab12")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_fails_when_name_and_prefix_lookup_miss(self, cm_bot):
|
||||
"""send_dm should fail when recipient cannot be resolved by name or prefix."""
|
||||
cm_bot.connected = True
|
||||
cm_bot.meshcore = Mock()
|
||||
cm_bot.meshcore.get_contact_by_name = Mock(return_value=None)
|
||||
cm_bot.meshcore.contacts = {
|
||||
"contact1": {
|
||||
"name": "Bob",
|
||||
"public_key": "ffffdeadbeefcafebabe",
|
||||
}
|
||||
}
|
||||
cm_bot.bot_tx_rate_limiter.wait_for_tx = AsyncMock(return_value=None)
|
||||
manager = make_manager(cm_bot)
|
||||
|
||||
result = await manager.send_dm("ab12", "Hello mesh")
|
||||
|
||||
assert result is False
|
||||
cm_bot.meshcore.get_contact_by_name.assert_called_once_with("ab12")
|
||||
cm_bot.logger.error.assert_called()
|
||||
assert "Contact not found for DM recipient identifier" in cm_bot.logger.error.call_args.args[0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_send_does_not_invoke_listeners(self, cm_bot):
|
||||
"""When send_channel_message fails (e.g. channel not found), listeners are not called."""
|
||||
|
||||
@@ -286,3 +286,40 @@ class TestSchema:
|
||||
(idx,),
|
||||
)
|
||||
assert cur.fetchone() is not None
|
||||
|
||||
def test_purging_log_has_details_column_after_full_migration(self, runner, conn):
|
||||
runner.run()
|
||||
cursor = conn.cursor()
|
||||
assert _column_exists(cursor, "purging_log", "details") is True
|
||||
|
||||
def test_migration_adds_purging_log_details_for_legacy_schema(self, conn, logger):
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER NOT NULL,
|
||||
description TEXT,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
for version, description, _ in MIGRATIONS:
|
||||
if version <= 11:
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, description) VALUES (?, ?)",
|
||||
(version, description),
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS purging_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
action TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
reason TEXT
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
runner = MigrationRunner(conn, logger)
|
||||
runner.run()
|
||||
|
||||
cursor = conn.cursor()
|
||||
assert _column_exists(cursor, "purging_log", "details") is True
|
||||
@@ -1817,3 +1817,139 @@ class TestRespondToMentions:
|
||||
await mention_handler.process_message(msg)
|
||||
assert msg.content == "ping"
|
||||
mention_bot.command_manager.execute_commands.assert_called_once()
|
||||
|
||||
|
||||
class TestProcessMessageDmKeywordRouting:
|
||||
"""Regression tests for DM keyword reply routing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keyword_reply_uses_prefix_sender_id_for_dm_send(self, handler):
|
||||
"""DM keyword flow should route reply using prefix sender identity."""
|
||||
handler.should_process_message = Mock(return_value=True)
|
||||
handler.bot.command_manager.check_keywords = Mock(return_value=[("test", "ack")])
|
||||
handler.bot.command_manager.match_randomline = Mock(return_value=None)
|
||||
handler.bot.command_manager.execute_commands = AsyncMock()
|
||||
handler.bot.command_manager.get_rate_limit_key = Mock(return_value="ab12deadbeef")
|
||||
handler.bot.command_manager.send_dm = AsyncMock(return_value=True)
|
||||
handler.bot.command_manager.commands = {}
|
||||
|
||||
message = MeshMessage(
|
||||
content="test",
|
||||
sender_id="ab12",
|
||||
sender_pubkey="ab12deadbeefcafebabe",
|
||||
is_dm=True,
|
||||
)
|
||||
|
||||
await handler.process_message(message)
|
||||
|
||||
handler.bot.command_manager.send_dm.assert_awaited_once()
|
||||
args, kwargs = handler.bot.command_manager.send_dm.await_args
|
||||
assert args[0] == "ab12"
|
||||
assert args[1] == "ack"
|
||||
assert args[2].startswith("keyword_test_ab12_")
|
||||
assert kwargs["rate_limit_key"] == "ab12deadbeef"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# handle_new_contact — auto_manage_contacts (companion path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _NewContactEvent:
|
||||
def __init__(self, payload: dict) -> None:
|
||||
self.payload = payload
|
||||
|
||||
|
||||
def _companion_contact_payload() -> dict:
|
||||
pk = "ab" * 32
|
||||
return {
|
||||
"public_key": pk,
|
||||
"adv_name": "Alice",
|
||||
"name": "Alice",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"out_path": "",
|
||||
"out_path_len": 0,
|
||||
"out_path_hash_mode": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def new_contact_env(mock_logger):
|
||||
"""Bot + MessageHandler with mocked repeater_manager and meshcore for NEW_CONTACT tests."""
|
||||
bot = Mock()
|
||||
bot.logger = mock_logger
|
||||
bot.config = configparser.ConfigParser()
|
||||
bot.config.add_section("Bot")
|
||||
bot.config.set("Bot", "enabled", "true")
|
||||
bot.config.set("Bot", "rf_data_timeout", "15.0")
|
||||
bot.config.set("Bot", "message_correlation_timeout", "10.0")
|
||||
bot.config.set("Bot", "enable_enhanced_correlation", "true")
|
||||
bot.config.add_section("Channels")
|
||||
bot.config.set("Channels", "respond_to_dms", "true")
|
||||
bot.connection_time = None
|
||||
bot.prefix_hex_chars = 8
|
||||
bot.command_manager = Mock()
|
||||
bot.command_manager.monitor_channels = ["general"]
|
||||
bot.command_manager.is_user_banned = Mock(return_value=False)
|
||||
bot.command_manager.commands = {}
|
||||
|
||||
handler = MessageHandler(bot)
|
||||
bot.message_handler = handler
|
||||
|
||||
rm = Mock()
|
||||
rm.track_contact_advertisement = AsyncMock()
|
||||
rm.check_and_auto_purge = AsyncMock()
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={
|
||||
"is_near_limit": False,
|
||||
"usage_percentage": 10.0,
|
||||
"current_contacts": 5,
|
||||
"estimated_limit": 300,
|
||||
}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock(return_value={"success": True})
|
||||
rm.add_companion_from_contact_data = AsyncMock(return_value=True)
|
||||
rm.log_purging_action = Mock()
|
||||
rm.db_manager = Mock()
|
||||
rm.db_manager.execute_update = Mock()
|
||||
rm._is_repeater_device = Mock(return_value=False)
|
||||
bot.repeater_manager = rm
|
||||
|
||||
mesh = Mock()
|
||||
mesh.commands = Mock()
|
||||
mesh.commands.add_contact = AsyncMock()
|
||||
bot.meshcore = mesh
|
||||
|
||||
return bot, handler, rm, mesh
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestHandleNewContactAutoManage:
|
||||
async def test_manual_mode_no_device_add(self, new_contact_env):
|
||||
bot, handler, rm, mesh = new_contact_env
|
||||
bot.config.set("Bot", "auto_manage_contacts", "false")
|
||||
ev = _NewContactEvent(_companion_contact_payload())
|
||||
await handler.handle_new_contact(ev, None)
|
||||
rm.track_contact_advertisement.assert_awaited_once()
|
||||
mesh.commands.add_contact.assert_not_called()
|
||||
rm.add_companion_from_contact_data.assert_not_called()
|
||||
rm.log_purging_action.assert_called_once()
|
||||
|
||||
async def test_device_mode_no_bot_add_contact(self, new_contact_env):
|
||||
bot, handler, rm, mesh = new_contact_env
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
ev = _NewContactEvent(_companion_contact_payload())
|
||||
await handler.handle_new_contact(ev, None)
|
||||
rm.track_contact_advertisement.assert_awaited_once()
|
||||
mesh.commands.add_contact.assert_not_called()
|
||||
rm.add_companion_from_contact_data.assert_not_called()
|
||||
rm.get_contact_list_status.assert_awaited()
|
||||
|
||||
async def test_bot_mode_uses_add_companion_from_contact_data(self, new_contact_env):
|
||||
bot, handler, rm, mesh = new_contact_env
|
||||
bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
ev = _NewContactEvent(_companion_contact_payload())
|
||||
await handler.handle_new_contact(ev, None)
|
||||
rm.add_companion_from_contact_data.assert_awaited_once()
|
||||
mesh.commands.add_contact.assert_not_called()
|
||||
@@ -3,8 +3,10 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
|
||||
from modules.message_handler import MessageHandler
|
||||
from modules.repeater_manager import RepeaterManager
|
||||
|
||||
|
||||
def _make_config_get():
|
||||
@@ -31,11 +33,20 @@ def message_handler():
|
||||
|
||||
@pytest.fixture
|
||||
def companion_new_contact_setup():
|
||||
"""Bot + MessageHandler wired for companion NEW_CONTACT → add_contact."""
|
||||
"""Bot + MessageHandler wired for companion NEW_CONTACT → add_contact (bot auto-manage)."""
|
||||
bot = MagicMock()
|
||||
bot.logger = MagicMock()
|
||||
|
||||
def _config_get(section, key, **kw):
|
||||
defaults = {
|
||||
("Bot", "rf_data_timeout"): "15.0",
|
||||
("Bot", "message_correlation_timeout"): "10.0",
|
||||
("Bot", "auto_manage_contacts"): "bot",
|
||||
}
|
||||
return defaults.get((section, key), kw.get("fallback", ""))
|
||||
|
||||
bot.config = MagicMock()
|
||||
bot.config.get = MagicMock(side_effect=_make_config_get())
|
||||
bot.config.get = MagicMock(side_effect=_config_get)
|
||||
bot.config.getboolean = MagicMock(return_value=True)
|
||||
bot.prefix_hex_chars = 8
|
||||
|
||||
@@ -43,14 +54,26 @@ def companion_new_contact_setup():
|
||||
bot.message_handler = mh
|
||||
|
||||
rm = MagicMock()
|
||||
rm.bot = bot
|
||||
rm.logger = bot.logger
|
||||
rm._is_repeater_device = MagicMock(return_value=False)
|
||||
rm.track_contact_advertisement = AsyncMock()
|
||||
rm.check_and_auto_purge = AsyncMock()
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={"is_near_limit": False, "usage_percentage": 0.0}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock()
|
||||
rm.db_manager = MagicMock()
|
||||
rm.db_manager.execute_update = MagicMock()
|
||||
|
||||
async def _add_companion(contact_data, contact_name, public_key):
|
||||
return await RepeaterManager.add_companion_from_contact_data(rm, contact_data, contact_name, public_key)
|
||||
|
||||
rm.add_companion_from_contact_data = AsyncMock(side_effect=_add_companion)
|
||||
bot.repeater_manager = rm
|
||||
|
||||
ok = MagicMock()
|
||||
ok.type = MagicMock()
|
||||
ok.type.name = "OK"
|
||||
ok.type = EventType.OK
|
||||
bot.meshcore = MagicMock()
|
||||
bot.meshcore.commands = MagicMock()
|
||||
bot.meshcore.commands.add_contact = AsyncMock(return_value=ok)
|
||||
|
||||
@@ -9,7 +9,10 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
import pytest
|
||||
from meshcore import EventType
|
||||
|
||||
from modules.repeater_manager import RepeaterManager
|
||||
from modules.repeater_manager import (
|
||||
RepeaterManager,
|
||||
collect_protected_pubkeys_for_device_mode,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -87,6 +90,30 @@ class TestDetermineContactRole:
|
||||
assert rm._determine_contact_role({}) == "companion"
|
||||
|
||||
|
||||
class TestPurgingLogCompatibility:
|
||||
def test_log_purging_action_uses_details_when_available(self, rm):
|
||||
rm._purging_log_has_details = True
|
||||
rm.db_manager.execute_update = Mock()
|
||||
|
||||
rm.log_purging_action("contact_management", "managed contacts")
|
||||
|
||||
rm.db_manager.execute_update.assert_called_once_with(
|
||||
"INSERT INTO purging_log (action, details) VALUES (?, ?)",
|
||||
("contact_management", "managed contacts"),
|
||||
)
|
||||
|
||||
def test_log_purging_action_falls_back_to_legacy_columns(self, rm):
|
||||
rm._purging_log_has_details = False
|
||||
rm.db_manager.execute_update = Mock()
|
||||
|
||||
rm.log_purging_action("contact_management", "managed contacts")
|
||||
|
||||
rm.db_manager.execute_update.assert_called_once_with(
|
||||
"INSERT INTO purging_log (action, public_key, name, reason) VALUES (?, ?, ?, ?)",
|
||||
("contact_management", "", "contact_management", "managed contacts"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _determine_device_type
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -496,6 +523,7 @@ class TestCheckAndAutoPurge:
|
||||
rm.auto_purge_enabled = True
|
||||
rm.auto_purge_threshold = 10
|
||||
rm.companion_purge_enabled = True
|
||||
rm._auto_manage_contacts = 'bot'
|
||||
rm.bot.meshcore = Mock()
|
||||
rm.bot.meshcore.contacts = {str(i): {} for i in range(15)}
|
||||
|
||||
@@ -510,6 +538,59 @@ class TestCheckAndAutoPurge:
|
||||
mock_comp.assert_called_once()
|
||||
assert result is True
|
||||
|
||||
async def test_companion_purge_skipped_when_auto_manage_device(self, bot):
|
||||
"""Device mode: count-based purge is suppressed; companions never auto-purged from radio."""
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
rm.companion_purge_enabled = True
|
||||
rm.bot.meshcore = Mock()
|
||||
rm.bot.meshcore.contacts = {str(i): {} for i in range(15)}
|
||||
rm.bot.meshcore.commands = Mock()
|
||||
rm.bot.meshcore.commands.send_device_query = AsyncMock(return_value=Mock(type=Mock()))
|
||||
|
||||
with patch.object(rm, "_auto_purge_repeaters", new_callable=AsyncMock) as mock_rep, \
|
||||
patch.object(rm, "_auto_purge_companions", new_callable=AsyncMock) as mock_comp:
|
||||
result = await rm.check_and_auto_purge()
|
||||
|
||||
mock_rep.assert_not_called()
|
||||
mock_comp.assert_not_called()
|
||||
assert result is False
|
||||
assert rm.contact_limit >= 15
|
||||
assert rm.auto_purge_threshold == rm.contact_limit + 1
|
||||
|
||||
async def test_device_mode_returns_true_when_still_full_no_repeaters(self, bot):
|
||||
"""Device mode: no repeater purge path when mesh is within synced limit (no false failure)."""
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
rm.companion_purge_enabled = False
|
||||
rm.bot.meshcore = Mock()
|
||||
rm.bot.meshcore.contacts = {str(i): {} for i in range(15)}
|
||||
rm.bot.meshcore.commands = Mock()
|
||||
rm.bot.meshcore.commands.send_device_query = AsyncMock(return_value=Mock(type=Mock()))
|
||||
|
||||
with patch.object(rm, "_auto_purge_repeaters", new_callable=AsyncMock) as mock_rep, \
|
||||
patch.object(rm, "_auto_purge_companions", new_callable=AsyncMock) as mock_comp:
|
||||
result = await rm.check_and_auto_purge()
|
||||
|
||||
mock_rep.assert_not_called()
|
||||
mock_comp.assert_not_called()
|
||||
assert result is False
|
||||
|
||||
async def test_device_mode_floors_contact_limit_to_mesh_when_firmware_under_reports(self, bot):
|
||||
"""If live contact count exceeds DEVICE_INFO max_contacts, raise limit to match the radio."""
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
rm.bot.meshcore = Mock()
|
||||
rm.bot.meshcore.contacts = {str(i): {} for i in range(331)}
|
||||
info = SimpleNamespace(type=EventType.DEVICE_INFO, payload={"max_contacts": 300})
|
||||
rm.bot.meshcore.commands = Mock()
|
||||
rm.bot.meshcore.commands.send_device_query = AsyncMock(return_value=info)
|
||||
|
||||
await rm._update_contact_limit_from_device()
|
||||
|
||||
assert rm.contact_limit == 331
|
||||
assert rm.auto_purge_threshold == 332
|
||||
|
||||
async def test_returns_false_when_purge_fails(self, rm):
|
||||
"""When both purge counts succeed=False, check_and_auto_purge returns False."""
|
||||
rm.auto_purge_enabled = True
|
||||
@@ -1302,3 +1383,240 @@ class TestPurgeDedupConcurrency:
|
||||
assert first_result is False
|
||||
assert second_result is True
|
||||
assert rm.bot.meshcore.commands.remove_contact.await_count == 2
|
||||
|
||||
|
||||
class TestCollectProtectedPubkeysForDeviceMode:
|
||||
"""collect_protected_pubkeys_for_device_mode matches Admin + announcements ACL union."""
|
||||
|
||||
def test_admin_and_announcements_merged(self, mock_logger):
|
||||
cfg = configparser.ConfigParser()
|
||||
pk_a = "aa" * 32
|
||||
pk_b = "bb" * 32
|
||||
cfg.add_section("Admin_ACL")
|
||||
cfg.set("Admin_ACL", "admin_pubkeys", pk_a)
|
||||
cfg.add_section("Announcements_Command")
|
||||
cfg.set("Announcements_Command", "announcements_acl", pk_b)
|
||||
keys = collect_protected_pubkeys_for_device_mode(cfg, mock_logger)
|
||||
assert keys == {pk_a.lower(), pk_b.lower()}
|
||||
|
||||
def test_admin_only_when_no_announcements_acl(self, mock_logger):
|
||||
cfg = configparser.ConfigParser()
|
||||
pk_a = "cc" * 32
|
||||
cfg.add_section("Admin_ACL")
|
||||
cfg.set("Admin_ACL", "admin_pubkeys", pk_a)
|
||||
cfg.add_section("Announcements_Command")
|
||||
cfg.set("Announcements_Command", "announcements_acl", "")
|
||||
keys = collect_protected_pubkeys_for_device_mode(cfg, mock_logger)
|
||||
assert keys == {pk_a.lower()}
|
||||
|
||||
|
||||
class TestAddCompanionFromContactData:
|
||||
"""RepeaterManager.add_companion_from_contact_data TABLE_FULL retry."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_after_table_full(self, rm, bot):
|
||||
pk = "dd" * 32
|
||||
contact_data = {
|
||||
"public_key": pk,
|
||||
"adv_name": "Bob",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"out_path": "",
|
||||
"out_path_len": 0,
|
||||
"out_path_hash_mode": 0,
|
||||
"last_advert": 0,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
}
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
err = SimpleNamespace(type=EventType.ERROR, payload={"error_code": 3, "code_string": "ERR_CODE_TABLE_FULL"})
|
||||
ok = SimpleNamespace(type=EventType.OK, payload={})
|
||||
bot.meshcore.commands.add_contact = AsyncMock(side_effect=[err, ok])
|
||||
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={
|
||||
"is_near_limit": False,
|
||||
"usage_percentage": 50.0,
|
||||
"current_contacts": 100,
|
||||
"estimated_limit": 300,
|
||||
}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock(return_value={"success": True})
|
||||
|
||||
result = await rm.add_companion_from_contact_data(contact_data, "Bob", pk)
|
||||
assert result is True
|
||||
assert bot.meshcore.commands.add_contact.await_count == 2
|
||||
rm.manage_contact_list.assert_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_on_first_ok_without_retry(self, rm, bot):
|
||||
pk = "ee" * 32
|
||||
contact_data = {
|
||||
"public_key": pk,
|
||||
"adv_name": "Ann",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"out_path": "",
|
||||
"out_path_len": 0,
|
||||
"out_path_hash_mode": 0,
|
||||
"last_advert": 0,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
}
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
ok = SimpleNamespace(type=EventType.OK, payload={})
|
||||
bot.meshcore.commands.add_contact = AsyncMock(return_value=ok)
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={"is_near_limit": False, "usage_percentage": 10.0}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock()
|
||||
|
||||
result = await rm.add_companion_from_contact_data(contact_data, "Ann", pk)
|
||||
assert result is True
|
||||
bot.meshcore.commands.add_contact.assert_awaited_once()
|
||||
rm.manage_contact_list.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_near_limit_triggers_manage_before_add(self, rm, bot):
|
||||
pk = "ff" * 32
|
||||
contact_data = {
|
||||
"public_key": pk,
|
||||
"adv_name": "Near",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
"out_path": "",
|
||||
"out_path_len": 0,
|
||||
"out_path_hash_mode": 0,
|
||||
"last_advert": 0,
|
||||
"adv_lat": 0.0,
|
||||
"adv_lon": 0.0,
|
||||
}
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
ok = SimpleNamespace(type=EventType.OK, payload={})
|
||||
bot.meshcore.commands.add_contact = AsyncMock(return_value=ok)
|
||||
rm.get_contact_list_status = AsyncMock(
|
||||
return_value={"is_near_limit": True, "usage_percentage": 85.0}
|
||||
)
|
||||
rm.manage_contact_list = AsyncMock(return_value={"success": True})
|
||||
|
||||
result = await rm.add_companion_from_contact_data(contact_data, "Near", pk)
|
||||
assert result is True
|
||||
rm.manage_contact_list.assert_awaited_once()
|
||||
bot.meshcore.commands.add_contact.assert_awaited_once()
|
||||
|
||||
|
||||
class TestApplyDeviceModeFirmwarePreferences:
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_sets_manual_and_autoadd(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
ok = SimpleNamespace(type=EventType.OK)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.set_manual_add_contacts = AsyncMock(return_value=ok)
|
||||
bot.meshcore.commands.set_autoadd_config = AsyncMock(return_value=ok)
|
||||
|
||||
assert await rm.apply_device_mode_firmware_preferences() is True
|
||||
bot.meshcore.commands.set_autoadd_config.assert_awaited_once_with(0x03)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_not_device_mode(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
rm = RepeaterManager(bot)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
|
||||
assert await rm.apply_device_mode_firmware_preferences() is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_false_when_autoadd_not_ok(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
rm = RepeaterManager(bot)
|
||||
ok = SimpleNamespace(type=EventType.OK)
|
||||
bad = SimpleNamespace(type=EventType.ERROR, payload={})
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.set_manual_add_contacts = AsyncMock(return_value=ok)
|
||||
bot.meshcore.commands.set_autoadd_config = AsyncMock(return_value=bad)
|
||||
|
||||
assert await rm.apply_device_mode_firmware_preferences() is False
|
||||
|
||||
|
||||
class TestSyncDeviceModeFavourites:
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass1_no_op_when_not_device(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
rm = RepeaterManager(bot)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.get_contacts = AsyncMock()
|
||||
bot.meshcore.commands.change_contact_flags = AsyncMock()
|
||||
|
||||
await rm.sync_device_mode_favourites_pass1()
|
||||
|
||||
bot.meshcore.commands.get_contacts.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass1_favourites_protected_contact(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
bot.config.set("Bot", "contact_flag_update_spacing_ms", "0")
|
||||
pk = "aa" * 32
|
||||
bot.config.add_section("Admin_ACL")
|
||||
bot.config.set("Admin_ACL", "admin_pubkeys", pk)
|
||||
rm = RepeaterManager(bot)
|
||||
ok = SimpleNamespace(type=EventType.OK)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.contacts = {pk: {"public_key": pk, "flags": 0, "adv_name": "A", "type": 1}}
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.get_contacts = AsyncMock()
|
||||
bot.meshcore.commands.change_contact_flags = AsyncMock(return_value=ok)
|
||||
|
||||
await rm.sync_device_mode_favourites_pass1()
|
||||
|
||||
bot.meshcore.commands.change_contact_flags.assert_awaited_once()
|
||||
args, _kwargs = bot.meshcore.commands.change_contact_flags.await_args
|
||||
assert args[1] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pass2_clears_favourite_for_non_protected(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
bot.config.set("Bot", "contact_flag_update_spacing_ms", "0")
|
||||
pk_prot = "bb" * 32
|
||||
pk_other = "cc" * 32
|
||||
bot.config.add_section("Admin_ACL")
|
||||
bot.config.set("Admin_ACL", "admin_pubkeys", pk_prot)
|
||||
rm = RepeaterManager(bot)
|
||||
ok = SimpleNamespace(type=EventType.OK)
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.contacts = {
|
||||
pk_other: {"public_key": pk_other, "flags": 1, "adv_name": "X", "type": 1},
|
||||
}
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.get_contacts = AsyncMock()
|
||||
bot.meshcore.commands.change_contact_flags = AsyncMock(return_value=ok)
|
||||
|
||||
await rm.sync_device_mode_favourites_pass2()
|
||||
|
||||
bot.meshcore.commands.change_contact_flags.assert_awaited_once()
|
||||
args, _kwargs = bot.meshcore.commands.change_contact_flags.await_args
|
||||
assert args[1] == 0
|
||||
|
||||
|
||||
class TestUpdateContactLimitFromDevice:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_mode_uses_firmware_max_and_threshold(self, bot):
|
||||
bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
rm = RepeaterManager(bot)
|
||||
info = SimpleNamespace(type=EventType.DEVICE_INFO, payload={"max_contacts": 300})
|
||||
bot.meshcore = Mock()
|
||||
bot.meshcore.contacts = {str(i): {} for i in range(10)}
|
||||
bot.meshcore.commands = Mock()
|
||||
bot.meshcore.commands.send_device_query = AsyncMock(return_value=info)
|
||||
|
||||
await rm._update_contact_limit_from_device()
|
||||
|
||||
assert rm.contact_limit == 300
|
||||
assert rm.auto_purge_threshold == 280
|
||||
@@ -1620,3 +1620,72 @@ class TestZombieAlertEmailSsrfGuard:
|
||||
sched.send_zombie_alert_email(fail_count=5, threshold=3, interval=60)
|
||||
mock_smtp.assert_not_called()
|
||||
mock_smtp_ssl.assert_not_called()
|
||||
|
||||
|
||||
class TestDeviceModeSchedulerJobs:
|
||||
"""_setup_device_mode_scheduler_jobs registers one-shot jobs when auto_manage_contacts=device."""
|
||||
|
||||
def test_registers_three_jobs_in_device_mode(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
mock_ap = MagicMock()
|
||||
scheduler._apscheduler = mock_ap
|
||||
scheduler._setup_device_mode_scheduler_jobs()
|
||||
assert mock_ap.add_job.call_count == 3
|
||||
ids = {call.kwargs["id"] for call in mock_ap.add_job.call_args_list}
|
||||
assert ids == {
|
||||
"device_mode_firmware_autoadd",
|
||||
"device_mode_favourite_pass1",
|
||||
"device_mode_favourite_pass2",
|
||||
}
|
||||
for call in mock_ap.add_job.call_args_list:
|
||||
assert call.kwargs.get("replace_existing") is True
|
||||
|
||||
def test_no_jobs_when_not_device_mode(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
mock_ap = MagicMock()
|
||||
scheduler._apscheduler = mock_ap
|
||||
scheduler._setup_device_mode_scheduler_jobs()
|
||||
mock_ap.add_job.assert_not_called()
|
||||
|
||||
def test_firmware_job_sync_skips_when_not_device(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||||
with patch.object(scheduler, "_run_async_on_main_loop") as mock_run:
|
||||
scheduler._device_mode_firmware_job_sync()
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_run_async_warns_without_running_loop(self, scheduler, mock_logger):
|
||||
scheduler.bot.logger = mock_logger
|
||||
scheduler.bot.main_event_loop = None
|
||||
|
||||
async def trivial():
|
||||
pass
|
||||
|
||||
coro = trivial()
|
||||
try:
|
||||
scheduler._run_async_on_main_loop(coro, timeout=1.0)
|
||||
finally:
|
||||
coro.close()
|
||||
mock_logger.warning.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_mode_firmware_coro_calls_repeater_manager(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
scheduler.bot.repeater_manager = Mock()
|
||||
scheduler.bot.repeater_manager.apply_device_mode_firmware_preferences = AsyncMock(return_value=True)
|
||||
|
||||
await scheduler._device_mode_firmware_coro()
|
||||
|
||||
scheduler.bot.repeater_manager.apply_device_mode_firmware_preferences.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_mode_favourite_coros_delegate(self, scheduler):
|
||||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||||
scheduler.bot.repeater_manager = Mock()
|
||||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass1 = AsyncMock()
|
||||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass2 = AsyncMock()
|
||||
|
||||
await scheduler._device_mode_favourite_pass1_coro()
|
||||
await scheduler._device_mode_favourite_pass2_coro()
|
||||
|
||||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass1.assert_awaited_once()
|
||||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass2.assert_awaited_once()
|
||||
+106
-2
@@ -1,9 +1,12 @@
|
||||
"""Tests for modules.commands.trace_command — pure logic functions."""
|
||||
|
||||
import configparser
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.commands.trace_command import TraceCommand
|
||||
from modules.trace_runner import RunTraceResult
|
||||
from tests.conftest import mock_message
|
||||
|
||||
|
||||
@@ -367,7 +370,6 @@ class TestTraceExecute:
|
||||
|
||||
def test_execute_no_meshcore_commands(self):
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
bot = _make_bot()
|
||||
bot.connected = True
|
||||
bot.meshcore = MagicMock()
|
||||
@@ -377,3 +379,105 @@ class TestTraceExecute:
|
||||
msg = mock_message(content="trace 01,7a")
|
||||
result = asyncio.run(cmd.execute(msg))
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestMultibyteParsePathArg:
|
||||
"""Multibyte path parsing: 2-byte (4-char) and 3-byte (6-char) nodes."""
|
||||
|
||||
def setup_method(self):
|
||||
self.cmd = TraceCommand(_make_bot())
|
||||
|
||||
def test_two_byte_comma_separated(self):
|
||||
result = self.cmd._parse_path_arg("trace feed,6ddf,feed")
|
||||
assert result == ["feed", "6ddf", "feed"]
|
||||
|
||||
def test_two_byte_single_pair(self):
|
||||
result = self.cmd._parse_path_arg("trace feed,6ddf")
|
||||
assert result == ["feed", "6ddf"]
|
||||
|
||||
def test_two_byte_tracer_prefix(self):
|
||||
result = self.cmd._parse_path_arg("tracer feed,6ddf")
|
||||
assert result == ["feed", "6ddf"]
|
||||
|
||||
def test_mixed_length_returns_none(self):
|
||||
result = self.cmd._parse_path_arg("trace feed,01")
|
||||
assert result is None
|
||||
|
||||
def test_three_byte_comma_separated(self):
|
||||
result = self.cmd._parse_path_arg("trace feedca,6ddf01")
|
||||
assert result == ["feedca", "6ddf01"]
|
||||
|
||||
def test_contiguous_hex_still_splits_by_two(self):
|
||||
result = self.cmd._parse_path_arg("trace feed6ddf")
|
||||
assert result == ["fe", "ed", "6d", "df"]
|
||||
|
||||
def test_two_byte_bang_prefix(self):
|
||||
result = self.cmd._parse_path_arg("!trace feed,6ddf")
|
||||
assert result == ["feed", "6ddf"]
|
||||
|
||||
|
||||
class TestMultibyteExtractPathFromMessage:
|
||||
"""_extract_path_from_message handles 1-byte, 2-byte, and 3-byte path hashes."""
|
||||
|
||||
def setup_method(self):
|
||||
self.cmd = TraceCommand(_make_bot())
|
||||
|
||||
def test_two_byte_multi_hop(self):
|
||||
msg = mock_message(content="trace", path="feed,6ddf,feed (3 hops via FLOOD)")
|
||||
assert self.cmd._extract_path_from_message(msg) == ["feed", "6ddf", "feed"]
|
||||
|
||||
def test_two_byte_single_node(self):
|
||||
msg = mock_message(content="trace", path="feed (1 hop)")
|
||||
assert self.cmd._extract_path_from_message(msg) == ["feed"]
|
||||
|
||||
def test_three_byte_multi_hop(self):
|
||||
msg = mock_message(content="trace", path="feedca,6ddf01 (2 hops via FLOOD)")
|
||||
assert self.cmd._extract_path_from_message(msg) == ["feedca", "6ddf01"]
|
||||
|
||||
def test_three_byte_single_node(self):
|
||||
msg = mock_message(content="trace", path="feedca (1 hop)")
|
||||
assert self.cmd._extract_path_from_message(msg) == ["feedca"]
|
||||
|
||||
def test_mixed_length_parts_skipped(self):
|
||||
msg = mock_message(content="trace", path="feed,01,ab")
|
||||
result = self.cmd._extract_path_from_message(msg)
|
||||
assert len({len(p) for p in result}) <= 1
|
||||
|
||||
|
||||
class TestMultibyteReciprocalPath:
|
||||
"""_build_reciprocal_path works with multibyte nodes."""
|
||||
|
||||
def setup_method(self):
|
||||
self.cmd = TraceCommand(_make_bot())
|
||||
|
||||
def test_two_byte_three_node_reciprocal(self):
|
||||
result = self.cmd._build_reciprocal_path(["feed", "6ddf", "feed"])
|
||||
assert result == ["feed", "6ddf", "feed", "6ddf", "feed"]
|
||||
|
||||
|
||||
class TestFlagsAutoDetection:
|
||||
"""execute() passes flags derived from path element length, not from trace_mode config."""
|
||||
|
||||
def setup_method(self):
|
||||
self.bot = _make_bot()
|
||||
self.bot.connected = True
|
||||
self.bot.meshcore = MagicMock()
|
||||
self.bot.meshcore.commands = MagicMock()
|
||||
self.cmd = TraceCommand(self.bot)
|
||||
self.cmd.send_response = AsyncMock(return_value=True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_byte_path_uses_flags_zero(self):
|
||||
with patch("modules.commands.trace_command.run_trace") as mock_run:
|
||||
mock_run.return_value = RunTraceResult(success=False, tag=0, error_message="x")
|
||||
await self.cmd.execute(mock_message(content="trace 01,7a,55"))
|
||||
mock_run.assert_called_once()
|
||||
assert mock_run.call_args.kwargs["flags"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_byte_path_uses_flags_one(self):
|
||||
with patch("modules.commands.trace_command.run_trace") as mock_run:
|
||||
mock_run.return_value = RunTraceResult(success=False, tag=0, error_message="x")
|
||||
await self.cmd.execute(mock_message(content="trace feed,6ddf,feed"))
|
||||
mock_run.assert_called_once()
|
||||
assert mock_run.call_args.kwargs["flags"] == 1
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for path-byte gating across test/multitest/path commands."""
|
||||
|
||||
import configparser
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.commands.multitest_command import MultitestCommand, MultitestSession
|
||||
from modules.commands.path_command import PathCommand
|
||||
from modules.commands.test_command import TestCommand as MeshTestCommand
|
||||
from tests.conftest import mock_message
|
||||
|
||||
|
||||
def _base_bot() -> MagicMock:
|
||||
bot = MagicMock()
|
||||
bot.logger = Mock()
|
||||
bot.config = configparser.ConfigParser()
|
||||
bot.config.add_section("Bot")
|
||||
bot.config.set("Bot", "bot_name", "TestBot")
|
||||
bot.config.add_section("Channels")
|
||||
bot.config.set("Channels", "monitor_channels", "general")
|
||||
bot.config.set("Channels", "respond_to_dms", "true")
|
||||
bot.config.add_section("Keywords")
|
||||
bot.config.set("Keywords", "test", "ack")
|
||||
bot.command_manager = MagicMock()
|
||||
bot.command_manager.monitor_channels = ["general"]
|
||||
bot.command_manager.send_response = AsyncMock(return_value=True)
|
||||
bot.translator = MagicMock()
|
||||
bot.translator.translate = Mock(side_effect=lambda key, **kwargs: key)
|
||||
bot.prefix_hex_chars = 2
|
||||
return bot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_command_rejects_silently_when_path_bytes_too_short():
|
||||
bot = _base_bot()
|
||||
bot.config.add_section("Test_Command")
|
||||
bot.config.set("Test_Command", "enabled", "true")
|
||||
bot.config.set("Test_Command", "require_path_bytes_greater_or_equal_to", "2")
|
||||
bot.config.add_section("Path_Command")
|
||||
bot.config.set("Path_Command", "recency_weight", "0.2")
|
||||
|
||||
cmd = MeshTestCommand(bot)
|
||||
cmd.handle_keyword_match = AsyncMock(return_value=True)
|
||||
message = mock_message(content="test", channel="general", routing_info={"path_byte_length": 1})
|
||||
|
||||
result = await cmd.execute(message)
|
||||
|
||||
assert result is True
|
||||
cmd.handle_keyword_match.assert_not_called()
|
||||
bot.command_manager.send_response.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_command_sends_failure_response_when_configured():
|
||||
bot = _base_bot()
|
||||
bot.config.add_section("Test_Command")
|
||||
bot.config.set("Test_Command", "enabled", "true")
|
||||
bot.config.set("Test_Command", "require_path_bytes_greater_or_equal_to", "3")
|
||||
bot.config.set("Test_Command", "require_path_bytes_failure_response", "Need 3-byte path")
|
||||
bot.config.add_section("Path_Command")
|
||||
bot.config.set("Path_Command", "recency_weight", "0.2")
|
||||
|
||||
cmd = MeshTestCommand(bot)
|
||||
cmd.handle_keyword_match = AsyncMock(return_value=True)
|
||||
message = mock_message(content="test", channel="general", routing_info={"path_byte_length": 2})
|
||||
|
||||
result = await cmd.execute(message)
|
||||
|
||||
assert result is True
|
||||
cmd.handle_keyword_match.assert_not_called()
|
||||
bot.command_manager.send_response.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_command_mode_one_behaves_like_zero():
|
||||
bot = _base_bot()
|
||||
bot.config.add_section("Path_Command")
|
||||
bot.config.set("Path_Command", "enabled", "true")
|
||||
bot.config.set("Path_Command", "require_path_bytes_greater_or_equal_to", "1")
|
||||
|
||||
cmd = PathCommand(bot)
|
||||
cmd._send_path_response = AsyncMock(return_value=True)
|
||||
cmd._extract_path_from_recent_messages = AsyncMock(return_value="decoded")
|
||||
message = mock_message(content="path", channel="general", routing_info={"path_byte_length": 0})
|
||||
|
||||
result = await cmd.execute(message)
|
||||
|
||||
assert result is True
|
||||
cmd._extract_path_from_recent_messages.assert_awaited_once()
|
||||
cmd._send_path_response.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multitest_execute_rejects_with_custom_failure_message():
|
||||
bot = _base_bot()
|
||||
bot.config.add_section("Multitest_Command")
|
||||
bot.config.set("Multitest_Command", "enabled", "true")
|
||||
bot.config.set("Multitest_Command", "require_path_bytes_greater_or_equal_to", "2")
|
||||
bot.config.set("Multitest_Command", "require_path_bytes_failure_response", "Need >=2 path bytes")
|
||||
|
||||
cmd = MultitestCommand(bot)
|
||||
message = mock_message(content="multitest", channel="general", routing_info={"path_byte_length": 1})
|
||||
|
||||
result = await cmd.execute(message)
|
||||
|
||||
assert result is True
|
||||
bot.command_manager.send_response.assert_awaited_once()
|
||||
|
||||
|
||||
def test_multitest_listener_filters_matching_hash_by_required_path_bytes():
|
||||
bot = _base_bot()
|
||||
bot.config.add_section("Multitest_Command")
|
||||
bot.config.set("Multitest_Command", "enabled", "true")
|
||||
cmd = MultitestCommand(bot)
|
||||
cmd.extract_path_from_rf_data = Mock(return_value="0101")
|
||||
cmd.get_rf_data_for_message = Mock(
|
||||
return_value={
|
||||
"packet_hash": "abc123",
|
||||
"routing_info": {"path_byte_length": 2, "path_nodes": ["0101"]},
|
||||
}
|
||||
)
|
||||
|
||||
session = MultitestSession(
|
||||
user_id="alice",
|
||||
target_packet_hash="abc123",
|
||||
triggering_timestamp=0.0,
|
||||
listening_start_time=0.0,
|
||||
listening_duration=9999.0,
|
||||
collected_paths=set(),
|
||||
required_path_bytes_mode=3,
|
||||
)
|
||||
cmd._active_sessions["alice"] = session
|
||||
message = mock_message(content="x", sender_id="alice")
|
||||
|
||||
cmd.on_message_received(message)
|
||||
|
||||
assert session.collected_paths == set()
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for DARC MoWaS CAP alert parsing
|
||||
"""
|
||||
|
||||
import xml.dom.minidom
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.service_plugins.darc_mowas_service import (
|
||||
TRDECapAlert,
|
||||
TRDECapAlertArea,
|
||||
TRDECapAlertInfo,
|
||||
)
|
||||
|
||||
DARC_MOWAS_EXAMPLE_CAP = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>test</identifier>
|
||||
<sender>test</sender>
|
||||
<sent>2024-12-31T23:59:59+02:00</sent>
|
||||
<status>Actual</status>
|
||||
<msgType>Alert</msgType>
|
||||
<scope>Public</scope>
|
||||
<info>
|
||||
<language>DE</language>
|
||||
<category>Fire</category>
|
||||
<event>Gefahreninformation</event>
|
||||
<urgency>Immediate</urgency>
|
||||
<severity>Minor</severity>
|
||||
<certainty>Observed</certainty>
|
||||
<eventCode>
|
||||
<valueName>profile:DE-BBK-EVENTCODE:01.00R</valueName>
|
||||
<value>BBK-EVC-010</value>
|
||||
</eventCode>
|
||||
<headline>Test der MoWaS Zulieferung für den DARC</headline>
|
||||
<description>Test der MoWaS Zulieferung für den DARC</description>
|
||||
<instruction></instruction>
|
||||
<contact />
|
||||
<parameter>
|
||||
<valueName>warnVerwaltungsbereiche</valueName>
|
||||
<value>100000000000</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>instructionCode</valueName>
|
||||
<value>Test</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>sender_langname</valueName>
|
||||
<value>DARC e.V.</value>
|
||||
</parameter>
|
||||
<parameter>
|
||||
<valueName>sender_signature</valueName>
|
||||
<value>DARC e.V.
|
||||
Lindenallee 4
|
||||
34225 Baunatal</value>
|
||||
</parameter>
|
||||
<area>
|
||||
<areaDesc>Deutschland</areaDesc>
|
||||
<geocode>
|
||||
<valueName>SHN</valueName>
|
||||
<value>100000000000</value>
|
||||
</geocode>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cap_alert() -> TRDECapAlert:
|
||||
doc = xml.dom.minidom.parseString(DARC_MOWAS_EXAMPLE_CAP)
|
||||
alert_el = doc.getElementsByTagName("alert")[0]
|
||||
return TRDECapAlert.from_xml(alert_el)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestMoWaSAlertParsing:
|
||||
def test_alert_top_level_fields(self, cap_alert):
|
||||
alert = cap_alert
|
||||
assert alert.identifier == "test"
|
||||
assert alert.sender == "test"
|
||||
assert alert.sent == datetime(
|
||||
2024, 12, 31, 23, 59, 59, tzinfo=timezone(timedelta(hours=2))
|
||||
)
|
||||
assert alert.status == "Actual"
|
||||
assert alert.msgType == "Alert"
|
||||
assert alert.scope == "Public"
|
||||
assert alert.references is None
|
||||
|
||||
def test_alert_info(self, cap_alert):
|
||||
assert len(cap_alert.info) == 1
|
||||
info = cap_alert.info[0]
|
||||
assert isinstance(info, TRDECapAlertInfo)
|
||||
assert info.language == "DE"
|
||||
assert info.category == "Fire"
|
||||
assert info.event == "Gefahreninformation"
|
||||
assert info.urgency == "Immediate"
|
||||
assert info.severity == "Minor"
|
||||
assert info.certainty == "Observed"
|
||||
assert info.headline == "Test der MoWaS Zulieferung für den DARC"
|
||||
assert info.description == "Test der MoWaS Zulieferung für den DARC"
|
||||
|
||||
def test_alert_info_parameters(self, cap_alert):
|
||||
params = cap_alert.info[0].parameter
|
||||
assert ("warnVerwaltungsbereiche", "100000000000") in params
|
||||
assert ("instructionCode", "Test") in params
|
||||
assert ("sender_langname", "DARC e.V.") in params
|
||||
assert any(name == "sender_signature" for name, _ in params)
|
||||
|
||||
def test_alert_area(self, cap_alert):
|
||||
area = cap_alert.info[0].area[0]
|
||||
assert isinstance(area, TRDECapAlertArea)
|
||||
assert area.areaDesc == "Deutschland"
|
||||
assert ("SHN", "100000000000") in area.geocode
|
||||
@@ -0,0 +1,81 @@
|
||||
"""RX_LOG vs RAW correlation: prune caches and RAW dedupe vs meshcore-packet-capture."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.service_plugins.packet_capture_service import PacketCaptureService
|
||||
|
||||
|
||||
def test_prune_correlation_caches_removes_stale_entries():
|
||||
svc = object.__new__(PacketCaptureService)
|
||||
svc.rf_data_cache_timeout = 15.0
|
||||
svc.raw_duplicate_window = 2.0
|
||||
now = 1000.0
|
||||
svc.rf_data_cache = {
|
||||
"alive": {"timestamp": now - 5.0},
|
||||
"stale": {"timestamp": now - 20.0},
|
||||
}
|
||||
svc.recent_rf_packets = {
|
||||
"RECENT": now - 0.5,
|
||||
"OLD": now - 3.0,
|
||||
}
|
||||
PacketCaptureService._prune_correlation_caches(svc, now)
|
||||
assert list(svc.rf_data_cache.keys()) == ["alive"]
|
||||
assert list(svc.recent_rf_packets.keys()) == ["RECENT"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_raw_data_skips_duplicate_hex_within_window():
|
||||
"""RAW_DATA hex equal to RX_LOG-published hex must not call process_packet again."""
|
||||
svc = object.__new__(PacketCaptureService)
|
||||
svc.logger = logging.getLogger("test_pc_rf")
|
||||
svc.logger.addHandler(logging.NullHandler())
|
||||
svc.raw_duplicate_window = 2.0
|
||||
|
||||
dup_hex = "AABBCCDD"
|
||||
svc.recent_rf_packets = {dup_hex.upper(): time.time()}
|
||||
svc.rf_data_cache = {}
|
||||
|
||||
mock_pp = AsyncMock()
|
||||
svc.process_packet = mock_pp
|
||||
|
||||
evt = MagicMock()
|
||||
evt.payload = {"data": dup_hex.lower()}
|
||||
|
||||
await PacketCaptureService.handle_raw_data(svc, evt, metadata=None)
|
||||
|
||||
mock_pp.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_raw_data_merges_rf_cache_by_prefix():
|
||||
svc = object.__new__(PacketCaptureService)
|
||||
svc.logger = logging.getLogger("test_pc_rf2")
|
||||
svc.logger.addHandler(logging.NullHandler())
|
||||
svc.raw_duplicate_window = 2.0
|
||||
svc.recent_rf_packets = {}
|
||||
prefix42 = "42" * 16 # 32 hex chars prefix
|
||||
full_hex = prefix42 + "9999"
|
||||
svc.rf_data_cache = {
|
||||
prefix42: {"snr": 12.5, "rssi": -88, "timestamp": time.time(), "payload_length": None},
|
||||
}
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
async def capture_pp(raw_hex, payload, metadata):
|
||||
captured["payload"] = payload
|
||||
|
||||
svc.process_packet = capture_pp
|
||||
|
||||
evt = MagicMock()
|
||||
evt.payload = {"data": full_hex.upper()}
|
||||
|
||||
await PacketCaptureService.handle_raw_data(svc, evt, metadata=None)
|
||||
|
||||
assert captured["payload"]["snr"] == 12.5
|
||||
assert captured["payload"]["rssi"] == -88
|
||||
@@ -1,8 +1,40 @@
|
||||
"""Packet capture TRACE decode: path must come from payload, not RF SNR bytes."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from modules.service_plugins.packet_capture_service import PacketCaptureService
|
||||
from modules.utils import calculate_packet_hash
|
||||
|
||||
|
||||
def test_calculate_packet_hash_trace_uses_wire_byte():
|
||||
"""TRACE hash must use the raw path_len wire byte, not the decoded byte count.
|
||||
|
||||
When size_code > 0 (multi-byte-per-hop paths), path_len_byte != path_byte_length.
|
||||
The firmware hashes path_len_byte (the raw wire value), so we must too.
|
||||
"""
|
||||
# header 0x26: route_type=DIRECT(2), payload_type=TRACE(9), version=VER_1(0)
|
||||
# path_len_byte 0x42: size_code=1 → 2 bytes/hop, hop_count=2 → path_byte_length=4
|
||||
# path: AABBCCDD (4 bytes), payload: 01020304
|
||||
raw_hex = "2642AABBCCDD01020304"
|
||||
|
||||
# Correct: use path_len_byte=0x42 (66), matching firmware Packet::calculatePacketHash()
|
||||
expected = hashlib.sha256(
|
||||
bytes([0x09])
|
||||
+ (0x42).to_bytes(2, "little")
|
||||
+ bytes.fromhex("01020304")
|
||||
).hexdigest()[:16].upper()
|
||||
|
||||
result = calculate_packet_hash(raw_hex, 9)
|
||||
assert result == expected, f"Expected {expected}, got {result}"
|
||||
|
||||
# Confirm this differs from the old buggy value (path_byte_length=4 instead of wire byte 0x42)
|
||||
buggy = hashlib.sha256(
|
||||
bytes([0x09])
|
||||
+ (4).to_bytes(2, "little")
|
||||
+ bytes.fromhex("01020304")
|
||||
).hexdigest()[:16].upper()
|
||||
assert result != buggy, "Hash must not match old path_byte_length computation"
|
||||
|
||||
|
||||
def test_decode_packet_trace_uses_payload_route_hashes():
|
||||
|
||||
@@ -1050,5 +1050,20 @@
|
||||
"analytics": "Analytik-Befehle",
|
||||
"meshcore_info": "MeshCore Info-Befehle",
|
||||
"management": "Verwaltungs-Befehle"
|
||||
},
|
||||
"services": {
|
||||
"darcmowas" : {
|
||||
"messagetype": {
|
||||
"eu-alert-level-1": "Notfallalarm",
|
||||
"eu-alert-level-2": "Extreme Gefahr",
|
||||
"eu-alert-level-3": "Erhebliche Gefahr",
|
||||
"eu-alert-level-4": "Gefahreninformation",
|
||||
"eu-test": "Testwarnung"
|
||||
},
|
||||
"fields": {
|
||||
"sender": "Herausgeber: {sender}",
|
||||
"area": "Betr. Gebiete: {areas}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1114,5 +1114,20 @@
|
||||
"analytics": "Analytics Commands",
|
||||
"meshcore_info": "MeshCore Info Commands",
|
||||
"management": "Management Commands"
|
||||
},
|
||||
"services": {
|
||||
"darcmowas" : {
|
||||
"messagetype": {
|
||||
"eu-alert-level-1": "Emergency Alert",
|
||||
"eu-alert-level-2": "Extreme Danger",
|
||||
"eu-alert-level-3": "Significant Danger",
|
||||
"eu-alert-level-4": "Danger Information",
|
||||
"eu-test": "Test Warning"
|
||||
},
|
||||
"fields": {
|
||||
"sender": "Sender: {sender}",
|
||||
"area": "Area: {areas}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user