Merge pull request #234 from agessaman/feat/neighbors-mqtt

Implement zero-hop neighbor discovery and improve cycle management
This commit is contained in:
Adam Gessaman
2026-08-06 21:03:36 -07:00
committed by GitHub
22 changed files with 4965 additions and 23 deletions
+42
View File
@@ -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 (12336 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 13 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
+89 -2
View File
@@ -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
# # <mqttN_topic_prefix>/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)
+156
View File
@@ -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 13 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 `<prefix>/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?**
+303
View File
@@ -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
+2
View File
@@ -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"):
+61
View File
@@ -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
13 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),
]
+38
View File
@@ -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] = {}
+705
View File
@@ -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
@@ -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 <topic prefix>/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 ``<topic_prefix>/neighbors``, mirroring how the packet path
falls back to ``<prefix>/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).
+184 -4
View File
@@ -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})
+40 -8
View File
@@ -243,9 +243,10 @@
</div>
<div class="col-md-2">
<label for="filter-evidence" class="form-label">Evidence</label>
<select class="form-select" id="filter-evidence" title="Multi-byte only: build the graph solely from unique multi-byte path observations, ignoring ambiguous single-byte evidence">
<select class="form-select" id="filter-evidence" title="Multi-byte only: build the graph solely from unique multi-byte path observations, ignoring ambiguous single-byte evidence. Neighbours only: only links this node confirmed directly by zero-hop discovery, with measured SNR.">
<option value="all">All Evidence</option>
<option value="multibyte" selected>Multi-byte Only</option>
<option value="neighbors">Neighbours Only</option>
</select>
</div>
<div class="col-md-2">
@@ -294,6 +295,10 @@
<span>Bidirectional</span>
</div>
<hr style="margin: 4px 0;">
<div class="connection-legend-item">
<span style="display: inline-block; width: 18px; border-top: 4px solid currentColor; vertical-align: middle;"></span>
<span>Confirmed neighbour (zero-hop)</span>
</div>
<div class="connection-legend-item">
<span style="display: inline-block; width: 18px; border-top: 2px solid currentColor; vertical-align: middle;"></span>
<span>Multi-byte evidence</span>
@@ -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 @@
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Evidence:</strong></td>
<td style="padding: 2px 0;">${evidenceLabel}</td>
</tr>` : ''}
${isNeighborEdge(edge) && formatSnr(edge.best_snr) ? `<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Best SNR:</strong></td>
<td style="padding: 2px 0;">${formatSnr(edge.best_snr)}${formatSnr(edge.last_snr) ? ` (last ${formatSnr(edge.last_snr)})` : ''}</td>
</tr>` : ''}
<tr>
<td style="padding: 2px 8px 2px 0; color: #888;"><strong>Distance:</strong></td>
<td style="padding: 2px 0;">${edgeDistance ? edgeDistance.toFixed(2) + ' km' : 'N/A'}</td>
@@ -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) {
+2 -1
View File
@@ -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",
+3 -1
View File
@@ -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
+384
View File
@@ -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
+18 -5
View File
@@ -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."""
+76
View File
@@ -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
+138
View File
@@ -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)
+76
View File
@@ -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("""
+222
View File
@@ -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
+811
View File
@@ -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
+919
View File
@@ -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 <prefix>/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)
+16
View File
@@ -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.",