mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-21 10:00:00 +00:00
Port the observer firmware's neighbours feature into the bot's packet capture
service, by way of meshcore-packet-capture (upstream PRs #42/#43). On a long
interval the bot asks which repeaters it hears directly and records each
confirmed link with its measured SNR.
This is the strongest link evidence the bot collects: a first-party RF
measurement between two full 32-byte public keys. Path inference works from
1-3 byte prefixes with no keys, and complete_contact_tracking.hop_count
over-claims zero-hop (800 claimed vs 68 corroborated on the live database).
modules/neighbors_discovery.py keeps upstream's public names so its fixes and
tests stay portable. Two deliberate divergences:
- No command_lock plumbing. _SerializedCommands in modules/core.py already
serialises and paces every radio command, strictly more than upstream's
reentrant lock did.
- neighbors_collect_scopes defaults off. Upstream's zero-hop scope probe
relies on a neighbour not being a known contact; this bot tracks contacts,
and for a repeater with no stored path the library reaches zero-hop by
calling change_contact_path() then reset_path() -- mutating the device's
contact table per neighbour. Scope requests also hold the radio lock for
their whole round trip (~25s), stalling bot replies. The default cycle is
one command plus a passive listen window, during which the bot stays
responsive.
Evidence lands in neighbor_links and neighbor_observations (migration 22)
rather than mesh_connections, which cannot persist provenance. The viewer
exposes it as evidence=neighbors on /api/mesh/edges and a Neighbours Only
mode on the mesh page, with populated public keys and real SNR; confirmed
neighbours also relabel edges in the combined view and count as
provenance-trusted when framing the initial map.
neighbors_enabled is the single switch. Every enabled broker publishes once
it is on (mqttN_neighbors defaults true; set false to hold one back). The
topic derives from each broker's packets topic with the last segment swapped,
so a templated broker gets meshcore/{IATA}/{PUBLIC_KEY}/neighbors -- the
topic the firmware uses -- instead of an unrelated flat one. A derived
location-routed topic is skipped with a warning when no iata is set, rather
than publishing into meshcore/XYZ/... on a shared namespace. Snapshots are
non-retained: heard_secs_ago is relative to publish time, so a retained copy
would read as current days later.
Also adds a DM-gated `neighbors` command (the 12h interval floor makes
waiting for the scheduler impractical), which acks immediately and reports in
a second message once the window closes.
Requires meshcore >= 2.3.8 for send_node_discover_req / req_regions_sync.
276 lines
7.9 KiB
Python
276 lines
7.9 KiB
Python
"""Tests for bounded SQLite retention deletes."""
|
|
|
|
from configparser import ConfigParser
|
|
from contextlib import closing, contextmanager
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from modules.db_retention import (
|
|
delete_timestamp_rows_in_chunks,
|
|
retention_delete_settings,
|
|
)
|
|
|
|
|
|
def test_chunked_delete_commits_and_yields_between_batches(
|
|
tmp_path, monkeypatch
|
|
):
|
|
import sqlite3
|
|
|
|
db_path = tmp_path / "retention.db"
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE events (id INTEGER PRIMARY KEY, seen INTEGER NOT NULL);
|
|
CREATE TABLE live_writes (id INTEGER PRIMARY KEY);
|
|
"""
|
|
)
|
|
conn.executemany(
|
|
"INSERT INTO events(seen) VALUES (?)",
|
|
[(1,), (2,), (3,), (4,), (5,), (100,)],
|
|
)
|
|
conn.commit()
|
|
|
|
opened_connections = 0
|
|
|
|
@contextmanager
|
|
def connection():
|
|
nonlocal opened_connections
|
|
opened_connections += 1
|
|
with closing(sqlite3.connect(db_path, timeout=0.1)) as conn:
|
|
yield conn
|
|
|
|
pauses = []
|
|
|
|
def live_writer_during_pause(seconds):
|
|
pauses.append(seconds)
|
|
with closing(sqlite3.connect(db_path, timeout=0.1)) as conn:
|
|
conn.execute(
|
|
"INSERT INTO live_writes DEFAULT VALUES"
|
|
)
|
|
conn.commit()
|
|
|
|
monkeypatch.setattr(
|
|
"modules.db_retention.time.sleep",
|
|
live_writer_during_pause,
|
|
)
|
|
|
|
deleted = delete_timestamp_rows_in_chunks(
|
|
connection,
|
|
"events",
|
|
"seen",
|
|
10,
|
|
batch_size=2,
|
|
pause_seconds=0.01,
|
|
)
|
|
|
|
assert deleted == 5
|
|
assert opened_connections == 3
|
|
assert pauses == [0.01, 0.01]
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
assert conn.execute("SELECT seen FROM events").fetchall() == [(100,)]
|
|
assert conn.execute("SELECT COUNT(*) FROM live_writes").fetchone()[0] == 2
|
|
|
|
|
|
def test_chunked_delete_reports_progress_every_ten_full_batches(tmp_path):
|
|
import sqlite3
|
|
|
|
db_path = tmp_path / "retention.db"
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
conn.execute(
|
|
"CREATE TABLE events (id INTEGER PRIMARY KEY, seen INTEGER NOT NULL)"
|
|
)
|
|
conn.executemany(
|
|
"INSERT INTO events(seen) VALUES (?)",
|
|
[(1,) for _ in range(21)],
|
|
)
|
|
conn.commit()
|
|
|
|
@contextmanager
|
|
def connection():
|
|
with closing(sqlite3.connect(db_path)) as conn:
|
|
yield conn
|
|
|
|
logger = Mock()
|
|
deleted = delete_timestamp_rows_in_chunks(
|
|
connection,
|
|
"events",
|
|
"seen",
|
|
10,
|
|
batch_size=2,
|
|
pause_seconds=0,
|
|
logger=logger,
|
|
progress_label="test events",
|
|
)
|
|
|
|
assert deleted == 21
|
|
logger.info.assert_called_once_with(
|
|
"Retention cleanup progress for %s: %d rows deleted",
|
|
"test events",
|
|
20,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("table", "column"),
|
|
[
|
|
("events; DROP TABLE events", "seen"),
|
|
("events", "seen OR 1=1"),
|
|
],
|
|
)
|
|
def test_chunked_delete_rejects_invalid_identifiers(table, column):
|
|
with pytest.raises(ValueError, match="Invalid retention"):
|
|
delete_timestamp_rows_in_chunks(
|
|
Mock(),
|
|
table,
|
|
column,
|
|
10,
|
|
)
|
|
|
|
|
|
def test_retention_settings_are_configurable_and_bounded():
|
|
config = ConfigParser()
|
|
config.add_section("Data_Retention")
|
|
config.set("Data_Retention", "retention_delete_batch_size", "25000")
|
|
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)
|