mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-22 18:40:14 +00:00
fix(web-viewer): derive neighbours and hop distance from path evidence
307 direct neighbours was not plausible, and it was not real. complete_contact_tracking.hop_count claims 800 zero-hop contacts. Only 68 of them have any one-hop path in observed_paths to corroborate that. Their stored SNR piles up in a 1.5 dB band — 655 of 800 between 11.25 and 12.75 dB — and their RSSI clusters at -39..-48 dBm. Hundreds of radios at different distances and terrain cannot land in a 10 dB window. That is the signature of one strong local link being recorded against every node whose traffic happened to arrive through it. Their return paths agree: these "direct" contacts have out_path_len of 3 to 11. The writer's intent is sound — repeater_manager only stores RSSI/SNR when signal_info reports hops == 0 — so the field being fed to it does not mean what the surrounding code assumes. Left as is; this change stops the dashboard depending on it. Neighbour membership now comes from path evidence: an advert whose path_length equals its bytes_per_hop travelled exactly one hop. That yields 38 nodes in 24h and 124 in 7d, with a plausible spread. Signal is shown only where the path evidence and the stored hop count agree, which is 5 and 12 nodes respectively; the rest read "no signal reading" rather than borrowing a measurement taken on somebody else's link. A 24h/7d selector bounds the window, capped well under observed_paths' 90-day retention because a month-old link says nothing about today. Separately, this fixes a bug I introduced. path_length is a BYTE count, and with 2- or 3-byte hop encoding a 3-hop path is 6 or 9 bytes long. The path-length histogram plotted that raw value on an axis readers would take as hops, overstating distance two- to threefold on a mesh that is ~95% multibyte. It is replaced by a single hops-away chart computed as path_length / bytes_per_hop, which also retires the histogram built on the untrustworthy stored hop count. The result is unimodal, peaking at 3 hops and decaying — the shape a mesh should have, and not the bimodal one the old chart drew.
This commit is contained in:
+17
-7
@@ -23,13 +23,23 @@ semantic versioning.
|
||||
- New dashboard tiles and charts: routing mix (flood vs direct), hop-count and
|
||||
path-length histograms, a 30-day multibyte adoption trend, busiest repeaters,
|
||||
and a role mix.
|
||||
- Direct-neighbour signal panel: the nodes heard with no repeater in between,
|
||||
their SNR distribution, and the weakest links named. Scoped to `hop_count = 0`
|
||||
because that is the only population where SNR means anything — a relayed
|
||||
packet's SNR measures the last hop into this radio, not the link to whoever
|
||||
sent it, so a network-wide average describes nothing in particular.
|
||||
`complete_contact_tracking` records signal for exactly the zero-hop contacts,
|
||||
so this is a complete census of the neighbours rather than a sample.
|
||||
- One-hop neighbours panel, with a 24-hour / 7-day selector: the nodes whose
|
||||
advert reached this radio in a single hop, weakest measured link first.
|
||||
Membership is derived from observed path evidence rather than from
|
||||
`complete_contact_tracking.hop_count`, which is not trustworthy — it claims
|
||||
800 zero-hop contacts on the live database while only 68 have any one-hop
|
||||
path to corroborate it, their stored SNR piles up in a 1.5 dB band (655 of
|
||||
800 between 11.25 and 12.75 dB), and their RSSI clusters near -45 dBm. That
|
||||
is the signature of one strong local link being recorded against every node
|
||||
whose traffic arrived through it, not of hundreds of separate radios. SNR is
|
||||
therefore shown only where the path evidence and the stored hop count agree;
|
||||
the rest read "no signal reading" rather than borrowing another link's
|
||||
measurement.
|
||||
- Hop-distance chart derived as `path_length / bytes_per_hop`. `path_length` is
|
||||
a byte count, so with 2- or 3-byte hop encoding — about 95% of adverts on the
|
||||
live mesh — reading it directly as a hop count overstates distance two- to
|
||||
threefold. This replaces both the raw path-length chart and the chart built
|
||||
on the untrustworthy stored hop count.
|
||||
- New `[Web_Viewer]` settings: `dashboard_snapshot_enabled`,
|
||||
`dashboard_snapshot_interval_seconds`, `dashboard_snapshot_history_days`, and
|
||||
`dashboard_packet_backfill_rows`.
|
||||
|
||||
+19
-9
@@ -141,17 +141,27 @@ proxy_set_header X-Forwarded-Proto $scheme;
|
||||
and snapshot age with a manual refresh control
|
||||
- **Mesh**: nodes heard, adverts, new nodes, nodes gone quiet, and geographic
|
||||
coverage — the count tiles carry a 30-day sparkline and a change chip
|
||||
- Routing mix (flood vs direct), hop-count and path-length histograms, and the
|
||||
role mix
|
||||
- Routing mix (flood vs direct), a hop-distance chart, and the role mix
|
||||
- Path encoding: multibyte share among contacts and among incoming packets,
|
||||
plus a 30-day multibyte adoption trend
|
||||
- Busiest repeaters, and **direct neighbours** — the nodes heard with no
|
||||
repeater in between, their SNR distribution, and the weakest links named.
|
||||
Restricted to `hop_count = 0` deliberately: a relayed packet's SNR measures
|
||||
the last hop into this radio, not the link to whoever originated it, so
|
||||
mixing hop counts together yields a number that describes nothing. The bot's
|
||||
contact records store signal for exactly the zero-hop contacts, which makes
|
||||
this a complete census of the neighbours rather than a sample.
|
||||
- Busiest repeaters, and **one-hop neighbours** (24-hour or 7-day window)
|
||||
|
||||
Two measurement notes for the mesh charts:
|
||||
|
||||
**Hops are derived, not read.** `observed_paths.path_length` is a *byte* count,
|
||||
and with 2- or 3-byte hop encoding a three-hop path is six or nine bytes long.
|
||||
The dashboard divides by `bytes_per_hop`; charting the raw value would overstate
|
||||
distance two- to threefold on a mesh that is ~95% multibyte.
|
||||
|
||||
**Neighbour signal is reported only where two sources agree.**
|
||||
`complete_contact_tracking.hop_count` is not a reliable direct-neighbour marker:
|
||||
on a representative database it claims 800 zero-hop contacts while only 68 have
|
||||
any one-hop path to corroborate it, and the SNR stored against them clusters in
|
||||
a ~1.5 dB band with RSSI near -45 dBm — one strong local link recorded against
|
||||
every node whose traffic arrived through it. Neighbour membership therefore
|
||||
comes from path evidence, and SNR/RSSI appear only when the stored hop count
|
||||
agrees; otherwise the row reads "no signal reading". A relayed packet's SNR
|
||||
measures the last hop into this radio, never the link to whoever sent it.
|
||||
- **Bot**: messages, commands, reply rate, and unique users, plus the top
|
||||
commands/users/channels and longest paths
|
||||
- Live activity feed
|
||||
|
||||
@@ -33,7 +33,6 @@ Two conventions matter throughout:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
@@ -85,16 +84,21 @@ SERIES_METRICS: dict[str, str] = {
|
||||
|
||||
SUMMARY_SERIES_POINTS = 30
|
||||
|
||||
# Direct-neighbour signal panel: how many of the weakest links to list, and the
|
||||
# width of the SNR histogram buckets in dB.
|
||||
WEAKEST_NEIGHBOURS = 8
|
||||
SNR_BIN_DB = 2
|
||||
|
||||
# Metrics that are already a ratio: a period "total" has to be the mean of the
|
||||
# daily values, not their sum — adding percentages together means nothing.
|
||||
RATIO_METRICS = frozenset({"multibyte_share"})
|
||||
|
||||
TOP_KINDS = ("users", "commands", "channels", "paths", "repeaters")
|
||||
TOP_KINDS = ("users", "commands", "channels", "paths", "repeaters", "neighbors")
|
||||
|
||||
# Neighbour windows are capped well below observed_paths' 90-day retention: a
|
||||
# link last exercised a month ago says nothing about whether it works today.
|
||||
NEIGHBOR_WINDOWS = ("24h", "7d")
|
||||
|
||||
# A path is one hop when its byte length equals the per-hop encoding width.
|
||||
# path_length is measured in BYTES, and with 2- or 3-byte hop encoding a 3-hop
|
||||
# path is 6 or 9 bytes long — reading the raw value as a hop count inflates
|
||||
# every multibyte path by 2-3x.
|
||||
ONE_HOP_PATH = "op.bytes_per_hop > 0 AND op.path_length = op.bytes_per_hop"
|
||||
|
||||
# Roles the firmware reports as an unmapped enum ordinal. They are real
|
||||
# contacts, so they belong in the mix — just not as sixteen singleton slices.
|
||||
@@ -124,22 +128,6 @@ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
||||
return row is not None
|
||||
|
||||
|
||||
def _percentile(sorted_values: list[float], fraction: float) -> float | None:
|
||||
"""Linear-interpolated percentile over a pre-sorted list."""
|
||||
if not sorted_values:
|
||||
return None
|
||||
if len(sorted_values) == 1:
|
||||
return round(float(sorted_values[0]), 1)
|
||||
position = fraction * (len(sorted_values) - 1)
|
||||
low = math.floor(position)
|
||||
high = math.ceil(position)
|
||||
if low == high:
|
||||
return round(float(sorted_values[low]), 1)
|
||||
weight = position - low
|
||||
value = sorted_values[low] * (1 - weight) + sorted_values[high] * weight
|
||||
return round(float(value), 1)
|
||||
|
||||
|
||||
def _change_pct(current: float | None, previous: float | None) -> float | None:
|
||||
"""Percent change, or None when the baseline cannot support one.
|
||||
|
||||
@@ -727,32 +715,14 @@ class DashboardStatsService:
|
||||
mesh["states"] = row[1] or 0
|
||||
mesh["cities"] = row[2] or 0
|
||||
|
||||
mesh["hop_histogram"] = [
|
||||
[int(hop), int(count)]
|
||||
for hop, count in conn.execute(
|
||||
"""
|
||||
SELECT hop_count, COUNT(*) FROM complete_contact_tracking
|
||||
WHERE hop_count IS NOT NULL AND hop_count BETWEEN 0 AND 32
|
||||
GROUP BY hop_count ORDER BY hop_count
|
||||
"""
|
||||
)
|
||||
]
|
||||
mesh["role_mix"] = self._mix(conn, "role")
|
||||
mesh["neighbors"] = self._direct_neighbor_signal(conn)
|
||||
|
||||
if sources & SOURCE_OBSERVED_PATHS:
|
||||
mesh["path_len_histogram"] = [
|
||||
[int(length), int(count)]
|
||||
for length, count in conn.execute(
|
||||
"""
|
||||
SELECT path_length, COUNT(*) FROM observed_paths
|
||||
WHERE packet_type = 'advert' AND path_length IS NOT NULL
|
||||
AND path_length BETWEEN 0 AND 32
|
||||
AND last_seen >= datetime('now','localtime','-7 days')
|
||||
GROUP BY path_length ORDER BY path_length
|
||||
"""
|
||||
)
|
||||
]
|
||||
mesh["hops_histogram"] = self._hops_histogram(conn)
|
||||
mesh["neighbors"] = {
|
||||
window: self._count_one_hop_nodes(conn, window)
|
||||
for window in NEIGHBOR_WINDOWS
|
||||
}
|
||||
|
||||
if sources & SOURCE_PACKET_STREAM:
|
||||
row = conn.execute(
|
||||
@@ -795,74 +765,100 @@ class DashboardStatsService:
|
||||
buckets[key] = buckets.get(key, 0) + (count or 0)
|
||||
return [[name, count] for name, count in sorted(buckets.items(), key=lambda kv: -kv[1])]
|
||||
|
||||
def _direct_neighbor_signal(self, conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
"""Per-neighbour signal for nodes heard directly, weakest link first.
|
||||
def _hops_histogram(self, conn: sqlite3.Connection) -> list[list[int]]:
|
||||
"""Nodes by how many hops away their closest observed advert path was.
|
||||
|
||||
Scoped to ``hop_count = 0`` because that is the only population where
|
||||
SNR means anything: a relayed packet's SNR describes the last hop into
|
||||
this radio, not the link to the node that originated it. Averaging
|
||||
those together produces a number that describes nothing in particular.
|
||||
|
||||
This is also why the data is complete rather than sampled —
|
||||
complete_contact_tracking records snr and signal_strength for exactly
|
||||
the zero-hop contacts and leaves them NULL for everything further away.
|
||||
|
||||
Recency-scoped as well: a neighbour last heard two months ago says
|
||||
nothing about the link today.
|
||||
Derived from ``path_length / bytes_per_hop`` rather than read from
|
||||
``complete_contact_tracking.hop_count``. Two reasons: path_length is a
|
||||
byte count, so with multibyte encoding the raw value overstates hops by
|
||||
2-3x; and the stored hop_count disagrees with the path evidence badly
|
||||
enough at the low end to be unusable (see _one_hop_rows).
|
||||
"""
|
||||
window_days = 7
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT public_key, name, role, snr, signal_strength, hop_count, last_heard
|
||||
FROM complete_contact_tracking
|
||||
WHERE hop_count = 0 AND snr IS NOT NULL
|
||||
AND last_heard > datetime('now', 'localtime', ?)
|
||||
ORDER BY snr ASC
|
||||
""",
|
||||
(f"-{window_days} days",),
|
||||
SELECT MIN(path_length / bytes_per_hop) AS hops
|
||||
FROM observed_paths
|
||||
WHERE packet_type = 'advert' AND bytes_per_hop > 0
|
||||
AND last_seen >= datetime('now','localtime','-7 days')
|
||||
GROUP BY public_key
|
||||
"""
|
||||
).fetchall()
|
||||
counts: dict[int, int] = {}
|
||||
for row in rows:
|
||||
hops = row["hops"]
|
||||
if hops is None or not 0 <= hops <= 32:
|
||||
continue
|
||||
counts[int(hops)] = counts.get(int(hops), 0) + 1
|
||||
if not counts:
|
||||
return []
|
||||
return [[hop, counts.get(hop, 0)] for hop in range(min(counts), max(counts) + 1)]
|
||||
|
||||
values = sorted(float(row["snr"]) for row in rows)
|
||||
rssi_values = sorted(
|
||||
float(row["signal_strength"]) for row in rows if row["signal_strength"] is not None
|
||||
)
|
||||
return {
|
||||
"count": len(rows),
|
||||
"window_days": window_days,
|
||||
"snr": {
|
||||
"min": round(values[0], 1) if values else None,
|
||||
"p50": _percentile(values, 0.50),
|
||||
"max": round(values[-1], 1) if values else None,
|
||||
},
|
||||
"rssi": {"p50": _percentile(rssi_values, 0.50)},
|
||||
"histogram": self._snr_histogram(values),
|
||||
"weakest": [
|
||||
{
|
||||
"name": row["name"] or (row["public_key"] or "")[:12],
|
||||
"role": normalize_role(row["role"]),
|
||||
"snr": round(float(row["snr"]), 1),
|
||||
"rssi": (
|
||||
round(float(row["signal_strength"]), 0)
|
||||
if row["signal_strength"] is not None
|
||||
else None
|
||||
),
|
||||
"last_heard": row["last_heard"],
|
||||
}
|
||||
for row in rows[:WEAKEST_NEIGHBOURS]
|
||||
],
|
||||
}
|
||||
def _count_one_hop_nodes(self, conn: sqlite3.Connection, window: str) -> int:
|
||||
return conn.execute(
|
||||
f"""
|
||||
SELECT COUNT(DISTINCT op.public_key) FROM observed_paths op
|
||||
WHERE op.packet_type = 'advert' AND {ONE_HOP_PATH}
|
||||
AND op.last_seen > datetime('now','localtime', ?)
|
||||
""", # noqa: S608 - ONE_HOP_PATH is a module constant, not input
|
||||
(self._window_offset(window),),
|
||||
).fetchone()[0] or 0
|
||||
|
||||
@staticmethod
|
||||
def _snr_histogram(sorted_values: list[float]) -> list[list[Any]]:
|
||||
"""Bin neighbour SNR into fixed dB buckets so the shape is comparable over time."""
|
||||
if not sorted_values:
|
||||
return []
|
||||
bins: dict[int, int] = {}
|
||||
for value in sorted_values:
|
||||
edge = int(math.floor(value / SNR_BIN_DB) * SNR_BIN_DB)
|
||||
bins[edge] = bins.get(edge, 0) + 1
|
||||
low, high = min(bins), max(bins)
|
||||
return [[edge, bins.get(edge, 0)] for edge in range(low, high + SNR_BIN_DB, SNR_BIN_DB)]
|
||||
def _window_offset(window: str) -> str:
|
||||
return {"24h": "-1 days", "7d": "-7 days", "30d": "-30 days"}.get(window, "-7 days")
|
||||
|
||||
def _one_hop_rows(self, conn: sqlite3.Connection, window: str, limit: int) -> list[dict]:
|
||||
"""Nodes whose advert reached this radio in a single hop, weakest first.
|
||||
|
||||
Membership comes from path evidence, not from
|
||||
``complete_contact_tracking.hop_count``. That column claims 800 zero-hop
|
||||
contacts, but only 68 of them have any one-hop path to corroborate it,
|
||||
their stored SNR piles up in a 1.5 dB band (655 of 800 between 11.25 and
|
||||
12.75), and their RSSI clusters around -45 dBm — the signature of one
|
||||
strong local link being recorded against every node whose traffic came
|
||||
through it, not of hundreds of separate radios.
|
||||
|
||||
Signal is therefore reported only where the path evidence and the stored
|
||||
hop_count agree; everything else lists as unknown rather than being given
|
||||
a number that belongs to somebody else's link.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT op.public_key,
|
||||
MAX(op.last_seen) AS last_seen,
|
||||
c.name, c.role, c.snr, c.signal_strength, c.hop_count
|
||||
FROM observed_paths op
|
||||
LEFT JOIN complete_contact_tracking c ON c.public_key = op.public_key
|
||||
WHERE op.packet_type = 'advert' AND {ONE_HOP_PATH}
|
||||
AND op.last_seen > datetime('now','localtime', ?)
|
||||
GROUP BY op.public_key
|
||||
""", # noqa: S608 - ONE_HOP_PATH is a module constant, not input
|
||||
(self._window_offset(window),),
|
||||
).fetchall()
|
||||
|
||||
measured: list[dict[str, Any]] = []
|
||||
unmeasured: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
corroborated = row["hop_count"] == 0 and row["snr"] is not None
|
||||
item = {
|
||||
"name": row["name"] or (row["public_key"] or "")[:12],
|
||||
"public_key": row["public_key"],
|
||||
"role": normalize_role(row["role"]),
|
||||
"snr": round(float(row["snr"]), 1) if corroborated else None,
|
||||
"rssi": (
|
||||
round(float(row["signal_strength"]))
|
||||
if corroborated and row["signal_strength"] is not None
|
||||
else None
|
||||
),
|
||||
"signal_corroborated": corroborated,
|
||||
"last_seen": row["last_seen"],
|
||||
}
|
||||
(measured if corroborated else unmeasured).append(item)
|
||||
|
||||
# Weakest measured links first — those are the ones worth acting on.
|
||||
measured.sort(key=lambda item: item["snr"])
|
||||
unmeasured.sort(key=lambda item: item["last_seen"] or "", reverse=True)
|
||||
return (measured + unmeasured)[:limit]
|
||||
|
||||
def _bot_snapshot(self, conn: sqlite3.Connection, sources: int) -> dict[str, Any]:
|
||||
"""Rolling 24-hour bot activity (distinct from the calendar-day rollup)."""
|
||||
@@ -1200,7 +1196,16 @@ class DashboardStatsService:
|
||||
retention = self.stats_retention_days
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
if kind == "repeaters":
|
||||
total: int | None = None
|
||||
|
||||
if kind == "neighbors":
|
||||
if window not in NEIGHBOR_WINDOWS:
|
||||
window = NEIGHBOR_WINDOWS[0]
|
||||
retention = self.adverts_retention_days
|
||||
if _table_exists(conn, "observed_paths"):
|
||||
items = self._one_hop_rows(conn, window, limit)
|
||||
total = self._count_one_hop_nodes(conn, window)
|
||||
elif kind == "repeaters":
|
||||
retention = self.adverts_retention_days
|
||||
days = {"24h": 1, "7d": 7, "30d": 30, "90d": 90}.get(window, retention)
|
||||
since = (datetime.now() - timedelta(days=days - 1)).strftime("%Y-%m-%d")
|
||||
@@ -1288,6 +1293,7 @@ class DashboardStatsService:
|
||||
"truncated_by_retention": bool(
|
||||
requested_days is None or requested_days > retention
|
||||
),
|
||||
"total": total if total is not None else len(items),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@@ -1332,5 +1338,12 @@ class DashboardStatsService:
|
||||
"channels": stats_options,
|
||||
"paths": stats_options,
|
||||
"repeaters": options(self.adverts_retention_days),
|
||||
# Deliberately not retention-derived: observed_paths keeps 90
|
||||
# days, but a link last used a month ago tells you nothing about
|
||||
# whether it works now.
|
||||
"neighbors": [
|
||||
{"value": value, "label": self._window_label(value, self.adverts_retention_days)}
|
||||
for value in NEIGHBOR_WINDOWS
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -287,6 +287,18 @@
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Hatched, not empty: "we have no reading" reads differently from "zero". */
|
||||
.neighbor-row__track--unknown {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--bg-tertiary),
|
||||
var(--bg-tertiary) 3px,
|
||||
transparent 3px,
|
||||
transparent 6px
|
||||
);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.neighbor-row__values {
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -298,29 +310,6 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Signal percentile readout ─────────────────────────────────────────── */
|
||||
|
||||
.signal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.signal-grid__value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.signal-grid__label {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Leaderboard rows ──────────────────────────────────────────────────── */
|
||||
|
||||
.top-list-row {
|
||||
|
||||
@@ -316,6 +316,7 @@
|
||||
);
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
const data = await response.json();
|
||||
if (config.onLoad) config.onLoad(data);
|
||||
if (!data.items || data.items.length === 0) {
|
||||
container.replaceChildren(
|
||||
makeTextElement('div', config.empty, 'dashboard-empty')
|
||||
@@ -365,9 +366,7 @@
|
||||
|
||||
// Routing, encoding, distributions
|
||||
this.renderRouteMix(mesh.route_mix, coverage.packets_window_label);
|
||||
this.renderHistogram('hopHistogramChart', mesh.hop_histogram, 'Contacts', COLOR.accent);
|
||||
this.renderHistogram('pathLenHistogramChart', mesh.path_len_histogram, 'Advert paths', COLOR.flood);
|
||||
this.renderNeighbors(mesh.neighbors);
|
||||
this.renderHopsHistogram(mesh.hops_histogram);
|
||||
this.renderDoughnut('contactsEncodingChart', 'contacts-encoding-summary',
|
||||
(mesh.encoding || {}).contacts_7d, 'contacts');
|
||||
this.renderDoughnut('packetsEncodingChart', 'packets-encoding-summary',
|
||||
@@ -501,85 +500,11 @@
|
||||
);
|
||||
}
|
||||
|
||||
renderHistogram(canvasId, points, label, color) {
|
||||
renderHopsHistogram(points) {
|
||||
const canvasId = 'hopsHistogramChart';
|
||||
const canvas = el(canvasId);
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
const list = Array.isArray(points) ? points : [];
|
||||
const colors = themeColors();
|
||||
|
||||
if (this.charts[canvasId]) {
|
||||
this.charts[canvasId].destroy();
|
||||
delete this.charts[canvasId];
|
||||
}
|
||||
if (list.length === 0) return;
|
||||
|
||||
this.charts[canvasId] = new Chart(canvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: list.map((p) => p[0]),
|
||||
datasets: [{
|
||||
label: label,
|
||||
data: list.map((p) => p[1]),
|
||||
backgroundColor: color,
|
||||
borderRadius: 3,
|
||||
}],
|
||||
},
|
||||
options: noAnimation({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: colors.muted, font: { size: 10 } },
|
||||
grid: { display: false },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: colors.muted, font: { size: 10 }, precision: 0 },
|
||||
grid: { color: colors.grid },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct (zero-hop) neighbours: the distribution plus the weakest links.
|
||||
* Only these have a meaningful SNR — a relayed packet's SNR describes
|
||||
* the last hop, not the node that sent it.
|
||||
*/
|
||||
renderNeighbors(neighbors) {
|
||||
const data = neighbors || {};
|
||||
const snr = data.snr || {};
|
||||
const count = data.count || 0;
|
||||
|
||||
setText('neighbors-count', formatNumber(count));
|
||||
setText('neighbors-window', 'heard in the last ' + (data.window_days || 7) + 'd');
|
||||
setText('neighbors-snr-min', formatSigned(snr.min));
|
||||
setText('neighbors-snr-p50', formatSigned(snr.p50));
|
||||
setText('neighbors-snr-max', formatSigned(snr.max));
|
||||
setText('neighbors-rssi', formatSigned((data.rssi || {}).p50));
|
||||
|
||||
this.renderNeighborHistogram(data.histogram);
|
||||
|
||||
const list = el('neighbors-weakest');
|
||||
if (!list) return;
|
||||
const weakest = Array.isArray(data.weakest) ? data.weakest : [];
|
||||
if (weakest.length === 0) {
|
||||
list.replaceChildren(
|
||||
makeTextElement('div', 'No direct neighbours heard recently.', 'dashboard-empty')
|
||||
);
|
||||
return;
|
||||
}
|
||||
list.replaceChildren();
|
||||
weakest.forEach((item) => list.appendChild(neighborRow(item)));
|
||||
}
|
||||
|
||||
renderNeighborHistogram(histogram) {
|
||||
const canvasId = 'neighborSnrChart';
|
||||
const canvas = el(canvasId);
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
const bins = Array.isArray(histogram) ? histogram : [];
|
||||
const bins = Array.isArray(points) ? points : [];
|
||||
const colors = themeColors();
|
||||
|
||||
if (this.charts[canvasId]) {
|
||||
@@ -593,9 +518,9 @@
|
||||
data: {
|
||||
labels: bins.map((b) => b[0]),
|
||||
datasets: [{
|
||||
label: 'Neighbours',
|
||||
label: 'Nodes',
|
||||
data: bins.map((b) => b[1]),
|
||||
backgroundColor: bins.map((b) => snrColor(b[0])),
|
||||
backgroundColor: COLOR.accent,
|
||||
borderRadius: 3,
|
||||
}],
|
||||
},
|
||||
@@ -606,17 +531,14 @@
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const low = Number(items[0].label);
|
||||
return low + ' to ' + (low + 2) + ' dB SNR';
|
||||
},
|
||||
label: (ctx) => formatNumber(ctx.parsed.y) + ' neighbours',
|
||||
title: (items) => items[0].label + ' hops away',
|
||||
label: (ctx) => formatNumber(ctx.parsed.y) + ' nodes',
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
title: { display: true, text: 'SNR (dB)', color: colors.muted,
|
||||
title: { display: true, text: 'Hops away', color: colors.muted,
|
||||
font: { size: 10 } },
|
||||
ticks: { color: colors.muted, font: { size: 10 } },
|
||||
grid: { display: false },
|
||||
@@ -883,7 +805,13 @@
|
||||
return '#198754';
|
||||
}
|
||||
|
||||
/** One weakest-link row: name, SNR bar positioned on a fixed scale, values. */
|
||||
/**
|
||||
* One one-hop neighbour: name, SNR bar on a fixed scale, values.
|
||||
*
|
||||
* Signal is shown only where the stored hop count corroborates the path
|
||||
* evidence. For the rest the row says "no signal reading" rather than
|
||||
* borrowing a number measured on somebody else's link.
|
||||
*/
|
||||
function neighborRow(item) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'neighbor-row';
|
||||
@@ -891,27 +819,35 @@
|
||||
const name = makeTextElement('div', item.name, 'neighbor-row__name');
|
||||
name.title = String(item.name ?? '') + ' (' + String(item.role ?? '') + ')';
|
||||
|
||||
// Fixed -12..+14 dB scale so bars are comparable between refreshes
|
||||
// rather than rescaling to whatever the current worst link happens to be.
|
||||
const track = document.createElement('div');
|
||||
track.className = 'neighbor-row__track';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'neighbor-row__fill';
|
||||
const ratio = Math.max(0, Math.min(1, ((Number(item.snr) + 12) / 26)));
|
||||
fill.style.width = (ratio * 100).toFixed(1) + '%';
|
||||
fill.style.backgroundColor = snrColor(item.snr);
|
||||
track.appendChild(fill);
|
||||
|
||||
const values = document.createElement('div');
|
||||
values.className = 'neighbor-row__values';
|
||||
values.append(
|
||||
makeTextElement('span', formatSigned(item.snr) + ' dB', 'neighbor-row__snr'),
|
||||
makeTextElement('span',
|
||||
item.rssi === null || item.rssi === undefined
|
||||
? ''
|
||||
: ' / ' + Math.round(item.rssi) + ' dBm',
|
||||
'neighbor-row__rssi')
|
||||
);
|
||||
|
||||
if (item.signal_corroborated) {
|
||||
// Fixed -12..+14 dB scale so bars are comparable between refreshes
|
||||
// rather than rescaling to whatever the current worst link happens to be.
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'neighbor-row__fill';
|
||||
fill.style.width = (Math.max(0, Math.min(1, (Number(item.snr) + 12) / 26)) * 100)
|
||||
.toFixed(1) + '%';
|
||||
fill.style.backgroundColor = snrColor(item.snr);
|
||||
track.appendChild(fill);
|
||||
values.append(
|
||||
makeTextElement('span', formatSigned(item.snr) + ' dB', 'neighbor-row__snr'),
|
||||
makeTextElement('span',
|
||||
item.rssi === null || item.rssi === undefined
|
||||
? ''
|
||||
: ' / ' + Math.round(item.rssi) + ' dBm',
|
||||
'neighbor-row__rssi')
|
||||
);
|
||||
} else {
|
||||
track.classList.add('neighbor-row__track--unknown');
|
||||
const unknown = makeTextElement('span', 'no signal reading', 'neighbor-row__rssi');
|
||||
unknown.title = 'Heard one hop away, but no corroborated direct-reception '
|
||||
+ 'measurement is stored for this node.';
|
||||
values.appendChild(unknown);
|
||||
}
|
||||
|
||||
row.append(name, track, values);
|
||||
return row;
|
||||
@@ -987,6 +923,19 @@
|
||||
empty: 'No repeater adverts recorded.',
|
||||
render: (item, i) => rankedRow(i + 1, item.name, formatNumber(item.count) + ' adverts', 'bg-success'),
|
||||
},
|
||||
neighbors: {
|
||||
select: 'neighbors-window',
|
||||
container: 'neighbors-list',
|
||||
limit: 10,
|
||||
empty: 'No one-hop adverts heard in this window.',
|
||||
render: (item) => neighborRow(item),
|
||||
onLoad: (data) => {
|
||||
setText('neighbors-count', formatNumber(data.total));
|
||||
const shown = Math.min(data.total || 0, (data.items || []).length);
|
||||
setText('neighbors-shown',
|
||||
data.total > shown ? 'showing ' + shown + ' of ' + formatNumber(data.total) : '');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ── live activity feed ───────────────────────────────────────────────
|
||||
@@ -1130,12 +1079,18 @@
|
||||
'Change compares the last complete calendar day against the day before it — ' +
|
||||
'not a rolling 24 hours. The headline number above it is a rolling 24-hour count.',
|
||||
'neighbors-info':
|
||||
'Nodes heard directly, with no repeater in between, in the last 7 days. ' +
|
||||
'Only these have a meaningful SNR: a relayed packet\'s SNR measures the last ' +
|
||||
'hop into this radio, not the link to whoever sent it, so averaging across all ' +
|
||||
'hop counts describes nothing in particular. Bars use a fixed -12 to +14 dB ' +
|
||||
'scale; below 0 dB the signal is under the noise floor and only the spreading ' +
|
||||
'factor is recovering it.',
|
||||
'Nodes whose advert reached this radio in a single hop, newest evidence first, ' +
|
||||
'with the weakest measured links promoted to the top. Membership comes from the ' +
|
||||
'observed path length divided by its bytes-per-hop encoding, not from the stored ' +
|
||||
'hop count — that field claims far more direct neighbours than the path evidence ' +
|
||||
'supports. SNR is shown only where both agree; a relayed packet\'s SNR measures ' +
|
||||
'the last hop into this radio rather than the link to whoever sent it, so the ' +
|
||||
'rest are left blank instead of borrowing another link\'s number.',
|
||||
'hops-info':
|
||||
'Nodes by the fewest hops any of their adverts took to reach this radio, over the ' +
|
||||
'last 7 days. Hops are derived as path length divided by bytes per hop: path ' +
|
||||
'length is a byte count, so with 2- or 3-byte hop encoding the raw value would ' +
|
||||
'overstate the distance by two to three times.',
|
||||
};
|
||||
|
||||
function initTooltips() {
|
||||
|
||||
@@ -121,21 +121,25 @@
|
||||
</p>
|
||||
<div class="stacked-bar" id="route-mix-bar"></div>
|
||||
<div class="stacked-bar__legend" id="route-mix-legend"></div>
|
||||
<hr class="my-3">
|
||||
<p class="dashboard-note mb-2">Hop count across known contacts</p>
|
||||
<div class="chart-box" style="height:150px">
|
||||
<canvas id="hopHistogramChart" aria-label="Contacts by hop count"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header"><i class="fas fa-ruler-horizontal"></i> Path length</div>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-ruler-horizontal"></i> Hops away</span>
|
||||
<button type="button" class="dashboard-info-btn" id="hops-info"
|
||||
aria-label="How hop distance is derived"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="dashboard-note mb-2">Advert paths observed in the last 7 days</p>
|
||||
<p class="dashboard-note mb-2">
|
||||
Nodes by their closest advert path, last 7 days
|
||||
</p>
|
||||
<div class="chart-box chart-box--tall">
|
||||
<canvas id="pathLenHistogramChart" aria-label="Advert paths by path length"></canvas>
|
||||
<canvas id="hopsHistogramChart" aria-label="Nodes by hop distance"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,41 +232,26 @@
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="fas fa-signal"></i> Direct neighbours & signal</span>
|
||||
<button type="button" class="dashboard-info-btn" id="neighbors-info"
|
||||
aria-label="What counts as a direct neighbour"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<span>
|
||||
<i class="fas fa-signal"></i> One-hop neighbours
|
||||
<button type="button" class="dashboard-info-btn" id="neighbors-info"
|
||||
aria-label="What counts as a one-hop neighbour"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>
|
||||
<select id="neighbors-window" class="form-select form-select-sm"
|
||||
style="width:auto;font-size:0.75rem" aria-label="Neighbour time window">
|
||||
<option value="24h">Last 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-baseline gap-2 flex-wrap mb-2">
|
||||
<div class="d-flex align-items-baseline gap-2 flex-wrap mb-1">
|
||||
<span class="stat-tile__value" id="neighbors-count">—</span>
|
||||
<span class="dashboard-note">nodes heard with no repeater in between,
|
||||
<span id="neighbors-window">—</span></span>
|
||||
<span class="dashboard-note">nodes reached this radio in a single hop</span>
|
||||
</div>
|
||||
<div class="signal-grid mb-3">
|
||||
<div>
|
||||
<div class="signal-grid__value" id="neighbors-snr-min">—</div>
|
||||
<div class="signal-grid__label">worst SNR</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="signal-grid__value" id="neighbors-snr-p50">—</div>
|
||||
<div class="signal-grid__label">median SNR</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="signal-grid__value" id="neighbors-snr-max">—</div>
|
||||
<div class="signal-grid__label">best SNR</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-box" style="height:130px">
|
||||
<canvas id="neighborSnrChart"
|
||||
aria-label="Direct neighbours by signal-to-noise ratio"></canvas>
|
||||
</div>
|
||||
<p class="dashboard-note mt-3 mb-2">
|
||||
Weakest links (median RSSI <span id="neighbors-rssi">—</span> dBm)
|
||||
</p>
|
||||
<div id="neighbors-weakest"><div class="dashboard-empty">Loading…</div></div>
|
||||
<p class="dashboard-note mb-3" id="neighbors-shown"></p>
|
||||
<div id="neighbors-list"><div class="dashboard-empty">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+107
-66
@@ -587,84 +587,125 @@ class TestDerivedWindows:
|
||||
assert windows["windows"]["users"][-1]["label"] == f"All retained ({retention}d)"
|
||||
|
||||
|
||||
class TestDirectNeighbourSignal:
|
||||
"""SNR is only meaningful for nodes heard with no repeater in between."""
|
||||
class TestOneHopNeighbours:
|
||||
"""Neighbour membership comes from path evidence, not the stored hop_count.
|
||||
|
||||
def _seed_neighbours(self, viewer, rows):
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO complete_contact_tracking
|
||||
(public_key, name, role, hop_count, snr, signal_strength, last_heard)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime', ?))
|
||||
""",
|
||||
rows,
|
||||
)
|
||||
On the live database hop_count claims 800 zero-hop contacts while only 68
|
||||
have any one-hop path to corroborate it, and their stored SNR piles up in a
|
||||
1.5 dB band — one strong local link recorded against every node whose
|
||||
traffic came through it.
|
||||
"""
|
||||
|
||||
def _signal(self, viewer):
|
||||
def _seed_contact(self, conn, pk, name, role, hop_count, snr, rssi):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO complete_contact_tracking
|
||||
(public_key, name, role, hop_count, snr, signal_strength, last_heard)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime','-1 hours'))
|
||||
""",
|
||||
(pk, name, role, hop_count, snr, rssi),
|
||||
)
|
||||
|
||||
def _seed_path(self, conn, pk, path_length, bytes_per_hop, age="-1 hours"):
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO observed_paths
|
||||
(public_key, from_prefix, to_prefix, path_hex, path_length,
|
||||
bytes_per_hop, packet_type, last_seen)
|
||||
VALUES (?, 'aa', 'bb', ?, ?, ?, 'advert', datetime('now','localtime', ?))
|
||||
""",
|
||||
(pk, "ab" * path_length, path_length, bytes_per_hop, age),
|
||||
)
|
||||
|
||||
def _top(self, viewer, window="24h", limit=10):
|
||||
with closing(viewer._dashboard_connection()) as conn:
|
||||
return viewer.dashboard_stats._direct_neighbor_signal(conn)
|
||||
return viewer.dashboard_stats.read_top(conn, "neighbors", window, limit)
|
||||
|
||||
def test_relayed_contacts_are_excluded(self, viewer):
|
||||
"""A relayed packet's SNR describes the last hop, not the sender."""
|
||||
self._seed_neighbours(viewer, [
|
||||
(_pk(1), "direct", "repeater", 0, 5.0, -70.0, "-1 hours"),
|
||||
(_pk(2), "one-hop", "repeater", 1, -9.0, -110.0, "-1 hours"),
|
||||
(_pk(3), "far", "companion", 5, 12.0, -50.0, "-1 hours"),
|
||||
])
|
||||
signal = self._signal(viewer)
|
||||
assert signal["count"] == 1
|
||||
assert [n["name"] for n in signal["weakest"]] == ["direct"]
|
||||
assert signal["snr"]["p50"] == 5.0
|
||||
def test_multibyte_paths_are_not_read_as_extra_hops(self, viewer):
|
||||
"""path_length is bytes: 3 bytes at 3 bytes/hop is ONE hop, not three."""
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
self._seed_contact(conn, _pk(1), "onebyte-1hop", "repeater", 0, 5.0, -70.0)
|
||||
self._seed_path(conn, _pk(1), path_length=1, bytes_per_hop=1)
|
||||
self._seed_contact(conn, _pk(2), "multibyte-1hop", "repeater", 0, 6.0, -60.0)
|
||||
self._seed_path(conn, _pk(2), path_length=3, bytes_per_hop=3)
|
||||
self._seed_contact(conn, _pk(3), "multibyte-2hop", "repeater", 0, 7.0, -50.0)
|
||||
self._seed_path(conn, _pk(3), path_length=6, bytes_per_hop=3)
|
||||
|
||||
def test_weakest_links_come_first(self, viewer):
|
||||
self._seed_neighbours(viewer, [
|
||||
(_pk(i), f"n{i}", "repeater", 0, snr, -60.0 - i, "-2 hours")
|
||||
for i, snr in enumerate([8.0, -9.5, 2.0, -3.0, 13.0])
|
||||
])
|
||||
signal = self._signal(viewer)
|
||||
assert [n["snr"] for n in signal["weakest"]] == [-9.5, -3.0, 2.0, 8.0, 13.0]
|
||||
assert signal["snr"]["min"] == -9.5
|
||||
assert signal["snr"]["max"] == 13.0
|
||||
names = {item["name"] for item in self._top(viewer)["items"]}
|
||||
assert names == {"onebyte-1hop", "multibyte-1hop"}
|
||||
|
||||
def test_stale_neighbours_are_excluded(self, viewer):
|
||||
"""A link not exercised in a week says nothing about the link today."""
|
||||
self._seed_neighbours(viewer, [
|
||||
(_pk(1), "recent", "repeater", 0, 4.0, -80.0, "-2 days"),
|
||||
(_pk(2), "stale", "repeater", 0, -11.0, -115.0, "-40 days"),
|
||||
])
|
||||
signal = self._signal(viewer)
|
||||
assert signal["count"] == 1
|
||||
assert [n["name"] for n in signal["weakest"]] == ["recent"]
|
||||
def test_uncorroborated_signal_is_withheld(self, viewer):
|
||||
"""Path evidence without a matching hop_count gets no SNR figure."""
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
self._seed_contact(conn, _pk(1), "agrees", "repeater", 0, 4.0, -80.0)
|
||||
self._seed_path(conn, _pk(1), 1, 1)
|
||||
# hop_count says 4 hops but a one-hop path exists: the stored signal
|
||||
# belongs to some other link, so it must not be shown.
|
||||
self._seed_contact(conn, _pk(2), "disagrees", "repeater", 4, 12.0, -45.0)
|
||||
self._seed_path(conn, _pk(2), 1, 1)
|
||||
|
||||
def test_histogram_bins_are_fixed_width_and_contiguous(self, viewer):
|
||||
self._seed_neighbours(viewer, [
|
||||
(_pk(i), f"n{i}", "repeater", 0, snr, -70.0, "-1 hours")
|
||||
for i, snr in enumerate([-5.0, -4.5, 0.5, 5.0])
|
||||
])
|
||||
histogram = self._signal(viewer)["histogram"]
|
||||
edges = [b[0] for b in histogram]
|
||||
assert edges == list(range(-6, 6, 2)), histogram
|
||||
assert sum(b[1] for b in histogram) == 4
|
||||
# An empty bucket in the middle is present with a zero, not skipped.
|
||||
assert dict(histogram)[-2] == 0
|
||||
items = {item["name"]: item for item in self._top(viewer)["items"]}
|
||||
assert items["agrees"]["signal_corroborated"] is True
|
||||
assert items["agrees"]["snr"] == 4.0
|
||||
assert items["disagrees"]["signal_corroborated"] is False
|
||||
assert items["disagrees"]["snr"] is None
|
||||
assert items["disagrees"]["rssi"] is None
|
||||
|
||||
def test_no_neighbours_degrades_cleanly(self, viewer):
|
||||
signal = self._signal(viewer)
|
||||
assert signal["count"] == 0
|
||||
assert signal["weakest"] == []
|
||||
assert signal["histogram"] == []
|
||||
assert signal["snr"]["p50"] is None
|
||||
def test_contacts_with_no_one_hop_path_are_excluded(self, viewer):
|
||||
"""hop_count = 0 alone does not make something a neighbour."""
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
self._seed_contact(conn, _pk(1), "claims-direct", "repeater", 0, 12.0, -45.0)
|
||||
self._seed_path(conn, _pk(1), path_length=5, bytes_per_hop=1)
|
||||
assert self._top(viewer)["items"] == []
|
||||
assert self._top(viewer)["total"] == 0
|
||||
|
||||
def test_snapshot_exposes_neighbours_not_device_mix(self, viewer):
|
||||
"""role and device_type are the same field twice — only role is charted."""
|
||||
self._seed_neighbours(viewer, [(_pk(1), "n", "repeater", 0, 3.0, -75.0, "-1 hours")])
|
||||
def test_weakest_measured_links_are_promoted(self, viewer):
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
for i, snr in enumerate([9.0, -8.0, 2.0]):
|
||||
self._seed_contact(conn, _pk(i), f"m{i}", "repeater", 0, snr, -70.0)
|
||||
self._seed_path(conn, _pk(i), 1, 1)
|
||||
self._seed_contact(conn, _pk(9), "unmeasured", "repeater", 3, None, None)
|
||||
self._seed_path(conn, _pk(9), 1, 1)
|
||||
|
||||
items = self._top(viewer)["items"]
|
||||
assert [i["name"] for i in items[:3]] == ["m1", "m2", "m0"]
|
||||
assert items[-1]["name"] == "unmeasured"
|
||||
|
||||
def test_window_bounds_membership(self, viewer):
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
self._seed_contact(conn, _pk(1), "today", "repeater", 0, 5.0, -70.0)
|
||||
self._seed_path(conn, _pk(1), 1, 1, age="-2 hours")
|
||||
self._seed_contact(conn, _pk(2), "last-week", "repeater", 0, 5.0, -70.0)
|
||||
self._seed_path(conn, _pk(2), 1, 1, age="-4 days")
|
||||
|
||||
assert {i["name"] for i in self._top(viewer, "24h")["items"]} == {"today"}
|
||||
assert {i["name"] for i in self._top(viewer, "7d")["items"]} == {"today", "last-week"}
|
||||
|
||||
def test_windows_are_capped_below_retention(self, viewer):
|
||||
"""observed_paths keeps 90 days; a month-old link says nothing about today."""
|
||||
options = viewer.dashboard_stats.derive_windows()["windows"]["neighbors"]
|
||||
assert [o["value"] for o in options] == ["24h", "7d"]
|
||||
|
||||
def test_snapshot_reports_hops_not_path_bytes(self, viewer):
|
||||
with sqlite3.connect(viewer.db_path) as conn:
|
||||
self._seed_contact(conn, _pk(1), "near", "repeater", 0, 5.0, -70.0)
|
||||
self._seed_path(conn, _pk(1), path_length=6, bytes_per_hop=3) # 2 hops
|
||||
self._seed_contact(conn, _pk(2), "far", "repeater", 4, None, None)
|
||||
self._seed_path(conn, _pk(2), path_length=4, bytes_per_hop=1) # 4 hops
|
||||
_refresh(viewer)
|
||||
|
||||
with viewer.app.test_client() as client:
|
||||
mesh = client.get("/api/dashboard/summary").get_json()["mesh"]
|
||||
assert "device_mix" not in mesh
|
||||
assert "role_mix" in mesh
|
||||
assert mesh["neighbors"]["count"] == 1
|
||||
assert "device_mix" not in mesh # same field as role
|
||||
assert "hop_histogram" not in mesh # replaced by path-derived hops
|
||||
assert "path_len_histogram" not in mesh # was byte length labelled as hops
|
||||
assert dict(mesh["hops_histogram"]) == {2: 1, 3: 0, 4: 1}
|
||||
assert mesh["neighbors"] == {"24h": 0, "7d": 0}
|
||||
|
||||
def test_no_neighbours_degrades_cleanly(self, viewer):
|
||||
payload = self._top(viewer)
|
||||
assert payload["items"] == []
|
||||
assert payload["total"] == 0
|
||||
|
||||
|
||||
class TestRoleBucketing:
|
||||
|
||||
Reference in New Issue
Block a user