Import the Repeater's stored history

On first start the plugin now imports everything the Repeater has
stored (initial_backfill_hours, default "all"):

- packets from /api/bulk_packets, paged by timestamp with no row cap
- every advert received, noise-floor samples and CRC error events,
  which then keep flowing every snapshot interval
- RRD aggregates for up to a year, fetched per archive tier

Each source resumes from its own cursor and advances it only after a
whole window has shipped. Re-imports are idempotent and can be started
with --backfill [HOURS|all] or by changing backfill_request_id in the
plugin settings. RRD import is retried after transient errors, and its
resolution tag comes from the step the Repeater actually returned.

Also adds a History row to the dashboard, stored history to the dev
fake Repeater, and tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
richandClaude Opus 5.5 committed 2026-10-02 10:30:41 -04:00
1 parent ebd5e6d149
commit 2da26f899b
15 files changed
+1339 -70

No files matched your search

+49 -4
View File
@@ -9,6 +9,7 @@ openHop Repeater ──REST /api/*────────► metrics (every
──WS /ws/packets───► packets (real time, gap backfill)
──SSE /api/logs_stream► logs (real time, resumable)
──REST /api/*────────► snapshots (every 5 min)
──REST history APIs──► history (full import, then every 5 min)
│
batch + gzip + retry + disk spool
▼
@@ -32,6 +33,10 @@ several repeaters can share one bucket.
| `openhop_advert` | `/api/stats` `neighbors` | `pubkey`, `contact_type`, `is_repeater`, `zero_hop` | `node_name`, `rssi`, `snr`, `advert_count`, `first_seen`, `last_seen`, `last_seen_age_seconds`, `latitude`, `longitude` |
| `openhop_gps` | `/api/gps` | `state` | fix, satellites, DOP, speed, `latitude`, `longitude`, `altitude_m` |
| `openhop_identities`, `openhop_acl`, `openhop_rooms`, `openhop_db`, `openhop_mqtt`, `openhop_crc_errors`, `openhop_advert_rate_limit` | matching `/api/*` endpoints | `item` for list payloads | every numeric value, flattened (`a_b_c`) |
| `openhop_advert_received` | `/api/adverts_by_contact_type` (every advert the Repeater stored) | `pubkey`, `contact_type`, `is_repeater`, `zero_hop`, `new_neighbor` | `node_name`, `rssi`, `snr`, `route_type`, `advert_count`, `first_seen`, `last_seen`, `latitude`, `longitude` |
| `openhop_noise_floor` | `/api/noise_floor_history` | — | `noise_floor_dbm` (each stored sample) |
| `openhop_crc_error` | `/api/crc_error_history` | — | `count` (each stored CRC error event) |
| `openhop_rrd` | `/api/rrd_data` (imported once) | `resolution` (`1m`, `5m`, `1h`) | `rx_count_per_second`, `tx_count_per_second`, `drop_count_per_second`, `type_N_per_second`, `avg_rssi`, `avg_snr`, `avg_length`, `avg_score`, `neighbor_count` |
| `openhop_exporter` | the plugin itself | `stream` | lines written, errors, spool size, connection state |
The metric families come from the official
@@ -140,11 +145,13 @@ plugin manager runs in the same container.
| `influx_username` / `influx_password` | `""` | v1 basic auth |
| `influx_create_database` | `true` | v1 only: run `CREATE DATABASE` if the database is missing (needs admin) |
| `influx_verify_tls` | `true` | Verify InfluxDB TLS certificates |
| `stream_metrics` / `stream_packets` / `stream_logs` / `stream_snapshots` | `true` | Enable each stream |
| `stream_metrics` / `stream_packets` / `stream_logs` / `stream_snapshots` / `stream_history` | `true` | Enable each stream |
| `metrics_interval_seconds` | `30` | Metrics poll interval (5–3600) |
| `snapshot_interval_seconds` | `300` | Snapshot poll interval (30–86400) |
| `request_timeout_seconds` | `10` | HTTP timeout for Repeater and InfluxDB calls |
| `initial_backfill_hours` | `24` | On first start, also ship stored packets from this many hours back (0 disables) |
| `initial_backfill_hours` | `"all"` | On first start, import the Repeater's stored history: `"all"`, a number of hours, or `0` for none |
| `backfill_hours` | `"all"` | Window for a manual re-import (`--backfill` default, `backfill_request_id`) |
| `backfill_request_id` | `""` | Set to any new value and save with restart to re-import `backfill_hours` of history once |
| `log_min_level` | `INFO` | Minimum Repeater log level to ship |
| `include_payload` / `include_raw_packet` | `false` | Ship packet payload hex / raw packet hex |
| `include_locations` | `true` | Ship GPS and advert coordinates |
@@ -154,6 +161,40 @@ plugin manager runs in the same container.
Restart the plugin after changing settings (the Settings dialog can do this).
## Importing history
On its **first start** the plugin imports everything the Repeater has stored
(`initial_backfill_hours`, default `"all"`), alongside the live streams:
| Stored on the Repeater | How far back | Imported as |
| --- | --- | --- |
| Every packet (SQLite) | `storage.retention.sqlite_cleanup_days` (default 31 days) | `openhop_packet` |
| Every advert received | same retention | `openhop_advert_received` |
| Noise-floor samples | same retention | `openhop_noise_floor` |
| CRC error events | same retention | `openhop_crc_error` |
| Aggregated traffic/RF (RRD) | 1-min steps for 7 days, 5-min for 30 days, hourly for **1 year** | `openhop_rrd` |
| Logs | only the in-memory buffer | `openhop_log` (sent on connect) |
Counters such as total RX/forwarded have no history on the Repeater; their series
start when the plugin starts (the RRD import covers the past year at lower resolution).
After the import, adverts, noise floor and CRC errors keep flowing every
`snapshot_interval_seconds`, and packets keep flowing live. Each source resumes
from its own cursor in `state-influxdb.json` and only advances it after a whole
window has shipped, so an interrupted import is redone rather than left with a gap.
Packets are paged by timestamp, so even a month of history on a Pi imports with
bounded memory.
**Re-importing** is safe at any time: points keep their original timestamps and
tags, so InfluxDB overwrites them instead of duplicating. Two ways:
- From the Repeater UI (no shell needed): in the plugin's **Settings**, set
`backfill_request_id` to any new value (for example today's date), optionally set
`backfill_hours`, and save with restart. The import runs once in the background;
`GET /api/plugins/runtime?id=community.influxdb` shows `backfill` → `done`.
- From a shell: `openhop-influxdb --backfill` (everything) or `--backfill 72`
(last 72 hours). It flushes and exits; the running service's cursors are untouched.
## Reliability
- **Independent streams.** Each stream runs in its own thread with exponential
@@ -179,6 +220,7 @@ Restart the plugin after changing settings (the Settings dialog can do this).
openhop-influxdb --check-config # validate config.json, print it redacted
openhop-influxdb --once --dry-run # one collection cycle, print line protocol
openhop-influxdb --once # one collection cycle, write to InfluxDB
openhop-influxdb --backfill [HOURS|all] # import stored Repeater history, then exit
openhop-influxdb # run as a service (what the plugin manager does)
```
@@ -221,8 +263,11 @@ WebSocket and the `/api/logs_stream` SSE feed with live synthetic data:
# then use "repeater_url": "http://127.0.0.1:8765", "repeater_api_token": "dev-token"
```
Note: a `--dry-run` never saves the packet resume point, so a real run afterwards
still performs the full `initial_backfill_hours` backfill.
The fake keeps 35 days of packets, 30 days of adverts, 31 days of noise samples and
a year of RRD data, so the history import can be exercised end to end.
Note: `--dry-run` and `--backfill` never save resume points, so a later service run
still performs its own first-start import.
## Licence
+4 -1
View File
@@ -18,10 +18,13 @@
"stream_packets": true,
"stream_logs": true,
"stream_snapshots": true,
"stream_history": true,
"metrics_interval_seconds": 30,
"snapshot_interval_seconds": 300,
"request_timeout_seconds": 10,
"initial_backfill_hours": 24,
"initial_backfill_hours": "all",
"backfill_hours": "all",
"backfill_request_id": "",
"log_min_level": "INFO",
"include_payload": false,
"include_raw_packet": false,
+197 -7
View File
@@ -1080,7 +1080,7 @@
},
{
"type": "row",
"title": "Exporter health",
"title": "History (imported from the Repeater, continued live)",
"id": 30,
"collapsed": false,
"gridPos": {
@@ -1093,17 +1093,207 @@
},
{
"type": "timeseries",
"title": "Lines written per minute",
"title": "Long-term traffic (RRD, packets/min)",
"id": 31,
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 91
},
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"query": "SELECT mean(\"rx_count_per_second\") * 60 AS \"received\", mean(\"tx_count_per_second\") * 60 AS \"transmitted\", mean(\"drop_count_per_second\") * 60 AS \"dropped\" FROM \"openhop_rrd\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter GROUP BY time($__interval) fill(none)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {}
},
{
"type": "timeseries",
"title": "Long-term RF (RRD)",
"id": 32,
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 91
},
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"query": "SELECT mean(\"avg_rssi\") AS \"avg RSSI (dBm)\", mean(\"avg_snr\") AS \"avg SNR (dB)\" FROM \"openhop_rrd\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter GROUP BY time($__interval) fill(none)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {}
},
{
"type": "timeseries",
"title": "Noise floor samples",
"id": 33,
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 91
"y": 99
},
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"query": "SELECT mean(\"noise_floor_dbm\") AS \"noise floor\" FROM \"openhop_noise_floor\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter GROUP BY time($__interval) fill(none)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series"
}
],
"fieldConfig": {
"defaults": {
"unit": "dBm"
},
"overrides": []
},
"options": {}
},
{
"type": "timeseries",
"title": "Adverts received by contact type",
"id": 34,
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 8,
"y": 99
},
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"query": "SELECT count(\"last_seen\") FROM \"openhop_advert_received\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter GROUP BY time($__interval), \"contact_type\" fill(0)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series",
"alias": "$tag_contact_type"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"stacking": {
"mode": "normal"
},
"fillOpacity": 80
}
},
"overrides": []
},
"options": {}
},
{
"type": "timeseries",
"title": "CRC errors",
"id": 35,
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 16,
"y": 99
},
"targets": [
{
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"query": "SELECT sum(\"count\") FROM \"openhop_crc_error\" WHERE \"repeater\" =~ /^$repeater$/ AND $timeFilter GROUP BY time($__interval) fill(0)",
"rawQuery": true,
"refId": "A",
"resultFormat": "time_series",
"alias": "CRC errors"
}
],
"fieldConfig": {
"defaults": {
"custom": {
"drawStyle": "bars",
"fillOpacity": 80
}
},
"overrides": []
},
"options": {}
},
{
"type": "row",
"title": "Exporter health",
"id": 36,
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 107
},
"panels": []
},
{
"type": "timeseries",
"title": "Lines written per minute",
"id": 37,
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
},
"gridPos": {
"h": 8,
"w": 8,
"x": 0,
"y": 108
},
"targets": [
{
@@ -1127,7 +1317,7 @@
{
"type": "timeseries",
"title": "Spool backlog",
"id": 32,
"id": 38,
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
@@ -1136,7 +1326,7 @@
"h": 8,
"w": 8,
"x": 8,
"y": 91
"y": 108
},
"targets": [
{
@@ -1162,7 +1352,7 @@
{
"type": "timeseries",
"title": "Stream connections",
"id": 33,
"id": 39,
"datasource": {
"type": "influxdb",
"uid": "${datasource}"
@@ -1171,7 +1361,7 @@
"h": 8,
"w": 8,
"x": 16,
"y": 91
"y": 108
},
"targets": [
{
+151 -9
View File
@@ -66,6 +66,10 @@ class FakeRepeater:
self.log_waiters: list[asyncio.Queue] = []
self.packet_id = 0
self.log_id = 0
self.max_packets = 50_000
self.advert_rows: list[dict] = []
self.noise_rows: list[dict] = []
self.crc_rows: list[dict] = []
# -- auth ------------------------------------------------------------------
@@ -137,7 +141,29 @@ class FakeRepeater:
"lbt_channel_busy": random.random() < 0.1,
}
self.packets.append(packet)
del self.packets[:-5000]
if ptype == 4 and self.advert_rows:
pubkey = random.choice(list(NODES))
prev = next(a for a in reversed(self.advert_rows) if a["pubkey"] == pubkey)
self.advert_rows.append(
dict(
prev,
id=len(self.advert_rows) + 1,
timestamp=packet["timestamp"],
last_seen=packet["timestamp"],
rssi=rssi,
snr=snr,
advert_count=prev["advert_count"] + 1,
is_new_neighbor=False,
)
)
if self.noise_rows and random.random() < 0.2:
self.noise_rows.append(
{
"timestamp": packet["timestamp"],
"noise_floor_dbm": round(random.gauss(-112, 1.5), 1),
}
)
del self.packets[: -self.max_packets]
return packet
def make_log(self, message: str | None = None, level: str | None = None) -> dict:
@@ -147,7 +173,8 @@ class FakeRepeater:
"timestamp": datetime.now().isoformat(),
"level": level or random.choice(["INFO"] * 6 + ["DEBUG", "WARNING", "ERROR"]),
"logger": random.choice(["Engine", "PacketRouter", "HTTPServer", "Radio"]),
"message": message or random.choice(
"message": message
or random.choice(
[
"Forwarded flood packet",
"Duplicate packet suppressed",
@@ -161,11 +188,50 @@ class FakeRepeater:
del self.logs[:-1000]
return entry
async def producer(self) -> None:
for _ in range(300): # history for backfill
p = self.make_packet()
p["timestamp"] -= random.uniform(60, 3600)
def seed_history(self, packets: int = 20_000, days: int = 35) -> None:
"""Stored history like a Repeater that has been running for a while."""
now = time.time()
for _ in range(packets):
packet = self.make_packet()
packet["timestamp"] = now - random.uniform(60, days * 86400)
packet["payload"] = packet["raw_packet"] = None # keep memory small
self.packets.sort(key=lambda p: p["timestamp"])
for i in range(len(self.packets)):
self.packets[i]["id"] = i + 1
for pubkey, node in NODES.items():
first = now - 30 * 86400
ts, count = first, 0
while ts < now:
count += 1
self.advert_rows.append(
{
"id": len(self.advert_rows) + 1,
"timestamp": ts,
"pubkey": pubkey,
**node,
"route_type": 1,
"first_seen": first,
"last_seen": ts,
"rssi": round(random.gauss(-95, 6)),
"snr": round(random.gauss(5, 3), 2),
"advert_count": count,
"is_new_neighbor": count == 1,
"zero_hop": node["is_repeater"],
}
)
ts += random.uniform(1, 3) * 3600
ts = now - 31 * 86400
while ts < now:
self.noise_rows.append(
{"timestamp": ts, "noise_floor_dbm": round(random.gauss(-112, 1.5), 1)}
)
ts += 300
self.crc_rows = sorted(
({"timestamp": now - random.uniform(0, 31 * 86400), "count": 1} for _ in range(60)),
key=lambda c: c["timestamp"],
)
async def producer(self) -> None:
while True:
await asyncio.sleep(random.uniform(0.3, 1.5))
packet = self.make_packet()
@@ -311,9 +377,12 @@ class FakeRepeater:
limit = min(int(q.get("limit", 1000)), 10000)
offset = int(q.get("offset", 0))
start = float(q.get("start_timestamp") or 0)
rows = [p for p in reversed(self.packets) if p["timestamp"] >= start]
out = [{k: v for k, v in p.items() if k not in {"raw_packet", "airtime_ms"}}
for p in rows[offset : offset + limit]]
end = float(q.get("end_timestamp") or 1e12)
rows = [p for p in reversed(self.packets) if start <= p["timestamp"] <= end]
out = [
{k: v for k, v in p.items() if k not in {"raw_packet", "airtime_ms"}}
for p in rows[offset : offset + limit]
]
for row in out: # the DB returns sqlite ints and JSON-encoded paths
row["transmitted"] = int(row["transmitted"])
row["is_duplicate"] = int(row["is_duplicate"])
@@ -415,6 +484,74 @@ class FakeRepeater:
return web.json_response({"error": "not found"}, status=404)
return self.ok(data)
@staticmethod
def _cutoff(request) -> float:
return time.time() - int(request.query.get("hours", 24)) * 3600
async def adverts_by_contact_type(self, request):
q = request.query
if not q.get("contact_type"):
return web.json_response(
{"success": False, "error": "contact_type parameter is required"}
)
cutoff = self._cutoff(request)
rows = [
a
for a in reversed(self.advert_rows)
if a["contact_type"] == q["contact_type"] and a["timestamp"] > cutoff
]
offset, limit = int(q.get("offset", 0)), int(q.get("limit", 500))
return self.ok(rows[offset : offset + limit], count=len(rows))
async def noise_floor_history(self, request):
q = request.query
cutoff = self._cutoff(request)
rows = [n for n in reversed(self.noise_rows) if n["timestamp"] > cutoff]
offset, limit = int(q.get("offset", 0)), int(q.get("limit", 2000))
page = rows[offset : offset + limit][::-1]
return self.ok({"history": page, "count": len(page), "offset": offset, "limit": limit})
async def crc_error_history(self, request):
cutoff = self._cutoff(request)
limit = int(request.query.get("limit") or 1000)
rows = [c for c in self.crc_rows if c["timestamp"] > cutoff][-limit:]
return self.ok({"history": rows, "count": len(rows)})
async def rrd_data(self, request):
"""rrdtool fetch semantics: the finest archive covering the requested range."""
now = int(time.time())
start = int(request.query.get("start_time") or now - 86400)
end = int(request.query.get("end_time") or now)
age = now - start
step = 60 if age <= 7 * 86400 else 300 if age <= 30 * 86400 else 3600
stamps = list(range(start - start % step + step, end + 1, step))
rx = [round(random.uniform(0.05, 0.4), 4) for _ in stamps]
metrics = {
"rx_count": rx,
"tx_count": [round(v * 0.6, 4) for v in rx],
"drop_count": [round(v * 0.1, 4) for v in rx],
"avg_rssi": [round(random.gauss(-97, 3), 2) for _ in stamps],
"avg_snr": [round(random.gauss(4, 2), 2) for _ in stamps],
"avg_length": [round(random.uniform(40, 120), 1) for _ in stamps],
"avg_score": [round(random.random(), 3) for _ in stamps],
"neighbor_count": [6.0 for _ in stamps],
}
types = {f"type_{t}": [round(v / 8, 4) for v in rx] for t in PACKET_TYPES}
if stamps:
for values in [*metrics.values(), *types.values()]:
values[-1] = None # the newest RRD row is usually still unknown
return self.ok(
{
"start_time": start,
"end_time": end,
"step": step,
"timestamps": stamps,
"metrics": metrics,
"packet_types": types,
"data_source": "rrd",
}
)
async def ws_packets(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
@@ -467,6 +604,7 @@ class FakeRepeater:
async def main(port: int, token: str) -> None:
fake = FakeRepeater(token)
fake.seed_history()
for _ in range(40):
fake.make_log()
app = web.Application(middlewares=[fake.auth_middleware])
@@ -480,6 +618,10 @@ async def main(port: int, token: str) -> None:
web.get("/api/noise_floor_stats", fake.noise),
web.get("/api/neighbor_links", fake.neighbor_links),
web.get("/api/logs_stream", fake.logs_stream),
web.get("/api/adverts_by_contact_type", fake.adverts_by_contact_type),
web.get("/api/noise_floor_history", fake.noise_floor_history),
web.get("/api/crc_error_history", fake.crc_error_history),
web.get("/api/rrd_data", fake.rrd_data),
web.get("/api/{name}", fake.generic),
web.get("/ws/packets", fake.ws_packets),
]
+4 -1
View File
@@ -29,10 +29,13 @@
"stream_packets": true,
"stream_logs": true,
"stream_snapshots": true,
"stream_history": true,
"metrics_interval_seconds": 30,
"snapshot_interval_seconds": 300,
"request_timeout_seconds": 10,
"initial_backfill_hours": 24,
"initial_backfill_hours": "all",
"backfill_hours": "all",
"backfill_request_id": "",
"log_min_level": "INFO",
"include_payload": false,
"include_raw_packet": false,
+26
View File
@@ -197,6 +197,32 @@ panel("table", "Repeater log", [target(
fmt="table")], 0, 24, 12)
# -- Exporter ------------------------------------------------------------------
row("History (imported from the Repeater, continued live)")
panel("timeseries", "Long-term traffic (RRD, packets/min)", [target(
'SELECT mean("rx_count_per_second") * 60 AS "received", '
'mean("tx_count_per_second") * 60 AS "transmitted", '
'mean("drop_count_per_second") * 60 AS "dropped" '
f'FROM "openhop_rrd" WHERE {R} AND $timeFilter GROUP BY time($__interval) fill(none)')],
0, 12)
panel("timeseries", "Long-term RF (RRD)", [target(
'SELECT mean("avg_rssi") AS "avg RSSI (dBm)", mean("avg_snr") AS "avg SNR (dB)" '
f'FROM "openhop_rrd" WHERE {R} AND $timeFilter GROUP BY time($__interval) fill(none)')],
12, 12)
panel("timeseries", "Noise floor samples", [target(
'SELECT mean("noise_floor_dbm") AS "noise floor" FROM "openhop_noise_floor" '
f"WHERE {R} AND $timeFilter GROUP BY time($__interval) fill(none)")], 0, 8, unit="dBm")
panel("timeseries", "Adverts received by contact type", [target(
f'SELECT count("last_seen") FROM "openhop_advert_received" WHERE {R} AND $timeFilter '
'GROUP BY time($__interval), "contact_type" fill(0)', alias="$tag_contact_type")],
8, 8, extra={"fieldConfig": {"defaults": {"custom": {
"drawStyle": "bars", "stacking": {"mode": "normal"}, "fillOpacity": 80}},
"overrides": []}})
panel("timeseries", "CRC errors", [target(
f'SELECT sum("count") FROM "openhop_crc_error" WHERE {R} AND $timeFilter '
"GROUP BY time($__interval) fill(0)", alias="CRC errors")], 16, 8,
extra={"fieldConfig": {"defaults": {"custom": {"drawStyle": "bars", "fillOpacity": 80}},
"overrides": []}})
row("Exporter health")
panel("timeseries", "Lines written per minute", [target(
'SELECT non_negative_derivative(last("lines_written"), 1m) FROM "openhop_exporter" '
+27 -5
View File
@@ -26,10 +26,13 @@ DEFAULT_CONFIG: dict[str, Any] = {
"stream_packets": True,
"stream_logs": True,
"stream_snapshots": True,
"stream_history": True,
"metrics_interval_seconds": 30,
"snapshot_interval_seconds": 300,
"request_timeout_seconds": 10,
"initial_backfill_hours": 24,
"initial_backfill_hours": "all",
"backfill_hours": "all",
"backfill_request_id": "",
"log_min_level": "INFO",
"include_payload": False,
"include_raw_packet": False,
@@ -71,11 +74,14 @@ class PluginConfig:
stream_packets: bool
stream_logs: bool
stream_snapshots: bool
stream_history: bool
metrics_interval_seconds: int
snapshot_interval_seconds: int
request_timeout_seconds: float
initial_backfill_hours: int
initial_backfill_hours: int | None # None = everything the Repeater still has
backfill_hours: int | None
backfill_request_id: str
log_min_level: str
include_payload: bool
@@ -144,6 +150,21 @@ def _float(value: Any, name: str, low: float, high: float) -> float:
return number
MAX_BACKFILL_HOURS = 24 * 3650
def parse_hours(value: Any, name: str = "hours") -> int | None:
"""Hours of history to import: an integer, or "all" (returned as None)."""
if isinstance(value, str) and value.strip().lower() == "all":
return None
if isinstance(value, bool):
raise ConfigError(f'{name} must be a number of hours or "all"')
try:
return _int(value, name, 0, MAX_BACKFILL_HOURS)
except ConfigError as exc:
raise ConfigError(f'{name} must be 0-{MAX_BACKFILL_HOURS} hours or "all"') from exc
def _str(value: Any) -> str:
return str(value if value is not None else "").strip()
@@ -186,6 +207,7 @@ def parse_config(raw: dict[str, Any]) -> PluginConfig:
stream_packets=_bool(cfg["stream_packets"], "stream_packets"),
stream_logs=_bool(cfg["stream_logs"], "stream_logs"),
stream_snapshots=_bool(cfg["stream_snapshots"], "stream_snapshots"),
stream_history=_bool(cfg["stream_history"], "stream_history"),
metrics_interval_seconds=_int(
cfg["metrics_interval_seconds"], "metrics_interval_seconds", 5, 3600
),
@@ -195,9 +217,9 @@ def parse_config(raw: dict[str, Any]) -> PluginConfig:
request_timeout_seconds=_float(
cfg["request_timeout_seconds"], "request_timeout_seconds", 1, 120
),
initial_backfill_hours=_int(
cfg["initial_backfill_hours"], "initial_backfill_hours", 0, 24 * 90
),
initial_backfill_hours=parse_hours(cfg["initial_backfill_hours"], "initial_backfill_hours"),
backfill_hours=parse_hours(cfg["backfill_hours"], "backfill_hours"),
backfill_request_id=_str(cfg["backfill_request_id"]),
log_min_level=level,
include_payload=_bool(cfg["include_payload"], "include_payload"),
include_raw_packet=_bool(cfg["include_raw_packet"], "include_raw_packet"),
+38 -2
View File
@@ -11,13 +11,14 @@ import time
from pathlib import Path
from . import __version__
from .config import ConfigError, PluginConfig, load_config
from .config import ConfigError, PluginConfig, load_config, parse_hours
from .lineprotocol import Point, now_ns
from .repeater_api import RepeaterAPI, RepeaterAPIError, resolve_repeater_tag
from .spool import Spool
from .state import PersistentState
from .status import Status, write_runtime_json
from .streams.base import StreamContext, StreamWorker
from .streams.history import BackfillRequestWorker, HistoryStream, run_backfill
from .streams.logs import LogStream
from .streams.metrics import MetricsStream
from .streams.packets import PacketStream
@@ -50,6 +51,13 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
action="store_true",
help="collect metrics + snapshots once (and backfill packets), flush, and exit",
)
parser.add_argument(
"--backfill",
nargs="?",
const="all",
metavar="HOURS|all",
help="import all stored Repeater history (or the last HOURS), flush, and exit",
)
parser.add_argument(
"--dry-run",
action="store_true",
@@ -134,6 +142,8 @@ def build_streams(ctx: StreamContext, *, once: bool) -> list[StreamWorker]:
streams.append(MetricsStream(ctx))
if cfg.stream_snapshots:
streams.append(SnapshotStream(ctx))
if cfg.stream_history:
streams.append(HistoryStream(ctx))
if not once:
if cfg.stream_packets:
streams.append(PacketStream(ctx))
@@ -158,6 +168,12 @@ def run_once(ctx: StreamContext) -> int:
except Exception as exc:
failures += 1
logger.error("snapshots: %s", exc)
if cfg.stream_history:
try:
total += HistoryStream(ctx).poll()
except Exception as exc:
failures += 1
logger.error("history: %s", exc)
if cfg.stream_packets:
try:
total += PacketStream(ctx).backfill()
@@ -212,7 +228,10 @@ def main(argv: list[str] | None = None) -> int:
api = RepeaterAPI(config)
spool = Spool(data_dir / "spool", config.spool_max_mb * 1024 * 1024)
writer = InfluxWriter(config, spool, dry_run=args.dry_run)
state = PersistentState(data_dir / "state-influxdb.json", readonly=args.dry_run)
# Dry runs ship nothing and manual backfills leave cursors to the service.
state = PersistentState(
data_dir / "state-influxdb.json", readonly=args.dry_run or args.backfill is not None
)
ctx = StreamContext(config, api, writer, status, state, data_dir, stop)
status.message = "waiting for Repeater API"
@@ -221,6 +240,20 @@ def main(argv: list[str] | None = None) -> int:
status.repeater_tag = tag
logger.info("Tagging all points with repeater=%s", tag)
if args.backfill is not None:
try:
hours = parse_hours(args.backfill, "--backfill")
except ConfigError as exc:
print(exc, file=sys.stderr)
return 2
writer.start()
counts = run_backfill(ctx, hours, logger)
writer.close(timeout=config.request_timeout_seconds + 30)
failed = [name for name, count in counts.items() if count < 0]
if writer.stats()["spool_files"]:
logger.warning("Some data could not be written yet and is spooled for the service")
return 1 if failed or stop.is_set() else 0
if args.once:
writer.start()
code = run_once(ctx)
@@ -230,6 +263,9 @@ def main(argv: list[str] | None = None) -> int:
writer.start()
streams = build_streams(ctx, once=False)
if BackfillRequestWorker.pending(ctx):
logger.info("backfill_request_id %r is new; importing history", config.backfill_request_id)
streams.append(BackfillRequestWorker(ctx, logger))
for stream in streams:
stream.start()
status.message = "running"
+8 -3
View File
@@ -10,7 +10,9 @@ from .config import PluginConfig
class RepeaterAPIError(RuntimeError):
pass
def __init__(self, message: str, status: int | None = None):
super().__init__(message)
self.status = status
class RepeaterAPI:
@@ -48,10 +50,13 @@ class RepeaterAPI:
raise RepeaterAPIError(f"GET {path} failed: {exc}") from exc
if response.status_code == 401:
raise RepeaterAPIError(
f"GET {path} returned HTTP 401; set repeater_api_token to a valid API token"
f"GET {path} returned HTTP 401; set repeater_api_token to a valid API token",
401,
)
if response.status_code >= 400:
raise RepeaterAPIError(f"GET {path} returned HTTP {response.status_code}")
raise RepeaterAPIError(
f"GET {path} returned HTTP {response.status_code}", response.status_code
)
try:
return response.json()
except ValueError as exc:
@@ -78,3 +78,19 @@ class PollingWorker(StreamWorker):
def poll(self) -> int:
raise NotImplementedError
def wait_for_writer(ctx: StreamContext) -> None:
"""Backpressure for bulk imports: never let them overflow the writer queue."""
limit = ctx.writer.max_queue // 2
while ctx.writer.queued() > limit and not ctx.stop.is_set():
ctx.stop.wait(0.2)
def window_start(hours: int | None, now: float) -> float | None:
"""Start of an import window: None hours means everything (0.0); 0 means no import."""
if hours is None:
return 0.0
if hours <= 0:
return None
return now - hours * 3600
@@ -0,0 +1,405 @@
"""Import the Repeater's stored history, then keep those series flowing.
The Repeater keeps, in SQLite (pruned after ``storage.retention.sqlite_cleanup_days``,
31 by default): every packet, every advert received, noise-floor samples and CRC
error events. Its RRD file keeps aggregated traffic for up to a year (1-minute
steps for 7 days, 5-minute for 30 days, hourly for 365 days).
Every importer is idempotent: points carry the original timestamps and stable
tags, so re-importing overwrites rather than duplicates.
"""
from __future__ import annotations
import math
import threading
import time
from collections.abc import Callable
from typing import Any
from ..lineprotocol import Point, seconds_to_ns
from ..repeater_api import RepeaterAPIError
from .base import (
MAX_RECONNECT_BACKOFF,
PollingWorker,
StreamContext,
wait_for_writer,
window_start,
)
from .packets import PacketStream
ADVERT_PAGE = 1000
NOISE_PAGE = 10000
CRC_LIMIT = 1_000_000
ALL_HOURS = 24 * 3650
# Repeater RRD archives: 1-minute steps for 7 days, 5-minute for 30, hourly for 365.
# rrdtool answers a fetch from the finest archive covering the whole range, and an
# archive's coverage is measured from its last update, so each tier starts an hour
# inside its archive (tiers overlap by that hour; rewriting a point is harmless).
RRD_MARGIN = 3600
RRD_TIERS = ( # (seconds back where the tier starts, seconds back where it ends)
(7 * 86400 - RRD_MARGIN, 0),
(30 * 86400 - RRD_MARGIN, 7 * 86400 - 2 * RRD_MARGIN),
(365 * 86400 - RRD_MARGIN, 30 * 86400 - 2 * RRD_MARGIN),
)
RRD_LABELS = {60: "1m", 300: "5m", 3600: "1h"}
RRD_COUNTERS = {"rx_count", "tx_count", "drop_count"}
def _hours_since(since: float, now: float) -> int:
"""The Repeater's history endpoints take an ``hours`` window ending now."""
if since <= 0:
return ALL_HOURS
return max(1, min(ALL_HOURS, math.ceil((now - since) / 3600) + 1))
def _num(value: Any) -> float | None:
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)) and math.isfinite(float(value)):
return float(value)
return None
def _ts(row: dict[str, Any]) -> float | None:
return _num(row.get("timestamp"))
# -- adverts --------------------------------------------------------------------
def advert_received_point(row: dict[str, Any], *, include_locations: bool) -> Point | None:
ts = _ts(row)
pubkey = row.get("pubkey")
if ts is None or not pubkey:
return None
fields: dict[str, Any] = {}
for key in ("rssi", "snr", "route_type", "advert_count", "first_seen", "last_seen"):
number = _num(row.get(key))
if number is not None:
fields[key] = number
if include_locations:
lat, lon = _num(row.get("latitude")), _num(row.get("longitude"))
if lat is not None and lon is not None and not (lat == 0.0 and lon == 0.0):
fields["latitude"], fields["longitude"] = lat, lon
if isinstance(row.get("node_name"), str) and row["node_name"]:
fields["node_name"] = row["node_name"]
if not fields:
fields["received"] = 1.0
tags = {
"pubkey": str(pubkey),
"contact_type": str(row.get("contact_type") or "unknown"),
"is_repeater": "true" if row.get("is_repeater") else "false",
"zero_hop": "true" if row.get("zero_hop") else "false",
"new_neighbor": "true" if row.get("is_new_neighbor") else "false",
}
return Point("openhop_advert_received", fields, tags, seconds_to_ns(ts))
def _contact_types(ctx: StreamContext) -> list[str]:
"""Advert history is queried per contact type; take the types the Repeater knows."""
stats = ctx.api.get_raw("/api/stats")
neighbors = stats.get("neighbors") if isinstance(stats, dict) else None
types = {
str(info["contact_type"])
for info in (neighbors or {}).values()
if isinstance(info, dict) and info.get("contact_type")
}
return sorted(types)
def import_adverts(ctx: StreamContext, since: float) -> tuple[int, float]:
now = time.time()
hours = _hours_since(since, now)
total, newest = 0, 0.0
for contact_type in _contact_types(ctx):
offset = 0
while not ctx.stop.is_set():
wait_for_writer(ctx)
rows = ctx.api.get(
"/api/adverts_by_contact_type",
{
"contact_type": contact_type,
"hours": hours,
"limit": ADVERT_PAGE,
"offset": offset,
},
)
rows = [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
points = []
for row in rows:
ts = _ts(row)
if ts is None or ts < since:
continue
point = advert_received_point(row, include_locations=ctx.config.include_locations)
if point:
points.append(point)
newest = max(newest, ts)
total += ctx.writer.submit(points)
oldest = min((_ts(r) or 0.0 for r in rows), default=0.0)
if len(rows) < ADVERT_PAGE or oldest < since:
break
offset += ADVERT_PAGE
return total, newest
# -- noise floor & CRC ---------------------------------------------------------------
def import_noise_floor(ctx: StreamContext, since: float) -> tuple[int, float]:
hours = _hours_since(since, time.time())
total, newest, offset = 0, 0.0, 0
while not ctx.stop.is_set():
wait_for_writer(ctx)
data = ctx.api.get(
"/api/noise_floor_history", {"hours": hours, "limit": NOISE_PAGE, "offset": offset}
)
rows = data.get("history") if isinstance(data, dict) else None
rows = [r for r in rows if isinstance(r, dict)] if isinstance(rows, list) else []
points = []
for row in rows:
ts, value = _ts(row), _num(row.get("noise_floor_dbm"))
if ts is None or value is None or ts < since:
continue
points.append(
Point("openhop_noise_floor", {"noise_floor_dbm": value}, {}, seconds_to_ns(ts))
)
newest = max(newest, ts)
total += ctx.writer.submit(points)
if len(rows) < NOISE_PAGE:
break
offset += NOISE_PAGE
return total, newest
def import_crc_errors(ctx: StreamContext, since: float) -> tuple[int, float]:
data = ctx.api.get(
"/api/crc_error_history", {"hours": _hours_since(since, time.time()), "limit": CRC_LIMIT}
)
rows = data.get("history") if isinstance(data, dict) else None
points, newest = [], 0.0
for row in rows if isinstance(rows, list) else []:
ts = _ts(row) if isinstance(row, dict) else None
if ts is None or ts < since:
continue
count = _num(row.get("count"))
points.append(
Point(
"openhop_crc_error",
{"count": 1.0 if count is None else count},
{},
seconds_to_ns(ts),
)
)
newest = max(newest, ts)
return ctx.writer.submit(points), newest
# -- RRD -------------------------------------------------------------------------------
def rrd_resolution(data: dict[str, Any]) -> str:
"""Label from the step the Repeater actually returned, not the one requested."""
step = _num(data.get("step"))
if step is None:
return "unknown"
return RRD_LABELS.get(int(step), f"{int(step)}s")
def rrd_points(data: dict[str, Any], since: float) -> list[Point]:
resolution = rrd_resolution(data)
timestamps = data.get("timestamps")
if not isinstance(timestamps, list):
return []
series: dict[str, list[Any]] = {}
for name, values in (data.get("metrics") or {}).items():
if isinstance(values, list):
series[f"{name}_per_second" if name in RRD_COUNTERS else name] = values
for name, values in (data.get("packet_types") or {}).items():
if isinstance(values, list):
series[f"{name}_per_second"] = values # RRD COUNTERs are fetched as rates
points = []
for i, ts in enumerate(timestamps):
ts_num = _num(ts)
if ts_num is None or ts_num < since:
continue
fields = {}
for name, values in series.items():
value = _num(values[i]) if i < len(values) else None
if value is not None:
fields[name] = value
if fields:
points.append(
Point("openhop_rrd", fields, {"resolution": resolution}, int(ts_num) * 10**9)
)
return points
def import_rrd(ctx: StreamContext, since: float) -> int:
"""Import RRD aggregates, each tier at its native resolution."""
now = int(time.time())
total = 0
for back_start, back_end in RRD_TIERS:
start, end = now - back_start, now - back_end
if end <= since or ctx.stop.is_set():
continue
start = max(start, int(since))
wait_for_writer(ctx)
data = ctx.api.get(
"/api/rrd_data", {"start_time": start, "end_time": end, "resolution": "average"}
)
if not isinstance(data, dict):
continue
if data.get("data_source", "rrd") != "rrd":
# No RRD on this Repeater; its SQLite fallback duplicates the packet import.
ctx.status.error(
"history", "RRD not available on this Repeater; skipped", state="running"
)
return total
total += ctx.writer.submit(rrd_points(data, since))
return total
# -- orchestration -----------------------------------------------------------------
Importer = Callable[[StreamContext, float], "tuple[int, float]"]
INCREMENTAL: tuple[tuple[str, Importer], ...] = (
("adverts", import_adverts),
("noise_floor", import_noise_floor),
("crc_errors", import_crc_errors),
)
RRD_STATE_KEY = "history_rrd_imported"
def cursor_key(name: str) -> str:
return f"history_{name}_last_timestamp"
def run_backfill(ctx: StreamContext, hours: int | None, log) -> dict[str, int]:
"""Manual re-import of everything stored in the last ``hours`` (None = all).
Resume cursors are left untouched; the running streams keep their own.
"""
since = window_start(hours, time.time())
counts: dict[str, int] = {}
if since is None:
return counts
log.info("Importing Repeater history (%s)", "all" if hours is None else f"{hours} h")
steps: list[tuple[str, Callable[[], int]]] = [
("packets", lambda: PacketStream(ctx).backfill(since=since)),
*[(name, (lambda fn=fn: fn(ctx, since)[0])) for name, fn in INCREMENTAL],
("rrd", lambda: import_rrd(ctx, since)),
]
for name, step in steps:
if ctx.stop.is_set():
break
try:
counts[name] = step()
except (RepeaterAPIError, RuntimeError) as exc:
log.warning("History import of %s failed: %s", name, exc)
counts[name] = -1
log.info("History import finished: %s", ", ".join(f"{k}={v}" for k, v in counts.items()))
return counts
class HistoryStream(PollingWorker):
"""Imports stored adverts, noise floor and CRC history on first start (and the
RRD archive once), then fetches new rows every ``snapshot_interval_seconds``."""
stream_name = "history"
def __init__(self, ctx: StreamContext):
super().__init__(ctx)
self.interval_seconds = ctx.config.snapshot_interval_seconds
def poll(self) -> int:
ctx, total = self.ctx, 0
initial = window_start(ctx.config.initial_backfill_hours, time.time())
for name, importer in INCREMENTAL:
if ctx.stop.is_set():
break
key = cursor_key(name)
since = ctx.state.get(key)
first = since is None
if first:
if initial is None:
ctx.state.set(key, time.time()) # history import disabled
continue
since = initial
try:
count, newest = importer(ctx, since)
except RepeaterAPIError as exc:
# Older Repeaters may lack an endpoint; keep the other sources going.
self.log.debug("history %s skipped: %s", name, exc)
continue
if ctx.stop.is_set():
break
# Advance only after the whole window shipped; inclusive next time.
ctx.state.set(key, max(newest, since))
total += count
if first and count:
self.log.info("Imported %d stored %s rows", count, name)
if initial is not None and not ctx.state.get(RRD_STATE_KEY) and not ctx.stop.is_set():
try:
count = import_rrd(ctx, initial)
except RepeaterAPIError as exc:
if exc.status != 404:
# Transient (or server-side) failure: try again next poll.
self.log.warning("RRD history import failed, will retry: %s", exc)
ctx.state.save()
return total
self.log.info("RRD history not available on this Repeater: %s", exc)
count = 0
if not ctx.stop.is_set():
ctx.state.set(RRD_STATE_KEY, True)
total += count
if count:
self.log.info("Imported %d RRD history points", count)
ctx.state.save()
return total
class BackfillRequestWorker(threading.Thread):
"""Runs a manual history import when ``backfill_request_id`` changes.
Lets users without shell access trigger a re-import from the Repeater's
plugin Settings dialog: set a new id and save with restart.
"""
stream_name = "backfill"
STATE_KEY = "backfill_request_done"
def __init__(self, ctx: StreamContext, log):
super().__init__(name="stream-backfill", daemon=True)
self.ctx = ctx
self.log = log
ctx.status.stream(self.stream_name)
@classmethod
def pending(cls, ctx: StreamContext) -> bool:
request = ctx.config.backfill_request_id
return bool(request) and ctx.state.get(cls.STATE_KEY) != request
def interrupt(self) -> None:
pass
def run(self) -> None:
backoff = 5.0
status = self.ctx.status
while not self.ctx.stop.is_set():
status.ok(self.stream_name, state="importing")
counts = run_backfill(self.ctx, self.ctx.config.backfill_hours, self.log)
if self.ctx.stop.is_set():
return
if counts and min(counts.values()) < 0:
status.error(self.stream_name, "some history sources failed; retrying")
if self.ctx.stop.wait(backoff):
return
backoff = min(MAX_RECONNECT_BACKOFF * 5, backoff * 2)
continue
self.ctx.state.set(self.STATE_KEY, self.ctx.config.backfill_request_id)
self.ctx.state.save()
status.ok(self.stream_name, sum(counts.values()), state="done")
return
+62 -34
View File
@@ -3,12 +3,13 @@ from __future__ import annotations
import json
import socket
import time
from collections.abc import Iterator
from typing import Any
import websocket
from ..lineprotocol import Point, seconds_to_ns
from .base import StreamContext, StreamWorker
from .base import StreamContext, StreamWorker, wait_for_writer, window_start
# MeshCore payload / route type names (openhop_core.protocol constants).
PAYLOAD_TYPES = {
@@ -51,7 +52,6 @@ STRING_FIELDS = (
)
BACKFILL_PAGE = 5000
BACKFILL_MAX_ROWS = 200_000
RECV_TIMEOUT = 75.0 # server pings every ~30 s; silence beyond this means a dead link
STATE_KEY = "packets_last_timestamp"
@@ -144,6 +144,43 @@ def packet_to_point(
)
def _fmt_ts(ts: float) -> str:
return "the beginning" if ts <= 0 else time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
def iter_packet_pages(ctx: StreamContext, since: float) -> Iterator[list[dict[str, Any]]]:
"""Yield pages of stored packets with timestamp >= since, newest first.
Pages are keyed on timestamp (end_timestamp = oldest row of the previous
page) rather than a growing OFFSET, which stays fast on large tables thanks
to the Repeater's packets.timestamp index. Boundary rows can repeat; that is
harmless because rewriting a packet point is idempotent.
"""
end: float | None = None
offset = 0
while not ctx.stop.is_set():
wait_for_writer(ctx)
params: dict[str, Any] = {
"limit": BACKFILL_PAGE,
"offset": offset,
"start_timestamp": since,
}
if end is not None:
params["end_timestamp"] = end
page = ctx.api.get("/api/bulk_packets", params)
rows = [r for r in page if isinstance(r, dict)] if isinstance(page, list) else []
if not rows:
return
yield rows
if len(page) < BACKFILL_PAGE:
return
oldest = min(float(r.get("timestamp") or 0) for r in rows)
if end is not None and oldest >= end:
offset += BACKFILL_PAGE # a whole page shares one timestamp; step past it
else:
end, offset = oldest, 0
class PacketStream(StreamWorker):
"""Live packets from /ws/packets, with REST backfill of any gap on (re)connect."""
@@ -189,46 +226,37 @@ class PacketStream(StreamWorker):
self.ctx.state.save()
self._last_saved = time.monotonic()
def _wait_for_writer(self) -> None:
"""Backpressure: never let a large backfill overflow the writer queue."""
limit = self.ctx.writer.max_queue // 2
while self.ctx.writer.queued() > limit and not self.ctx.stop.is_set():
self.ctx.stop.wait(0.2)
def backfill(self, since: float | None = None) -> int:
"""Import stored packets from /api/bulk_packets.
def backfill(self) -> int:
"""Fetch packets newer than the last one shipped (or the initial window)."""
if self.last_ts is not None:
start = self.last_ts
elif self.ctx.config.initial_backfill_hours > 0:
start = time.time() - self.ctx.config.initial_backfill_hours * 3600
else:
return 0
total = 0
offset = 0
newest = 0.0
# Pages arrive newest-first. Write order does not matter to InfluxDB, so
# stream each page straight through; only advance the resume cursor once
# the whole window is shipped, so an interrupted backfill is redone.
while offset < BACKFILL_MAX_ROWS and not self.ctx.stop.is_set():
self._wait_for_writer()
page = self.ctx.api.get(
"/api/bulk_packets",
{"limit": BACKFILL_PAGE, "offset": offset, "start_timestamp": start},
With ``since`` None this resumes from the persisted cursor (or, on first
start, from ``initial_backfill_hours``) and advances the cursor once the
whole window has shipped, so an interrupted import is redone. An explicit
``since`` is a manual re-import and leaves the cursor alone.
"""
manual = since is not None
if not manual:
since = (
self.last_ts
if self.last_ts is not None
else window_start(self.ctx.config.initial_backfill_hours, time.time())
)
if not isinstance(page, list) or not page:
break
count, page_newest = self._emit([r for r in page if isinstance(r, dict)], advance=False)
if since is None:
return 0
total = 0
newest = 0.0
for page in iter_packet_pages(self.ctx, since):
count, page_newest = self._emit(page, advance=False)
total += count
newest = max(newest, page_newest)
if len(page) < BACKFILL_PAGE:
break
offset += BACKFILL_PAGE
if total and total % 50_000 < len(page):
self.log.info("Packet import: %d packets so far", total)
if self.ctx.stop.is_set():
return total
if newest:
if newest and not manual:
self._advance(newest)
if total:
self.log.info("Backfilled %d packets since %.3f", total, start)
self.log.info("Imported %d stored packets since %s", total, _fmt_ts(since))
self.ctx.status.ok(self.stream_name, total)
return total
+95 -3
View File
@@ -74,10 +74,74 @@ PACKETS = [
]
DAY = 86400
# History older than the 24 h default window, to prove "all" reaches it.
ADVERTS = [
{
"timestamp": NOW - 20 * DAY,
"pubkey": "ab12cd34",
"node_name": "Alice",
"is_repeater": 0,
"route_type": 1,
"contact_type": "Chat Node",
"latitude": 40.1,
"longitude": -75.2,
"first_seen": NOW - 20 * DAY,
"last_seen": NOW - 20 * DAY,
"rssi": -91,
"snr": 6.0,
"advert_count": 1,
"is_new_neighbor": 1,
"zero_hop": 1,
},
{
"timestamp": NOW - 2 * DAY,
"pubkey": "ab12cd34",
"node_name": "Alice",
"is_repeater": 0,
"route_type": 1,
"contact_type": "Chat Node",
"latitude": 40.1,
"longitude": -75.2,
"first_seen": NOW - 20 * DAY,
"last_seen": NOW - 2 * DAY,
"rssi": -88,
"snr": 7.0,
"advert_count": 2,
"is_new_neighbor": 0,
"zero_hop": 1,
},
]
NOISE = [{"timestamp": NOW - h * 3600, "noise_floor_dbm": -110.0 - h % 3} for h in range(0, 600)]
CRC = [{"timestamp": NOW - 10 * DAY, "count": 1}, {"timestamp": NOW - 60, "count": 2}]
def rrd_payload(start: int, end: int) -> dict:
"""Mimic rrdtool fetch: the finest archive that covers [start, end]."""
age = NOW - start
step = 60 if age <= 7 * DAY + 60 else 300 if age <= 30 * DAY + 300 else 3600
stamps = list(range(start - start % step + step, end, step))[:50]
return {
"start_time": start,
"end_time": end,
"step": step,
"timestamps": stamps,
"metrics": {
"rx_count": [0.5] * len(stamps),
"avg_rssi": [-95.0] * (len(stamps) - 1) + [None],
},
"packet_types": {"type_4": [0.1] * len(stamps)},
"data_source": "rrd",
}
class FakeRepeater:
def __init__(self):
self.requests: list[tuple[str, dict, dict]] = []
self.token = "secret-token"
self.packets = list(PACKETS)
self.rrd_source = "rrd"
outer = self
class Handler(BaseHTTPRequestHandler):
@@ -107,9 +171,37 @@ class FakeRepeater:
)
if parts.path == "/api/bulk_packets":
start = float(query.get("start_timestamp", 0))
rows = [p for p in PACKETS if p["timestamp"] >= start]
offset = int(query.get("offset", 0))
return self._json(200, {"success": True, "data": rows[offset:]})
end = float(query.get("end_timestamp", 1e12))
rows = sorted(
(p for p in outer.packets if start <= p["timestamp"] <= end),
key=lambda p: p["timestamp"],
reverse=True,
)
offset, limit = int(query.get("offset", 0)), int(query.get("limit", 1000))
return self._json(200, {"success": True, "data": rows[offset : offset + limit]})
if parts.path == "/api/adverts_by_contact_type":
cutoff = NOW - int(query["hours"]) * 3600
rows = [
a
for a in ADVERTS
if a["contact_type"] == query["contact_type"] and a["timestamp"] > cutoff
][::-1]
offset, limit = int(query["offset"]), int(query["limit"])
return self._json(200, {"success": True, "data": rows[offset : offset + limit]})
if parts.path == "/api/noise_floor_history":
cutoff = NOW - int(query["hours"]) * 3600
rows = [n for n in NOISE if n["timestamp"] > cutoff]
offset, limit = int(query["offset"]), int(query["limit"])
page = rows[offset : offset + limit][::-1]
return self._json(200, {"success": True, "data": {"history": page}})
if parts.path == "/api/crc_error_history":
cutoff = NOW - int(query["hours"]) * 3600
rows = [c for c in CRC if c["timestamp"] > cutoff]
return self._json(200, {"success": True, "data": {"history": rows}})
if parts.path == "/api/rrd_data":
data = rrd_payload(int(query["start_time"]), int(query["end_time"]))
data["data_source"] = outer.rrd_source
return self._json(200, {"success": True, "data": data})
if parts.path == "/api/db_stats":
return self._json(
200, {"success": True, "data": {"packets": 2, "size": {"bytes": 4096}}}
+256
View File
@@ -0,0 +1,256 @@
import json
import logging
import threading
import pytest
from openhop_influxdb_plugin.config import ConfigError, parse_config, parse_hours
from openhop_influxdb_plugin.main import main
from openhop_influxdb_plugin.repeater_api import RepeaterAPI
from openhop_influxdb_plugin.state import PersistentState
from openhop_influxdb_plugin.status import Status
from openhop_influxdb_plugin.streams import packets as packets_mod
from openhop_influxdb_plugin.streams.base import StreamContext
from openhop_influxdb_plugin.streams.history import (
RRD_STATE_KEY,
BackfillRequestWorker,
HistoryStream,
cursor_key,
rrd_points,
run_backfill,
)
from openhop_influxdb_plugin.streams.packets import PacketStream
from .conftest import ADVERTS, CRC, DAY, NOISE, NOW, rrd_payload
from .test_streams import CaptureWriter
log = logging.getLogger("test")
@pytest.fixture
def ctx(make_config, repeater, tmp_path):
def factory(**overrides):
cfg = make_config(repeater_url=repeater.url, **overrides)
return StreamContext(
cfg,
RepeaterAPI(cfg),
CaptureWriter(cfg),
Status(),
PersistentState(tmp_path / "state.json"),
tmp_path,
threading.Event(),
)
return factory
def _by(points, measurement):
return [p for p in points if p.measurement == measurement]
@pytest.mark.parametrize(
"value, expected", [("all", None), ("ALL ", None), (48, 48), ("48", 48), (0, 0)]
)
def test_parse_hours(value, expected):
assert parse_hours(value) == expected
@pytest.mark.parametrize("value", [-1, "forever", True, 24 * 3650 + 1])
def test_parse_hours_rejects(value):
with pytest.raises(ConfigError, match='or "all"'):
parse_hours(value)
def test_defaults_import_everything():
cfg = parse_config({})
assert cfg.initial_backfill_hours is None and cfg.backfill_hours is None
assert cfg.stream_history is True
def test_packet_import_all_reaches_old_rows_and_pages_by_timestamp(ctx, repeater, monkeypatch):
# 250 old packets, 120 of which share one timestamp (more than a page), plus the
# fixtures. With a page of 50 this forces both keyset paging and the offset fallback.
monkeypatch.setattr(packets_mod, "BACKFILL_PAGE", 50)
old = [
dict(repeater.packets[1], packet_hash=f"old{i}", timestamp=NOW - 40 * DAY + i)
for i in range(130)
]
same = [
dict(repeater.packets[1], packet_hash=f"same{i}", timestamp=NOW - 35 * DAY)
for i in range(120)
]
repeater.packets += old + same
c = ctx() # initial_backfill_hours defaults to "all"
stream = PacketStream(c)
stream.backfill()
hashes = {p.fields["packet_hash"] for p in c.writer.points}
assert len(hashes) == len(repeater.packets) == 252
calls = [q for path, q, _ in repeater.requests if path == "/api/bulk_packets"]
assert any("end_timestamp" in q for q in calls)
assert any(int(q["offset"]) > 0 for q in calls)
assert stream.last_ts == max(p["timestamp"] for p in repeater.packets)
def test_manual_packet_import_leaves_cursor(ctx):
c = ctx()
stream = PacketStream(c)
stream.last_ts = 123.0
assert stream.backfill(since=0.0) == 2
assert stream.last_ts == 123.0
def test_history_stream_imports_all_then_resumes(ctx, repeater):
c = ctx()
stream = HistoryStream(c)
stream.poll()
adverts = _by(c.writer.points, "openhop_advert_received")
assert len(adverts) == len(ADVERTS) # includes the 20-day-old advert
assert adverts[0].tags["pubkey"] == "ab12cd34"
assert {a.tags["new_neighbor"] for a in adverts} == {"true", "false"}
assert len(_by(c.writer.points, "openhop_noise_floor")) == len(NOISE)
assert len(_by(c.writer.points, "openhop_crc_error")) == len(CRC)
assert _by(c.writer.points, "openhop_rrd")
assert c.state.get(RRD_STATE_KEY) is True
assert c.state.get(cursor_key("noise_floor")) == pytest.approx(NOW)
saved = json.loads((c.data_dir / "state.json").read_text())
assert saved[cursor_key("adverts")] == pytest.approx(NOW - 2 * DAY)
c.writer.points.clear()
stream.poll()
# Only the inclusive boundary rows come again; RRD is imported once only.
assert len(_by(c.writer.points, "openhop_noise_floor")) == 1
assert len(_by(c.writer.points, "openhop_advert_received")) == 1
assert not _by(c.writer.points, "openhop_rrd")
def test_history_window_limits_import(ctx):
c = ctx(initial_backfill_hours=24)
HistoryStream(c).poll()
assert not _by(c.writer.points, "openhop_advert_received")
# Hourly samples at 0..599 h old: 0..23 h are inside a 24 h window.
assert len(_by(c.writer.points, "openhop_noise_floor")) == 24
assert len(_by(c.writer.points, "openhop_crc_error")) == 1
assert {p.tags["resolution"] for p in _by(c.writer.points, "openhop_rrd")} == {"1m"}
def test_history_import_disabled_starts_from_now(ctx):
c = ctx(initial_backfill_hours=0)
HistoryStream(c).poll()
assert c.writer.points == []
assert c.state.get(cursor_key("adverts")) >= NOW
def test_locations_respected_in_advert_history(ctx):
c = ctx(include_locations=False)
HistoryStream(c).poll()
assert all("latitude" not in p.fields for p in _by(c.writer.points, "openhop_advert_received"))
def test_rrd_points_rename_counters_and_skip_nulls():
start = int(NOW - DAY)
points = rrd_points(rrd_payload(start, start + 300), since=0)
assert points[0].tags == {"resolution": "1m"}
assert points[0].fields == {
"rx_count_per_second": 0.5,
"avg_rssi": -95.0,
"type_4_per_second": 0.1,
}
assert "avg_rssi" not in points[-1].fields
def test_rrd_resolution_comes_from_returned_step():
from openhop_influxdb_plugin.streams.history import rrd_resolution
assert [rrd_resolution({"step": s}) for s in (60, 300, 3600, 120)] == ["1m", "5m", "1h", "120s"]
def test_rrd_tier_requests_stay_inside_each_archive(ctx, repeater):
c = ctx()
HistoryStream(c).poll()
calls = [q for path, q, _ in repeater.requests if path == "/api/rrd_data"]
ages = sorted(int(NOW) - int(q["start_time"]) for q in calls)
assert len(ages) == 3
for age, limit in zip(ages, (7 * DAY, 30 * DAY, 365 * DAY), strict=True):
assert limit - 2 * 3600 < age < limit
def test_rrd_tiers_cover_a_year(ctx):
c = ctx()
HistoryStream(c).poll()
tiers = {p.tags["resolution"] for p in _by(c.writer.points, "openhop_rrd")}
assert tiers == {"1m", "5m", "1h"}
def test_rrd_skipped_without_rrdtool(ctx, repeater):
repeater.rrd_source = "sqlite"
c = ctx()
HistoryStream(c).poll()
assert not _by(c.writer.points, "openhop_rrd")
assert "RRD not available" in c.status.streams["history"].last_error
def test_run_backfill_reimports_without_touching_cursors(ctx):
c = ctx()
c.state.set(cursor_key("adverts"), NOW)
counts = run_backfill(c, None, log)
assert counts["packets"] == 2 and counts["adverts"] == 2
assert counts["noise_floor"] == len(NOISE) and counts["rrd"] > 0
assert c.state.get(cursor_key("adverts")) == NOW
assert run_backfill(c, 0, log) == {}
def test_backfill_request_worker_runs_once(ctx):
c = ctx(backfill_request_id="r1", backfill_hours=49)
assert BackfillRequestWorker.pending(c)
worker = BackfillRequestWorker(c, log)
worker.run()
assert c.state.get(BackfillRequestWorker.STATE_KEY) == "r1"
assert not BackfillRequestWorker.pending(c)
assert c.status.streams["backfill"].state == "done"
assert len(_by(c.writer.points, "openhop_advert_received")) == 1 # 2-day-old one only
def test_backfill_cli(tmp_path, repeater, influx):
(tmp_path / "config.json").write_text(
json.dumps(
{
"repeater_url": repeater.url,
"repeater_api_token": "secret-token",
"influx_url": influx.url,
"influx_org": "o",
}
)
)
assert main(["--data-dir", str(tmp_path), "--backfill"]) == 0
lines = influx.lines
assert sum(line.startswith("openhop_packet,") for line in lines) == 2
assert sum(line.startswith("openhop_advert_received,") for line in lines) == 2
assert sum(line.startswith("openhop_noise_floor,") for line in lines) == len(NOISE)
assert any(line.startswith("openhop_rrd,") for line in lines)
assert not (tmp_path / "state-influxdb.json").exists() # cursors belong to the service
assert main(["--data-dir", str(tmp_path), "--backfill", "nonsense"]) == 2
def test_rrd_failure_is_retried_but_missing_endpoint_is_not(ctx, repeater, monkeypatch):
from openhop_influxdb_plugin.repeater_api import RepeaterAPIError
from openhop_influxdb_plugin.streams import history as history_mod
c = ctx()
stream = HistoryStream(c)
errors = [RepeaterAPIError("boom", 500)]
def flaky(ctx_, since):
if errors:
raise errors.pop(0)
return 7
monkeypatch.setattr(history_mod, "import_rrd", flaky)
stream.poll()
assert not c.state.get(RRD_STATE_KEY) # 500: not marked, retried next poll
stream.poll()
assert c.state.get(RRD_STATE_KEY) is True
c2 = ctx()
errors.append(RepeaterAPIError("missing", 404))
HistoryStream(c2).poll()
assert c2.state.get(RRD_STATE_KEY) is True # 404: endpoint absent, stop asking
+1 -1
View File
@@ -87,7 +87,7 @@ def test_service_runs_writes_and_stops_within_budget(tmp_path, repeater, influx)
time.sleep(0.1)
runtime = json.loads((tmp_path / "runtime.json").read_text())
assert runtime["repeater_tag"] == "hilltop-rpt"
assert set(runtime["streams"]) == {"metrics", "snapshots", "packets", "logs"}
assert set(runtime["streams"]) == {"metrics", "snapshots", "history", "packets", "logs"}
finally:
started = time.time()
proc.send_signal(signal.SIGTERM)