diff --git a/CHANGELOG.md b/CHANGELOG.md index f20db05..91f90df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,48 @@ semantic versioning. ### Added +- Zero-hop neighbour discovery in the packet capture service, ported from + `meshcore-packet-capture` (itself a port of the observer firmware's neighbours + feature). On a long interval (12–336 h, default 24) the bot asks which + repeaters it hears **directly** and records each confirmed link with its + measured SNR. `[PacketCapture] neighbors_enabled` is the single switch and is + off by default; every enabled broker publishes the snapshot once it is on + (`mqttN_neighbors` defaults true, so set it false to hold a broker back). The + neighbours topic is derived from each broker's packets topic by swapping the + last segment, so a templated broker gets + `meshcore/{IATA}/{PUBLIC_KEY}/neighbors` — the topic the firmware uses. A + derived location-routed topic is skipped with a warning when no `iata` is set, + rather than publishing into `meshcore/XYZ/...`. Snapshots are non-retained, + because `heard_secs_ago` is relative to publish time. +- Confirmed direct links are now the strongest evidence class in the database: + two full 32-byte public keys plus a first-party RF measurement, where path + inference has only 1–3 byte prefixes and no keys. Stored in `neighbor_links` + (adjacency, migration 22) and `neighbor_observations` (per-cycle history, + pruned by `neighbor_observations_retention_days`, default 365). +- Mesh graph integration: a **Neighbours Only** evidence mode on the mesh page + and `GET /api/mesh/edges?evidence=neighbors`, deriving edges purely from + `neighbor_links`. Unlike the multi-byte mode these edges carry populated public + keys and real SNR, and they render as heavier lines. Confirmed neighbours are + also labelled as such in the combined view, and count as provenance-trusted + when framing the initial map. `neighbors_feed_mesh_graph` (default on) also + writes them to `mesh_connections`. +- `neighbors` DM command to run one cycle on demand — the scheduled interval has + a 12 h floor, which makes testing impractical otherwise. Acknowledges + immediately and reports in a second DM once the listen window closes. Worth + adding to `[Admin_ACL] admin_commands`, since a cycle spends airtime. +- Optional region-scope collection (`neighbors_collect_scopes`), **off by + default**: each request holds the bot's single radio command lock for up to + ~25 s, and for a repeater with no stored path the meshcore library reaches + zero-hop by temporarily rewriting that contact's path on the device. The + default cycle costs one radio command plus a passive listen window, during + which the bot stays fully responsive. + +### Changed + +- `meshcore` minimum raised from 2.3.6 to 2.3.8. Required for + `send_node_discover_req` / `req_regions_sync`, and for the bounded, serialised + BLE write. + - Rebuilt web-viewer dashboard, served from a background snapshot instead of recomputing statistics on every request. A refresher thread in the viewer process writes `daily_rollup` (one row per local date) and diff --git a/config.ini.example b/config.ini.example index 71abc24..082e124 100644 --- a/config.ini.example +++ b/config.ini.example @@ -350,7 +350,8 @@ admin_pubkeys = # These commands will only work for users in the admin_pubkeys list # reload: Reload configuration without restarting (radio settings cannot be changed) # channelpause: DM-only; channelpause / channelresume — pause or resume bot responses on channels (not persisted) -admin_commands = repeater,webviewer,reload,channelpause +# neighbors: DM-only; runs a zero-hop neighbour discovery cycle (spends airtime — admin-gate this) +admin_commands = repeater,webviewer,reload,channelpause,neighbors [Plugin_Overrides] # Plugin Overrides - Use alternative plugin implementations # Format: command_name = alternative_file_name @@ -960,6 +961,12 @@ purging_log_retention_days = 90 # # Mesh connections (path graph edges). Should be >= Path_Command graph_edge_expiration_days. mesh_connections_retention_days = 7 +# +# Zero-hop neighbour observation history (see [PacketCapture] neighbors_enabled). +# At most one row per neighbour per cycle, and cycles are >= 12h apart, so this +# grows very slowly and a long window is cheap. The neighbor_links aggregate that +# the mesh graph reads is never pruned. +neighbor_observations_retention_days = 365 [Path_Command] # Enable or disable the path command enabled = true @@ -1472,6 +1479,13 @@ enabled = true [Advert_Command] enabled = true # channels = +[Neighbors_Command] +# Runs one zero-hop neighbour discovery cycle on demand (DM only, 15min cooldown). +# The scheduled interval has a 12h floor, so this is how you test a change or +# refresh after moving the node. Requires [PacketCapture] neighbors_enabled = true. +# Worth adding to Admin_ACL admin_commands, since a cycle spends airtime. +enabled = true +# channels = [Test_Command] enabled = true # Require minimum path byte length before responding to test/t @@ -1759,9 +1773,70 @@ log_max_bytes = 50MB log_rotation_when = midnight log_backup_count = 5 +# --- Neighbour discovery (zero-hop) --- +# Periodically asks which repeaters this node can hear DIRECTLY, then records each +# confirmed link with its measured SNR. This is the strongest link evidence the bot +# has: two full 32-byte public keys and a first-party RF measurement, as opposed to +# path inference over 1-3 byte prefixes. +# +# Results go to the neighbor_links / neighbor_observations tables, feed the mesh +# graph, and are published to every enabled broker (mqttN_neighbors defaults true; +# set it false on a broker you want to hold back). +# Trigger one on demand with the `neighbors` DM command (see [Neighbors_Command]). +# +# This is the single switch for the whole feature. Off by default because a cycle +# spends real airtime; turning it on needs no other change. +neighbors_enabled = false + +# How often a cycle runs, in hours. Clamped to 12-336 to match the firmware; an +# out-of-range value is clamped with a warning rather than silently honoured. +neighbors_interval_hours = 24 + +# Seconds spent listening for responses. Repeaters answer after a randomised delay, +# so a short window simply finds fewer neighbours (floor: 5s). The bot stays fully +# responsive during this window — it is a passive listen, not a busy wait. +neighbors_discover_window = 60 + +# Cap on neighbours recorded per cycle, most useful first (most recently heard, +# then strongest SNR). +neighbors_max = 32 + +# Also add each confirmed direct link to the mesh graph as an edge. +neighbors_feed_mesh_graph = true + +# Also ask each neighbour for its region scopes. OFF BY DEFAULT, and worth +# understanding before enabling: +# - Every bot radio command is serialised, and a scope request waits for its +# reply while holding that lock — up to ~25s per neighbour. With many +# neighbours the bot's own replies stall in bursts for minutes. +# - The zero-hop probe assumes the neighbour is not a known contact. This bot +# does track contacts, and for a repeater with no stored path the meshcore +# library reaches zero-hop by temporarily rewriting that contact's path on the +# device, then restoring it. +# With this off, the snapshot reports every neighbour it heard with empty scopes. +neighbors_collect_scopes = false + +# Cap on the discover request itself; a stalled BLE/serial write can otherwise +# block far longer than the library's own reply timeout. +neighbors_command_timeout = 20 + +# Scope-request tuning (only used when neighbors_collect_scopes = true) +# neighbors_scope_timeout: per-request wait; 0 = use the device's own airtime estimate +# neighbors_scope_min_timeout: floor under the device-suggested timeout +# neighbors_scope_gap: settle delay between requests (they must not overlap on air) +# neighbors_cycle_timeout: overall budget for the pass; unreached neighbours report timeout +neighbors_scope_timeout = 0 +neighbors_scope_min_timeout = 8 +neighbors_scope_gap = 2.0 +neighbors_cycle_timeout = 600 + +# Override for this node's own "self.scopes" value in the published payload. +# Empty asks the device for its default flood scope. +neighbors_self_scopes = + # Owner information (for packet analyzer registration) # Owner public key (64-character hex string) -owner_public_key = +owner_public_key = # Owner email address owner_email = @@ -1809,6 +1884,18 @@ iata = XYZ # mqttN_client_id = # MQTT client ID (optional, auto-generated from bot name) # mqttN_upload_packet_types = # Comma-separated packet types to upload (e.g. 2,4); empty = all # mqttN_include_decoded = true/false # Publish the decoded object to this broker (default: include_decoded) +# mqttN_neighbors = true/false # Publish the zero-hop neighbours snapshot to this broker. +# # Default TRUE — neighbors_enabled below is the single +# # switch, so nothing is sent until you turn that on. +# # Set false to hold just this broker back. +# mqttN_topic_neighbors = # Neighbours topic template. Defaults to this broker's +# # packets topic with its last segment swapped for +# # "neighbors" (so a templated packets topic yields +# # meshcore/{IATA}/{PUBLIC_KEY}/neighbors), else +# # /neighbors. A derived +# # location-routed topic is skipped with a warning when +# # no iata is set (blank or XYZ), rather than publishing +# # into that shared namespace. # # Topic template placeholders: # {IATA} - Uppercase IATA code (e.g., SEA) diff --git a/docs/packet-capture.md b/docs/packet-capture.md index fb5442c..462c42d 100644 --- a/docs/packet-capture.md +++ b/docs/packet-capture.md @@ -326,6 +326,162 @@ Tokens are valid for 24 hours and auto-renewed. The service tries on-device sign --- +## Neighbour Discovery (zero-hop) + +Periodically asks which repeaters this node can hear **directly**, and records each +confirmed link with its measured SNR. Ported from the observer firmware's neighbours +feature by way of `meshcore-packet-capture`. **Off by default.** + +This is the strongest link evidence the bot collects. Everything else is weaker: +path inference works from 1–3 byte prefixes with no public keys, and +`complete_contact_tracking.hop_count` over-claims zero-hop (it asserts ~800 zero-hop +contacts where only ~68 have corroborating path evidence). A discover response is a +first-party RF measurement between two full 32-byte public keys. + +```ini +[PacketCapture] +enabled = true +neighbors_enabled = true # the only switch you need +neighbors_interval_hours = 24 # clamped to 12-336 +``` + +That one setting turns the whole feature on. Every enabled broker publishes the +snapshot by default (`mqttN_neighbors` defaults to true) — set it false on any broker +you want to hold back: + +```ini +mqtt2_neighbors = false +# mqtt1_topic_neighbors = meshcore/{IATA}/{PUBLIC_KEY}/neighbors # optional override +``` + +The neighbours topic is derived from the broker's packets topic by swapping its last +segment, so a broker configured with `meshcore/{IATA}/{PUBLIC_KEY}/packets` publishes to +`meshcore/{IATA}/{PUBLIC_KEY}/neighbors` — the same topic the firmware uses. Brokers +with only a `topic_prefix` get `/neighbors`. If a derived topic is +location-routed but no `iata` is set, that broker is skipped with a warning rather than +publishing into `meshcore/XYZ/...` on a shared namespace. + +Each cycle sends one zero-hop node-discover request, then listens for +`neighbors_discover_window` seconds (60 by default). **The bot stays fully responsive +during the window** — it is a passive listen, and only the single discover command +touches the radio. + +Results go three places, independently of each other: + +- **`neighbor_links`** — current adjacency (full public keys, observation count, + best/last/mean SNR). This is the source of truth. +- **`neighbor_observations`** — one row per neighbour per cycle, for signal history. + Pruned by `[Data_Retention] neighbor_observations_retention_days` (default 365). +- **The mesh graph** — as edges, when `neighbors_feed_mesh_graph = true` (default), + plus a dedicated **Neighbours Only** evidence mode on the mesh page and + `GET /api/mesh/edges?evidence=neighbors`. Confirmed neighbours render as heavier + lines and show their SNR. + + `mesh_connections` cannot record *why* an edge exists, so the combined view + re-derives the `neighbors` label from `neighbor_links` — matching on the 3-byte + prefix pair *or* the full public-key pair, since the graph deliberately keeps + some edges at a 1-byte prefix while still filling in the keys discovery gave it. + The label honours the view's `days` window: `neighbor_links` is never pruned, so + without that a link last heard years ago would keep claiming a recent + path-derived edge is a current direct neighbour. + +Snapshots are published **non-retained**. `heard_secs_ago` is relative to publish time, +so a retained copy replayed days later would still claim the neighbour was heard seconds +ago. Consumers that want the current picture should subscribe and wait for the next +cycle, or read `timestamp` and correct for the age. No broker is required at all — the +database is a perfectly good consumer on its own. + +### Triggering a cycle manually + +The minimum interval is 12 hours, so use the DM command to test: + +``` +neighbors +``` + +It acknowledges immediately and reports the result in a second DM once the window +closes. Enabled via `[Neighbors_Command]`; add `neighbors` to +`[Admin_ACL] admin_commands` to restrict it, since a cycle spends airtime. + +Two guards keep the airtime bounded. Both live in the service rather than in the +command, because what is being rationed belongs to the whole mesh and every +trigger reaches the same radio — the scheduler included: + +- **Only one cycle at a time.** An overlapping cycle is refused whichever trigger + asks, so two discover rounds can never collect into each other's window. +- **At most one cycle every 15 minutes.** Measured from the last cycle that + reached the radio, including one that failed *after* the discover request went + out, since a lost acknowledgement spends the airtime just the same. Users + cannot take turns and keep the radio discovering continuously, and the + scheduler's own retry-after-failure backoff waits this out rather than + re-transmitting every five minutes. A cycle that bailed out *before* + transmitting (radio down, unsupported build) does not start the clock, so + re-checking those stays quick. + +The DM command reports the wait instead of failing opaquely, and rewinds the +sender's personal cooldown to expire with the shared one — the command manager +records an execution before the command runs, so otherwise being told "wait one +more minute" would be followed by fourteen more minutes of personal cooldown. + +### Region scopes are opt-in, and why + +`neighbors_collect_scopes` additionally asks each neighbour for its region scopes. +It defaults to **false** for two reasons specific to running inside the bot: + +1. **It stalls bot replies.** Every bot radio command is serialised through one lock + (`modules/core.py` `_SerializedCommands`), and `req_regions_sync` waits for its + reply *inside* the call — so one request holds the radio for up to ~25 s. With 32 + neighbours the bot's own messages stall in bursts for minutes. +2. **It mutates device contact state.** The zero-hop probe relies on the neighbour + *not* being a known contact. The bot does track contacts, and for a repeater with + no stored path the meshcore library reaches zero-hop by calling + `change_contact_path()` and then `reset_path()` — temporarily rewriting that + contact's path on the device. Those two calls are not paired by a + `try`/`finally` upstream, and one error path returns between them, so a request + cut short — or one whose path change was applied but not acknowledged — would + leave the contact pinned to zero-hop and every later message to it sent + direct-only. `modules/neighbors_discovery.py` restores the path itself in each + of those cases, and warns if the device rejects the restore (which it reports + as an error event rather than an exception), since that contact's routing is + then wrong until something else fixes it. + +With it off, the snapshot reports every neighbour it heard with empty `scopes` and +`status: responded`. Enable it on a bench radio first. + +### Published payload + +```json +{ + "timestamp": "2026-08-04T12:00:00.000000+00:00", + "origin": "MeshCore-HOWL", + "origin_id": "A1B2C3D4E5F67890...", + "total_neighbors": 6, + "queried_neighbors": 6, + "truncated": false, + "self": { "scopes": "" }, + "neighbors": [ + { + "pubkey": "0011223344556677...", + "snr": 9.75, + "heard_secs_ago": 42, + "scopes": "", + "status": "responded" + } + ] +} +``` + +`total_neighbors` is how many were discovered, `queried_neighbors` how many were kept +after the `neighbors_max` cap, and `truncated` is true when either that cap or the +10 KB payload budget dropped entries. Entries are ordered most- to least-useful (most +recently heard, then stronger SNR). `status` is `responded`, `timeout`, or +`send_failed`. + +Requires `meshcore >= 2.3.8` and a firmware build exposing `CMD_SEND_CONTROL_DATA`; +the service logs once and skips the feature if either is missing. + +--- + ## FAQ **Q: Do I need to provide a private key?** diff --git a/modules/commands/neighbors_command.py b/modules/commands/neighbors_command.py new file mode 100644 index 0000000..00f93ac --- /dev/null +++ b/modules/commands/neighbors_command.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Neighbors command for the MeshCore Bot +Triggers one zero-hop neighbor discovery cycle on demand +""" + +import asyncio +import math +import time +from typing import Any, Optional + +from ..models import MeshMessage +from .base_command import BaseCommand + +# How long a refused sender must wait before retrying. Matches the default +# discover window: long enough that a busy cycle is usually done, short enough +# that "busy" / "disabled" is not a fifteen-minute lockout. +_REFUSAL_RETRY_SECONDS = 60.0 + + +class NeighborsCommand(BaseCommand): + """Runs one neighbor discovery cycle immediately. + + The scheduled interval has a 12 hour floor (the firmware's band), which makes + waiting for the scheduler impractical when testing or after moving the node. + This is the bot's equivalent of the firmware's ``discover.neighbors``. + + A cycle takes at least ``neighbors_discover_window`` seconds — 60 by default — + so this acknowledges immediately and reports the result in a second message + rather than holding the reply open. + + Two cooldowns apply. ``cooldown_seconds`` is this sender's own, from the base + class. The node-wide one belongs to the service (``MIN_CYCLE_GAP_SECONDS``), + because airtime is spent by whichever trigger asks — the scheduler included — + and it is only *reported* here, so the reply can say how long is left instead + of failing opaquely. + """ + + # Plugin metadata + name = "neighbors" + keywords = ['neighbors', 'neighbours'] + description = "Runs a zero-hop neighbor discovery cycle (DM only)" + requires_dm = True + cooldown_seconds = 900 # 15 minutes, per sender *and* per node; see above + category = "special" + + def __init__(self, bot: Any): + """Initialize the neighbors command. + + Args: + bot: The bot instance. + """ + super().__init__(bot) + self.command_enabled = self.get_config_value( + 'Neighbors_Command', 'enabled', fallback=True, value_type='bool' + ) + # Tracked so a second invocation cannot start an overlapping *report* + # task. Overlapping cycles are refused by the service itself, which is + # where the scheduler's own trigger is also visible. + self._cycle_task: Optional[asyncio.Task] = None + + def get_help_text(self) -> str: + """Get help text for the neighbors command. + + Returns: + str: The help text for this command. + """ + return self.translate('commands.neighbors.description') + + def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool: + """Check if the neighbors command can be executed. + + Args: + message: The message triggering the command. + skip_channel_check: Passed through to the base implementation. + + Returns: + bool: True if the command can be executed, False otherwise. + """ + if not self.command_enabled: + return False + return super().can_execute(message, skip_channel_check=skip_channel_check) + + def _get_capture_service(self) -> Any: + """The packet capture service instance, or None when unavailable.""" + service = getattr(self.bot, 'packet_capture_service', None) + if service is not None: + return service + # The alias is set up at init; fall back to the service registry in case + # the service was loaded but not aliased. + services = getattr(self.bot, 'services', None) or {} + try: + return services.get('packetcapture') + except AttributeError: + return None + + def _shared_cooldown_remaining(self, service: Any) -> float: + """Seconds left before *any* sender may trigger another cycle. + + The service owns this rule — it applies to the scheduler and to every + other trigger too, and it would refuse the cycle regardless. Asking it + here only buys a clearer reply than a generic failure summary, so a + service that cannot answer is treated as "no wait known" and left to + refuse for itself. + """ + remaining = getattr(service, 'neighbors_cooldown_remaining', None) + if not callable(remaining): + return 0.0 + try: + return float(remaining()) + except Exception as e: + self.logger.debug(f"Neighbors: could not read the shared cooldown: {e}") + return 0.0 + + def _busy_retry_seconds(self, service: Any) -> float: + """How long a 'busy' refusal should hold this sender's personal cooldown. + + Aligns with the discover window when known: by then a normal cycle has + finished listening, so a retry is meaningful. + """ + cfg = getattr(service, 'neighbors_config', None) + window = getattr(cfg, 'discover_window', None) + if isinstance(window, (int, float)) and window > 0: + return float(window) + return _REFUSAL_RETRY_SECONDS + + def _yield_user_cooldown(self, user_id: Optional[str], remaining: float) -> None: + """Rewind this sender's own cooldown to expire after *remaining* seconds. + + The command manager records the execution *before* calling execute(), so a + request refused in here has already spent the sender's 15 minutes. Without + this, "wait 1 more minute" (or "busy, try again shortly") would be a lie: + retrying when ready would be refused for another fourteen minutes by their + personal cooldown. + + Rewound rather than cleared, so the refusal itself cannot be spammed — a + DM reply is airtime too. + """ + if not user_id or self.cooldown_seconds <= 0: + return + spent = max(0.0, self.cooldown_seconds - remaining) + self._user_cooldowns[user_id] = time.time() - spent + + async def execute(self, message: MeshMessage) -> bool: + """Execute the neighbors command. + + Args: + message: The message triggering the command. + + Returns: + bool: True if handled (including the error, busy and cooldown notices). + """ + service = self._get_capture_service() + if service is None or not getattr(service, 'neighbors_enabled', False): + # Same yield as other refusals: the manager already recorded a full + # personal cooldown, and "disabled" must not become a 15-minute lockout + # after the operator turns the feature on. + self._yield_user_cooldown(message.sender_id, _REFUSAL_RETRY_SECONDS) + await self.send_response(message, self.translate('commands.neighbors.disabled')) + return True + + if self._cycle_task is not None and not self._cycle_task.done(): + self._yield_user_cooldown(message.sender_id, self._busy_retry_seconds(service)) + await self.send_response(message, self.translate('commands.neighbors.busy')) + return True + + # The service refuses an overlapping cycle on its own; this only decides + # what the requester is told, and rations airtime across senders. + if getattr(service, 'neighbors_cycle_active', False): + self._yield_user_cooldown(message.sender_id, self._busy_retry_seconds(service)) + await self.send_response(message, self.translate('commands.neighbors.busy')) + return True + + remaining = self._shared_cooldown_remaining(service) + if remaining > 0: + self.logger.debug( + f"Neighbors: refusing {message.sender_id}'s request, " + f"{remaining:.0f}s left on the shared cooldown" + ) + self._yield_user_cooldown(message.sender_id, remaining) + await self.send_response( + message, + self.translate('commands.neighbors.cooldown_active', + minutes=max(1, math.ceil(remaining / 60))), + ) + return True + + cfg = service.neighbors_config + self.logger.info(f"User {message.sender_id} requested a neighbors discovery cycle") + + # Claim before acknowledging. send_response awaits, and without the lock + # the scheduler can start a cycle in that gap — the user would then get + # "started" followed by "failed: already running". + claimed = False + claim = getattr(service, 'claim_neighbors_cycle', None) + if callable(claim): + refusal = claim() + if refusal is not None: + if "already" in refusal: + self._yield_user_cooldown( + message.sender_id, self._busy_retry_seconds(service) + ) + await self.send_response( + message, self.translate('commands.neighbors.busy') + ) + else: + left = self._shared_cooldown_remaining(service) or _REFUSAL_RETRY_SECONDS + self._yield_user_cooldown(message.sender_id, left) + await self.send_response( + message, + self.translate( + 'commands.neighbors.cooldown_active', + minutes=max(1, math.ceil(left / 60)), + ), + ) + return True + claimed = True + + try: + await self.send_response( + message, + self.translate('commands.neighbors.started', seconds=int(cfg.discover_window)), + ) + except Exception: + release = getattr(service, 'release_neighbors_cycle', None) + if claimed and callable(release): + release() + raise + + # Run detached so the discover window does not hold the command open, and + # bound it so a stalled radio link cannot leave the task alive forever. + self._cycle_task = asyncio.create_task( + self._run_and_report( + message, service, cfg.cycle_budget, already_claimed=claimed + ) + ) + return True + + async def _run_and_report( + self, + message: MeshMessage, + service: Any, + budget: float, + *, + already_claimed: bool = False, + ) -> None: + """Run one cycle and DM the outcome.""" + try: + summary = await asyncio.wait_for( + service.run_neighbors_cycle(already_claimed=already_claimed), + timeout=budget, + ) + except asyncio.TimeoutError: + self.logger.error(f"Neighbors: manual cycle exceeded {budget:.0f}s and was abandoned") + await self.send_response( + message, + self.translate('commands.neighbors.error', + error=f"timed out after {budget:.0f}s"), + skip_user_rate_limit=True, + ) + return + except asyncio.CancelledError: + raise + except Exception as e: + self.logger.error(f"Neighbors: manual cycle failed: {e}", exc_info=True) + await self.send_response( + message, + self.translate('commands.neighbors.error', error=str(e)), + skip_user_rate_limit=True, + ) + return + + await self.send_response( + message, self._format_summary(summary), skip_user_rate_limit=True + ) + + def _format_summary(self, summary: dict[str, Any]) -> str: + """Render a cycle summary short enough for a mesh DM.""" + if not summary.get('ok'): + reason = summary.get('reason') or 'unknown error' + return self.translate('commands.neighbors.failed', reason=reason) + + found = summary.get('queried', 0) + if not found: + return self.translate('commands.neighbors.none') + + best = summary.get('best_snr') + best_text = f"{best:.1f}dB" if isinstance(best, (int, float)) else "n/a" + text = self.translate( + 'commands.neighbors.success', + count=found, + best_snr=best_text, + recorded=summary.get('recorded', 0), + ) + # Only mention brokers when at least one was actually tried, so an + # operator with no MQTT does not see a confusing "0/0". + if summary.get('attempted'): + text += " " + self.translate( + 'commands.neighbors.published', + succeeded=summary.get('succeeded', 0), + attempted=summary.get('attempted', 0), + ) + return text diff --git a/modules/db_manager.py b/modules/db_manager.py index cac4139..c795328 100644 --- a/modules/db_manager.py +++ b/modules/db_manager.py @@ -59,6 +59,8 @@ class DBManager: 'purging_log', # Repeater manager 'mesh_connections', # Mesh graph for path validation 'observed_paths', # Repeater manager - observed paths from adverts and messages + 'neighbor_links', # Zero-hop neighbor discovery - current adjacency + 'neighbor_observations', # Zero-hop neighbor discovery - per-cycle history } def __init__(self, bot: Any, db_path: str = "meshcore_bot.db"): diff --git a/modules/db_migrations.py b/modules/db_migrations.py index b67bce6..dbc8013 100644 --- a/modules/db_migrations.py +++ b/modules/db_migrations.py @@ -728,6 +728,66 @@ def _m0021_daily_rollup_packet_type_encoding(cursor: sqlite3.Cursor) -> None: _add_column(cursor, "daily_rollup", "packet_type_encoding", "TEXT") +def _m0022_neighbor_tables(cursor: sqlite3.Cursor) -> None: + """Tables for zero-hop neighbor discovery (see modules/neighbors_discovery.py). + + A discover response is the strongest link evidence this codebase has: a + confirmed direct RF reception between two *full* 32-byte public keys, with a + measured SNR. Every other edge source is weaker — ``observed_paths`` carries + 1–3 byte prefixes and no keys, and ``complete_contact_tracking.hop_count`` + over-claims zero-hop. So these keep the full keys rather than prefixes, and + stay separate from ``mesh_connections``, which cannot represent provenance + (its ``confirmed_2byte`` flag is memory-only and never persisted). + + Two tables because they answer different questions: ``neighbor_links`` is the + current adjacency the mesh graph reads, ``neighbor_observations`` is the + per-cycle history a signal trend needs. SNR is stored as sum+count rather + than a mean, matching ``daily_rollup``, so any window re-aggregates exactly. + + Index names are database-global in SQLite (see the note on migration 20), so + every name here is table-qualified. + """ + cursor.executescript( + """ + CREATE TABLE IF NOT EXISTS neighbor_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + self_public_key TEXT NOT NULL, + neighbor_public_key TEXT NOT NULL, + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + observation_count INTEGER DEFAULT 1, + snr_sum REAL DEFAULT 0, + snr_count INTEGER DEFAULT 0, + best_snr REAL, + last_snr REAL, + last_status TEXT, + scopes TEXT, + UNIQUE(self_public_key, neighbor_public_key) + ); + + CREATE TABLE IF NOT EXISTS neighbor_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + observed_at TIMESTAMP NOT NULL, + self_public_key TEXT NOT NULL, + neighbor_public_key TEXT NOT NULL, + snr REAL, + heard_secs_ago INTEGER, + scopes TEXT, + status TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_neighbor_links_last_seen + ON neighbor_links(last_seen); + CREATE INDEX IF NOT EXISTS idx_neighbor_links_neighbor + ON neighbor_links(neighbor_public_key); + CREATE INDEX IF NOT EXISTS idx_neighbor_observations_observed_at + ON neighbor_observations(observed_at); + CREATE INDEX IF NOT EXISTS idx_neighbor_observations_neighbor + ON neighbor_observations(neighbor_public_key, observed_at); + """ + ) + + # --------------------------------------------------------------------------- # Migration registry — append new entries here, never remove or reorder. # --------------------------------------------------------------------------- @@ -756,6 +816,7 @@ MIGRATIONS: list[MigrationEntry] = [ (19, "packet_stream: denormalized packet dimensions", _m0019_packet_stream_denorm_dims), (20, "mesh_connections: table-specific last_seen index", _m0020_mesh_connections_last_seen_index), (21, "daily_rollup: per-payload-type multibyte split", _m0021_daily_rollup_packet_type_encoding), + (22, "neighbor discovery tables", _m0022_neighbor_tables), ] diff --git a/modules/maintenance.py b/modules/maintenance.py index cd74492..b866315 100644 --- a/modules/maintenance.py +++ b/modules/maintenance.py @@ -155,6 +155,9 @@ class MaintenanceRunner: daily_stats_days = get_retention_days('Data_Retention', 'daily_stats_retention_days', 90) observed_paths_days = get_retention_days('Data_Retention', 'observed_paths_retention_days', 90) mesh_connections_days = get_retention_days('Data_Retention', 'mesh_connections_retention_days', 7) + neighbor_observations_days = get_retention_days( + 'Data_Retention', 'neighbor_observations_retention_days', 365 + ) stats_days = get_retention_days('Stats_Command', 'data_retention_days', 7) try: @@ -197,6 +200,11 @@ class MaintenanceRunner: if hasattr(self.bot, 'mesh_graph') and self.bot.mesh_graph and hasattr(self.bot.mesh_graph, 'delete_expired_edges_from_db'): self.bot.mesh_graph.delete_expired_edges_from_db(mesh_connections_days) + # neighbor_observations is per-cycle history; neighbor_links is the + # aggregate adjacency the mesh graph reads and is deliberately not + # pruned here (losing it would silently drop confirmed direct links). + self._cleanup_neighbor_observations(neighbor_observations_days) + ran_at = _utc_now().isoformat() self._last_retention_stats['ran_at'] = ran_at try: @@ -215,6 +223,36 @@ class MaintenanceRunner: except Exception: pass + def _cleanup_neighbor_observations(self, retention_days: int) -> None: + """Prune zero-hop neighbor observation history past the retention window. + + Volume is tiny (at most one row per neighbor per cycle, and cycles are + 12h apart at minimum), so the default window is generous — the point of + this table is the long-run signal history. + """ + if retention_days <= 0: + return + db_manager = getattr(self.bot, 'db_manager', None) + if not db_manager or not hasattr(db_manager, 'delete_timestamp_rows_in_chunks'): + return + try: + cutoff = (_utc_now() - datetime.timedelta(days=retention_days)).isoformat() + deleted = db_manager.delete_timestamp_rows_in_chunks( + 'neighbor_observations', + 'observed_at', + cutoff, + progress_label='neighbor observations', + ) + if deleted > 0: + self.logger.info( + f"Cleaned up {deleted} old neighbor_observations entries " + f"(older than {retention_days} days)" + ) + except Exception as e: + # A missing table (pre-migration-22 database) must not abort the rest + # of the retention run. + self.logger.debug(f"Neighbor observation retention skipped: {e}") + def collect_email_stats(self) -> dict[str, Any]: """Gather summary stats for the nightly digest.""" stats: dict[str, Any] = {} diff --git a/modules/neighbors_discovery.py b/modules/neighbors_discovery.py new file mode 100644 index 0000000..f20de4a --- /dev/null +++ b/modules/neighbors_discovery.py @@ -0,0 +1,705 @@ +#!/usr/bin/env python3 +"""Zero-hop neighbor discovery and scope collection. + +Ported from the ``meshcore-packet-capture`` project's ``neighbors.py``, which is +itself a port of the observer firmware's ``WITH_MQTT_NEIGHBORS`` feature (see +``examples/simple_repeater/MyMesh.cpp``). Two stages per cycle: + +1. A zero-hop node-discover request; repeaters answer with their pubkey and the + SNR we heard them at. Responses are collected for a fixed window. +2. One anon-regions request per discovered neighbor, yielding that neighbor's + scope names. + +Both requests are issued through meshcore_py (``send_node_discover_req`` and +``req_regions_sync``); nothing here re-encodes packets. + +Stage 1 is the valuable part for this bot: it produces a *confirmed direct RF +link* between two full 32-byte public keys, with a measured SNR. That is +stronger evidence than anything path inference can offer, and it costs one radio +command plus a passive listen window. + +Stage 2 (scopes) is deliberately optional here and defaults off, for two reasons +that do not apply to the upstream capture tool: + +* ``req_regions_sync`` awaits its reply inside the call, and every bot command + is serialized through ``modules.core._SerializedCommands``, so one scope + request holds the radio for its whole round trip -- stalling message sends. +* Upstream relies on a freshly discovered neighbor *not* being a known contact, + which is what makes ``send_anon_req`` ask for a zero-hop reply path. This bot + populates the library's contact cache. For a contact with no path + (``out_path_len == -1``, the common case for a flood repeater) the library + reaches zero-hop by calling ``change_contact_path()`` and then + ``reset_path()`` -- i.e. it *mutates the device's contact table* per neighbor. + Those two calls are not paired by ``try``/``finally`` upstream, so a request + cut short between them leaves the contact pinned to zero-hop; collect_scopes + restores it itself (see ``_restore_flood_path``). + +No device-command lock is passed around: unlike upstream, every coroutine on +``meshcore.commands`` is already serialized and paced by +``modules.core._SerializedCommands``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import random +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable, Optional + +from meshcore import EventType + +from .enums import AdvertFlags + +# The firmware discovers repeaters only. The request filter is a bitmask over +# advert *types*, so the bit index is the type value (2 -> 0x04). +DISCOVER_FILTER_REPEATER = 1 << AdvertFlags.ADV_TYPE_REPEATER.value + +# Matches MQTTBridge::NEIGHBORS_JSON_BUFFER_SIZE. Entries past this budget are +# dropped from the tail so the payload stays within the firmware's contract. +NEIGHBORS_JSON_BUDGET = 10240 + +STATUS_RESPONDED = "responded" +STATUS_TIMEOUT = "timeout" +STATUS_SEND_FAILED = "send_failed" + +# Firmware interval band (MQTTPrefsStorage.h). +MIN_INTERVAL_HOURS = 12 +MAX_INTERVAL_HOURS = 336 +DEFAULT_INTERVAL_HOURS = 24 + +# Minimum wall time between cycles that reach the radio, whichever trigger asks. +# A cycle broadcasts a discover request and draws a reply from every direct +# neighbour, so the cost being rationed belongs to the whole mesh rather than to +# one caller. The neighbors command advertises this as its cooldown. +MIN_CYCLE_GAP_SECONDS = 900.0 + +# Floors that keep a misconfiguration from producing an empty snapshot forever. +# Repeaters answer a discover request after a randomised delay, so a very short +# window collects nothing at all. +MIN_DISCOVER_WINDOW = 5.0 +MIN_CYCLE_TIMEOUT = 10.0 +MIN_COMMAND_TIMEOUT = 1.0 + +# meshcore_py's CommandHandlerBase.DEFAULT_TIMEOUT: how long req_regions_sync +# waits for MSG_SENT before it even begins waiting for the response. +LIBRARY_MSG_SENT_TIMEOUT = 15.0 + +# A contact with no stored path. This is the value the library both tests for +# before pinning a contact to zero-hop and restores afterwards. +CONTACT_NO_PATH = -1 + + +def clamp_interval_hours(hours: int) -> int: + """Clamp to the firmware's 12-336h band, falling back to the 24h default.""" + if hours <= 0: + return DEFAULT_INTERVAL_HOURS + return max(MIN_INTERVAL_HOURS, min(MAX_INTERVAL_HOURS, hours)) + + +@dataclass +class NeighborsConfig: + """Tuning for one neighbors cycle. Defaults mirror the firmware.""" + + interval_hours: int = DEFAULT_INTERVAL_HOURS + discover_window: float = 60.0 # stage 1 collection window + command_timeout: float = 20.0 # cap on the discover request itself + collect_scopes: bool = False # stage 2 opt-in (bot-only; see module docstring) + scope_timeout: float = 0.0 # 0 = let the device suggest it + scope_min_timeout: float = 8.0 # floor under the suggested timeout + scope_gap: float = 2.0 # settle delay between scope requests + cycle_timeout: float = 600.0 # overall budget for the scope pass + max_neighbors: int = 32 + self_scopes: str = "" # explicit override; "" = ask the device + + def __post_init__(self) -> None: + # Values that would silently produce a permanently empty snapshot get a + # floor rather than being honoured: a 0s discover window collects nothing, + # and max_neighbors = 0 queries nobody. + self.interval_hours = clamp_interval_hours(self.interval_hours) + if self.discover_window < MIN_DISCOVER_WINDOW: + self.discover_window = MIN_DISCOVER_WINDOW + if self.max_neighbors < 1: + self.max_neighbors = 1 + if self.cycle_timeout < MIN_CYCLE_TIMEOUT: + self.cycle_timeout = MIN_CYCLE_TIMEOUT + if self.scope_gap < 0: + self.scope_gap = 0.0 + # wait_for(timeout=0) raises immediately, so 0 here would break every + # cycle forever -- and it reads as "no cap" by analogy with scope_timeout. + if self.command_timeout < MIN_COMMAND_TIMEOUT: + self.command_timeout = MIN_COMMAND_TIMEOUT + + @property + def scope_request_budget(self) -> float: + """Hard ceiling on one scope request. + + req_regions_sync waits for MSG_SENT (the library default, 15s) and then + for the response, so the budget has to exceed both or healthy requests + would be cut off. + """ + wait = self.scope_timeout if self.scope_timeout > 0 else self.scope_min_timeout + return LIBRARY_MSG_SENT_TIMEOUT + max(wait, self.scope_min_timeout) + self.command_timeout + + @property + def interval_seconds(self) -> float: + return self.interval_hours * 3600.0 + + @property + def cycle_budget(self) -> float: + """Worst-case wall time for one full cycle, for callers that bound it. + + Covers stage 1, the scope pass, and the self-scope query. Used by the + manual trigger so a stalled link cannot hang the caller indefinitely. + """ + budget = self.discover_window + self.command_timeout + self.command_timeout + if self.collect_scopes: + budget += self.cycle_timeout + self.scope_request_budget + self.scope_gap + return budget + + +@dataclass +class NeighborEntry: + """One discovered neighbor. Snapshotted so later table changes can't alter it.""" + + pubkey: str + snr: float + heard_at: float # wall clock of the response + scopes: str = "" + status: str = STATUS_TIMEOUT + + def heard_secs_ago(self, now: Optional[float] = None) -> int: + now = time.time() if now is None else now + return max(0, int(now - self.heard_at)) + + +def sort_key(entry: NeighborEntry, now: Optional[float] = None) -> tuple[int, float, str]: + """Firmware ordering: most recently heard, then stronger SNR, then pubkey. + + Mirrors neighborPublishEntryComesBefore() and the pre-query sort added in + firmware commit aba571ed, so query order and publish order agree -- a + truncated cycle still covers the most useful neighbors. + + Recency is compared as the published heard_secs_ago, ascending -- the exact + value the firmware's comparator uses. Sorting on the raw float clock instead + would quantise differently from the field we publish, so the output could be + non-monotonic in heard_secs_ago, and SNR would never break a tie: the most + recent response would always win outright. That matters because this order + decides which entries survive the payload budget. + """ + return (entry.heard_secs_ago(now), -entry.snr, entry.pubkey) + + +def sort_entries(entries: list[NeighborEntry], + now: Optional[float] = None) -> list[NeighborEntry]: + # One `now` for the whole sort, and the same one the payload uses, so the + # published heard_secs_ago values are ordered exactly as sorted. + now = time.time() if now is None else now + return sorted(entries, key=lambda e: sort_key(e, now)) + + +async def discover_neighbors( + meshcore: Any, + cfg: NeighborsConfig, + self_pubkey: Optional[str], + logger: logging.Logger, + *, + debug: bool = False, + still_valid: Optional[Callable[[], bool]] = None, +) -> Optional[list[NeighborEntry]]: + """Stage 1: zero-hop node-discover, collecting responses for the window. + + Returns the discovered entries (possibly empty), or None if the request + could not be sent or the session was invalidated mid-window. + + ``still_valid`` is an optional predicate that must keep returning True for + the collected data to mean anything. A reconnect part-way through the window + tears down every event subscription (the service clears them wholesale), so + our response handler silently stops firing -- without this check the cycle + would happily record "0 neighbours" as though the mesh were empty. + """ + collected: dict[str, NeighborEntry] = {} + self_key = (self_pubkey or "").lower() + # EventDispatcher spawns async callbacks as background tasks, so an event + # already dequeued can still reach the handler after unsubscribe() returns. + # This latch keeps a straggler from mutating entries we have already returned. + closed = False + + # Generate the tag ourselves rather than letting the library pick one, so it is + # known *before* the subscription goes live. Otherwise a response arriving + # between subscribe and send-completion would be accepted with no tag check -- + # which is how a stale round, or another client's round, could leak in. + # DISCOVER_RESPONSE reports the tag as little-endian hex. + tag = random.randint(1, 0xFFFFFFFF) + expected_tag = tag.to_bytes(4, "little").hex() + + async def on_discover_response(event: Any) -> None: + if closed: + return + payload = getattr(event, "payload", None) or {} + pubkey = str(payload.get("pubkey", "")).lower() + # Full 32-byte pubkeys only; the firmware rejects short prefixes too. + if len(pubkey) != 64: + return + if self_key and pubkey == self_key: + return + if payload.get("node_type") != AdvertFlags.ADV_TYPE_REPEATER.value: + return + if str(payload.get("tag", "")).lower() != expected_tag: + return + + snr = float(payload.get("SNR", 0) or 0) + existing = collected.get(pubkey) + if existing is None: + collected[pubkey] = NeighborEntry(pubkey=pubkey, snr=snr, heard_at=time.time()) + else: + # Same neighbor heard again (repeaters delay responses randomly). + # putNeighbour() refreshes the timestamp on every response, so do + # that unconditionally; keep the strongest SNR seen. + existing.heard_at = time.time() + existing.snr = max(existing.snr, snr) + + # Subscribe per-run rather than at startup: the service drops every + # subscription on reconnect, and this handler is only wanted for the + # duration of the window. + subscription = meshcore.subscribe(EventType.DISCOVER_RESPONSE, on_discover_response) + try: + # Bounded: on a stalled link the underlying BLE/serial write can block far + # longer than the library's own response timeout, and an unbounded wait + # here stalls the whole cycle. + try: + result = await asyncio.wait_for( + meshcore.commands.send_node_discover_req( + DISCOVER_FILTER_REPEATER, + prefix_only=False, # we need the full 32-byte pubkey + tag=tag, + ), + timeout=cfg.command_timeout, + ) + except asyncio.TimeoutError: + logger.warning( + f"Neighbors: node-discover request did not complete within " + f"{cfg.command_timeout:.0f}s, abandoning this cycle" + ) + return None + + if result is None or result.type == EventType.ERROR: + reason = "" + if result is not None: + reason = (getattr(result, "payload", None) or {}).get("reason", "") + # Logged at debug: the caller owns the user-facing message, because on a + # build that lacks the command this fails every cycle forever. + logger.debug( + f"Neighbors: node-discover request failed{f' ({reason})' if reason else ''}" + ) + return None + + if debug: + logger.debug( + f"Neighbors: node-discover sent (tag={expected_tag}), " + f"collecting for {cfg.discover_window:.0f}s" + ) + await asyncio.sleep(cfg.discover_window) + + if still_valid is not None and not still_valid(): + logger.warning( + "Neighbors: device session was reset during the discovery window " + "(event subscriptions are torn down on reconnect), abandoning this cycle" + ) + return None + finally: + closed = True + try: + meshcore.unsubscribe(subscription) + except Exception as exc: + logger.debug(f"Neighbors: error unsubscribing discover handler: {exc}") + + return sort_entries(list(collected.values())) + + +def _contact_has_no_path(meshcore: Any, pubkey: str) -> bool: + """True when *pubkey* is a known contact the library will pin to zero-hop. + + That is the one case where ``send_anon_req`` mutates the device's contact + table (``out_path_len == -1`` -> zero-hop, restored after the send). An + unknown contact, or one with a real stored path, is left alone. + """ + try: + contact = meshcore.get_contact_by_key_prefix(pubkey.lower()) + except Exception: + # A stubbed or older client without the lookup: assume no mutation + # rather than "restoring" a path we know nothing about. + return False + if not isinstance(contact, dict): + return False + return contact.get("out_path_len", CONTACT_NO_PATH) == CONTACT_NO_PATH + + +def _event_error_reason(event: Any) -> Optional[str]: + """The failure reason when *event* is an ERROR event, else None. + + Device commands report a rejection -- and their own response timeout -- as an + ERROR event rather than an exception, so a returned event has to be inspected + before the command can be called successful. + """ + if event is None or getattr(event, "type", None) != EventType.ERROR: + return None + payload = getattr(event, "payload", None) + if isinstance(payload, dict): + reason = payload.get("reason") or payload.get("error") + if reason: + return str(reason) + return "error" + + +async def _restore_flood_path(meshcore: Any, pubkey: str, + logger: logging.Logger) -> bool: + """Put a contact back to "no path" after an interrupted scope request. + + ``send_anon_req`` sets the zero-hop path and restores it *after* the send, + with no ``try``/``finally``, and one of its error paths returns between the + two. Either way the contact stays pinned to zero-hop on the device and every + later message to it is sent direct-only, so we repair it here instead. + + Returns True when the device confirmed the reset. A failure is worth a + warning rather than a retry: the operator needs to know that contact's + routing is wrong, and hammering an unresponsive device does not help. + """ + try: + event = await meshcore.commands.reset_path(pubkey) + except Exception as exc: + logger.warning( + f"Neighbors: could not restore the flood path for {pubkey[:12]} " + f"after an interrupted scope request ({exc}); it may be left " + f"pinned to zero-hop on the device" + ) + return False + + reason = _event_error_reason(event) + if reason is not None: + logger.warning( + f"Neighbors: the device rejected the flood-path restore for " + f"{pubkey[:12]} ({reason}); it may be left pinned to zero-hop, " + f"so messages to it will be sent direct-only" + ) + return False + + logger.info( + f"Neighbors: restored flood path for {pubkey[:12]} after an " + f"interrupted scope request" + ) + return True + + +def _restore_flood_path_detached(meshcore: Any, pubkey: str, + logger: logging.Logger) -> None: + """Schedule the repair as its own task, for use while being cancelled. + + Awaiting inside a ``except CancelledError`` block cannot work — the next + suspension point re-raises — so the repair has to outlive this coroutine. + """ + try: + task = asyncio.create_task(_restore_flood_path(meshcore, pubkey, logger)) + except RuntimeError as exc: + # No running loop (shutdown): nothing left to repair with. + logger.warning( + f"Neighbors: {pubkey[:12]} may be left pinned to zero-hop; " + f"could not schedule a repair ({exc})" + ) + return + # Nothing awaits this task, so make sure a failure is not swallowed silently. + task.add_done_callback(lambda t: t.cancelled() or t.exception()) + + +async def collect_scopes( + meshcore: Any, + entries: list[NeighborEntry], + cfg: NeighborsConfig, + logger: logging.Logger, + *, + debug: bool = False, +) -> None: + """Stage 2: one anon-regions request per neighbor, paced, updating in place. + + Entries must already be sorted (see sort_entries) so that if the cycle + budget runs out the most useful neighbors have been covered. Anything not + reached keeps its initial ``timeout`` status, matching the firmware's + fallback. + + Opt-in only -- see the module docstring for why. In particular, for a + neighbor that *is* a known contact with no stored path, the library reaches + zero-hop by temporarily rewriting that contact's path on the device. + + This is the single entry point for stage 2, including when it is disabled, + so that the meaning of ``status`` is decided in exactly one place. + """ + if not entries: + return + + if not cfg.collect_scopes: + # Stage 2 disabled. Every entry here answered the discover request, and + # that reception is the entire claim the snapshot makes, so `responded` + # is accurate -- whereas leaving the `timeout` default would report a + # live neighbor as unreachable. Scopes stay empty. + for entry in entries: + entry.status = STATUS_RESPONDED + if debug: + logger.debug( + f"Neighbors: scope collection disabled, reporting " + f"{len(entries)} neighbor(s) without scopes" + ) + return + + deadline = time.time() + cfg.cycle_timeout + # 0 means "let the device decide": req_regions_sync derives the wait from the + # suggested_timeout the radio returns, which is its own airtime estimate. + timeout = cfg.scope_timeout if cfg.scope_timeout > 0 else 0 + + for index, entry in enumerate(entries): + if time.time() >= deadline: + dropped = len(entries) - index + logger.warning( + f"Neighbors: cycle budget ({cfg.cycle_timeout:.0f}s) reached, " + f"{dropped} of {len(entries)} neighbor(s) left unqueried (reported as timeout)" + ) + break + + # Settle gap between requests, standing in for the firmware's + # wait-for-TX-completion gating. + if index > 0 and cfg.scope_gap > 0: + await asyncio.sleep(cfg.scope_gap) + + # Only a known contact with no stored path gets rewritten by the library, + # and only then does an interrupted request need repairing (see + # _restore_flood_path). Read it before the request, because the library + # updates the same dict in place. + pinned_to_zero_hop = _contact_has_no_path(meshcore, entry.pubkey) + + try: + # Bounded: this holds the shared radio command lock for its whole + # round trip, and an unbounded stall here would block every other + # bot command for as long as the write hangs. + scopes = await asyncio.wait_for( + meshcore.commands.req_regions_sync( + entry.pubkey, + timeout=timeout, + min_timeout=cfg.scope_min_timeout, + ), + timeout=cfg.scope_request_budget, + ) + except asyncio.CancelledError: + if pinned_to_zero_hop: + _restore_flood_path_detached(meshcore, entry.pubkey, logger) + raise + except asyncio.TimeoutError: + entry.status = STATUS_SEND_FAILED + logger.warning( + f"Neighbors: scope request to {entry.pubkey[:12]} exceeded " + f"{cfg.scope_request_budget:.0f}s; the device link may be stalled" + ) + if pinned_to_zero_hop: + await _restore_flood_path(meshcore, entry.pubkey, logger) + continue + except Exception as exc: + entry.status = STATUS_SEND_FAILED + logger.debug(f"Neighbors: scope request to {entry.pubkey[:12]} failed: {exc}") + if pinned_to_zero_hop: + await _restore_flood_path(meshcore, entry.pubkey, logger) + continue + + if scopes is None: + # req_regions_sync collapses every failure to None: a real timeout, + # but also a device-level send rejection. We report timeout, which is + # the firmware's own fallback for anything that isn't a clean send + # failure or response. + entry.status = STATUS_TIMEOUT + if debug: + logger.debug(f"Neighbors: no scope response from {entry.pubkey[:12]}") + # One of those collapsed failures is send_anon_req giving up because + # change_contact_path returned an error -- which it also does when the + # device *applied* the zero-hop path but its acknowledgement was lost. + # That returns before the library's own reset_path, so repair it here + # too. On the far more common "neighbour just did not answer" path the + # library has already restored the path and this is a redundant device + # command: no airtime, idempotent, and it re-syncs the contact cache. + if pinned_to_zero_hop: + await _restore_flood_path(meshcore, entry.pubkey, logger) + continue + + entry.scopes = str(scopes).strip() + entry.status = STATUS_RESPONDED + if debug: + logger.debug( + f"Neighbors: {entry.pubkey[:12]} scopes=" + f"{entry.scopes if entry.scopes else '(none)'}" + ) + + +async def fetch_self_scopes(meshcore: Any, cfg: NeighborsConfig, + logger: logging.Logger) -> str: + """This node's own scope names for the message's ``self`` object. + + A companion radio has no region_map, so the closest analogue to the + firmware's exportNamesTo(REGION_DENY_FLOOD) is the default flood scope name. + ``neighbors_self_scopes`` overrides it outright and is honoured even when + stage 2 is disabled. + """ + if cfg.self_scopes: + return cfg.self_scopes + + # With stage 2 off the snapshot reports no scopes for anyone, so spending a + # radio command to learn our own would be inconsistent as well as wasteful: + # the default cycle is meant to cost one command plus a listen window. + if not cfg.collect_scopes: + return "" + + # A reconnect can null out the device handle mid-cycle (a cycle spans minutes). + commands = getattr(meshcore, "commands", None) + getter = getattr(commands, "get_default_flood_scope", None) + if not callable(getter): + return "" + + try: + result = await asyncio.wait_for(getter(), timeout=cfg.command_timeout) + except Exception as exc: + logger.debug(f"Neighbors: could not read default flood scope: {exc}") + return "" + + if result is None or result.type == EventType.ERROR: + return "" + return str((getattr(result, "payload", None) or {}).get("scope_name", "") or "").strip() + + +def record_neighbors( + db_manager: Any, + self_pubkey: str, + entries: list[NeighborEntry], + logger: logging.Logger, + *, + observed_at: Optional[str] = None, +) -> int: + """Persist one cycle to ``neighbor_observations`` and ``neighbor_links``. + + Returns the number of links written. Only entries we actually heard are + recorded: an entry left at ``timeout`` because the scope pass ran out of + budget was still a real zero-hop reception, but one that never answered + discovery never reaches this function at all. + + SNR is accumulated as sum+count rather than a running mean so that any later + window can re-aggregate exactly (the ``daily_rollup`` convention). + """ + if not entries or not self_pubkey: + return 0 + + self_key = self_pubkey.lower() + stamp = observed_at or datetime.now(timezone.utc).isoformat() + written = 0 + + try: + with db_manager.connection() as conn: + cursor = conn.cursor() + for entry in entries: + cursor.execute( + """ + INSERT INTO neighbor_observations + (observed_at, self_public_key, neighbor_public_key, + snr, heard_secs_ago, scopes, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (stamp, self_key, entry.pubkey.lower(), entry.snr, + entry.heard_secs_ago(), entry.scopes or "", entry.status), + ) + # ON CONFLICT rather than a read-modify-write: the aggregate must + # stay correct even if two cycles were ever to overlap. + cursor.execute( + """ + INSERT INTO neighbor_links + (self_public_key, neighbor_public_key, first_seen, last_seen, + observation_count, snr_sum, snr_count, best_snr, last_snr, + last_status, scopes) + VALUES (?, ?, ?, ?, 1, ?, 1, ?, ?, ?, ?) + ON CONFLICT(self_public_key, neighbor_public_key) DO UPDATE SET + last_seen = excluded.last_seen, + observation_count = observation_count + 1, + snr_sum = snr_sum + excluded.snr_sum, + snr_count = snr_count + 1, + best_snr = MAX(COALESCE(best_snr, excluded.best_snr), excluded.best_snr), + last_snr = excluded.last_snr, + last_status = excluded.last_status, + -- Keep the last non-empty scopes: a cycle with stage 2 + -- disabled must not erase scopes an earlier cycle learned. + scopes = CASE + WHEN excluded.scopes != '' THEN excluded.scopes ELSE scopes + END + """, + (self_key, entry.pubkey.lower(), stamp, stamp, + entry.snr, entry.snr, entry.snr, entry.status, entry.scopes or ""), + ) + written += 1 + conn.commit() + except Exception as exc: + logger.error(f"Neighbors: could not persist snapshot: {exc}") + return 0 + + return written + + +def build_neighbors_message( + origin: str, + origin_id: str, + self_scopes: str, + entries: list[NeighborEntry], + *, + timestamp: Optional[str] = None, + now: Optional[float] = None, + budget: int = NEIGHBORS_JSON_BUDGET, + total_neighbors: Optional[int] = None, +) -> tuple[dict[str, Any], int]: + """Build the neighbors payload, dropping the tail past ``budget`` bytes. + + Matches MQTTPayloadBuilder::buildNeighborsMessage. Returns the message and + the number of entries dropped. Key order is part of the contract. + """ + now = time.time() if now is None else now + # total_neighbors is how many were discovered before any max_neighbors cap; + # queried_neighbors is how many we actually asked. Firmware key order + # (MQTTPayloadBuilder.cpp): these sit between origin_id and self. + queried = len(entries) + total = queried if total_neighbors is None else total_neighbors + message: dict[str, Any] = { + "timestamp": timestamp or datetime.now(timezone.utc).isoformat(), + "origin": origin, + "origin_id": origin_id, + "total_neighbors": total, + "queried_neighbors": queried, + # Set below once we know whether the payload budget dropped a tail. + "truncated": total > queried, + "self": {"scopes": self_scopes or ""}, + "neighbors": [], + } + + neighbors = message["neighbors"] + dropped = 0 + for position, entry in enumerate(sort_entries(entries, now)): + neighbors.append( + { + "pubkey": entry.pubkey.upper(), + "snr": entry.snr, + "heard_secs_ago": entry.heard_secs_ago(now), + "scopes": entry.scopes or "", + "status": entry.status, + } + ) + # Entries are ordered most- to least-useful, so once one overflows, drop + # it and everything after it. + if len(json.dumps(message)) >= budget: + neighbors.pop() + dropped = len(entries) - position + break + + # Truncated covers both causes: the max_neighbors cap and the payload budget. + message["truncated"] = bool(dropped) or total > queried + return message, dropped diff --git a/modules/service_plugins/packet_capture_service.py b/modules/service_plugins/packet_capture_service.py index 18412ad..509d535 100644 --- a/modules/service_plugins/packet_capture_service.py +++ b/modules/service_plugins/packet_capture_service.py @@ -24,6 +24,18 @@ from ..meshcore_payload_decode import ( ChannelKeyStore, decode_payload, ) +from ..neighbors_discovery import ( + MAX_INTERVAL_HOURS, + MIN_CYCLE_GAP_SECONDS, + MIN_INTERVAL_HOURS, + STATUS_RESPONDED, + NeighborsConfig, + build_neighbors_message, + collect_scopes, + discover_neighbors, + fetch_self_scopes, + record_neighbors, +) # Import bot's utilities for packet hash from ..utils import ( @@ -47,6 +59,26 @@ import contextlib from .base_service import BaseServicePlugin from .packet_capture_utils import create_auth_token_async, read_private_key_file +# bot_metadata key holding the last neighbors cycle timestamp. Namespaced because +# bot_metadata is shared across the whole bot. +NEIGHBORS_STATE_KEY = "packet_capture.last_neighbors_publish" + +# A cycle can spend airtime without producing a publish timestamp (for example, +# when the discover acknowledgement is lost). Persist that attempt separately so +# a process restart cannot bypass the short airtime guard while still allowing +# the scheduler to retry after MIN_CYCLE_GAP_SECONDS rather than waiting a full +# configured interval. +NEIGHBORS_ATTEMPT_STATE_KEY = "packet_capture.last_neighbors_attempt" + +# How long the scheduler waits before re-checking after a cycle that produced no +# result. Short on purpose: the usual causes (radio down, unsupported build) cost +# nothing to re-test. A cycle that did transmit is held by MIN_CYCLE_GAP_SECONDS +# instead, which is longer. +NEIGHBORS_RETRY_BACKOFF_SECONDS = 300.0 + +# Sentinel meaning "no IATA configured" (documented as invalid in config.ini.example). +DEFAULT_IATA = "XYZ" + def _decode_key_str(key_str: str) -> Optional[bytes]: """Decode a 16-byte channel key from a hex (32 chars) or base64 string.""" @@ -176,6 +208,45 @@ class PacketCaptureService(BaseServicePlugin): "min": 1, "default": 43200, "unit": "s", "help": "Default JWT renewal interval (per-broker overrides apply)."}, {"key": "jwt_ttl_seconds", "label": "JWT TTL", "type": "int", "group": "Advanced", "min": 1, "default": 86400, "unit": "s", "help": "Default JWT lifetime (per-broker overrides apply)."}, + # --- Neighbors (zero-hop discovery; see modules/neighbors_discovery.py) --- + {"key": "neighbors_enabled", "label": "Neighbors discovery", "type": "bool", "group": "Neighbors", + "default": False, + "help": "Periodically ask which repeaters this node hears directly. Records confirmed " + "direct links with SNR, and publishes to brokers that opted in."}, + {"key": "neighbors_interval_hours", "label": "Interval", "type": "int", "group": "Neighbors", + "min": 12, "max": 336, "default": 24, "unit": "h", + "help": "How often a discovery cycle runs. Clamped to 12-336h, matching the firmware."}, + {"key": "neighbors_discover_window", "label": "Discover window", "type": "float", "group": "Neighbors", + "min": 5, "default": 60.0, "unit": "s", + "help": "How long responses are collected. Repeaters reply after a random delay, so a " + "short window finds fewer neighbours. The bot stays responsive throughout."}, + {"key": "neighbors_max", "label": "Max neighbours", "type": "int", "group": "Neighbors", + "min": 1, "default": 32, "help": "Cap per cycle, most useful first (recent, then stronger SNR)."}, + {"key": "neighbors_feed_mesh_graph", "label": "Feed mesh graph", "type": "bool", "group": "Neighbors", + "default": True, "help": "Also add confirmed direct links as mesh graph edges."}, + {"key": "neighbors_collect_scopes", "label": "Collect region scopes", "type": "bool", + "group": "Neighbors", "default": False, + "help": "SLOW — also ask each neighbour for its region scopes. Each request holds the radio " + "for up to ~25s, delaying bot replies, and for a repeater with no stored path the " + "library temporarily rewrites that contact's path on the device. Leave off unless " + "you specifically need scopes."}, + {"key": "neighbors_command_timeout", "label": "Command timeout", "type": "float", + "group": "Neighbors", "min": 1, "default": 20.0, "unit": "s", + "help": "Cap on the discover request itself; a stalled link can otherwise block."}, + {"key": "neighbors_scope_timeout", "label": "Scope timeout", "type": "float", "group": "Neighbors", + "min": 0, "default": 0.0, "unit": "s", + "help": "Per-scope-request wait. 0 uses the device's own airtime estimate."}, + {"key": "neighbors_scope_min_timeout", "label": "Scope min timeout", "type": "float", + "group": "Neighbors", "min": 0, "default": 8.0, "unit": "s", + "help": "Floor under the device-suggested scope timeout."}, + {"key": "neighbors_scope_gap", "label": "Scope gap", "type": "float", "group": "Neighbors", + "min": 0, "default": 2.0, "unit": "s", "help": "Settle delay between scope requests."}, + {"key": "neighbors_cycle_timeout", "label": "Scope pass budget", "type": "float", + "group": "Neighbors", "min": 10, "default": 600.0, "unit": "s", + "help": "Overall budget for the scope pass; unreached neighbours report as timeout."}, + {"key": "neighbors_self_scopes", "label": "Own scopes override", "type": "str", + "group": "Neighbors", "default": "", "width": "lg", + "help": "Override for this node's own \"self.scopes\" value. Empty asks the device."}, ] # Repeating structured blocks (see modules/settings_schema.py). Each MQTT @@ -206,6 +277,13 @@ class PacketCaptureService(BaseServicePlugin): "help": "JWT audience claim (when using auth token)."}, {"key": "topic_status", "label": "Status topic", "type": "str", "default": ""}, {"key": "topic_packets", "label": "Packets topic", "type": "str", "default": ""}, + {"key": "neighbors", "label": "Publish neighbours", "type": "bool", "default": True, + "help": "Send the zero-hop neighbours snapshot to this broker. On by default, but " + "nothing is sent until Neighbours discovery is enabled above. Turn off to " + "hold just this broker back."}, + {"key": "topic_neighbors", "label": "Neighbours topic", "type": "str", "default": "", + "help": "Defaults to the packets topic with its last segment swapped for " + "'neighbors', else /neighbors."}, {"key": "websocket_path", "label": "WebSocket path", "type": "str", "default": "/mqtt", "help": "Path when transport is websockets."}, {"key": "client_id", "label": "Client ID", "type": "str", "default": ""}, @@ -305,6 +383,24 @@ class PacketCaptureService(BaseServicePlugin): self.cached_firmware_info = None self.radio_info = None + # Neighbors discovery runtime state. Kept out of _load_config so a future + # config reload cannot orphan a running scheduler or replay a cycle + # (map_uploader_service re-invokes its own _load_config on reload). + self.neighbors_task = None + self.neighbors_capability_state = None + self.neighbors_discover_failures = 0 + self.neighbors_topic_warned: set[int] = set() + self.last_neighbors_publish = self._load_neighbors_state() + # When a cycle last reached the radio, whether or not it produced a + # result. Callers that ration airtime need this rather than + # last_neighbors_publish, which a failed cycle never stamps. + self.last_neighbors_attempt = self._load_neighbors_attempt_state() + # Single-flight across every trigger (scheduler, command, future callers): + # two overlapping cycles would collect into each other's discover window + # and double the airtime. asyncio is single-threaded, so a plain flag set + # and cleared without an await in between is enough. + self.neighbors_cycle_active = False + # Background tasks self.background_tasks: list[asyncio.Task] = [] self.should_exit = False @@ -365,8 +461,12 @@ class PacketCaptureService(BaseServicePlugin): self.mqtt_enabled = config.getboolean("PacketCapture", "mqtt_enabled", fallback=True) self.mqtt_brokers = self._parse_mqtt_brokers(config) - # Global IATA - self.global_iata = config.get("PacketCapture", "iata", fallback="XYZ").lower() + # Global IATA. Blank stays blank so packet/status {IATA} topics keep + # their historical empty-segment resolution; a missing key still falls + # back to the XYZ sentinel. Neighbors treats blank and XYZ as unset. + self.global_iata = config.get( + "PacketCapture", "iata", fallback=DEFAULT_IATA + ).strip().lower() # Owner information self.owner_public_key = config.get("PacketCapture", "owner_public_key", fallback=None) @@ -403,6 +503,100 @@ class PacketCaptureService(BaseServicePlugin): # 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 + self._load_neighbors_config(config) + + def _load_neighbors_config(self, config) -> None: + """Build the neighbors cycle configuration (see modules/neighbors_discovery.py). + + Off by default: a cycle spends real airtime and, with scope collection + enabled, holds the shared radio command lock for seconds at a time. + """ + self.neighbors_enabled = config.getboolean("PacketCapture", "neighbors_enabled", fallback=False) + self.neighbors_feed_mesh_graph = config.getboolean( + "PacketCapture", "neighbors_feed_mesh_graph", fallback=True + ) + + requested_interval = config.getint("PacketCapture", "neighbors_interval_hours", fallback=24) + self.neighbors_config = NeighborsConfig( + interval_hours=requested_interval, + discover_window=config.getfloat("PacketCapture", "neighbors_discover_window", fallback=60.0), + command_timeout=config.getfloat("PacketCapture", "neighbors_command_timeout", fallback=20.0), + collect_scopes=config.getboolean("PacketCapture", "neighbors_collect_scopes", fallback=False), + scope_timeout=config.getfloat("PacketCapture", "neighbors_scope_timeout", fallback=0.0), + scope_min_timeout=config.getfloat("PacketCapture", "neighbors_scope_min_timeout", fallback=8.0), + scope_gap=config.getfloat("PacketCapture", "neighbors_scope_gap", fallback=2.0), + cycle_timeout=config.getfloat("PacketCapture", "neighbors_cycle_timeout", fallback=600.0), + max_neighbors=config.getint("PacketCapture", "neighbors_max", fallback=32), + self_scopes=config.get("PacketCapture", "neighbors_self_scopes", fallback="").strip(), + ) + # NeighborsConfig clamps out-of-range values; say so rather than silently + # honouring something different from what was configured. + if self.neighbors_enabled and self.neighbors_config.interval_hours != requested_interval: + self.logger.warning( + f"neighbors_interval_hours {requested_interval} is outside the supported " + f"{MIN_INTERVAL_HOURS}-{MAX_INTERVAL_HOURS}h range, " + f"using {self.neighbors_config.interval_hours}h" + ) + + def _load_neighbors_timestamp(self, key: str, label: str) -> float: + """Load and validate one neighbors wall-clock timestamp.""" + try: + raw = self.bot.db_manager.get_metadata(key) + except Exception as e: + self.logger.debug(f"Could not read {label}: {e}") + return 0.0 + if not raw: + return 0.0 + try: + value = float(raw) + except (TypeError, ValueError): + self.logger.warning(f"Ignoring malformed {label} value: {raw!r}") + return 0.0 + # A clock jump either way would otherwise pin the scheduler: a future + # timestamp suppresses cycles indefinitely, a nonsensical old one is noise. + now = time.time() + if value > now + 300 or value < now - (400 * 86400): + self.logger.warning( + f"Ignoring out-of-range {label} timestamp ({value})" + ) + return 0.0 + return value + + def _load_neighbors_state(self) -> float: + """Last completed cycle timestamp, from bot_metadata. + + Neighbors intervals are long (12-336h), so surviving a restart is what + keeps a bot that reboots often from re-running discovery every start. + """ + return self._load_neighbors_timestamp(NEIGHBORS_STATE_KEY, "neighbors state") + + def _load_neighbors_attempt_state(self) -> float: + """Last cycle that may have reached the radio, from bot_metadata.""" + return self._load_neighbors_timestamp( + NEIGHBORS_ATTEMPT_STATE_KEY, "neighbors attempt state" + ) + + def _save_neighbors_timestamp(self, key: str, value: float, label: str) -> None: + """Persist one neighbors wall-clock timestamp.""" + try: + self.bot.db_manager.set_metadata(key, str(value)) + except Exception as e: + self.logger.debug(f"Could not save {label}: {e}") + + def _save_neighbors_state(self) -> None: + """Persist the last cycle timestamp to bot_metadata.""" + self._save_neighbors_timestamp( + NEIGHBORS_STATE_KEY, self.last_neighbors_publish, "neighbors state" + ) + + def _save_neighbors_attempt_state(self) -> None: + """Persist the last cycle that may have reached the radio.""" + self._save_neighbors_timestamp( + NEIGHBORS_ATTEMPT_STATE_KEY, + self.last_neighbors_attempt, + "neighbors attempt state", + ) + def _build_channel_key_store(self, config) -> ChannelKeyStore: """Build a comprehensive channel key store for GRP_TXT decryption. @@ -528,6 +722,15 @@ class PacketCaptureService(BaseServicePlugin): "topic_prefix": config.get("PacketCapture", f"mqtt{broker_num}_topic_prefix", fallback=None), "topic_status": config.get("PacketCapture", f"mqtt{broker_num}_topic_status", fallback=None), "topic_packets": config.get("PacketCapture", f"mqtt{broker_num}_topic_packets", fallback=None), + "topic_neighbors": config.get( + "PacketCapture", f"mqtt{broker_num}_topic_neighbors", fallback=None + ), + # On by default, so `neighbors_enabled` is the single switch that + # turns the feature on everywhere. Set false per broker to hold one + # back. The whole feature is still off until neighbors_enabled. + "neighbors": config.getboolean( + "PacketCapture", f"mqtt{broker_num}_neighbors", fallback=True + ), "use_auth_token": config.getboolean( "PacketCapture", f"mqtt{broker_num}_use_auth_token", fallback=False ), @@ -1864,6 +2067,481 @@ class PacketCaptureService(BaseServicePlugin): task = asyncio.create_task(self.mqtt_reconnection_monitor()) self.background_tasks.append(task) + # Neighbors discovery. Unlike the upstream capture tool this does not + # require a broker: the database is a legitimate consumer on its own, so + # the cycle runs whenever the feature is enabled and publishes to + # whichever brokers opted in (possibly none). + if self.neighbors_enabled: + self.neighbors_task = asyncio.create_task(self.neighbors_scheduler()) + self.background_tasks.append(self.neighbors_task) + + def neighbors_brokers(self) -> list[dict[str, Any]]: + """Connected MQTT clients whose broker opted into the neighbors topic.""" + return [ + info for info in self.mqtt_clients + if info.get("connected", False) and info["config"].get("neighbors", False) + ] + + def neighbors_commands_available(self) -> bool: + """Detect whether the connected build exposes the neighbors commands. + + ``send_node_discover_req`` needs companion CMD_SEND_CONTROL_DATA (v8+); + ``req_regions_sync`` needs CMD_SEND_ANON_REQ and is only required when + scope collection is enabled. Logged once per state change, like stats. + """ + if not self.meshcore or not hasattr(self.meshcore, "commands"): + return False + + commands = self.meshcore.commands + required = ["send_node_discover_req"] + if self.neighbors_config.collect_scopes: + required.append("req_regions_sync") + available = all(callable(getattr(commands, attr, None)) for attr in required) + state = "available" if available else "missing" + if state != self.neighbors_capability_state: + if available: + self.logger.info("MeshCore neighbors commands detected - neighbors discovery enabled") + else: + self.logger.warning( + "MeshCore neighbors commands not available - neighbors discovery disabled " + "(needs a newer firmware/meshcore build)" + ) + self.neighbors_capability_state = state + return available + + def _neighbors_topic_template(self, broker_config: dict[str, Any]) -> Optional[str]: + """Unresolved neighbors topic template for one broker. + + Order matters because brokers publish neighbors by default, so the derived + value has to be *correct*, not merely non-empty: + + 1. Explicit ``topic_neighbors`` always wins. + 2. Otherwise swap the last segment of ``topic_packets``. A broker + configured with ``meshcore/{IATA}/{PUBLIC_KEY}/packets`` then gets + ``meshcore/{IATA}/{PUBLIC_KEY}/neighbors`` — the topic the firmware + actually publishes — instead of an unrelated flat topic. + 3. Otherwise ``/neighbors``, mirroring how the packet path + falls back to ``/packet``. + """ + explicit = broker_config.get("topic_neighbors") + if explicit: + return explicit + + packets = broker_config.get("topic_packets") + if packets: + if "/" in packets: + return packets.rsplit("/", 1)[0] + "/neighbors" + return "neighbors" + + prefix = broker_config.get("topic_prefix") + if prefix: + return f"{prefix}/neighbors" + return None + + def _iata_is_unset(self) -> bool: + """True when no real IATA is configured (blank or the XYZ sentinel).""" + return (not self.global_iata) or self.global_iata == DEFAULT_IATA.lower() + + def _resolve_neighbors_topic(self, broker_config: dict[str, Any]) -> Optional[str]: + """Resolved topic for one broker's neighbors snapshot, or None if unroutable.""" + template = self._neighbors_topic_template(broker_config) + if not template: + return None + + # An unset IATA is blank or the documented sentinel "XYZ", and this topic + # is location-routed on the community brokers. Publishing a snapshot to + # meshcore/XYZ/... (or meshcore//...) would pollute a shared namespace, so + # refuse instead — matching the upstream rule that neighbors needs a real + # IATA to route. Only applies to a template we derived; an explicit topic + # is the operator's call. + # .upper() catches both the {IATA} and {iata} placeholder spellings. + if ( + not broker_config.get("topic_neighbors") + and "{IATA}" in template.upper() + and self._iata_is_unset() + ): + return None + + return self._resolve_topic_template(template, "neighbors") + + def publish_neighbors_mqtt(self, message: dict[str, Any]) -> dict[str, int]: + """Publish a neighbors snapshot to every opted-in, connected broker. + + Non-retained: a snapshot taken every 12-336h is not a useful last-will + value, and a stale retained copy would read as current. + """ + metrics = {"attempted": 0, "succeeded": 0} + if not self.mqtt_enabled or mqtt is None: + return metrics + + payload = json.dumps(message, default=str) + for mqtt_client_info in self.neighbors_brokers(): + config = mqtt_client_info["config"] + broker_num = config.get("broker_num", 0) + host = config.get("host", "unknown") + topic = self._resolve_neighbors_topic(config) + if not topic: + # Warn once per broker: the cycle spent real airtime, so silently + # dropping the result would be worse than noisy. + if broker_num not in self.neighbors_topic_warned: + self.neighbors_topic_warned.add(broker_num) + template = self._neighbors_topic_template(config) + if template and "{IATA}" in template.upper(): + reason = ( + f"its topic is location-routed ({template}) but no IATA is set; " + f"set [PacketCapture] iata, or mqtt{broker_num}_topic_neighbors " + f"to a topic that does not need one" + ) + else: + reason = ( + f"no neighbors topic could be resolved (set " + f"mqtt{broker_num}_topic_neighbors or a topic prefix)" + ) + self.logger.warning( + f"Not publishing neighbors to {host}: {reason}" + ) + continue + + try: + metrics["attempted"] += 1 + result = mqtt_client_info["client"].publish(topic, payload, qos=0, retain=False) + if result.rc == mqtt.MQTT_ERR_SUCCESS: + metrics["succeeded"] += 1 + self.logger.debug(f"Published neighbors to '{topic}' on {host}") + else: + self.logger.warning( + f"Failed to publish neighbors to '{topic}' on {host}: " + f"{result.rc} ({mqtt.error_string(result.rc)})" + ) + except Exception as e: + self.logger.error(f"Error publishing neighbors to MQTT on {host}: {e}") + + return metrics + + def _feed_mesh_graph(self, self_pubkey: str, entries: list[Any]) -> None: + """Add each confirmed direct link to the mesh graph, both directions. + + Both directions are correct: a discover response proves we transmitted, + they received, they transmitted, and we received. + + Note that ``mesh_connections`` cannot represent *why* an edge exists — + its multi-byte confirmation flag is memory-only and never persisted — so + ``neighbor_links`` remains the source of truth for this evidence, and the + viewer derives its neighbors evidence mode from that table instead. + """ + mesh_graph = getattr(self.bot, "mesh_graph", None) + if not mesh_graph or not self_pubkey: + return + + self_prefix = self_pubkey.lower()[:6] + added = 0 + for entry in entries: + neighbor_prefix = entry.pubkey.lower()[:6] + if not neighbor_prefix or neighbor_prefix == self_prefix: + continue + try: + # hop_position 1: a direct link is the first hop of any path + # through it. add_edge honours the graph capture kill-switch. + mesh_graph.add_edge( + self_prefix, neighbor_prefix, + from_public_key=self_pubkey.lower(), + to_public_key=entry.pubkey.lower(), + hop_position=1, prefix_bytes=2, + ) + mesh_graph.add_edge( + neighbor_prefix, self_prefix, + from_public_key=entry.pubkey.lower(), + to_public_key=self_pubkey.lower(), + hop_position=1, prefix_bytes=2, + ) + added += 2 + except Exception as e: + self.logger.debug(f"Neighbors: could not add graph edge for {neighbor_prefix}: {e}") + + if added and self.debug: + self.logger.debug(f"Neighbors: fed {added} mesh graph edge(s)") + + def _device_public_key(self) -> str: + """This node's public key, lowercase hex, or '' when unavailable.""" + if not self.meshcore or not hasattr(self.meshcore, "self_info"): + return "" + try: + self_info = self.meshcore.self_info + if isinstance(self_info, dict): + key = self_info.get("public_key", "") + else: + key = getattr(self_info, "public_key", "") + if isinstance(key, (bytes, bytearray)): + key = bytes(key).hex() + key = str(key or "").replace("0x", "").replace(" ", "").strip().lower() + return "" if key in ("", "unknown") else key + except Exception as e: + self.logger.debug(f"Could not read device public key: {e}") + return "" + + @staticmethod + def _empty_neighbors_summary(reason: str = "") -> dict[str, Any]: + """A cycle summary that reports no work done, optionally with a reason.""" + return { + "ok": False, "reason": reason, "discovered": 0, "queried": 0, + "best_snr": None, "attempted": 0, "succeeded": 0, "recorded": 0, + } + + def neighbors_cooldown_remaining(self) -> float: + """Seconds until another cycle may reach the radio. + + Measured from the last cycle that got as far as transmitting, whichever + trigger started it — so a cycle that failed on a lost acknowledgement + still counts, because the discover broadcast went out regardless. A cycle + that bailed before touching the radio stamps nothing and so costs nothing. + """ + last = max(self.last_neighbors_attempt, self.last_neighbors_publish) + if last <= 0: + return 0.0 + return max(0.0, MIN_CYCLE_GAP_SECONDS - (time.time() - last)) + + def claim_neighbors_cycle(self) -> Optional[str]: + """Claim the single-flight lock before a cycle reaches the radio. + + Returns ``None`` when claimed. Returns a refusal reason when another + cycle is already running or the airtime cooldown has not expired. + + The ``neighbors`` command claims *before* acknowledging so an await + cannot let the scheduler sneak in and turn "started" into an immediate + failure. Pair every successful claim with ``release_neighbors_cycle`` + (``run_neighbors_cycle`` does this in ``finally``). + """ + if self.neighbors_cycle_active: + self.logger.info( + "Neighbors: a discovery cycle is already running, skipping this trigger" + ) + return "a discovery cycle is already running" + + cooldown = self.neighbors_cooldown_remaining() + if cooldown > 0: + self.logger.info( + f"Neighbors: last cycle was too recent, {cooldown:.0f}s left before " + f"another may run" + ) + return f"another cycle may run in {cooldown:.0f}s" + + self.neighbors_cycle_active = True + return None + + def release_neighbors_cycle(self) -> None: + """Drop the single-flight lock claimed by ``claim_neighbors_cycle``.""" + self.neighbors_cycle_active = False + + async def run_neighbors_cycle(self, *, already_claimed: bool = False) -> dict[str, Any]: + """Run one discovery cycle, subject to the airtime guards. + + Both guards live here rather than in a caller, because the scheduler, the + ``neighbors`` command and any future trigger all reach the same radio: + + * no overlap — two concurrent cycles would each collect the other's + discover responses and spend twice the airtime for no extra information; + * no cycle within ``MIN_CYCLE_GAP_SECONDS`` of the last one that + transmitted. + + Pass ``already_claimed=True`` when the caller has successfully called + ``claim_neighbors_cycle`` (the manual command does this so it can + acknowledge only after the lock is held). + + Returns a summary dict (also used by the ``neighbors`` command): + ``{'ok', 'reason', 'discovered', 'queried', 'best_snr', 'attempted', + 'succeeded', 'recorded'}``. + """ + if not already_claimed: + reason = self.claim_neighbors_cycle() + if reason is not None: + return self._empty_neighbors_summary(reason) + + try: + return await self._run_neighbors_cycle() + finally: + self.release_neighbors_cycle() + + async def _run_neighbors_cycle(self) -> dict[str, Any]: + """One discovery cycle: record it, feed the graph, publish it. + + Always call through run_neighbors_cycle, which holds the single-flight + guard for the whole cycle. + """ + summary = self._empty_neighbors_summary() + + if not self.neighbors_enabled: + summary["reason"] = "neighbors discovery is disabled" + return summary + if not self.meshcore or not self.bot.connected: + summary["reason"] = "radio not connected" + return summary + if not self.neighbors_commands_available(): + summary["reason"] = "radio build does not support neighbor discovery" + return summary + + cfg = self.neighbors_config + self_pubkey = self._device_public_key() + self.logger.info("Neighbors: starting discovery cycle") + + # A reconnect swaps in a fresh MeshCore object and clears every event + # subscription, so a cycle spanning one is collecting into a dead handler. + session = self.meshcore + + def session_intact() -> bool: + return self.meshcore is session and self.bot.connected + + # Stamp the attempt before the request, not after the cycle: from here on + # the discover broadcast may go out and spend airtime even if we never + # learn that it did (a lost acknowledgement fails the cycle without + # stamping last_neighbors_publish). Rate limiting has to charge for the + # transmission, not for the result. Persist this to ration requests + # across restarts as well. It is separate from + # last_neighbors_publish so a failed cycle retries after the short airtime + # gap rather than waiting the full configured schedule interval. + self.last_neighbors_attempt = time.time() + self._save_neighbors_attempt_state() + + entries = await discover_neighbors( + self.meshcore, cfg, self_pubkey, self.logger, + debug=self.debug, still_valid=session_intact, + ) + if entries is None: + # A build that rejects the discover command fails every cycle; warn + # once rather than on every wakeup forever. + self.neighbors_discover_failures += 1 + if self.neighbors_discover_failures == 1: + self.logger.warning( + "Neighbors: discovery request failed; will keep retrying quietly " + "on the configured interval" + ) + else: + self.logger.debug( + f"Neighbors: discovery request failed " + f"({self.neighbors_discover_failures} consecutive)" + ) + summary["reason"] = "discovery request failed" + return summary + self.neighbors_discover_failures = 0 + + total_discovered = len(entries) + summary["discovered"] = total_discovered + if total_discovered > cfg.max_neighbors: + self.logger.info( + f"Neighbors: {total_discovered} discovered, keeping the " + f"{cfg.max_neighbors} most useful" + ) + entries = entries[:cfg.max_neighbors] + + self.logger.info(f"Neighbors: {len(entries)} neighbor(s) discovered") + await collect_scopes(self.meshcore, entries, cfg, self.logger, debug=self.debug) + + if not session_intact(): + self.logger.warning( + "Neighbors: device session was reset mid-cycle, discarding this cycle " + "rather than recording partial data" + ) + summary["reason"] = "device session reset mid-cycle" + return summary + + summary["queried"] = len(entries) + if entries: + summary["best_snr"] = max(e.snr for e in entries) + + # Record before publishing: the data is valuable on its own, and a broker + # problem must not cost us the observation. + if entries and self_pubkey: + summary["recorded"] = record_neighbors( + self.bot.db_manager, self_pubkey, entries, self.logger + ) + elif entries and not self_pubkey: + self.logger.warning( + "Neighbors: device public key unavailable, cannot record links " + "(an unattributed link is not usable evidence)" + ) + + if entries and self.neighbors_feed_mesh_graph and self_pubkey: + self._feed_mesh_graph(self_pubkey, entries) + + self_scopes = await fetch_self_scopes(self.meshcore, cfg, self.logger) + origin_id = self_pubkey.upper() if self_pubkey else "DEVICE" + message, dropped = build_neighbors_message( + self._get_bot_name() or "MeshCore Device", + origin_id, self_scopes, entries, + total_neighbors=total_discovered, + ) + if dropped: + self.logger.warning( + f"Neighbors: payload budget reached, dropped {dropped} least-useful entry(ies)" + ) + + metrics = self.publish_neighbors_mqtt(message) + summary["attempted"] = metrics["attempted"] + summary["succeeded"] = metrics["succeeded"] + + responded = sum(1 for e in entries if e.status == STATUS_RESPONDED) + if metrics["attempted"]: + self.logger.info( + f"Neighbors: published {len(message['neighbors'])} entry(ies) " + f"({responded} responded) to {metrics['succeeded']}/{metrics['attempted']} broker(s)" + ) + else: + self.logger.info( + f"Neighbors: recorded {summary['recorded']} link(s); " + f"no broker has neighbors enabled, nothing published" + ) + + # Stamp the attempt even when nothing was published, so a persistently + # failing broker cannot turn every wakeup into a fresh discovery burst. + self.last_neighbors_publish = time.time() + self._save_neighbors_state() + summary["ok"] = True + return summary + + async def neighbors_scheduler(self) -> None: + """Run a discovery cycle on the configured interval.""" + interval_seconds = self.neighbors_config.interval_seconds + if self.debug: + self.logger.debug( + f"Starting neighbors scheduler " + f"({self.neighbors_config.interval_hours}h interval)" + ) + + while not self.should_exit: + try: + time_since_last = time.time() - self.last_neighbors_publish + if time_since_last < interval_seconds: + sleep_time = interval_seconds - time_since_last + if self.debug: + self.logger.debug(f"Next neighbors cycle in {sleep_time / 3600:.1f} hours") + if await self._wait_with_shutdown(sleep_time): + break + continue + + await self.run_neighbors_cycle() + + # run_neighbors_cycle stamps last_neighbors_publish when it got as + # far as a result. If it bailed early (not connected, unsupported + # build) back off before retrying so we re-check periodically + # rather than spinning — but never retry inside the airtime + # cooldown: a cycle that failed on a lost acknowledgement already + # put a discover broadcast on the air, and retrying every + # NEIGHBORS_RETRY_BACKOFF_SECONDS would spend triple the intended + # airtime. + if (time.time() - self.last_neighbors_publish) >= interval_seconds: + backoff = max(NEIGHBORS_RETRY_BACKOFF_SECONDS, + self.neighbors_cooldown_remaining()) + if await self._wait_with_shutdown(backoff): + break + + except asyncio.CancelledError: + if self.debug: + self.logger.debug("Neighbors scheduler cancelled") + break + except Exception as e: + self.logger.error(f"Error in neighbors scheduler: {e}") + if await self._wait_with_shutdown(NEIGHBORS_RETRY_BACKOFF_SECONDS): + break + async def stats_refresh_scheduler(self) -> None: """Periodically refresh stats and publish them via MQTT (matches original script). diff --git a/modules/web_viewer/app.py b/modules/web_viewer/app.py index 7879f98..5b98022 100644 --- a/modules/web_viewer/app.py +++ b/modules/web_viewer/app.py @@ -17,7 +17,7 @@ import time from contextlib import closing, contextmanager, suppress from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any +from typing import Any, NamedTuple from urllib.parse import urlparse # When started as a script (`python modules/web_viewer/app.py`), Python puts the @@ -79,6 +79,17 @@ from modules.web_viewer.dashboard_stats import ( STATS_ENDPOINT_SUNSET = "Fri, 01 Jan 2027 00:00:00 GMT" +class NeighborEvidenceKeys(NamedTuple): + """Directed edge identities that zero-hop neighbor discovery has confirmed. + + A ``mesh_connections`` edge counts as neighbor-confirmed if it matches on + either key space; see BotDataViewer._neighbor_evidence_edge_keys. + """ + + prefixes: set[tuple[str, str]] + public_keys: set[tuple[str, str]] + + def _validate_dynamic_key(key: str) -> "str | None": """Validate a dynamic-section row key. Returns an error message or None. @@ -974,6 +985,140 @@ class BotDataViewer: result.sort(key=lambda e: e['last_seen'] or '', reverse=True) return result + # Nodes in the neighbor tables are stored as full 32-byte public keys, so the + # graph's highest resolution (3 bytes) is always available for edge identity. + NEIGHBOR_PREFIX_HEX_CHARS = 6 + + def _neighbor_evidence_edge_keys( + self, + days: int | None = None, + ) -> 'NeighborEvidenceKeys': + """Directed pairs that confirmed zero-hop discovery has proven. + + Used to upgrade the evidence label in the combined view, where the edge + itself comes from ``mesh_connections`` and so has lost its provenance. + Two key spaces are returned because a ``mesh_connections`` edge can be + matched by either: + + * ``prefixes`` — 3-byte prefix pairs, matching edges the graph stores at + the same resolution neighbor discovery feeds it. + * ``public_keys`` — full-key pairs, for edges the graph deliberately keeps + at a *shorter* prefix (see ``MeshGraph.add_edge``: a 1-byte edge with no + public key is not promoted, so several nodes keep sharing it) while + still filling in the public keys discovery supplied. Truncating our + keys down to 2 chars instead would be wrong — it would relabel every + other node sharing that byte. + + ``days`` windows the evidence the same way the caller windows its edges. + ``neighbor_links`` is never pruned, so without it a link last seen years + ago would keep labelling a recent path-derived edge a current neighbor. + """ + try: + edges = self._derive_neighbor_evidence_graph(days=days)[0] + except Exception as exc: + # A pre-migration-22 database simply has no neighbor evidence. + self.logger.debug(f"Neighbor evidence keys unavailable: {exc}") + return NeighborEvidenceKeys(set(), set()) + + # Both directions are already emitted per link, so no reversing here. + prefixes = { + (edge['from_prefix'], edge['to_prefix']) + for edge in edges + if edge['from_prefix'] and edge['to_prefix'] + } + public_keys = { + (edge['from_public_key'], edge['to_public_key']) + for edge in edges + if edge['from_public_key'] and edge['to_public_key'] + } + return NeighborEvidenceKeys(prefixes, public_keys) + + def _compute_neighbor_evidence_edges(self) -> list[dict[str, Any]]: + """Derive mesh edges from confirmed zero-hop neighbor discovery. + + This is the strongest evidence class in the database: each row is a + direct RF reception between two *full* public keys with a measured SNR, + recorded by modules/neighbors_discovery.py. Two differences from the + multi-byte path derivation are worth noting: + + * ``from_public_key``/``to_public_key`` are populated. Path-derived edges + cannot fill these in, because a path carries prefixes only. + * ``snr``/``best_snr`` are real measurements. Unlike the dashboard's + one-hop panel, which withholds SNR unless two sources agree because + ``complete_contact_tracking.hop_count`` over-claims zero-hop, a + discover response *is* the authoritative first-party measurement. + + Both directions are emitted per link: a discover response proves we + transmitted, they received, they transmitted, and we received. + """ + chars = self.NEIGHBOR_PREFIX_HEX_CHARS + query = ''' + SELECT + self_public_key, + neighbor_public_key, + observation_count, + snr_sum, + snr_count, + best_snr, + last_snr, + first_seen, + last_seen + FROM neighbor_links + ''' + try: + with self._with_db_connection() as conn: + rows = conn.execute(query).fetchall() + except Exception as exc: + self.logger.debug(f"Neighbor evidence edges unavailable: {exc}") + return [] + + edges: list[dict[str, Any]] = [] + for row in rows: + self_key = (row['self_public_key'] or '').lower() + neighbor_key = (row['neighbor_public_key'] or '').lower() + if not self_key or not neighbor_key: + continue + snr_count = row['snr_count'] or 0 + mean_snr = (row['snr_sum'] / snr_count) if snr_count else None + for from_key, to_key in ((self_key, neighbor_key), (neighbor_key, self_key)): + edges.append({ + 'from_prefix': from_key[:chars], + 'to_prefix': to_key[:chars], + 'from_public_key': from_key, + 'to_public_key': to_key, + 'observation_count': row['observation_count'] or 1, + 'first_seen': row['first_seen'], + 'last_seen': row['last_seen'], + # A direct link is by definition the first hop of any path + # that crosses it. + 'avg_hop_position': 1.0, + 'geographic_distance': None, + 'snr': mean_snr, + 'best_snr': row['best_snr'], + 'last_snr': row['last_snr'], + 'evidence': 'neighbors', + }) + + edges.sort(key=lambda e: e['last_seen'] or '', reverse=True) + return edges + + def _derive_neighbor_evidence_graph( + self, + days: int | None = None, + min_observations: int | None = None, + ) -> tuple[list[dict[str, Any]], int]: + """Filtered neighbor-evidence edges plus their prefix resolution. + + Reuses the multi-byte view filter: it only touches ``last_seen`` and + ``observation_count`` (handling both naive and aware timestamps), which + is exactly the filtering these edges need. + """ + all_edges = self._compute_neighbor_evidence_edges() + filtered = self._filter_multibyte_evidence_edges( + all_edges, days=days, min_observations=min_observations + ) + return filtered, self.NEIGHBOR_PREFIX_HEX_CHARS + def _resolve_path(self, path_input: str) -> dict[str, Any]: """Resolve a hex path to repeater names/locations for the mesh map. @@ -2739,6 +2884,10 @@ class BotDataViewer: evidence=multibyte derives edges purely from unique multi-byte path observations (observed_paths, bytes_per_hop >= 2), bypassing the mesh_connections merge heuristics that single-byte evidence feeds into. + + evidence=neighbors derives edges purely from confirmed zero-hop + discovery (neighbor_links) — full public keys on both ends plus a + measured SNR, the strongest evidence class available. """ conn = None try: @@ -2762,6 +2911,23 @@ class BotDataViewer: 'evidence': 'multibyte', }) + if evidence == 'neighbors': + edges, prefix_hex_chars = self._derive_neighbor_evidence_graph( + days=days, + min_observations=min_observations, + ) + return jsonify({ + 'edges': edges, + 'prefix_hex_chars': prefix_hex_chars, + 'evidence': 'neighbors', + }) + + # Combined view: mesh_connections cannot record *why* an edge + # exists, so re-derive the strongest label from neighbor_links. + # Same window as the edges themselves, so stale evidence cannot + # claim a recent edge is a current direct neighbor. + neighbor_keys = self._neighbor_evidence_edge_keys(days=days) + conn = self._get_db_connection() cursor = conn.cursor() @@ -2831,9 +2997,23 @@ class BotDataViewer: # by a multi-byte path observation; 2-char keys carry only ambiguous # single-byte evidence. is_multibyte = bool(fp) and bool(tp) and len(fp) >= 4 and len(tp) >= 4 + from_lower = fp.lower() if fp else '' + to_lower = tp.lower() if tp else '' + from_key = (row['from_public_key'] or '').lower() + to_key = (row['to_public_key'] or '').lower() + if ( + (from_lower, to_lower) in neighbor_keys.prefixes + or (from_key and to_key + and (from_key, to_key) in neighbor_keys.public_keys) + ): + edge_evidence = 'neighbors' + elif is_multibyte: + edge_evidence = 'multibyte' + else: + edge_evidence = 'singlebyte' edges.append({ - 'from_prefix': fp.lower() if fp else '', - 'to_prefix': tp.lower() if tp else '', + 'from_prefix': from_lower, + 'to_prefix': to_lower, 'from_public_key': row['from_public_key'], 'to_public_key': row['to_public_key'], 'observation_count': row['observation_count'], @@ -2841,7 +3021,7 @@ class BotDataViewer: 'last_seen': row['last_seen'], 'avg_hop_position': row['avg_hop_position'], 'geographic_distance': row['geographic_distance'], - 'evidence': 'multibyte' if is_multibyte else 'singlebyte' + 'evidence': edge_evidence }) return jsonify({'edges': edges, 'prefix_hex_chars': prefix_hex_chars or 2}) diff --git a/modules/web_viewer/templates/mesh.html b/modules/web_viewer/templates/mesh.html index 0f94489..6057287 100644 --- a/modules/web_viewer/templates/mesh.html +++ b/modules/web_viewer/templates/mesh.html @@ -243,9 +243,10 @@
- +
@@ -294,6 +295,10 @@ Bidirectional

