mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 10:13:02 +02:00
Show statistics for regional messages. Closes #338.
This commit is contained in:
+12
-1
@@ -155,6 +155,17 @@ app/
|
||||
- Candidate region names come from `app_settings.known_regions` (user-editable, seeded by migration 063 from `flood_scope` + channel `flood_scope_override`).
|
||||
- Channel messages persist `messages.transport_code` (uint16, NULL = unscoped plain flood) and `messages.region` (resolved name, NULL = scoped but no list match) at ingest, so the chat region badge survives raw-packet purge. The packet inspector (`GET /packets/{id}` and the `raw_packet` WS broadcast) resolves region on the fly against the current list since it still holds the raw payload.
|
||||
|
||||
### Region-scope adoption stats (`region_scope_24h`)
|
||||
|
||||
`GET /statistics` reports regional flood-scope uptake as two views with different denominators that intentionally will not agree:
|
||||
|
||||
- **Traffic** (`bucket_region_scope` in `path_utils.py`) counts flood-routed (`route_type` 0/1) GroupText packets across all channels, including undecryptable ones. Zero-hop/direct sends are excluded because firmware reaches them through the non-transport `sendZeroHop`/`sendDirect` overloads and they can never carry transport codes.
|
||||
- **Senders** (`StatisticsRepository._region_scope_senders_24h`) counts distinct senders with at least one scoped message. Attribution requires decryption, so it only covers channels we hold keys for — narrower, but self-validating (a decrypted packet is provably not a corrupt capture) and immune to one chatty node skewing the result. Identity is `sender_key` falling back to `sender_name`; scoping reads `messages.transport_code`, falling back to the linked raw packet for rows stored before region tagging existed.
|
||||
|
||||
`false_positive_floor` exists because corrupt RF captures land in `raw_packets` with effectively random headers and a share of them claim `TRANSPORT_FLOOD`. That garbage spreads near-uniformly across payload-type buckets, so it is measured directly from payload types the protocol does not define (`0x0C`/`0x0D`/`0x0E`) and averaged per bucket. **A `scoped_messages` count at or below the floor is not evidence of adoption**; surface the two together and never show the percentage alone. Do not "fix" the floor by removing it — without it the metric reads several times higher than reality.
|
||||
|
||||
Both traffic buckets come from one 24h raw-packet scan (`_packet_shape_24h`) shared with `path_hash_width_24h`, so adding region stats costs no extra query or parse pass.
|
||||
|
||||
### Raw packet dedup policy
|
||||
|
||||
- Raw packet storage deduplicates by payload hash (`RawPacketRepository.create`), excluding routing/path bytes.
|
||||
@@ -297,7 +308,7 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
||||
- `POST /fanout/bots/disable-until-restart` — stop bot modules and keep bots disabled until restart
|
||||
|
||||
### Statistics
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
|
||||
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels, `region_scope_24h` regional adoption)
|
||||
|
||||
### Push
|
||||
- `GET /push/vapid-public-key` — VAPID public key for browser `PushManager.subscribe()`
|
||||
|
||||
@@ -1083,6 +1083,41 @@ class PacketsPerHourBucket(BaseModel):
|
||||
count: int = Field(description="Number of packets received in that hour")
|
||||
|
||||
|
||||
class RegionScopeStats(BaseModel):
|
||||
"""Regional flood-scope adoption over the last 24 hours.
|
||||
|
||||
Two independent views, deliberately not merged — they have different
|
||||
denominators and will not agree:
|
||||
|
||||
- Traffic (``total_messages``/``scoped_messages``) counts flood-routed
|
||||
channel-message packets across all channels, including ones we cannot
|
||||
decrypt. Broad coverage, but corrupt RF captures contribute false
|
||||
positives, hence ``false_positive_floor``.
|
||||
- Senders (``total_senders``/``scoped_senders``) counts distinct message
|
||||
senders, which requires decryption and so only covers channels we hold
|
||||
keys for. Narrower, but noise-free and immune to one chatty node skewing
|
||||
the result.
|
||||
"""
|
||||
|
||||
total_messages: int = Field(
|
||||
description="Flood-routed channel-message packets heard in the last 24h (unique payloads)"
|
||||
)
|
||||
scoped_messages: int = Field(description="Of those, how many carried a regional transport code")
|
||||
scoped_pct: float
|
||||
false_positive_floor: float = Field(
|
||||
description=(
|
||||
"Estimated false positives in scoped_messages, measured from transport-routed "
|
||||
"packets claiming a payload type the protocol does not define. A scoped_messages "
|
||||
"value at or below this is not evidence of regional adoption."
|
||||
)
|
||||
)
|
||||
total_senders: int = Field(
|
||||
description="Distinct channel-message senders in the last 24h (decryptable channels only)"
|
||||
)
|
||||
scoped_senders: int = Field(description="Of those, how many sent at least one scoped message")
|
||||
scoped_senders_pct: float
|
||||
|
||||
|
||||
class StatisticsResponse(BaseModel):
|
||||
busiest_channels_24h: list[BusyChannel]
|
||||
contact_count: int
|
||||
@@ -1098,6 +1133,7 @@ class StatisticsResponse(BaseModel):
|
||||
repeaters_heard: ContactActivityCounts
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
region_scope_24h: RegionScopeStats
|
||||
packets_per_hour_72h: list[PacketsPerHourBucket]
|
||||
noise_floor_24h: NoiseFloorHistoryStats
|
||||
|
||||
|
||||
@@ -304,6 +304,69 @@ def bucket_path_hash_widths(rows: Iterable) -> dict[str, int | float]:
|
||||
}
|
||||
|
||||
|
||||
# Payload types with no meaning in the MeshCore protocol. Any packet claiming one
|
||||
# is corrupt by definition, which makes them a usable gauge for how much RF garbage
|
||||
# is sitting in a given route-type bucket. See bucket_region_scope().
|
||||
UNDEFINED_PAYLOAD_TYPES = (0x0C, 0x0D, 0x0E)
|
||||
|
||||
_PAYLOAD_TYPE_GROUP_TEXT = 0x05
|
||||
_FLOOD_ROUTE_TYPES = (0x00, 0x01) # TRANSPORT_FLOOD, FLOOD
|
||||
|
||||
|
||||
def bucket_region_scope(rows: Iterable) -> dict[str, int | float]:
|
||||
"""Count flood-routed channel messages carrying a regional transport code.
|
||||
|
||||
*rows* must be an already-fetched list whose elements have a ``data`` column
|
||||
containing raw packet bytes.
|
||||
|
||||
Only flood-routed packets are counted. Zero-hop and direct sends can never
|
||||
carry transport codes (firmware reaches them through the non-transport
|
||||
``sendZeroHop``/``sendDirect`` overloads), so including them would silently
|
||||
dilute the percentage.
|
||||
|
||||
``false_positive_floor`` exists because corrupt RF captures still land in
|
||||
``raw_packets`` with effectively random header bytes, and a share of them
|
||||
claim TRANSPORT_FLOOD. That garbage spreads near-uniformly across payload-type
|
||||
buckets, so we measure it directly: count transport-routed packets claiming a
|
||||
payload type the protocol does not define, and average per bucket. A
|
||||
``scoped_messages`` count at or below ``false_positive_floor`` is not evidence
|
||||
of regional adoption, and callers should present the two together.
|
||||
"""
|
||||
total = 0
|
||||
scoped = 0
|
||||
undefined_scoped = 0
|
||||
|
||||
for row in rows:
|
||||
envelope = parse_packet_envelope(bytes(row["data"]))
|
||||
if envelope is None:
|
||||
continue
|
||||
|
||||
if (
|
||||
envelope.route_type == 0x00
|
||||
and envelope.payload_type in UNDEFINED_PAYLOAD_TYPES
|
||||
and envelope.transport_codes is not None
|
||||
):
|
||||
undefined_scoped += 1
|
||||
continue
|
||||
|
||||
if envelope.payload_type != _PAYLOAD_TYPE_GROUP_TEXT:
|
||||
continue
|
||||
if envelope.route_type not in _FLOOD_ROUTE_TYPES:
|
||||
continue
|
||||
|
||||
total += 1
|
||||
if envelope.transport_codes is not None:
|
||||
scoped += 1
|
||||
|
||||
noise_floor = undefined_scoped / len(UNDEFINED_PAYLOAD_TYPES)
|
||||
return {
|
||||
"total_messages": total,
|
||||
"scoped_messages": scoped,
|
||||
"scoped_pct": (scoped / total) * 100 if total else 0.0,
|
||||
"false_positive_floor": noise_floor,
|
||||
}
|
||||
|
||||
|
||||
def calculate_packet_hash(raw_bytes: bytes) -> str:
|
||||
"""Calculate packet hash matching MeshCore's Packet::calculatePacketHash().
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import aiosqlite
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings
|
||||
from app.path_utils import bucket_path_hash_widths
|
||||
from app.path_utils import bucket_path_hash_widths, bucket_region_scope, parse_packet_envelope
|
||||
from app.telemetry_interval import DEFAULT_TELEMETRY_INTERVAL_HOURS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -513,8 +513,12 @@ class StatisticsRepository:
|
||||
return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||
async def _packet_shape_24h() -> tuple[dict[str, int | float], dict[str, int | float]]:
|
||||
"""Bucket the last 24h of raw packets by hop hash width and region scope.
|
||||
|
||||
Both buckets come from one fetch so the snapshot only scans the packet
|
||||
table once. Returns ``(path_hash_width, region_scope)``.
|
||||
"""
|
||||
now = int(time.time())
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
@@ -522,7 +526,61 @@ class StatisticsRepository:
|
||||
(now - SECONDS_24H,),
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
return bucket_path_hash_widths(rows)
|
||||
return bucket_path_hash_widths(rows), bucket_region_scope(rows)
|
||||
|
||||
@staticmethod
|
||||
async def _region_scope_senders_24h() -> dict[str, int | float]:
|
||||
"""Count distinct channel-message senders who scoped at least one send.
|
||||
|
||||
Sender attribution requires having decrypted the message, so this only
|
||||
covers channels we hold keys for — a narrower population than the
|
||||
packet-level count, but a self-validating one: a message we decrypted is
|
||||
provably not a corrupt capture, so this number needs no noise floor.
|
||||
|
||||
Scoping is read from ``messages.transport_code`` where present, falling
|
||||
back to the linked raw packet for rows stored before region tagging
|
||||
existed. Senders are keyed by ``sender_key`` where resolved, falling back
|
||||
to ``sender_name`` — one physical operator may run several nodes, hence
|
||||
"senders" rather than "users".
|
||||
"""
|
||||
now = int(time.time())
|
||||
async with db.readonly() as conn:
|
||||
async with conn.execute(
|
||||
"""
|
||||
SELECT m.id, m.sender_key, m.sender_name, m.transport_code, p.data
|
||||
FROM messages m
|
||||
LEFT JOIN raw_packets p ON p.message_id = m.id
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0 AND m.received_at >= ?
|
||||
""",
|
||||
(now - SECONDS_24H,),
|
||||
) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
senders: set[str] = set()
|
||||
scoped_senders: set[str] = set()
|
||||
for row in rows:
|
||||
identity = row["sender_key"] or row["sender_name"]
|
||||
if not identity:
|
||||
continue
|
||||
senders.add(identity)
|
||||
|
||||
if row["transport_code"] is not None:
|
||||
scoped_senders.add(identity)
|
||||
continue
|
||||
raw = row["data"]
|
||||
if raw is None:
|
||||
continue
|
||||
envelope = parse_packet_envelope(bytes(raw))
|
||||
if envelope is not None and envelope.transport_codes is not None:
|
||||
scoped_senders.add(identity)
|
||||
|
||||
total = len(senders)
|
||||
scoped = len(scoped_senders)
|
||||
return {
|
||||
"total_senders": total,
|
||||
"scoped_senders": scoped,
|
||||
"scoped_senders_pct": (scoped / total) * 100 if total else 0.0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_all() -> dict:
|
||||
@@ -600,7 +658,8 @@ class StatisticsRepository:
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
path_hash_width_24h, region_scope = await StatisticsRepository._packet_shape_24h()
|
||||
region_scope.update(await StatisticsRepository._region_scope_senders_24h())
|
||||
packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
|
||||
|
||||
return {
|
||||
@@ -618,5 +677,6 @@ class StatisticsRepository:
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"known_channels_active": known_channels_active,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
"region_scope_24h": region_scope,
|
||||
"packets_per_hour_72h": packets_per_hour_72h,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user