+
+ + Confirmed neighbour (zero-hop) +
Multi-byte evidence @@ -741,7 +746,21 @@ return edge.evidence === 'singlebyte'; } + // A zero-hop discovery response is a direct RF measurement between two full + // public keys — the strongest evidence class, so it gets a heavier line. + function isNeighborEdge(edge) { + return edge.evidence === 'neighbors'; + } + + function formatSnr(value) { + return (typeof value === 'number' && isFinite(value)) ? `${value.toFixed(1)} dB` : null; + } + function getEvidenceLabel(edge) { + if (edge.evidence === 'neighbors') { + const snr = formatSnr(edge.snr); + return snr ? `Confirmed neighbour (avg SNR ${snr})` : 'Confirmed neighbour (zero-hop)'; + } if (edge.evidence === 'multibyte') { return edge.path_count ? `Multi-byte (${edge.path_count} unique path${edge.path_count === 1 ? '' : 's'})` @@ -931,14 +950,18 @@ // --- Initial map framing: home mesh component --- // Nodes occasionally carry wrong GPS, and fitting the view to every node can // stretch the initial frame across continents. Instead, frame the component - // reachable from the bot's location over multi-byte (provenance-trusted) - // evidence edges only; wrong-GPS phantoms are rarely connected to it. + // reachable from the bot's location over provenance-trusted evidence edges + // only; wrong-GPS phantoms are rarely connected to it. let botLocation = null; // {latitude, longitude} from /api/mesh/stats function getHomeComponentBounds() { if (!botLocation) return null; - // Both evidence modes tag edges, so this works even in "All Evidence" - const mbEdges = filteredEdges.filter(e => e.evidence === 'multibyte'); + // Every evidence mode tags its edges, so this works in "All Evidence" too. + // Confirmed neighbours count as trusted (they are measured, not inferred), + // and are the only tagged edges present in "Neighbours Only" mode. + const mbEdges = filteredEdges.filter( + e => e.evidence === 'multibyte' || e.evidence === 'neighbors' + ); if (!mbEdges.length) return null; const adj = new Map(); // nodeId -> Set of neighbor nodeIds @@ -1636,7 +1659,7 @@ const edgeDays = document.getElementById('filter-edge-days').value; const nodeDays = document.getElementById('filter-node-days').value; const edgeParams = new URLSearchParams(); - if (evidence === 'multibyte') edgeParams.set('evidence', 'multibyte'); + if (evidence === 'multibyte' || evidence === 'neighbors') edgeParams.set('evidence', evidence); if (edgeDays) edgeParams.set('days', edgeDays); if (forceRefresh) edgeParams.set('refresh', '1'); const nodeParams = new URLSearchParams(); @@ -1763,7 +1786,7 @@ if (filters.starredOnly !== undefined) { document.getElementById('filter-starred').checked = filters.starredOnly; } - if (filters.evidence === 'all' || filters.evidence === 'multibyte') { + if (['all', 'multibyte', 'neighbors'].includes(filters.evidence)) { document.getElementById('filter-evidence').value = filters.evidence; } if (filters.colorMode === 'age' || filters.colorMode === 'heat') { @@ -2135,6 +2158,10 @@ Evidence: ${evidenceLabel} ` : ''} + ${isNeighborEdge(edge) && formatSnr(edge.best_snr) ? ` + Best SNR: + ${formatSnr(edge.best_snr)}${formatSnr(edge.last_snr) ? ` (last ${formatSnr(edge.last_snr)})` : ''} + ` : ''} Distance: ${edgeDistance ? edgeDistance.toFixed(2) + ' km' : 'N/A'} @@ -3151,7 +3178,12 @@ // Shared edge width: log-scaled, capped so heavy edges don't blanket dense areas function getEdgeWeight(edge) { - return Math.max(1, Math.min(5, Math.log10(Math.max(1, edge.observation_count || 1)) * 1.5)); + const weight = Math.max(1, Math.min(5, Math.log10(Math.max(1, edge.observation_count || 1)) * 1.5)); + // Confirmed zero-hop links are the strongest evidence class, but they + // accumulate observations slowly (one per cycle, cycles are >=12h apart). + // Without a floor an inferred edge seen thousands of times would always + // outweigh a directly measured one, which inverts the confidence order. + return isNeighborEdge(edge) ? Math.max(weight, 3) : weight; } function getMapEdgeWeight(edge) { diff --git a/pyproject.toml b/pyproject.toml index ecd3d9d..a2b102d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,8 @@ dependencies = [ "maidenhead>=1.4.0", "pytz>=2023.3", "aiohttp>=3.8.0", - "meshcore>=2.3.6", + # 2.3.8+ required for zero-hop neighbour discovery (see requirements.txt) + "meshcore>=2.3.8", "openmeteo-requests>=1.7.2", "requests-cache>=1.1.1", "retry-requests>=1.0.0", diff --git a/requirements.txt b/requirements.txt index c3245f6..9978f18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,9 @@ geopy>=2.3.0 maidenhead>=1.4.0 pytz>=2023.3 aiohttp>=3.8.0 -meshcore>=2.3.6 +# 2.3.8+ is required, not merely preferred: send_node_discover_req / req_regions_sync +# (zero-hop neighbour discovery) and the bounded, serialised BLE write both landed there. +meshcore>=2.3.8 openmeteo-requests>=1.7.2 requests-cache>=1.1.1 retry-requests>=1.0.0 diff --git a/tests/commands/test_neighbors_command.py b/tests/commands/test_neighbors_command.py new file mode 100644 index 0000000..4c17d47 --- /dev/null +++ b/tests/commands/test_neighbors_command.py @@ -0,0 +1,384 @@ +"""Tests for the neighbors command (modules/commands/neighbors_command.py).""" + +from __future__ import annotations + +import asyncio +import time +import types + +import pytest + +from modules.commands.neighbors_command import NeighborsCommand +from modules.neighbors_discovery import MIN_CYCLE_GAP_SECONDS +from modules.service_plugins.packet_capture_service import PacketCaptureService +from tests.conftest import mock_message + + +def make_service(*, neighbors_enabled=True, summary=None, hang=False, + raises=None, cycle_budget=5.0, last_publish=0.0, + last_attempt=0.0, cycle_active=False): + """A stand-in for PacketCaptureService's neighbors surface.""" + service = types.SimpleNamespace() + service.neighbors_enabled = neighbors_enabled + service.neighbors_config = types.SimpleNamespace( + discover_window=60.0, cycle_budget=cycle_budget + ) + # 0.0 == "no cycle has ever run", so nothing is on cooldown. + service.last_neighbors_publish = last_publish + service.last_neighbors_attempt = last_attempt + service.neighbors_cycle_active = cycle_active + # The node-wide cooldown is the service's rule; mirror its arithmetic here so + # the command is tested against the same contract the real service offers. + service.neighbors_cooldown_remaining = lambda: PacketCaptureService.\ + neighbors_cooldown_remaining(service) + service.calls = 0 + + def claim_neighbors_cycle(): + if service.neighbors_cycle_active: + return "a discovery cycle is already running" + remaining = service.neighbors_cooldown_remaining() + if remaining > 0: + return f"another cycle may run in {remaining:.0f}s" + service.neighbors_cycle_active = True + return None + + def release_neighbors_cycle(): + service.neighbors_cycle_active = False + + async def run_cycle(*, already_claimed=False): + if not already_claimed: + reason = claim_neighbors_cycle() + if reason is not None: + return { + "ok": False, "reason": reason, "discovered": 0, "queried": 0, + "best_snr": None, "attempted": 0, "succeeded": 0, "recorded": 0, + } + service.calls += 1 + try: + if hang: + await asyncio.sleep(30) + if raises is not None: + raise raises + return summary or {"ok": True, "queried": 0, "recorded": 0, "attempted": 0} + finally: + release_neighbors_cycle() + + service.claim_neighbors_cycle = claim_neighbors_cycle + service.release_neighbors_cycle = release_neighbors_cycle + service.run_neighbors_cycle = run_cycle + return service + + +def make_command(command_mock_bot, service, *, enabled=True): + """Build the command without __init__ (which reads config and the ACL).""" + command_mock_bot.packet_capture_service = service + command = object.__new__(NeighborsCommand) + command.bot = command_mock_bot + command.logger = command_mock_bot.logger + command.command_enabled = enabled + command._cycle_task = None + # Normally set up by BaseCommand.__init__, which make_command skips. + command._user_cooldowns = {} + command.cooldown_seconds = NeighborsCommand.cooldown_seconds + + sent: list[str] = [] + + async def send_response(message, content, **kwargs): + sent.append(content) + return True + + command.send_response = send_response + command.translate = lambda key, **kwargs: ( + key.split(".")[-1] + (" " + " ".join(f"{k}={v}" for k, v in sorted(kwargs.items())) + if kwargs else "") + ) + return command, sent + + +@pytest.fixture +def message(): + return mock_message(content="neighbors", is_dm=True, sender_id="TestUser") + + +def test_command_metadata(): + # A cycle spends airtime and takes a minute, so it is DM-only with a cooldown. + assert NeighborsCommand.requires_dm is True + assert NeighborsCommand.cooldown_seconds == 900 + assert "neighbours" in NeighborsCommand.keywords + + +async def test_reports_when_discovery_is_disabled(command_mock_bot, message): + command, sent = make_command(command_mock_bot, make_service(neighbors_enabled=False)) + assert await command.execute(message) is True + assert sent == ["disabled"] + + +async def test_reports_when_the_service_is_absent(command_mock_bot, message): + command, sent = make_command(command_mock_bot, None) + command_mock_bot.services = {} + assert await command.execute(message) is True + assert sent == ["disabled"] + + +async def test_disabled_refusal_does_not_burn_the_senders_own_cooldown( + command_mock_bot, message +): + """Enabling the feature a minute later must not still be blocked for 14 more.""" + command, sent = make_command( + command_mock_bot, make_service(neighbors_enabled=False) + ) + command.record_execution(message.sender_id) + await command.execute(message) + assert sent == ["disabled"] + + can_execute, remaining = command.check_cooldown(message.sender_id) + assert can_execute is False + assert remaining == pytest.approx(60, abs=5) + + +async def test_acknowledges_before_running_the_cycle(command_mock_bot, message): + """The listen window is ~60s, far too long to hold the reply open.""" + service = make_service(summary={"ok": True, "queried": 2, "best_snr": 8.0, + "recorded": 2, "attempted": 0}) + command, sent = make_command(command_mock_bot, service) + + assert await command.execute(message) is True + assert sent == ["started seconds=60"] + assert service.calls == 0 # not awaited inline + # Claimed before the ack so the scheduler cannot sneak in during send_response. + assert service.neighbors_cycle_active is True + + await command._cycle_task + assert len(sent) == 2 + assert sent[1].startswith("success") + assert service.neighbors_cycle_active is False + + +async def test_claim_before_ack_survives_a_scheduler_race(command_mock_bot, message): + """If another trigger starts during send_response, we already hold the lock.""" + service = make_service(summary={"ok": True, "queried": 0, "recorded": 0, "attempted": 0}) + command, sent = make_command(command_mock_bot, service) + + raced = {} + + async def send_and_race(message, content, **kwargs): + sent.append(content) + if content.startswith("started"): + # Stands in for the scheduler waking during the ack await. + raced["summary"] = await service.run_neighbors_cycle() + return True + + command.send_response = send_and_race + await command.execute(message) + await command._cycle_task + + assert sent[0].startswith("started") + assert raced["summary"]["ok"] is False + assert "already running" in raced["summary"]["reason"] + assert service.calls == 1 + + +async def test_summary_reports_count_snr_and_records(command_mock_bot, message): + service = make_service(summary={"ok": True, "queried": 3, "best_snr": 8.25, + "recorded": 3, "attempted": 0}) + command, sent = make_command(command_mock_bot, service) + await command.execute(message) + await command._cycle_task + assert "count=3" in sent[1] + assert "recorded=3" in sent[1] + assert "best_snr=8.2dB" in sent[1] + + +async def test_summary_mentions_brokers_only_when_one_was_tried(command_mock_bot, message): + """A operator with no MQTT should not be shown a confusing 0/0.""" + without = make_service(summary={"ok": True, "queried": 1, "best_snr": 1.0, + "recorded": 1, "attempted": 0}) + command, sent = make_command(command_mock_bot, without) + await command.execute(message) + await command._cycle_task + assert "published" not in sent[1] + + with_broker = make_service(summary={"ok": True, "queried": 1, "best_snr": 1.0, + "recorded": 1, "attempted": 2, "succeeded": 1}) + command2, sent2 = make_command(command_mock_bot, with_broker) + await command2.execute(message) + await command2._cycle_task + assert "published" in sent2[1] + assert "succeeded=1" in sent2[1] + assert "attempted=2" in sent2[1] + + +async def test_reports_when_nothing_answered(command_mock_bot, message): + service = make_service(summary={"ok": True, "queried": 0, "recorded": 0, "attempted": 0}) + command, sent = make_command(command_mock_bot, service) + await command.execute(message) + await command._cycle_task + assert sent[1] == "none" + + +async def test_reports_the_reason_a_cycle_did_not_run(command_mock_bot, message): + service = make_service(summary={"ok": False, "reason": "radio not connected"}) + command, sent = make_command(command_mock_bot, service) + await command.execute(message) + await command._cycle_task + assert sent[1] == "failed reason=radio not connected" + + +async def test_missing_snr_does_not_break_the_summary(command_mock_bot, message): + service = make_service(summary={"ok": True, "queried": 1, "best_snr": None, + "recorded": 1, "attempted": 0}) + command, sent = make_command(command_mock_bot, service) + await command.execute(message) + await command._cycle_task + assert "best_snr=n/a" in sent[1] + + +async def test_cycle_errors_are_reported(command_mock_bot, message): + service = make_service(raises=RuntimeError("radio exploded")) + command, sent = make_command(command_mock_bot, service) + await command.execute(message) + await command._cycle_task + assert sent[1].startswith("error") + assert "radio exploded" in sent[1] + + +async def test_a_stalled_cycle_is_abandoned(command_mock_bot, message): + service = make_service(hang=True, cycle_budget=0.05) + command, sent = make_command(command_mock_bot, service) + await command.execute(message) + await command._cycle_task + assert sent[1].startswith("error") + assert "timed out" in sent[1] + + +async def test_a_second_request_will_not_overlap_the_first(command_mock_bot, message): + """Two concurrent discover rounds would collect into each other's window.""" + service = make_service(hang=True, cycle_budget=5.0) + command, sent = make_command(command_mock_bot, service) + + await command.execute(message) + await command.execute(message) + assert sent == ["started seconds=60", "busy"] + + command._cycle_task.cancel() + with pytest.raises(asyncio.CancelledError): + await command._cycle_task + + +async def test_busy_refusal_does_not_burn_the_senders_own_cooldown( + command_mock_bot, message +): + """A mid-cycle 'busy' clears with the discover window, not after 15 minutes.""" + service = make_service(cycle_active=True) + command, sent = make_command(command_mock_bot, service) + + command.record_execution(message.sender_id) + await command.execute(message) + assert sent == ["busy"] + + can_execute, remaining = command.check_cooldown(message.sender_id) + assert can_execute is False + assert remaining == pytest.approx(60, abs=5) + + +async def test_a_finished_cycle_does_not_block_the_next_request(command_mock_bot, message): + service = make_service(summary={"ok": True, "queried": 0, "recorded": 0, "attempted": 0}) + command, sent = make_command(command_mock_bot, service) + + await command.execute(message) + await command._cycle_task + await command.execute(message) + await command._cycle_task + + assert service.calls == 2 + assert "busy" not in sent + + +async def test_a_cycle_the_service_is_running_reads_as_busy(command_mock_bot, message): + """The scheduler's cycle is invisible to this command's own task guard.""" + service = make_service(cycle_active=True) + command, sent = make_command(command_mock_bot, service) + + assert await command.execute(message) is True + assert sent == ["busy"] + assert service.calls == 0 + + +async def test_the_cooldown_applies_across_senders(command_mock_bot): + """The base class rations per user; airtime has to be rationed per node. + + Otherwise N users can take turns and keep the radio discovering nonstop. + """ + service = make_service(last_publish=time.time() - 60) + command, sent = make_command(command_mock_bot, service) + + for sender in ("UserOne", "UserTwo"): + result = await command.execute( + mock_message(content="neighbors", is_dm=True, sender_id=sender) + ) + assert result is True + + assert sent == ["cooldown_active minutes=14"] * 2 + assert service.calls == 0 + assert command._cycle_task is None + + +async def test_a_failed_cycle_still_starts_the_cooldown(command_mock_bot): + """The discover broadcast may have gone out even when the cycle failed, so a + second sender must not be able to transmit another round immediately.""" + service = make_service(last_publish=0.0, last_attempt=time.time() - 60) + command, sent = make_command(command_mock_bot, service) + + result = await command.execute( + mock_message(content="neighbors", is_dm=True, sender_id="SomeoneElse") + ) + assert result is True + assert sent == ["cooldown_active minutes=14"] + assert service.calls == 0 + + +async def test_a_refusal_does_not_burn_the_senders_own_cooldown(command_mock_bot, message): + """The command manager records the execution before calling execute(). + + So a request refused for the shared cooldown has already spent this sender's + 15 minutes. Telling them to wait one minute and then refusing for fourteen + more — on their personal cooldown this time — would make the reply a lie. + """ + service = make_service(last_attempt=time.time() - 840) # 60s left + command, sent = make_command(command_mock_bot, service) + + command.record_execution(message.sender_id) # what the manager does first + await command.execute(message) + assert sent == ["cooldown_active minutes=1"] + + can_execute, remaining = command.check_cooldown(message.sender_id) + assert can_execute is False + # Expires with the shared cooldown, not 15 minutes from the refusal. + assert remaining == pytest.approx(60, abs=5) + + +async def test_a_refusal_still_blocks_an_immediate_retry(command_mock_bot, message): + """Rewound, not cleared: a refusal reply is airtime too.""" + service = make_service(last_attempt=time.time() - 60) + command, _ = make_command(command_mock_bot, service) + + command.record_execution(message.sender_id) + await command.execute(message) + + assert command.check_cooldown(message.sender_id)[0] is False + + +async def test_the_cooldown_expires(command_mock_bot, message): + service = make_service(last_publish=time.time() - (MIN_CYCLE_GAP_SECONDS + 1), + summary={"ok": True, "queried": 0, "recorded": 0, "attempted": 0}) + command, sent = make_command(command_mock_bot, service) + + await command.execute(message) + assert sent[0] == "started seconds=60" + await command._cycle_task + assert service.calls == 1 + + +def test_disabled_command_cannot_execute(command_mock_bot, message): + command, _ = make_command(command_mock_bot, make_service(), enabled=False) + assert command.can_execute(message) is False diff --git a/tests/test_dashboard_stats.py b/tests/test_dashboard_stats.py index 5b5af61..bc084ca 100644 --- a/tests/test_dashboard_stats.py +++ b/tests/test_dashboard_stats.py @@ -428,12 +428,25 @@ class TestRollupCorrectness: """Means cannot be re-aggregated across a window; sums and counts can.""" seed_database(viewer.db_path) _refresh(viewer) - row = _rollup(viewer, local_date_str()) - assert row["snr_count"] == MESSAGES - assert row["rssi_count"] == MESSAGES + today = local_date_str() + row = _rollup(viewer, today) + # Seed timestamps are `now - i`, so near midnight some rows land on + # yesterday. Compare against the same day window the rollup uses. + start, end = day_bounds(today) with sqlite3.connect(viewer.db_path) as conn: - expected = conn.execute("SELECT SUM(snr) FROM message_stats").fetchone()[0] - assert row["snr_sum"] == pytest.approx(expected) + expected = conn.execute( + """ + SELECT SUM(snr), + SUM(CASE WHEN snr IS NOT NULL THEN 1 ELSE 0 END), + SUM(CASE WHEN rssi IS NOT NULL THEN 1 ELSE 0 END) + FROM message_stats + WHERE timestamp >= ? AND timestamp < ? + """, + (start, end), + ).fetchone() + assert row["snr_count"] == expected[1] + assert row["rssi_count"] == expected[2] + assert row["snr_sum"] == pytest.approx(expected[0]) def test_missing_stats_tables_degrade_to_null(self, viewer): """`collect_stats = false` leaves the tables absent entirely.""" diff --git a/tests/test_db_migrations.py b/tests/test_db_migrations.py index 5d34fb9..0a23aad 100644 --- a/tests/test_db_migrations.py +++ b/tests/test_db_migrations.py @@ -392,3 +392,79 @@ class TestSchema: cursor = conn.cursor() assert _column_exists(cursor, "purging_log", "details") is True + + +# --------------------------------------------------------------------------- +# TestNeighborTables (migration 22) +# --------------------------------------------------------------------------- + + +class TestNeighborTables: + """Zero-hop neighbor discovery tables (see modules/neighbors_discovery.py).""" + + def test_neighbor_tables_created(self, runner, conn): + runner.run() + for table in ["neighbor_links", "neighbor_observations"]: + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", + (table,), + ) + assert cur.fetchone() is not None + + def test_neighbor_links_keeps_full_public_keys(self, runner, conn): + """Full 32-byte keys, not prefixes — that is the point of this evidence.""" + runner.run() + cursor = conn.cursor() + for column in ["self_public_key", "neighbor_public_key", "best_snr", + "last_snr", "snr_sum", "snr_count", "observation_count", + "first_seen", "last_seen", "last_status", "scopes"]: + assert _column_exists(cursor, "neighbor_links", column) is True + + def test_neighbor_observations_columns(self, runner, conn): + runner.run() + cursor = conn.cursor() + for column in ["observed_at", "self_public_key", "neighbor_public_key", + "snr", "heard_secs_ago", "scopes", "status"]: + assert _column_exists(cursor, "neighbor_observations", column) is True + + def test_neighbor_indexes_are_table_qualified(self, runner, conn): + """SQLite index names are database-global (see migration 20).""" + runner.run() + for idx, table in [ + ("idx_neighbor_links_last_seen", "neighbor_links"), + ("idx_neighbor_links_neighbor", "neighbor_links"), + ("idx_neighbor_observations_observed_at", "neighbor_observations"), + ("idx_neighbor_observations_neighbor", "neighbor_observations"), + ]: + row = conn.execute( + "SELECT tbl_name FROM sqlite_master WHERE type='index' AND name=?", + (idx,), + ).fetchone() + assert row is not None, f"missing index {idx}" + assert row[0] == table + + def test_neighbor_links_is_unique_per_directed_pair(self, runner, conn): + runner.run() + conn.execute( + "INSERT INTO neighbor_links (self_public_key, neighbor_public_key) VALUES ('a', 'b')" + ) + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO neighbor_links (self_public_key, neighbor_public_key) VALUES ('a', 'b')" + ) + + def test_migration_is_idempotent(self, conn, logger): + MigrationRunner(conn, logger).run() + MigrationRunner(conn, logger).run() + applied = conn.execute( + "SELECT COUNT(*) FROM schema_version WHERE version = 22" + ).fetchone()[0] + assert applied == 1 + + def test_neighbor_tables_are_writable_by_the_db_manager(self, runner, conn): + """DBManager.ALLOWED_TABLES gates create/drop/retention helpers.""" + from modules.db_manager import DBManager + + runner.run() + assert "neighbor_links" in DBManager.ALLOWED_TABLES + assert "neighbor_observations" in DBManager.ALLOWED_TABLES diff --git a/tests/test_db_retention.py b/tests/test_db_retention.py index a01e31b..071cf04 100644 --- a/tests/test_db_retention.py +++ b/tests/test_db_retention.py @@ -135,3 +135,141 @@ def test_retention_settings_are_configurable_and_bounded(): config.set("Data_Retention", "retention_delete_pause_seconds", "9") assert retention_delete_settings(config) == (10_000, 5.0) + + +# --------------------------------------------------------------------------- +# Neighbor observation retention (modules/maintenance.py) +# --------------------------------------------------------------------------- + + +def _utc_now(): + """Clock callable MaintenanceRunner takes for injection.""" + import datetime + + return datetime.datetime.now(datetime.timezone.utc) + + +def _maintenance_with_db(tmp_path, config, db_path=None): + """A MaintenanceRunner bound to a real migrated database.""" + import logging + import sqlite3 + from contextlib import contextmanager + + from modules.db_migrations import MigrationRunner + from modules.maintenance import MaintenanceRunner + + logger = logging.getLogger("test-neighbor-retention") + path = db_path or (tmp_path / "retention_neighbors.db") + with closing(sqlite3.connect(path)) as conn: + MigrationRunner(conn, logger).run() + + class DBManager: + @contextmanager + def connection(self): + with closing(sqlite3.connect(path)) as conn: + conn.row_factory = sqlite3.Row + yield conn + + def delete_timestamp_rows_in_chunks(self, table, column, cutoff, **kwargs): + return delete_timestamp_rows_in_chunks( + self.connection, table, column, cutoff, **kwargs + ) + + bot = Mock() + bot.config = config + bot.logger = logger + bot.db_manager = DBManager() + return MaintenanceRunner(bot, _utc_now), bot.db_manager, path + + +def _seed_observations(path, stamps): + import sqlite3 + + with closing(sqlite3.connect(path)) as conn: + conn.executemany( + """ + INSERT INTO neighbor_observations + (observed_at, self_public_key, neighbor_public_key, snr, + heard_secs_ago, scopes, status) + VALUES (?, 'ff', 'aa', 1.0, 0, '', 'responded') + """, + [(s,) for s in stamps], + ) + conn.commit() + + +def _observation_count(path): + import sqlite3 + + with closing(sqlite3.connect(path)) as conn: + return conn.execute("SELECT COUNT(*) FROM neighbor_observations").fetchone()[0] + + +def test_neighbor_observation_retention_deletes_only_old_rows(tmp_path): + import datetime + + maintenance, _, path = _maintenance_with_db(tmp_path, ConfigParser()) + now = datetime.datetime.now(datetime.timezone.utc) + recent = (now - datetime.timedelta(days=5)).isoformat() + ancient = (now - datetime.timedelta(days=500)).isoformat() + _seed_observations(path, [recent, ancient, ancient]) + + maintenance._cleanup_neighbor_observations(365) + assert _observation_count(path) == 1 + + +def test_neighbor_observation_retention_disabled_when_not_positive(tmp_path): + import datetime + + maintenance, _, path = _maintenance_with_db(tmp_path, ConfigParser()) + ancient = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=5000) + ).isoformat() + _seed_observations(path, [ancient]) + + maintenance._cleanup_neighbor_observations(0) + assert _observation_count(path) == 1 + + +def test_neighbor_observation_retention_survives_a_pre_migration_database(tmp_path): + """A database without migration 22 must not abort the whole retention run.""" + import logging + import sqlite3 + from contextlib import contextmanager + + from modules.maintenance import MaintenanceRunner + + path = tmp_path / "old.db" + sqlite3.connect(path).close() + + class DBManager: + @contextmanager + def connection(self): + with closing(sqlite3.connect(path)) as conn: + yield conn + + def delete_timestamp_rows_in_chunks(self, table, column, cutoff, **kwargs): + return delete_timestamp_rows_in_chunks( + self.connection, table, column, cutoff, **kwargs + ) + + bot = Mock() + bot.config = ConfigParser() + bot.logger = logging.getLogger("test-neighbor-retention") + bot.db_manager = DBManager() + + # Must not raise. + MaintenanceRunner(bot, _utc_now)._cleanup_neighbor_observations(365) + + +def test_neighbor_observation_retention_tolerates_a_missing_db_manager(tmp_path): + import logging + + from modules.maintenance import MaintenanceRunner + + bot = Mock() + bot.config = ConfigParser() + bot.logger = logging.getLogger("test-neighbor-retention") + bot.db_manager = None + + MaintenanceRunner(bot, _utc_now)._cleanup_neighbor_observations(365) diff --git a/tests/test_web_viewer_app.py b/tests/test_web_viewer_app.py index d297937..48fa7f6 100644 --- a/tests/test_web_viewer_app.py +++ b/tests/test_web_viewer_app.py @@ -403,6 +403,21 @@ def _seed_observed_path(db_path, path_hex, bytes_per_hop, observation_count=1, conn.commit() +def _insert_neighbor_link(conn, self_key, neighbor_key, *, last_seen, + observation_count=1): + """Insert a confirmed zero-hop link, the strongest evidence class.""" + conn.execute( + """ + INSERT INTO neighbor_links + (self_public_key, neighbor_public_key, first_seen, last_seen, + observation_count, snr_sum, snr_count, best_snr, last_snr, + last_status, scopes) + VALUES (?, ?, ?, ?, ?, 5.0, 1, 5.0, 5.0, 'responded', '') + """, + (self_key, neighbor_key, last_seen, last_seen, observation_count), + ) + + class TestApiMeshEdgesEvidence: def test_nodes_days_filter_is_applied_in_database(self, viewer_with_db): recent = time.strftime('%Y-%m-%dT%H:%M:%S') @@ -613,6 +628,67 @@ class TestApiMeshEdgesEvidence: for edge in data['edges'] } == {('cccc55', 'dddd66')} + def test_neighbor_label_matches_on_full_public_keys(self, viewer_with_db): + """MeshGraph keeps some 1-byte edges but fills in the keys discovery gave it. + + add_edge refuses to promote a 1-byte edge with no public key (several + nodes still share it), so a 3-byte prefix comparison alone would leave a + confirmed neighbour labelled 'singlebyte'. + """ + self_key, neighbor_key = 'aa' * 32, 'bb' * 32 + recent = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(viewer_with_db.db_path, timeout=60) as conn: + conn.execute( + """ + INSERT INTO mesh_connections + (from_prefix, to_prefix, from_public_key, to_public_key, + observation_count, last_seen) + VALUES ('aa', 'bb', ?, ?, 5, ?) + """, + (self_key, neighbor_key, recent), + ) + _insert_neighbor_link(conn, self_key, neighbor_key, last_seen=recent) + conn.commit() + + with viewer_with_db.app.test_client() as client: + response = client.get('/api/mesh/edges') + edges = {(e['from_prefix'], e['to_prefix']): e + for e in json.loads(response.data)['edges']} + assert edges[('aa', 'bb')]['evidence'] == 'neighbors' + + def test_stale_neighbor_evidence_does_not_relabel_a_recent_edge( + self, viewer_with_db + ): + """neighbor_links is never pruned, so a link last heard years ago must not + claim a recent path-derived edge is a current direct neighbour.""" + self_key, neighbor_key = 'aa' * 32, 'bb' * 32 + recent = datetime.now(timezone.utc).isoformat() + with sqlite3.connect(viewer_with_db.db_path, timeout=60) as conn: + conn.execute( + """ + INSERT INTO mesh_connections + (from_prefix, to_prefix, observation_count, last_seen) + VALUES (?, ?, 5, ?) + """, + (self_key[:6], neighbor_key[:6], recent), + ) + _insert_neighbor_link(conn, self_key, neighbor_key, + last_seen='2020-01-01T00:00:00+00:00') + conn.commit() + + key = (self_key[:6], neighbor_key[:6]) + with viewer_with_db.app.test_client() as client: + windowed = json.loads( + client.get('/api/mesh/edges?days=7').data + )['edges'] + assert {(e['from_prefix'], e['to_prefix']): e + for e in windowed}[key]['evidence'] == 'multibyte' + + # Unwindowed, the lifetime evidence still stands. + lifetime = json.loads(client.get('/api/mesh/edges').data)['edges'] + assert {(e['from_prefix'], e['to_prefix']): e + for e in lifetime}[key]['evidence'] == 'neighbors' + def test_stats_include_multibyte_edge_count(self, viewer_with_db): with sqlite3.connect(viewer_with_db.db_path, timeout=60) as conn: conn.execute(""" diff --git a/tests/unit/test_neighbor_evidence_edges.py b/tests/unit/test_neighbor_evidence_edges.py new file mode 100644 index 0000000..85ef274 --- /dev/null +++ b/tests/unit/test_neighbor_evidence_edges.py @@ -0,0 +1,222 @@ +"""Mesh-graph edges derived from confirmed zero-hop neighbor discovery. + +Covers BotDataViewer._compute_neighbor_evidence_edges and friends, which back +GET /api/mesh/edges?evidence=neighbors. +""" + +from __future__ import annotations + +import contextlib +import logging +import sqlite3 + +import pytest + +from modules.db_migrations import MigrationRunner +from modules.web_viewer.app import BotDataViewer + +LOGGER = logging.getLogger("test-neighbor-edges") + +SELF_KEY = "ff" * 32 +KEY_A = "aa" * 32 +KEY_B = "bb" * 32 + +RECENT = "2026-08-01T00:00:00+00:00" +ANCIENT = "2020-01-01T00:00:00+00:00" + + +class ViewerStub: + """Just the db plumbing the derivation methods need. + + Binding the real functions keeps this a test of production code rather than a + reimplementation; BotDataViewer.__init__ would want a Flask app and a bot. + """ + + logger = LOGGER + NEIGHBOR_PREFIX_HEX_CHARS = BotDataViewer.NEIGHBOR_PREFIX_HEX_CHARS + _compute_neighbor_evidence_edges = BotDataViewer._compute_neighbor_evidence_edges + _derive_neighbor_evidence_graph = BotDataViewer._derive_neighbor_evidence_graph + _neighbor_evidence_edge_keys = BotDataViewer._neighbor_evidence_edge_keys + _filter_multibyte_evidence_edges = staticmethod( + BotDataViewer._filter_multibyte_evidence_edges + ) + + def __init__(self, path): + self._path = path + + @contextlib.contextmanager + def _with_db_connection(self): + conn = sqlite3.connect(self._path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + +def insert_link(conn, self_key, neighbor_key, *, observations=1, snr_sum=5.0, + snr_count=1, best_snr=5.0, last_snr=5.0, first_seen=RECENT, + last_seen=RECENT): + conn.execute( + """ + INSERT INTO neighbor_links + (self_public_key, neighbor_public_key, first_seen, last_seen, + observation_count, snr_sum, snr_count, best_snr, last_snr, + last_status, scopes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'responded', '') + """, + (self_key, neighbor_key, first_seen, last_seen, observations, + snr_sum, snr_count, best_snr, last_snr), + ) + + +@pytest.fixture +def viewer(tmp_path): + path = tmp_path / "viewer.db" + conn = sqlite3.connect(path) + MigrationRunner(conn, LOGGER).run() + conn.close() + return ViewerStub(path) + + +@pytest.fixture +def seeded(viewer): + with viewer._with_db_connection() as conn: + insert_link(conn, SELF_KEY, KEY_A, observations=4, snr_sum=30.0, + snr_count=4, best_snr=9.5, last_snr=7.0, + first_seen="2026-01-01T00:00:00+00:00", last_seen=RECENT) + insert_link(conn, SELF_KEY, KEY_B, observations=1, snr_sum=-3.0, + snr_count=1, best_snr=-3.0, last_snr=-3.0, + last_seen=ANCIENT) + conn.commit() + return viewer + + +def find_edge(edges, from_key, to_key): + chars = ViewerStub.NEIGHBOR_PREFIX_HEX_CHARS + matches = [ + e for e in edges + if e["from_prefix"] == from_key[:chars] and e["to_prefix"] == to_key[:chars] + ] + assert len(matches) == 1 + return matches[0] + + +def test_each_link_yields_both_directions(seeded): + """A discover response proves both directions of the link.""" + edges = seeded._compute_neighbor_evidence_edges() + assert len(edges) == 4 + find_edge(edges, SELF_KEY, KEY_A) + find_edge(edges, KEY_A, SELF_KEY) + + +def test_edges_carry_full_public_keys(seeded): + """The multi-byte path derivation cannot supply these — paths have no keys.""" + edge = find_edge(seeded._compute_neighbor_evidence_edges(), SELF_KEY, KEY_A) + assert edge["from_public_key"] == SELF_KEY + assert edge["to_public_key"] == KEY_A + + +def test_edges_report_measured_snr(seeded): + edge = find_edge(seeded._compute_neighbor_evidence_edges(), SELF_KEY, KEY_A) + assert edge["snr"] == pytest.approx(30.0 / 4) + assert edge["best_snr"] == 9.5 + assert edge["last_snr"] == 7.0 + + +def test_edges_are_tagged_and_treated_as_first_hop(seeded): + edge = find_edge(seeded._compute_neighbor_evidence_edges(), SELF_KEY, KEY_A) + assert edge["evidence"] == "neighbors" + # A direct link is the first hop of any path crossing it. + assert edge["avg_hop_position"] == 1.0 + + +def test_edges_preserve_lifetime_counts_and_timestamps(seeded): + edge = find_edge(seeded._compute_neighbor_evidence_edges(), SELF_KEY, KEY_A) + assert edge["observation_count"] == 4 + assert edge["first_seen"] == "2026-01-01T00:00:00+00:00" + assert edge["last_seen"] == RECENT + + +def test_missing_snr_samples_yield_null_mean(viewer): + with viewer._with_db_connection() as conn: + insert_link(conn, SELF_KEY, KEY_A, snr_count=0, snr_sum=0.0, best_snr=None) + conn.commit() + edge = find_edge(viewer._compute_neighbor_evidence_edges(), SELF_KEY, KEY_A) + assert edge["snr"] is None + + +def test_days_filter_drops_stale_links(seeded): + edges, prefix_hex_chars = seeded._derive_neighbor_evidence_graph(days=30) + assert prefix_hex_chars == ViewerStub.NEIGHBOR_PREFIX_HEX_CHARS + # Only the recent link survives, in both directions. + assert len(edges) == 2 + assert {e["to_prefix"] for e in edges} | {e["from_prefix"] for e in edges} == { + SELF_KEY[:6], KEY_A[:6] + } + + +def test_min_observations_filter(seeded): + edges, _ = seeded._derive_neighbor_evidence_graph(min_observations=3) + assert len(edges) == 2 + assert all(e["observation_count"] >= 3 for e in edges) + + +def test_unfiltered_returns_everything(seeded): + edges, _ = seeded._derive_neighbor_evidence_graph() + assert len(edges) == 4 + + +def test_edge_keys_cover_both_directions(seeded): + """Used to relabel mesh_connections edges, which lost their provenance.""" + keys = seeded._neighbor_evidence_edge_keys() + assert (SELF_KEY[:6], KEY_A[:6]) in keys.prefixes + assert (KEY_A[:6], SELF_KEY[:6]) in keys.prefixes + assert (SELF_KEY[:6], KEY_B[:6]) in keys.prefixes + assert ("11", "22") not in keys.prefixes + + +def test_edge_keys_include_full_public_key_pairs(seeded): + """The graph keeps some edges at a 1-byte prefix but fills in the keys. + + MeshGraph.add_edge deliberately does not promote a 1-byte edge that has no + public key, so matching on the 3-byte prefix alone would leave a confirmed + neighbor labelled 'singlebyte'. + """ + keys = seeded._neighbor_evidence_edge_keys() + assert (SELF_KEY, KEY_A) in keys.public_keys + assert (KEY_A, SELF_KEY) in keys.public_keys + + +def test_edge_keys_honour_the_days_window(seeded): + """neighbor_links is never pruned, so stale evidence must not label edges.""" + keys = seeded._neighbor_evidence_edge_keys(days=30) + assert (SELF_KEY[:6], KEY_A[:6]) in keys.prefixes + # KEY_B was last heard in 2020. + assert (SELF_KEY[:6], KEY_B[:6]) not in keys.prefixes + assert (SELF_KEY, KEY_B) not in keys.public_keys + + +def test_blank_keys_are_skipped(viewer): + with viewer._with_db_connection() as conn: + insert_link(conn, "", KEY_A) + conn.commit() + assert viewer._compute_neighbor_evidence_edges() == [] + assert viewer._neighbor_evidence_edge_keys() == ((set(), set())) + + +def test_empty_database_yields_nothing(viewer): + assert viewer._compute_neighbor_evidence_edges() == [] + assert viewer._neighbor_evidence_edge_keys() == ((set(), set())) + + +def test_pre_migration_database_degrades_quietly(tmp_path): + """A database without migration 22 simply has no neighbor evidence.""" + path = tmp_path / "old.db" + sqlite3.connect(path).close() + stub = ViewerStub(path) + assert stub._compute_neighbor_evidence_edges() == [] + assert stub._neighbor_evidence_edge_keys() == ((set(), set())) + edges, chars = stub._derive_neighbor_evidence_graph(days=7) + assert edges == [] + assert chars == ViewerStub.NEIGHBOR_PREFIX_HEX_CHARS diff --git a/tests/unit/test_neighbors_discovery.py b/tests/unit/test_neighbors_discovery.py new file mode 100644 index 0000000..9b78e07 --- /dev/null +++ b/tests/unit/test_neighbors_discovery.py @@ -0,0 +1,811 @@ +"""Tests for zero-hop neighbor discovery (modules/neighbors_discovery.py). + +Ported from the meshcore-packet-capture project's test_neighbors.py, minus the +device-lock tests (the bot serialises radio commands globally in +modules/core.py _SerializedCommands, so the module takes no lock of its own). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import sqlite3 +import types + +import pytest +from meshcore import EventType + +from modules import neighbors_discovery as nb +from modules.db_migrations import MigrationRunner +from modules.enums import AdvertFlags + +LOGGER = logging.getLogger("test-neighbors") + +SELF_KEY = "ff" * 32 +KEY_A = "aa" * 32 +KEY_B = "bb" * 32 + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + +class FakeEvent: + def __init__(self, event_type, payload=None): + self.type = event_type + self.payload = payload or {} + + +class FakeRadio: + """Delivers DISCOVER_RESPONSE events synchronously from the send call.""" + + def __init__(self, responses=None, *, send_result="ok", scopes="DEN,APRS", + flood_scope="SEA", reset_result="ok"): + self.responses = responses or [] + self.send_result = send_result + self.scopes = scopes + self.reset_result = reset_result + self.handler = None + self.subscribed = 0 + self.unsubscribed = 0 + self.sent_tag = None + self.regions_calls = [] + self.reset_path_calls = [] + self.self_info = {"public_key": SELF_KEY} + # A real contact cache; stage 2 must never populate it. + self.contacts = {} + self.commands = types.SimpleNamespace( + send_node_discover_req=self._send, + req_regions_sync=self._regions, + get_default_flood_scope=self._flood, + reset_path=self._reset_path, + ) + self._flood_scope = flood_scope + + def add_contact(self, pubkey, *, out_path_len=-1): + """Seed the contact cache the way the bot's contact management would.""" + self.contacts[pubkey] = {"public_key": pubkey, "out_path_len": out_path_len, + "out_path": ""} + + def get_contact_by_key_prefix(self, prefix): + """Mirrors MeshCore.get_contact_by_key_prefix (prefix match on the key).""" + for contact in self.contacts.values(): + if contact.get("public_key", "").lower().startswith(prefix.lower()): + return contact + return None + + async def _reset_path(self, pubkey): + self.reset_path_calls.append(pubkey) + if self.reset_result == "raise": + raise RuntimeError("serial write failed") + if self.reset_result == "error": + # How the library reports a device rejection, and its own response + # timeout: an ERROR event, not an exception. + return FakeEvent(EventType.ERROR, {"reason": "timeout"}) + return FakeEvent(EventType.OK, {}) + + def subscribe(self, event_type, callback): + assert event_type == EventType.DISCOVER_RESPONSE + self.subscribed += 1 + self.handler = callback + return "subscription" + + def unsubscribe(self, subscription): + self.unsubscribed += 1 + + async def _send(self, filter_bits, prefix_only=True, tag=None): + self.sent_filter = filter_bits + self.sent_prefix_only = prefix_only + self.sent_tag = tag + if self.send_result == "error": + return FakeEvent(EventType.ERROR, {"reason": "unsupported"}) + if self.send_result == "none": + return None + if self.send_result == "hang": + await asyncio.sleep(30) + little_endian = tag.to_bytes(4, "little").hex() + for response in self.responses: + payload = dict(response) + payload.setdefault("tag", little_endian) + await self.handler(FakeEvent(EventType.DISCOVER_RESPONSE, payload)) + return FakeEvent(EventType.MSG_SENT, {}) + + async def _regions(self, pubkey, timeout=0, min_timeout=0): + self.regions_calls.append(pubkey) + if self.scopes == "raise": + raise RuntimeError("device rejected request") + if self.scopes == "hang": + await asyncio.sleep(120) + return self.scopes + + async def _flood(self): + return FakeEvent(EventType.SELF_INFO, {"scope_name": self._flood_scope}) + + +def response(pubkey, snr=5.0, node_type=None, **extra): + payload = { + "pubkey": pubkey, + "SNR": snr, + "node_type": AdvertFlags.ADV_TYPE_REPEATER.value if node_type is None else node_type, + } + payload.update(extra) + return payload + + +def entry(pubkey_char, snr, heard_at, **kwargs): + return nb.NeighborEntry(pubkey=pubkey_char * 64, snr=snr, heard_at=heard_at, **kwargs) + + +@pytest.fixture +def fast_cfg(): + """Config with the smallest window the module permits (floored at 5s). + + Tests that must not wait use monkeypatched sleep instead. + """ + return nb.NeighborsConfig(discover_window=nb.MIN_DISCOVER_WINDOW) + + +@pytest.fixture +def no_sleep(monkeypatch): + """Make asyncio.sleep inside the module a no-op so windows are instant.""" + async def instant(_seconds): + return None + monkeypatch.setattr(nb.asyncio, "sleep", instant) + + +# --------------------------------------------------------------------------- +# Interval clamping (firmware band: 12-336h, default 24h) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "given,expected", + [(11, 12), (12, 12), (24, 24), (48, 48), (336, 336), (400, 336), (0, 24), (-5, 24)], +) +def test_clamp_interval_hours(given, expected): + assert nb.clamp_interval_hours(given) == expected + + +def test_interval_seconds_uses_clamped_value(): + assert nb.NeighborsConfig(interval_hours=1).interval_seconds == 12 * 3600 + assert nb.NeighborsConfig(interval_hours=9999).interval_seconds == 336 * 3600 + + +def test_config_floors_values_that_would_never_produce_data(): + cfg = nb.NeighborsConfig( + discover_window=0, max_neighbors=0, cycle_timeout=1, scope_gap=-3, command_timeout=0 + ) + assert cfg.discover_window == nb.MIN_DISCOVER_WINDOW + assert cfg.max_neighbors == 1 + assert cfg.cycle_timeout == nb.MIN_CYCLE_TIMEOUT + assert cfg.scope_gap == 0.0 + # wait_for(timeout=0) raises immediately, which would break every cycle. + assert cfg.command_timeout == nb.MIN_COMMAND_TIMEOUT + + +def test_scope_request_budget_exceeds_library_msg_sent_wait(): + cfg = nb.NeighborsConfig(scope_min_timeout=8.0, command_timeout=20.0) + assert cfg.scope_request_budget > nb.LIBRARY_MSG_SENT_TIMEOUT + + +def test_cycle_budget_is_smaller_when_scopes_are_disabled(): + off = nb.NeighborsConfig(collect_scopes=False) + on = nb.NeighborsConfig(collect_scopes=True) + assert off.cycle_budget < on.cycle_budget + # Must still cover the listen window plus the discover request. + assert off.cycle_budget >= off.discover_window + off.command_timeout + + +def test_discover_filter_targets_repeaters(): + # A bitmask over advert types, so the bit index is the type value. + assert 1 << AdvertFlags.ADV_TYPE_REPEATER.value == nb.DISCOVER_FILTER_REPEATER + assert nb.DISCOVER_FILTER_REPEATER == 0x04 + + +# --------------------------------------------------------------------------- +# Ordering: most recently heard, then stronger SNR, then pubkey +# --------------------------------------------------------------------------- + +def test_sort_prefers_most_recently_heard(): + older = entry("a", 20.0, 100.0) + newer = entry("b", -5.0, 200.0) + assert nb.sort_entries([older, newer], now=200.0) == [newer, older] + + +def test_sort_breaks_recency_tie_by_snr(): + weak = entry("a", 1.0, 100.0) + strong = entry("b", 9.0, 100.0) + assert nb.sort_entries([weak, strong], now=100.0) == [strong, weak] + + +def test_sort_breaks_full_tie_by_pubkey_ascending(): + first = entry("1", 5.0, 100.0) + second = entry("2", 5.0, 100.0) + assert nb.sort_entries([second, first], now=100.0) == [first, second] + + +def test_sort_compares_whole_seconds_so_snr_can_break_ties(): + """Recency is compared as the *published* heard_secs_ago. + + Sorting on the raw float clock would quantise differently from the field we + publish, so SNR could never break a tie and the output could be non-monotonic + in heard_secs_ago. That order decides which entries survive the size budget. + """ + slightly_newer_weak = entry("a", 1.0, 100.4) + slightly_older_strong = entry("b", 9.0, 100.0) + ordered = nb.sort_entries([slightly_newer_weak, slightly_older_strong], now=100.9) + assert [e.heard_secs_ago(100.9) for e in ordered] == [0, 0] + assert ordered[0] is slightly_older_strong + + +# --------------------------------------------------------------------------- +# Payload shape and tail-drop +# --------------------------------------------------------------------------- + +def test_message_matches_firmware_contract(): + item = nb.NeighborEntry(pubkey=KEY_A, snr=9.75, heard_at=100.0, + scopes="DEN,APRS", status=nb.STATUS_RESPONDED) + message, dropped = nb.build_neighbors_message( + "MeshCore-HOWL", "A1B2", "DEN", [item], timestamp="TS", now=142.0 + ) + assert dropped == 0 + # Key order is part of the contract (MQTTPayloadBuilder.cpp). + assert list(message.keys()) == [ + "timestamp", "origin", "origin_id", "total_neighbors", + "queried_neighbors", "truncated", "self", "neighbors", + ] + assert message["self"] == {"scopes": "DEN"} + assert message["neighbors"] == [{ + "pubkey": KEY_A.upper(), + "snr": 9.75, + "heard_secs_ago": 42, + "scopes": "DEN,APRS", + "status": "responded", + }] + + +def test_message_reports_truncation_from_the_max_cap(): + items = [entry("a", 1.0, 100.0)] + message, dropped = nb.build_neighbors_message( + "o", "i", "", items, timestamp="TS", now=100.0, total_neighbors=9 + ) + assert dropped == 0 + assert message["total_neighbors"] == 9 + assert message["queried_neighbors"] == 1 + assert message["truncated"] is True + + +def test_message_drops_the_tail_past_the_size_budget(): + items = [nb.NeighborEntry(pubkey=f"{i:064x}", snr=float(i), heard_at=100.0) + for i in range(300)] + message, dropped = nb.build_neighbors_message( + "o", "i", "", items, timestamp="TS", now=100.0 + ) + assert dropped > 0 + assert len(message["neighbors"]) == len(items) - dropped + assert len(json.dumps(message)) < nb.NEIGHBORS_JSON_BUDGET + assert message["truncated"] is True + + +def test_message_publishes_pubkeys_uppercase(): + message, _ = nb.build_neighbors_message( + "o", "i", "", [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)], + timestamp="TS", now=0.0, + ) + assert message["neighbors"][0]["pubkey"] == KEY_A.upper() + + +# --------------------------------------------------------------------------- +# Stage 1: discovery +# --------------------------------------------------------------------------- + +async def test_discover_collects_and_sorts_responses(fast_cfg, no_sleep): + radio = FakeRadio([response(KEY_A, 3.0), response(KEY_B, 9.0)]) + entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + assert [e.pubkey for e in entries] == [KEY_B, KEY_A] + assert radio.sent_filter == nb.DISCOVER_FILTER_REPEATER + # Full 32-byte pubkeys are required, so prefix_only must be off. + assert radio.sent_prefix_only is False + + +async def test_discover_dedupes_keeping_strongest_snr(fast_cfg, no_sleep): + radio = FakeRadio([response(KEY_A, 3.0), response(KEY_A, 9.0), response(KEY_A, 5.0)]) + entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + assert len(entries) == 1 + assert entries[0].snr == 9.0 + + +async def test_discover_accepts_uppercase_pubkeys_as_lowercase(fast_cfg, no_sleep): + radio = FakeRadio([response(KEY_A.upper(), 1.0)]) + entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + assert entries[0].pubkey == KEY_A + + +@pytest.mark.parametrize("bad", [ + pytest.param({"pubkey": "aa" * 10}, id="short-pubkey"), + pytest.param({"node_type": AdvertFlags.ADV_TYPE_CHAT.value}, id="not-a-repeater"), + pytest.param({"tag": "deadbeef"}, id="wrong-tag"), +]) +async def test_discover_rejects_unwanted_responses(fast_cfg, no_sleep, bad): + payload = response(KEY_A, 5.0) + payload.update(bad) + radio = FakeRadio([payload]) + entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + assert entries == [] + + +async def test_discover_filters_out_self(fast_cfg, no_sleep): + radio = FakeRadio([response(SELF_KEY, 9.0), response(KEY_A, 1.0)]) + entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + assert [e.pubkey for e in entries] == [KEY_A] + + +async def test_discover_tag_is_known_before_subscribing(fast_cfg, no_sleep): + """The tag must exist before the handler goes live. + + Otherwise a response arriving between subscribe and send-completion would be + accepted with no tag check, letting a stale or foreign round leak in. + """ + radio = FakeRadio([response(KEY_A, 1.0)]) + await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + assert radio.sent_tag is not None + assert 1 <= radio.sent_tag <= 0xFFFFFFFF + + +@pytest.mark.parametrize("result", ["error", "none"]) +async def test_discover_returns_none_when_send_fails(fast_cfg, no_sleep, result): + radio = FakeRadio([], send_result=result) + assert await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) is None + + +async def test_discover_bounds_a_stalled_send(): + """A stalled BLE/serial write must not hang the cycle.""" + cfg = nb.NeighborsConfig(command_timeout=nb.MIN_COMMAND_TIMEOUT) + radio = FakeRadio([], send_result="hang") + assert await nb.discover_neighbors(radio, cfg, SELF_KEY, LOGGER) is None + + +async def test_discover_abandons_cycle_when_session_was_reset(fast_cfg, no_sleep): + """A reconnect tears down subscriptions, so the collector goes deaf. + + Without this check the cycle would record "0 neighbours" as though the mesh + were empty. + """ + radio = FakeRadio([response(KEY_A, 1.0)]) + result = await nb.discover_neighbors( + radio, fast_cfg, SELF_KEY, LOGGER, still_valid=lambda: False + ) + assert result is None + + +async def test_discover_always_unsubscribes(fast_cfg, no_sleep): + radio = FakeRadio([response(KEY_A, 1.0)]) + await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + assert radio.subscribed == 1 + assert radio.unsubscribed == 1 + + failing = FakeRadio([], send_result="error") + await nb.discover_neighbors(failing, fast_cfg, SELF_KEY, LOGGER) + assert failing.unsubscribed == 1 + + +async def test_discover_ignores_stragglers_after_the_window(fast_cfg, no_sleep): + """Callbacks are spawned as tasks, so one can land after unsubscribe returns.""" + radio = FakeRadio([response(KEY_A, 1.0)]) + entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + await radio.handler(FakeEvent(EventType.DISCOVER_RESPONSE, response(KEY_B, 9.0))) + assert [e.pubkey for e in entries] == [KEY_A] + + +async def test_discover_survives_an_unsubscribe_error(fast_cfg, no_sleep): + radio = FakeRadio([response(KEY_A, 1.0)]) + radio.unsubscribe = lambda s: (_ for _ in ()).throw(RuntimeError("gone")) + entries = await nb.discover_neighbors(radio, fast_cfg, SELF_KEY, LOGGER) + assert [e.pubkey for e in entries] == [KEY_A] + + +# --------------------------------------------------------------------------- +# Stage 2: scopes +# --------------------------------------------------------------------------- + +async def test_collect_scopes_disabled_marks_entries_responded(): + """Stage 2 off must not report a live neighbour as unreachable.""" + cfg = nb.NeighborsConfig(collect_scopes=False) + radio = FakeRadio() + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert entries[0].status == nb.STATUS_RESPONDED + assert entries[0].scopes == "" + # And it must not spend any airtime. + assert radio.regions_calls == [] + + +async def test_collect_scopes_populates_scopes_when_enabled(): + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes="DEN,APRS") + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert entries[0].scopes == "DEN,APRS" + assert entries[0].status == nb.STATUS_RESPONDED + assert radio.regions_calls == [KEY_A] + + +async def test_collect_scopes_reports_timeout_when_response_is_none(): + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes=None) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert entries[0].status == nb.STATUS_TIMEOUT + + +async def test_collect_scopes_reports_send_failed_on_exception(): + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes="raise") + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert entries[0].status == nb.STATUS_SEND_FAILED + + +async def test_collect_scopes_bounds_a_stalled_request(monkeypatch): + """A hung request must be cut off, not left holding the radio.""" + # scope_request_budget is floored by the library's 15s MSG_SENT wait, so patch + # that constant instead of waiting it out. + monkeypatch.setattr(nb, "LIBRARY_MSG_SENT_TIMEOUT", 0.01) + cfg = nb.NeighborsConfig( + collect_scopes=True, scope_gap=0, scope_min_timeout=0.01, + command_timeout=nb.MIN_COMMAND_TIMEOUT, + ) + cfg.scope_timeout = 0.01 + radio = FakeRadio(scopes="hang") + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert entries[0].status == nb.STATUS_SEND_FAILED + + +async def test_collect_scopes_stops_at_the_cycle_budget(monkeypatch): + """Unreached neighbours keep the timeout status, matching the firmware.""" + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0, cycle_timeout=nb.MIN_CYCLE_TIMEOUT) + radio = FakeRadio(scopes="DEN") + entries = [nb.NeighborEntry(pubkey=k, snr=1.0, heard_at=0.0) for k in (KEY_A, KEY_B)] + + clock = {"t": 0.0} + monkeypatch.setattr(nb.time, "time", lambda: clock["t"]) + real_regions = radio._regions + + async def advance_then_answer(pubkey, timeout=0, min_timeout=0): + clock["t"] += 1000.0 # blow the budget after the first request + return await real_regions(pubkey, timeout, min_timeout) + + radio.commands.req_regions_sync = advance_then_answer + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert entries[0].status == nb.STATUS_RESPONDED + assert entries[1].status == nb.STATUS_TIMEOUT + assert len(radio.regions_calls) == 1 + + +async def test_collect_scopes_reraises_cancellation(): + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio() + + async def cancelled(pubkey, timeout=0, min_timeout=0): + raise asyncio.CancelledError + + radio.commands.req_regions_sync = cancelled + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + with pytest.raises(asyncio.CancelledError): + await nb.collect_scopes(radio, entries, cfg, LOGGER) + + +@pytest.fixture +def tiny_budget(monkeypatch): + """A scope_request_budget small enough to cut off a hung request at once. + + scope_request_budget is floored by the library's 15s MSG_SENT wait, so the + constant has to be patched rather than waited out. + """ + monkeypatch.setattr(nb, "LIBRARY_MSG_SENT_TIMEOUT", 0.01) + cfg = nb.NeighborsConfig( + collect_scopes=True, scope_gap=0, scope_min_timeout=0.01, + command_timeout=nb.MIN_COMMAND_TIMEOUT, + ) + cfg.scope_timeout = 0.01 + return cfg + + +async def test_timed_out_request_restores_a_pinned_contact(tiny_budget): + """send_anon_req pins a path-less contact to zero-hop and restores it after + the send, with no try/finally — so our own timeout has to repair it.""" + radio = FakeRadio(scopes="hang") + radio.add_contact(KEY_A, out_path_len=-1) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, tiny_budget, LOGGER) + assert entries[0].status == nb.STATUS_SEND_FAILED + assert radio.reset_path_calls == [KEY_A] + + +async def test_failed_request_restores_a_pinned_contact(): + """A raising request leaves the same half-applied device state.""" + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes="raise") + radio.add_contact(KEY_A, out_path_len=-1) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert radio.reset_path_calls == [KEY_A] + + +async def test_contact_with_a_real_path_is_never_reset(tiny_budget): + """The library only rewrites path-less contacts, so resetting one that has a + path would throw away routing we did not touch.""" + radio = FakeRadio(scopes="hang") + radio.add_contact(KEY_A, out_path_len=2) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, tiny_budget, LOGGER) + assert radio.reset_path_calls == [] + + +async def test_unknown_contact_needs_no_repair(tiny_budget): + """An unknown pubkey is asked to reply zero-hop; no contact is mutated.""" + radio = FakeRadio(scopes="hang") + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, tiny_budget, LOGGER) + assert radio.reset_path_calls == [] + + +async def test_a_collapsed_send_error_restores_a_pinned_contact(): + """req_regions_sync returns None for a send_anon_req error too. + + One of those errors is change_contact_path failing *after* the device applied + the zero-hop path (a lost acknowledgement), which returns before the library's + own reset_path — so None cannot be assumed to mean "the path was restored". + """ + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes=None) + radio.add_contact(KEY_A, out_path_len=-1) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert entries[0].status == nb.STATUS_TIMEOUT + assert radio.reset_path_calls == [KEY_A] + + +async def test_no_response_needs_no_repair_without_a_known_contact(): + """Nothing was mutated, so nothing may be sent to the device.""" + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes=None) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert radio.reset_path_calls == [] + + +@pytest.mark.parametrize("reset_result,expected", [ + ("error", "rejected the flood-path restore"), + ("raise", "could not restore the flood path"), +]) +async def test_a_failed_restore_is_reported_as_a_failure(caplog, reset_result, expected): + """reset_path reports a rejection as an ERROR event, not an exception. + + Logging success there would hide a contact left pinned to zero-hop. + """ + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes=None, reset_result=reset_result) + radio.add_contact(KEY_A, out_path_len=-1) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + + with caplog.at_level(logging.WARNING, logger=LOGGER.name): + await nb.collect_scopes(radio, entries, cfg, LOGGER) + + assert radio.reset_path_calls == [KEY_A] + assert expected in caplog.text + assert "restored flood path" not in caplog.text + + +async def test_a_confirmed_restore_is_reported_as_success(caplog): + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes=None) + radio.add_contact(KEY_A, out_path_len=-1) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + + with caplog.at_level(logging.INFO, logger=LOGGER.name): + await nb.collect_scopes(radio, entries, cfg, LOGGER) + + assert "restored flood path" in caplog.text + + +async def test_successful_request_leaves_the_restore_to_the_library(): + """The library's own reset_path runs on the happy path; ours must not double up.""" + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes="DEN") + radio.add_contact(KEY_A, out_path_len=-1) + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert radio.reset_path_calls == [] + + +async def test_cancellation_schedules_the_repair_and_still_propagates(): + """The cycle budget cancels mid-request; the repair must outlive us.""" + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio() + radio.add_contact(KEY_A, out_path_len=-1) + + async def cancelled(pubkey, timeout=0, min_timeout=0): + raise asyncio.CancelledError + + radio.commands.req_regions_sync = cancelled + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + with pytest.raises(asyncio.CancelledError): + await nb.collect_scopes(radio, entries, cfg, LOGGER) + # Scheduled as an independent task, so it has not run yet. + assert radio.reset_path_calls == [] + await asyncio.sleep(0) + assert radio.reset_path_calls == [KEY_A] + + +async def test_collect_scopes_never_populates_the_contact_cache(): + """Zero-hop probing depends on the neighbour not being a known contact. + + Nothing in this module may add one; the bot's own contact management is a + separate concern and is why scope collection defaults off. + """ + cfg = nb.NeighborsConfig(collect_scopes=True, scope_gap=0) + radio = FakeRadio(scopes="DEN") + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)] + await nb.collect_scopes(radio, entries, cfg, LOGGER) + assert radio.contacts == {} + + +async def test_collect_scopes_handles_no_entries(): + await nb.collect_scopes(FakeRadio(), [], nb.NeighborsConfig(collect_scopes=True), LOGGER) + + +# --------------------------------------------------------------------------- +# Self scopes +# --------------------------------------------------------------------------- + +async def test_self_scopes_override_wins_even_when_stage_two_is_off(): + cfg = nb.NeighborsConfig(collect_scopes=False, self_scopes="OVERRIDE") + assert await nb.fetch_self_scopes(FakeRadio(), cfg, LOGGER) == "OVERRIDE" + + +async def test_self_scopes_skipped_when_stage_two_is_off(): + """No scope is published for anyone, so spending a command on ours is wrong.""" + radio = FakeRadio(flood_scope="SEA") + cfg = nb.NeighborsConfig(collect_scopes=False) + assert await nb.fetch_self_scopes(radio, cfg, LOGGER) == "" + + +async def test_self_scopes_read_from_device_when_stage_two_is_on(): + radio = FakeRadio(flood_scope="SEA") + cfg = nb.NeighborsConfig(collect_scopes=True) + assert await nb.fetch_self_scopes(radio, cfg, LOGGER) == "SEA" + + +async def test_self_scopes_tolerates_a_build_without_the_command(): + radio = FakeRadio() + radio.commands = types.SimpleNamespace() + cfg = nb.NeighborsConfig(collect_scopes=True) + assert await nb.fetch_self_scopes(radio, cfg, LOGGER) == "" + + +async def test_self_scopes_tolerates_a_missing_device_handle(): + cfg = nb.NeighborsConfig(collect_scopes=True) + assert await nb.fetch_self_scopes(types.SimpleNamespace(commands=None), cfg, LOGGER) == "" + + +# --------------------------------------------------------------------------- +# Persistence +# --------------------------------------------------------------------------- + +@pytest.fixture +def db(tmp_path): + """A real file-based database at the current schema version. + + File-based, not :memory:, because each in-memory connection would be a + separate database (matching the project's test_db fixture). + """ + path = tmp_path / "neighbors.db" + conn = sqlite3.connect(path) + MigrationRunner(conn, LOGGER).run() + conn.close() + + class Manager: + @contextlib.contextmanager + def connection(self): + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + return Manager() + + +def _link_rows(db): + with db.connection() as conn: + return [dict(r) for r in conn.execute("SELECT * FROM neighbor_links")] + + +def test_record_neighbors_writes_history_and_aggregate(db): + entries = [nb.NeighborEntry(pubkey=KEY_A, snr=7.5, heard_at=0.0, + status=nb.STATUS_RESPONDED)] + assert nb.record_neighbors(db, SELF_KEY, entries, LOGGER, observed_at="T1") == 1 + + rows = _link_rows(db) + assert len(rows) == 1 + assert rows[0]["self_public_key"] == SELF_KEY + assert rows[0]["neighbor_public_key"] == KEY_A + assert rows[0]["observation_count"] == 1 + assert rows[0]["best_snr"] == 7.5 + + with db.connection() as conn: + history = [dict(r) for r in conn.execute("SELECT * FROM neighbor_observations")] + assert len(history) == 1 + assert history[0]["observed_at"] == "T1" + assert history[0]["status"] == nb.STATUS_RESPONDED + + +def test_record_neighbors_accumulates_snr_as_sum_and_count(db): + """Sums, not means, so any later window re-aggregates exactly.""" + for snr, stamp in ((5.0, "T1"), (9.0, "T2"), (3.0, "T3")): + nb.record_neighbors( + db, SELF_KEY, + [nb.NeighborEntry(pubkey=KEY_A, snr=snr, heard_at=0.0)], + LOGGER, observed_at=stamp, + ) + row = _link_rows(db)[0] + assert row["observation_count"] == 3 + assert row["snr_sum"] == pytest.approx(17.0) + assert row["snr_count"] == 3 + assert row["snr_sum"] / row["snr_count"] == pytest.approx(17.0 / 3) + assert row["best_snr"] == 9.0 + assert row["last_snr"] == 3.0 + assert row["first_seen"] == "T1" + assert row["last_seen"] == "T3" + + +def test_record_neighbors_keeps_previously_learned_scopes(db): + """A stage-2-disabled cycle must not erase scopes an earlier cycle learned.""" + nb.record_neighbors( + db, SELF_KEY, + [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0, scopes="DEN,APRS")], + LOGGER, observed_at="T1", + ) + nb.record_neighbors( + db, SELF_KEY, + [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0, scopes="")], + LOGGER, observed_at="T2", + ) + assert _link_rows(db)[0]["scopes"] == "DEN,APRS" + + +def test_record_neighbors_stores_keys_lowercase(db): + nb.record_neighbors( + db, SELF_KEY.upper(), + [nb.NeighborEntry(pubkey=KEY_A.upper(), snr=1.0, heard_at=0.0)], + LOGGER, + ) + row = _link_rows(db)[0] + assert row["self_public_key"] == SELF_KEY + assert row["neighbor_public_key"] == KEY_A + + +def test_record_neighbors_noops_without_entries_or_self_key(db): + assert nb.record_neighbors(db, SELF_KEY, [], LOGGER) == 0 + assert nb.record_neighbors( + db, "", [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)], LOGGER + ) == 0 + assert _link_rows(db) == [] + + +def test_record_neighbors_reports_zero_on_database_error(tmp_path): + class Broken: + @contextlib.contextmanager + def connection(self): + raise sqlite3.OperationalError("database is locked") + yield # pragma: no cover + + written = nb.record_neighbors( + Broken(), SELF_KEY, + [nb.NeighborEntry(pubkey=KEY_A, snr=1.0, heard_at=0.0)], LOGGER, + ) + assert written == 0 diff --git a/tests/unit/test_packet_capture_neighbors.py b/tests/unit/test_packet_capture_neighbors.py new file mode 100644 index 0000000..ca13d0d --- /dev/null +++ b/tests/unit/test_packet_capture_neighbors.py @@ -0,0 +1,919 @@ +"""Neighbors wiring inside PacketCaptureService: config, gating, publish, cycle.""" + +from __future__ import annotations + +import asyncio +import configparser +import contextlib +import json +import logging +import sqlite3 +import types +from unittest.mock import MagicMock + +import pytest +from meshcore import EventType + +from modules import neighbors_discovery as nb +from modules.db_migrations import MigrationRunner +from modules.service_plugins.packet_capture_service import ( + NEIGHBORS_ATTEMPT_STATE_KEY, + NEIGHBORS_RETRY_BACKOFF_SECONDS, + NEIGHBORS_STATE_KEY, + PacketCaptureService, +) + +LOGGER = logging.getLogger("test-pc-neighbors") + +SELF_KEY = "ff" * 32 +KEY_A = "aa" * 32 +KEY_B = "bb" * 32 + + +class FakeEvent: + def __init__(self, event_type, payload=None): + self.type = event_type + self.payload = payload or {} + + +class FakeRadio: + """Minimal radio that answers a discover request with canned responses.""" + + def __init__(self, responses=None, *, commands=("send_node_discover_req",)): + self.responses = responses or [] + self.handler = None + self.discover_requests = 0 + self.self_info = {"public_key": SELF_KEY} + self.contacts = {} + available = {} + if "send_node_discover_req" in commands: + available["send_node_discover_req"] = self._send + if "req_regions_sync" in commands: + available["req_regions_sync"] = self._regions + self.commands = types.SimpleNamespace(**available) + + def subscribe(self, event_type, callback): + self.handler = callback + return "sub" + + def unsubscribe(self, subscription): + return None + + async def _send(self, filter_bits, prefix_only=True, tag=None): + self.discover_requests += 1 + little_endian = tag.to_bytes(4, "little").hex() + for item in self.responses: + payload = dict(item) + payload.setdefault("tag", little_endian) + await self.handler(FakeEvent(EventType.DISCOVER_RESPONSE, payload)) + return FakeEvent(EventType.MSG_SENT, {}) + + async def _regions(self, pubkey, timeout=0, min_timeout=0): + return "DEN" + + +class FakeMqttClient: + def __init__(self, rc=0): + self.published = [] + self._rc = rc + + def publish(self, topic, payload, qos=0, retain=False): + self.published.append({"topic": topic, "payload": payload, + "qos": qos, "retain": retain}) + return types.SimpleNamespace(rc=self._rc) + + +def response(pubkey, snr=5.0, node_type=2): + return {"pubkey": pubkey, "SNR": snr, "node_type": node_type} + + +@pytest.fixture +def db_manager(tmp_path): + """Real migrated database plus in-memory bot_metadata.""" + path = tmp_path / "bot.db" + conn = sqlite3.connect(path) + MigrationRunner(conn, LOGGER).run() + conn.close() + + class Manager: + def __init__(self): + self.metadata = {} + + @contextlib.contextmanager + def connection(self): + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + + def get_metadata(self, key): + return self.metadata.get(key) + + def set_metadata(self, key, value): + self.metadata[key] = value + + return Manager() + + +def build_service(ini: str, *, db_manager=None, radio=None, connected=True): + """Construct the service without running __init__. + + __init__ installs logging handlers and opens files; the project's other + packet-capture unit tests use this same idiom. + """ + config = configparser.ConfigParser() + config.read_string(ini.strip()) + + bot = MagicMock() + bot.config = config + bot.logger = LOGGER + bot.connected = connected + bot.meshcore = radio + if db_manager is not None: + bot.db_manager = db_manager + else: + bot.db_manager.get_metadata.return_value = None + + service = object.__new__(PacketCaptureService) + service.bot = bot + service.logger = LOGGER + service._load_config() + # Runtime state __init__ sets up alongside _load_config(). + service.neighbors_task = None + service.neighbors_capability_state = None + service.neighbors_discover_failures = 0 + service.neighbors_topic_warned = set() + service.last_neighbors_publish = service._load_neighbors_state() + service.last_neighbors_attempt = service._load_neighbors_attempt_state() + service.neighbors_cycle_active = False + service.debug = False + # global_iata deliberately not overridden: it comes from the ini, so tests can + # exercise both a real IATA and the unset sentinel. + service.mqtt_clients = [] + service.background_tasks = [] + service.should_exit = False + service._get_bot_name = lambda: "TestBot" + return service + + +BASE_INI = """ +[PacketCapture] +enabled = true +neighbors_enabled = true +""" + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +def test_neighbors_defaults_to_disabled(): + service = build_service("[PacketCapture]\nenabled = true\n") + assert service.neighbors_enabled is False + # Scope collection is separately off, for radio-lock and contact-path reasons. + assert service.neighbors_config.collect_scopes is False + # Graph feeding is on: the data is only useful if something consumes it. + assert service.neighbors_feed_mesh_graph is True + + +def test_neighbors_config_reads_every_key(): + service = build_service(""" +[PacketCapture] +enabled = true +neighbors_enabled = true +neighbors_interval_hours = 48 +neighbors_discover_window = 30 +neighbors_command_timeout = 11 +neighbors_collect_scopes = true +neighbors_scope_timeout = 4 +neighbors_scope_min_timeout = 6 +neighbors_scope_gap = 1.5 +neighbors_cycle_timeout = 300 +neighbors_max = 4 +neighbors_self_scopes = DEN,APRS +neighbors_feed_mesh_graph = false +""") + cfg = service.neighbors_config + assert (cfg.interval_hours, cfg.discover_window, cfg.command_timeout) == (48, 30.0, 11.0) + assert cfg.collect_scopes is True + assert (cfg.scope_timeout, cfg.scope_min_timeout, cfg.scope_gap) == (4.0, 6.0, 1.5) + assert (cfg.cycle_timeout, cfg.max_neighbors) == (300.0, 4) + assert cfg.self_scopes == "DEN,APRS" + assert service.neighbors_feed_mesh_graph is False + + +def test_out_of_range_interval_is_clamped_with_a_warning(caplog): + with caplog.at_level(logging.WARNING, logger=LOGGER.name): + service = build_service(BASE_INI + "neighbors_interval_hours = 3\n") + assert service.neighbors_config.interval_hours == nb.MIN_INTERVAL_HOURS + assert "outside the supported" in caplog.text + + +def test_in_range_interval_does_not_warn(caplog): + with caplog.at_level(logging.WARNING, logger=LOGGER.name): + service = build_service(BASE_INI + "neighbors_interval_hours = 24\n") + assert service.neighbors_config.interval_hours == 24 + assert "outside the supported" not in caplog.text + + +def test_disabled_feature_does_not_warn_about_the_interval(caplog): + """A stale value in a disabled section is not worth a startup warning.""" + with caplog.at_level(logging.WARNING, logger=LOGGER.name): + build_service("[PacketCapture]\nenabled = true\nneighbors_interval_hours = 1\n") + assert "outside the supported" not in caplog.text + + +# --------------------------------------------------------------------------- +# Per-broker opt-in and topic resolution +# --------------------------------------------------------------------------- + +BROKER_INI = BASE_INI + """ +iata = SEA +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt2_enabled = true +mqtt2_server = two.example.com +mqtt2_neighbors = false +mqtt3_enabled = true +mqtt3_server = three.example.com +mqtt3_topic_neighbors = custom/{IATA}/{PUBLIC_KEY}/nbrs +""" + + +def test_broker_neighbors_flag_defaults_on(): + """neighbors_enabled is the single switch; brokers opt out, not in.""" + service = build_service(BROKER_INI) + flags = {b["broker_num"]: b["neighbors"] for b in service.mqtt_brokers} + assert flags == {1: True, 2: False, 3: True} + + +def test_neighbors_topic_derives_from_the_broker_prefix(): + service = build_service(BROKER_INI) + broker = service.mqtt_brokers[0] + # Mirrors how topic_packets falls back to /packet. + assert service._resolve_neighbors_topic(broker) == "meshcore/packets/neighbors" + + +def test_neighbors_topic_follows_a_templated_packets_topic(): + """Brokers publish by default, so the derived topic has to be *right*. + + A broker configured with meshcore/{IATA}/{PUBLIC_KEY}/packets should get the + matching neighbors topic, not an unrelated flat one. + """ + service = build_service(BASE_INI + """ +iata = SEA +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt1_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets +""", radio=FakeRadio()) + broker = service.mqtt_brokers[0] + assert service._neighbors_topic_template(broker) == "meshcore/{IATA}/{PUBLIC_KEY}/neighbors" + assert service._resolve_neighbors_topic(broker) == ( + f"meshcore/SEA/{SELF_KEY.upper()}/neighbors" + ) + + +def test_explicit_topic_wins_over_the_packets_topic(): + service = build_service(BASE_INI + """ +iata = SEA +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt1_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets +mqtt1_topic_neighbors = my/own/topic +""", radio=FakeRadio()) + assert service._resolve_neighbors_topic(service.mqtt_brokers[0]) == "my/own/topic" + + +def test_location_routed_topic_is_refused_without_an_iata(): + """meshcore/XYZ/... would pollute a shared namespace on a community broker.""" + service = build_service(BASE_INI + """ +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt1_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets +""", radio=FakeRadio()) + assert service.global_iata == "xyz" + assert service._iata_is_unset() is True + assert service._resolve_neighbors_topic(service.mqtt_brokers[0]) is None + + +def test_location_routed_topic_is_refused_with_an_explicitly_empty_iata(): + """An empty configured value is just as unset as the XYZ fallback.""" + service = build_service(BASE_INI + """ +iata = +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt1_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets +""", radio=FakeRadio()) + # Blank stays blank for packet/status topic resolution; neighbors still + # treats it as unset and refuses location-routed publish. + assert service.global_iata == "" + assert service._iata_is_unset() is True + assert service._resolve_neighbors_topic(service.mqtt_brokers[0]) is None + + +def test_empty_iata_keeps_historical_packet_topic_resolution(): + """Empty must not become XYZ for packets — that would publish into XYZ.""" + service = build_service(BASE_INI + """ +iata = +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt1_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets +""", radio=FakeRadio()) + topic = service._resolve_topic_template( + "meshcore/{IATA}/{PUBLIC_KEY}/packets", "packet" + ) + assert topic == f"meshcore//{SELF_KEY.upper()}/packets" + + +def test_a_flat_derived_topic_still_works_without_an_iata(): + """The guard is about location routing, not about having an IATA at all.""" + service = build_service(BASE_INI + """ +mqtt1_enabled = true +mqtt1_server = one.example.com +""") + assert service.global_iata == "xyz" + assert service._resolve_neighbors_topic(service.mqtt_brokers[0]) == "meshcore/packets/neighbors" + + +def test_neighbors_topic_follows_a_flat_packets_topic(): + service = build_service(BASE_INI + """ +iata = SEA +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt1_topic_packets = packets +""") + broker = service.mqtt_brokers[0] + assert service._neighbors_topic_template(broker) == "neighbors" + assert service._resolve_neighbors_topic(broker) == "neighbors" + + +def test_an_explicit_topic_is_honoured_even_without_an_iata(): + """An operator naming the topic themselves has made the call.""" + service = build_service(BASE_INI + """ +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt1_topic_neighbors = my/{IATA}/nbrs +""", radio=FakeRadio()) + assert service._resolve_neighbors_topic(service.mqtt_brokers[0]) == "my/XYZ/nbrs" + + +def test_explicit_neighbors_topic_resolves_placeholders(): + service = build_service(BROKER_INI, radio=FakeRadio()) + topic = service._resolve_neighbors_topic(service.mqtt_brokers[2]) + assert topic == f"custom/SEA/{SELF_KEY.upper()}/nbrs" + + +def test_unroutable_broker_yields_no_topic(): + service = build_service(BROKER_INI) + assert service._resolve_neighbors_topic({"topic_prefix": None, "topic_neighbors": None}) is None + + +def test_neighbors_brokers_requires_connected_and_opted_in(): + service = build_service(BROKER_INI) + service.mqtt_clients = [ + {"client": FakeMqttClient(), "config": service.mqtt_brokers[0], "connected": True}, + {"client": FakeMqttClient(), "config": service.mqtt_brokers[1], "connected": True}, + {"client": FakeMqttClient(), "config": service.mqtt_brokers[2], "connected": False}, + ] + assert [i["config"]["broker_num"] for i in service.neighbors_brokers()] == [1] + + +# --------------------------------------------------------------------------- +# Publishing +# --------------------------------------------------------------------------- + +def test_publish_is_non_retained_and_only_to_opted_in_brokers(): + service = build_service(BROKER_INI) + service.mqtt_enabled = True + opted_in, not_opted_in = FakeMqttClient(), FakeMqttClient() + service.mqtt_clients = [ + {"client": opted_in, "config": service.mqtt_brokers[0], "connected": True}, + {"client": not_opted_in, "config": service.mqtt_brokers[1], "connected": True}, + ] + metrics = service.publish_neighbors_mqtt({"neighbors": []}) + + assert metrics == {"attempted": 1, "succeeded": 1} + assert not_opted_in.published == [] + assert len(opted_in.published) == 1 + # A snapshot taken every 12-336h is not a useful last-will value. + assert opted_in.published[0]["retain"] is False + assert opted_in.published[0]["qos"] == 0 + + +def test_publish_warns_once_for_an_unroutable_broker(caplog): + service = build_service(BROKER_INI) + service.mqtt_enabled = True + broker = dict(service.mqtt_brokers[0]) + broker["topic_prefix"] = None + broker["topic_neighbors"] = None + service.mqtt_clients = [{"client": FakeMqttClient(), "config": broker, "connected": True}] + + with caplog.at_level(logging.WARNING, logger=LOGGER.name): + first = service.publish_neighbors_mqtt({"neighbors": []}) + second = service.publish_neighbors_mqtt({"neighbors": []}) + + assert first == {"attempted": 0, "succeeded": 0} + assert second == {"attempted": 0, "succeeded": 0} + # Warned once, not on every cycle forever. + assert caplog.text.count("no neighbors topic could be resolved") == 1 + + +def test_publish_warning_names_a_missing_iata_as_the_cause(caplog): + """The generic "set a topic prefix" advice would be misleading here.""" + service = build_service(BASE_INI + """ +mqtt1_enabled = true +mqtt1_server = one.example.com +mqtt1_topic_packets = meshcore/{IATA}/{PUBLIC_KEY}/packets +""", radio=FakeRadio()) + service.mqtt_enabled = True + service.mqtt_clients = [ + {"client": FakeMqttClient(), "config": service.mqtt_brokers[0], "connected": True} + ] + with caplog.at_level(logging.WARNING, logger=LOGGER.name): + assert service.publish_neighbors_mqtt({"neighbors": []}) == { + "attempted": 0, "succeeded": 0 + } + assert "no IATA is set" in caplog.text + + +def test_publish_counts_a_broker_failure(): + service = build_service(BROKER_INI) + service.mqtt_enabled = True + service.mqtt_clients = [ + {"client": FakeMqttClient(rc=4), "config": service.mqtt_brokers[0], "connected": True} + ] + assert service.publish_neighbors_mqtt({"neighbors": []}) == {"attempted": 1, "succeeded": 0} + + +def test_publish_noops_when_mqtt_is_disabled(): + service = build_service(BROKER_INI) + service.mqtt_enabled = False + service.mqtt_clients = [ + {"client": FakeMqttClient(), "config": service.mqtt_brokers[0], "connected": True} + ] + assert service.publish_neighbors_mqtt({"neighbors": []}) == {"attempted": 0, "succeeded": 0} + + +# --------------------------------------------------------------------------- +# Capability detection +# --------------------------------------------------------------------------- + +def test_capability_needs_only_discover_when_scopes_are_off(): + service = build_service(BASE_INI, radio=FakeRadio(commands=("send_node_discover_req",))) + assert service.neighbors_commands_available() is True + + +def test_capability_needs_regions_when_scopes_are_on(): + service = build_service( + BASE_INI + "neighbors_collect_scopes = true\n", + radio=FakeRadio(commands=("send_node_discover_req",)), + ) + assert service.neighbors_commands_available() is False + + both = build_service( + BASE_INI + "neighbors_collect_scopes = true\n", + radio=FakeRadio(commands=("send_node_discover_req", "req_regions_sync")), + ) + assert both.neighbors_commands_available() is True + + +def test_capability_false_without_a_radio(): + service = build_service(BASE_INI, radio=None) + assert service.neighbors_commands_available() is False + + +# --------------------------------------------------------------------------- +# State persistence +# --------------------------------------------------------------------------- + +def test_state_round_trips_through_bot_metadata(db_manager): + service = build_service(BASE_INI, db_manager=db_manager) + assert service.last_neighbors_publish == 0.0 + + service.last_neighbors_publish = 1774482900.0 + service._save_neighbors_state() + assert db_manager.metadata[NEIGHBORS_STATE_KEY] == "1774482900.0" + + reloaded = build_service(BASE_INI, db_manager=db_manager) + assert reloaded.last_neighbors_publish == 1774482900.0 + + +def test_attempt_state_round_trips_through_bot_metadata(db_manager): + service = build_service(BASE_INI, db_manager=db_manager) + assert service.last_neighbors_attempt == 0.0 + + service.last_neighbors_attempt = 1774482900.0 + service._save_neighbors_attempt_state() + assert db_manager.metadata[NEIGHBORS_ATTEMPT_STATE_KEY] == "1774482900.0" + + reloaded = build_service(BASE_INI, db_manager=db_manager) + assert reloaded.last_neighbors_attempt == 1774482900.0 + + +def test_malformed_state_is_ignored(db_manager): + db_manager.metadata[NEIGHBORS_STATE_KEY] = "not-a-number" + assert build_service(BASE_INI, db_manager=db_manager).last_neighbors_publish == 0.0 + + +def test_far_future_state_is_ignored(db_manager): + """A clock jump forward would otherwise suppress cycles indefinitely.""" + db_manager.metadata[NEIGHBORS_STATE_KEY] = str(2**31) + assert build_service(BASE_INI, db_manager=db_manager).last_neighbors_publish == 0.0 + + +# --------------------------------------------------------------------------- +# Full cycle +# --------------------------------------------------------------------------- + +@pytest.fixture +def no_sleep(monkeypatch): + async def instant(_seconds): + return None + monkeypatch.setattr(nb.asyncio, "sleep", instant) + + +async def test_cycle_records_feeds_graph_and_publishes(db_manager, no_sleep): + radio = FakeRadio([response(KEY_A, 7.5), response(KEY_B, -2.0)]) + service = build_service(BROKER_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = True + client = FakeMqttClient() + service.mqtt_clients = [ + {"client": client, "config": service.mqtt_brokers[0], "connected": True} + ] + graph_calls = [] + service.bot.mesh_graph.add_edge.side_effect = lambda *a, **k: graph_calls.append((a, k)) + + summary = await service.run_neighbors_cycle() + + assert summary["ok"] is True + assert summary["discovered"] == 2 + assert summary["queried"] == 2 + assert summary["best_snr"] == 7.5 + assert summary["recorded"] == 2 + assert summary["attempted"] == 1 + assert summary["succeeded"] == 1 + + # Persisted, both tables. + with db_manager.connection() as conn: + links = [dict(r) for r in conn.execute( + "SELECT neighbor_public_key, best_snr FROM neighbor_links ORDER BY neighbor_public_key" + )] + history = conn.execute("SELECT COUNT(*) FROM neighbor_observations").fetchone()[0] + assert [row["neighbor_public_key"] for row in links] == [KEY_A, KEY_B] + assert history == 2 + + # Graph: both directions per link. + assert len(graph_calls) == 4 + assert {call[0] for call in graph_calls} == { + (SELF_KEY[:6], KEY_A[:6]), (KEY_A[:6], SELF_KEY[:6]), + (SELF_KEY[:6], KEY_B[:6]), (KEY_B[:6], SELF_KEY[:6]), + } + # Full public keys, which path-derived edges cannot supply. + assert all(len(call[1]["from_public_key"]) == 64 for call in graph_calls) + + # Published payload. + message = json.loads(client.published[0]["payload"]) + assert message["origin"] == "TestBot" + assert message["origin_id"] == SELF_KEY.upper() + assert [n["pubkey"] for n in message["neighbors"]] == [KEY_A.upper(), KEY_B.upper()] + + # State stamped. + assert float(db_manager.metadata[NEIGHBORS_STATE_KEY]) > 0 + + +async def test_cycle_never_touches_the_contact_cache(db_manager, no_sleep): + """Stage 1 is a broadcast; nothing here may add a contact.""" + radio = FakeRadio([response(KEY_A, 1.0)]) + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + await service.run_neighbors_cycle() + assert radio.contacts == {} + + +async def test_cycle_records_without_any_broker(db_manager, no_sleep): + """The database is a legitimate consumer on its own.""" + radio = FakeRadio([response(KEY_A, 1.0)]) + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + summary = await service.run_neighbors_cycle() + assert summary["ok"] is True + assert summary["recorded"] == 1 + assert summary["attempted"] == 0 + with db_manager.connection() as conn: + assert conn.execute("SELECT COUNT(*) FROM neighbor_links").fetchone()[0] == 1 + + +async def test_cycle_respects_the_max_cap(db_manager, no_sleep): + radio = FakeRadio([response(KEY_A, 9.0), response(KEY_B, 1.0)]) + service = build_service(BASE_INI + "neighbors_max = 1\n", + db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + summary = await service.run_neighbors_cycle() + assert summary["discovered"] == 2 + assert summary["queried"] == 1 + # Kept the strongest, and said so in the payload contract. + with db_manager.connection() as conn: + kept = conn.execute("SELECT neighbor_public_key FROM neighbor_links").fetchone()[0] + assert kept == KEY_A + + +async def test_cycle_skips_graph_when_disabled(db_manager, no_sleep): + radio = FakeRadio([response(KEY_A, 1.0)]) + service = build_service(BASE_INI + "neighbors_feed_mesh_graph = false\n", + db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + await service.run_neighbors_cycle() + service.bot.mesh_graph.add_edge.assert_not_called() + + +async def test_cycle_marks_entries_responded_with_scopes_off(db_manager, no_sleep): + """Reporting a live neighbour as `timeout` would be wrong.""" + radio = FakeRadio([response(KEY_A, 1.0)]) + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + await service.run_neighbors_cycle() + with db_manager.connection() as conn: + row = conn.execute("SELECT last_status, scopes FROM neighbor_links").fetchone() + assert row["last_status"] == nb.STATUS_RESPONDED + assert row["scopes"] == "" + + +@pytest.mark.parametrize("ini,radio,connected,expected", [ + ("[PacketCapture]\nenabled = true\n", FakeRadio(), True, "disabled"), + (BASE_INI, None, True, "not connected"), + (BASE_INI, FakeRadio(), False, "not connected"), + (BASE_INI, FakeRadio(commands=()), True, "does not support"), +]) +async def test_cycle_bails_with_a_reason(db_manager, ini, radio, connected, expected): + service = build_service(ini, db_manager=db_manager, radio=radio, connected=connected) + service.mqtt_enabled = False + summary = await service.run_neighbors_cycle() + assert summary["ok"] is False + assert expected in summary["reason"] + + +async def test_cycle_discards_when_the_session_resets_mid_cycle(db_manager, monkeypatch): + """Recording partial data from a torn-down session would be misleading.""" + radio = FakeRadio([response(KEY_A, 1.0)]) + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + async def swap_radio(_seconds): + # Simulate a reconnect replacing the meshcore object mid-window. + service.bot.meshcore = FakeRadio([]) + + monkeypatch.setattr(nb.asyncio, "sleep", swap_radio) + summary = await service.run_neighbors_cycle() + assert summary["ok"] is False + with db_manager.connection() as conn: + assert conn.execute("SELECT COUNT(*) FROM neighbor_links").fetchone()[0] == 0 + + +async def test_concurrent_cycles_are_refused(db_manager, monkeypatch): + """The scheduler and the command trigger independently, so the guard lives here. + + Two overlapping cycles would each collect the other's discover responses and + spend twice the airtime for no extra information. + """ + radio = FakeRadio([response(KEY_A, 1.0)]) + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + second = {} + + async def run_second_cycle_mid_window(_seconds): + # Stands in for the other trigger firing during the discover window. + second["summary"] = await service.run_neighbors_cycle() + + monkeypatch.setattr(nb.asyncio, "sleep", run_second_cycle_mid_window) + first = await service.run_neighbors_cycle() + + assert first["ok"] is True + assert second["summary"]["ok"] is False + assert "already running" in second["summary"]["reason"] + # One discover request, one set of links. + assert radio.discover_requests == 1 + with db_manager.connection() as conn: + assert conn.execute("SELECT COUNT(*) FROM neighbor_links").fetchone()[0] == 1 + # And the guard is released for the next trigger. + assert service.neighbors_cycle_active is False + + +def _lost_ack_radio(): + """A radio whose discover broadcast goes out but is never acknowledged. + + The airtime is spent; the host just never learns that it was, which is what + makes this different from a build that rejects the command outright. + """ + radio = FakeRadio([]) + + async def unacknowledged(filter_bits, prefix_only=True, tag=None): + radio.discover_requests += 1 + return FakeEvent(EventType.ERROR, {"reason": "no_event_received"}) + + radio.commands.send_node_discover_req = unacknowledged + return radio + + +async def test_a_failed_cycle_still_records_the_attempt(db_manager, caplog, no_sleep): + """A lost acknowledgement spends the airtime without completing the cycle. + + last_neighbors_publish stays unset in that case, so callers that ration + airtime need the attempt stamp or another sender could transmit immediately. + """ + radio = _lost_ack_radio() + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + summary = await service.run_neighbors_cycle() + assert summary["ok"] is False + assert service.last_neighbors_publish == 0 + assert service.last_neighbors_attempt > 0 + assert float(db_manager.metadata[NEIGHBORS_ATTEMPT_STATE_KEY]) == pytest.approx( + service.last_neighbors_attempt + ) + + +async def test_a_transmitted_failure_remains_on_cooldown_after_restart( + db_manager, no_sleep +): + """A watchdog restart must not turn a lost acknowledgement into another burst.""" + first_radio = _lost_ack_radio() + service = build_service(BASE_INI, db_manager=db_manager, radio=first_radio) + service.mqtt_enabled = False + + assert (await service.run_neighbors_cycle())["ok"] is False + assert first_radio.discover_requests == 1 + + restarted_radio = _lost_ack_radio() + restarted = build_service(BASE_INI, db_manager=db_manager, radio=restarted_radio) + restarted.mqtt_enabled = False + summary = await restarted.run_neighbors_cycle() + + assert summary["ok"] is False + assert "may run in" in summary["reason"] + assert restarted_radio.discover_requests == 0 + + +async def test_a_cycle_that_never_transmits_records_no_attempt(db_manager): + """Refusing before the radio is touched must not lock out a retry.""" + service = build_service(BASE_INI, db_manager=db_manager, radio=None) + service.mqtt_enabled = False + + summary = await service.run_neighbors_cycle() + assert summary["ok"] is False + assert service.last_neighbors_attempt == 0.0 + + +async def test_a_cycle_inside_the_airtime_cooldown_is_refused(db_manager, no_sleep): + """The guard belongs to the service, so it covers the scheduler too — not just + the DM command, which is one caller among several.""" + radio = FakeRadio([response(KEY_A, 1.0)]) + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + assert (await service.run_neighbors_cycle())["ok"] is True + assert radio.discover_requests == 1 + + summary = await service.run_neighbors_cycle() + assert summary["ok"] is False + assert "may run in" in summary["reason"] + # Refused before touching the radio. + assert radio.discover_requests == 1 + + +async def test_the_cooldown_covers_a_failure_that_transmitted(db_manager, no_sleep): + """The failing cycle never stamps last_neighbors_publish, so a guard reading + only that would let the next trigger transmit immediately.""" + radio = _lost_ack_radio() + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + assert (await service.run_neighbors_cycle())["ok"] is False + assert radio.discover_requests == 1 + + assert "may run in" in (await service.run_neighbors_cycle())["reason"] + assert radio.discover_requests == 1 + + +async def test_the_cooldown_does_not_delay_a_cycle_that_never_transmitted(db_manager): + """Re-checking a disconnected radio is free, so it must stay quick.""" + service = build_service(BASE_INI, db_manager=db_manager, radio=None) + service.mqtt_enabled = False + + await service.run_neighbors_cycle() + assert service.neighbors_cooldown_remaining() == 0.0 + + +def _record_scheduler_waits(service, monkeypatch): + """Capture what the scheduler waits, and stop it after one pass.""" + waits: list[float] = [] + + async def record_wait(timeout): + waits.append(timeout) + service.should_exit = True + return True + + monkeypatch.setattr(service, "_wait_with_shutdown", record_wait) + return waits + + +async def test_scheduler_waits_out_the_cooldown_before_retrying( + db_manager, monkeypatch, no_sleep +): + """Retrying on the short failure backoff alone would triple the airtime.""" + radio = _lost_ack_radio() + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + waits = _record_scheduler_waits(service, monkeypatch) + await service.neighbors_scheduler() + + assert radio.discover_requests == 1 + assert waits == [pytest.approx(nb.MIN_CYCLE_GAP_SECONDS, abs=5)] + assert waits[0] > NEIGHBORS_RETRY_BACKOFF_SECONDS + + +async def test_scheduler_retries_quickly_when_nothing_transmitted(db_manager, monkeypatch): + """A disconnected radio costs nothing to re-test, so keep the short backoff.""" + service = build_service(BASE_INI, db_manager=db_manager, radio=None) + service.mqtt_enabled = False + + waits = _record_scheduler_waits(service, monkeypatch) + await service.neighbors_scheduler() + + assert waits == [NEIGHBORS_RETRY_BACKOFF_SECONDS] + + +async def test_the_guard_is_released_when_a_cycle_fails(db_manager, no_sleep): + radio = FakeRadio([]) + + async def boom(filter_bits, prefix_only=True, tag=None): + raise RuntimeError("serial write failed") + + radio.commands.send_node_discover_req = boom + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + with contextlib.suppress(RuntimeError): + await service.run_neighbors_cycle() + assert service.neighbors_cycle_active is False + + +async def test_repeated_discover_failure_warns_only_once(db_manager, caplog, no_sleep): + radio = FakeRadio([]) + + async def failing(filter_bits, prefix_only=True, tag=None): + return FakeEvent(EventType.ERROR, {"reason": "unsupported"}) + + radio.commands.send_node_discover_req = failing + service = build_service(BASE_INI, db_manager=db_manager, radio=radio) + service.mqtt_enabled = False + + with caplog.at_level(logging.WARNING, logger=LOGGER.name): + for _ in range(3): + # Stands in for the scheduler waiting out the airtime cooldown between + # attempts; without it the guard would refuse cycles 2 and 3. + service.last_neighbors_attempt = 0.0 + assert (await service.run_neighbors_cycle())["ok"] is False + + assert caplog.text.count("discovery request failed; will keep retrying") == 1 + assert service.neighbors_discover_failures == 3 + + +# --------------------------------------------------------------------------- +# Scheduler registration +# --------------------------------------------------------------------------- + +async def _drain(service): + service.should_exit = True + for task in service.background_tasks: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + +def _quiet_other_tasks(service): + service.stats_status_enabled = False + service.stats_refresh_interval = 0 + service.health_check_interval = 0 + service.mqtt_enabled = False + + +async def test_scheduler_starts_only_when_enabled(db_manager): + disabled = build_service("[PacketCapture]\nenabled = true\n", db_manager=db_manager) + _quiet_other_tasks(disabled) + await disabled.start_background_tasks() + assert disabled.neighbors_task is None + assert disabled.background_tasks == [] + + enabled = build_service(BASE_INI, db_manager=db_manager) + _quiet_other_tasks(enabled) + await enabled.start_background_tasks() + assert enabled.neighbors_task is not None + assert enabled.neighbors_task in enabled.background_tasks + await _drain(enabled) diff --git a/translations/en.json b/translations/en.json index 96bc272..effd6d1 100644 --- a/translations/en.json +++ b/translations/en.json @@ -165,6 +165,10 @@ "advert": [ "advert" ], + "neighbors": [ + "neighbors", + "neighbours" + ], "multitest": [ "multitest", "mt" @@ -407,6 +411,18 @@ "success": "Flood advert sent successfully!", "error": "Error sending flood advert: {error}" }, + "neighbors": { + "description": "Runs a zero-hop neighbour discovery cycle (DM only)", + "disabled": "Neighbour discovery is not enabled. Set neighbors_enabled = true in the PacketCapture config.", + "busy": "A neighbour discovery cycle is already running. Try again once it finishes.", + "cooldown_active": "A neighbour discovery cycle ran recently. Please wait about {minutes} more minute(s) - discovery costs airtime for the whole mesh.", + "started": "Discovering neighbours - listening for about {seconds}s. I'll report back when done.", + "success": "Heard {count} neighbour(s), best SNR {best_snr}. Recorded {recorded} link(s).", + "published": "Published to {succeeded}/{attempted} broker(s).", + "none": "Discovery finished but no repeaters answered. Nothing is in direct range right now.", + "failed": "Neighbour discovery did not run: {reason}.", + "error": "Error running neighbour discovery: {error}" + }, "path": { "description": "Decode hex path data to show which repeaters were involved in message routing", "help": "Path: path [hex] - Decode path to show repeaters. Use path alone for current message path, or path [7e,01] for specific path.",