mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-30 21:43:14 +02:00
Merge branch 'main' into pr-339
# Conflicts: # frontend/AGENTS.md
This commit is contained in:
@@ -341,19 +341,20 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
|
||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater (escalates to one flood retry if the first attempt draws no reply) |
|
||||
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
|
||||
| POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL |
|
||||
| POST | `/api/contacts/{public_key}/repeater/node-info` | Fetch repeater name, location, and clock via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/regions` | Fetch repeater region hierarchy via CLI, falling back to the guest anon flood-allowed region names (`source`: `cli` or `anon`) |
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
| GET | `/api/contacts/{public_key}/repeater/telemetry-history` | Stored telemetry history for a repeater (read-only, no radio access) |
|
||||
| POST | `/api/contacts/{public_key}/telemetry` | Fetch CayenneLPP telemetry from any contact (single attempt, 10s timeout) |
|
||||
| GET | `/api/contacts/{public_key}/telemetry-history` | Stored LPP telemetry history for a contact (read-only, no radio access) |
|
||||
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server |
|
||||
| POST | `/api/contacts/{public_key}/room/login` | Log in to a room server (escalates to one flood retry if the first attempt draws no reply) |
|
||||
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
|
||||
@@ -392,7 +393,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
|
||||
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
|
||||
| POST | `/api/fanout/bots/disable-until-restart` | Stop bot fanout modules and keep bots disabled until the process restarts |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics |
|
||||
| GET | `/api/statistics` | Aggregated mesh network statistics, including `region_scope_24h` regional flood-scope adoption |
|
||||
| GET | `/api/push/vapid-public-key` | VAPID public key for browser push subscription |
|
||||
| POST | `/api/push/subscribe` | Register/upsert a push subscription |
|
||||
| GET | `/api/push/subscriptions` | List all push subscriptions |
|
||||
@@ -513,6 +514,7 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||
| `MESHCORE_LOAD_WITH_AUTOEVICT` | `false` | Enable autoevict contact loading: sets `AUTO_ADD_OVERWRITE_OLDEST` on the radio so adds never fail with TABLE_FULL, skips the removal phase during reconcile, and allows blind loading when `get_contacts` fails. Loaded contacts are not radio-favorited and may be evicted by new adverts when the table is full. |
|
||||
| `MESHCORE_SKIP_POST_CONNECT_SYNC` | `false` | Debug/diagnostic escape hatch: skip the contact/channel sync-and-offload, startup advertisement, and pending-message drain during post-connect setup, and do not start the periodic sync/advert/message-poll/telemetry background loops. Handler registration, key export, time sync, flood-scope apply, and auto message fetching still run. Useful when the radio's contact/channel state must be left untouched; not for normal operation. |
|
||||
| `MESHCORE_ENABLE_LOCAL_PRIVATE_KEY_EXPORT` | `false` | Enable `GET /api/radio/private-key` to return the in-memory private key as hex. Disabled by default; only enable on a trusted network where you need to retrieve the key (e.g. for backup or migration). |
|
||||
| `MESHCORE_VAPID_SUBJECT` | `mailto:noreply@meshcore.local` | Subject (`sub`) claim for Web Push VAPID tokens; must be a `mailto:` or `https:` contact. Apple's push service (APNs) rejects the default `.local` domain with `403 BadJwtToken`, so iOS/Safari operators must set this to a real address. Google FCM (Chrome/Android) accepts the default. |
|
||||
|
||||
|
||||
+37
-5
@@ -24,13 +24,14 @@ Keep it aligned with `app/` source files and router behavior.
|
||||
```text
|
||||
app/
|
||||
├── main.py # App startup/lifespan, router registration, static frontend mounting
|
||||
├── api_docs.py # OpenAPI description/tag metadata and docs route registration
|
||||
├── config.py # Env-driven runtime settings
|
||||
├── channel_constants.py # Public/default channel constants shared across sync/send logic
|
||||
├── database.py # SQLite connection + base schema + migration runner
|
||||
├── migrations/ # Schema migrations (SQLite user_version, per-version modules)
|
||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||
├── version_info.py # Unified version/build metadata resolution for debug + startup surfaces
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout, push_subscriptions, repeater_telemetry)
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout, push_subscriptions, repeater_telemetry, contact_telemetry)
|
||||
├── services/ # Shared orchestration/domain services
|
||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||
@@ -38,6 +39,7 @@ app/
|
||||
│ ├── dm_ack_apply.py # Shared DM ACK application over pending/buffered ACK state
|
||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||
│ ├── flood_scope.py # Firmware-version-aware flood-scope set/clear command seam
|
||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||
│ ├── radio_stats.py # In-memory local radio stats sampling and noise-floor history
|
||||
@@ -58,6 +60,7 @@ app/
|
||||
├── telemetry_interval.py # Shared telemetry interval math for tracked-repeater scheduler
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
├── region_scope.py # Normalize/validate regional flood-scope values
|
||||
├── region_resolver.py # Recompute transport codes per known region to name a packet's region
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
@@ -139,6 +142,23 @@ app/
|
||||
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
|
||||
- DM ACKs are matched from two independent radio emissions, so confirmation does not depend on the radio surfacing a host control frame: (1) the `EventType.ACK`/`SEND_CONFIRMED` host frame via `event_handlers.on_ack`, and (2) the raw RF packet itself via `packet_processor.process_raw_packet`. The packet processor extracts ACK codes both from PATH-return packets (flood replies, ACK embedded in `extra`) and from standalone `PayloadType.ACK` packets (direct replies, 4-byte cleartext payload), feeding both into `apply_dm_ack_code`. This matters for companion firmwares (e.g. pyMC over TCP) that do not reliably emit a separate host ACK frame for direct-routed replies.
|
||||
|
||||
### Server login route escalation
|
||||
|
||||
`prepare_authenticated_contact_connection` (`routers/server_control.py`, shared by repeater and room login) sends one login over the contact's effective route. If that draws **no reply at all**, it calls `reset_path(...)` and retries exactly once as flood.
|
||||
|
||||
This is intentionally *more* than the reference implementations do — do not "correct" it back to single-shot:
|
||||
- Firmware `BaseChatMesh::sendLogin` picks flood only when `out_path_len == OUT_PATH_UNKNOWN` and never retries; `CMD_SEND_LOGIN` calls it once.
|
||||
- `meshcore_py` has `send_msg_with_retry` (with `flood_after` + `reset_path`) but no login equivalent — `send_login`/`send_login_sync` are single-shot.
|
||||
- Firmware only clears a stale path when the *host* asks (`CMD_RESET_PATH`); client-side path learning is otherwise passive via `onContactPathRecv`.
|
||||
|
||||
Escalating is still correct because the **server** side treats an inbound flood as its cue to relearn the return path (`simple_repeater`/`simple_room_server`: `if (is_flood) client->out_path_len = OUT_PATH_UNKNOWN`). A flood login is therefore what repairs a broken route in both directions, and login gates the whole repeater dashboard.
|
||||
|
||||
Escalation is bounded to one extra attempt and only fires when:
|
||||
- the first attempt **timed out**. `LOGIN_FAILED` means the server heard us and refused, so the route is fine and retrying only hammers it with bad credentials; a send error is a local radio problem a different route will not fix.
|
||||
- the contact was **not already on flood** (`effective_route_source != "flood"`), since the retry would otherwise be byte-identical.
|
||||
|
||||
The retry deliberately does not re-run `_ensure_on_radio` — re-adding the contact would restore the route just cleared. `reset_path` clears the route on the radio only; the stored contact route is untouched, so the next `add_contact` re-stages it. That mirrors the DM retry and keeps one bad login from discarding a route that may be fine.
|
||||
|
||||
### Echo/repeat dedup
|
||||
|
||||
- Channel message uniqueness (`idx_messages_dedup_null_safe`): `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` where `type = 'CHAN'`.
|
||||
@@ -149,9 +169,20 @@ app/
|
||||
|
||||
- `ROUTE_TYPE_TRANSPORT_FLOOD`/`ROUTE_TYPE_TRANSPORT_DIRECT` packets carry a 4-byte transport-code block; `parse_packet_envelope` exposes it as `transport_codes = (code_1, code_2)` (little-endian uint16s; `code_2` is reserved/0).
|
||||
- `code_1` is a keyed MAC over the payload, not a stable per-region id: `code = HMAC-SHA256(SHA256("#" + region_name)[:16], payload_type || payload)[:2]` (firmware `TransportKeyStore.cpp`; reserved values `0x0000`/`0xFFFF` are nudged to `0x0001`/`0xFFFE`). There is **no** reverse lookup table — to name a packet's region you recompute the code per candidate region and check for a match (`app/region_resolver.py`).
|
||||
- Candidate region names come from `app_settings.known_regions` (user-editable, seeded by migration 064 from `flood_scope` + channel `flood_scope_override`).
|
||||
- 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.
|
||||
@@ -228,19 +259,20 @@ Web Push is a standalone subsystem in `app/push/`, separate from the fanout modu
|
||||
- `POST /contacts/{public_key}/routing-override`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
|
||||
- `POST /contacts/{public_key}/repeater/login`
|
||||
- `POST /contacts/{public_key}/repeater/login` — one attempt on the effective route, then one flood retry on timeout
|
||||
- `POST /contacts/{public_key}/repeater/status`
|
||||
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/repeater/neighbors`
|
||||
- `POST /contacts/{public_key}/repeater/acl`
|
||||
- `POST /contacts/{public_key}/repeater/node-info`
|
||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||
- `POST /contacts/{public_key}/repeater/regions` — CLI region hierarchy, falling back to the guest anon flood-allowed names (`source`: `cli` or `anon`)
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
- `GET /contacts/{public_key}/repeater/telemetry-history` — stored telemetry history for a repeater (read-only, no radio access)
|
||||
- `POST /contacts/{public_key}/telemetry` — on-demand CayenneLPP telemetry from any contact (persists in `contact_telemetry_history`)
|
||||
- `GET /contacts/{public_key}/telemetry-history` — stored LPP telemetry history for a contact (read-only)
|
||||
- `POST /contacts/{public_key}/room/login`
|
||||
- `POST /contacts/{public_key}/room/login` — one attempt on the effective route, then one flood retry on timeout
|
||||
- `POST /contacts/{public_key}/room/status`
|
||||
- `POST /contacts/{public_key}/room/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/room/acl`
|
||||
@@ -293,7 +325,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,
|
||||
}
|
||||
|
||||
+117
-15
@@ -80,6 +80,15 @@ def _login_timeout_message(label: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _login_flood_retry_timeout_message(label: str) -> str:
|
||||
return (
|
||||
f"No login confirmation was heard from the {label}, including a retry sent as flood "
|
||||
"in case the stored route was stale. That can mean the password was wrong, the "
|
||||
f"{label} is out of range, or the reply was missed in transit. "
|
||||
"You're free to attempt interaction; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
def extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
@@ -214,17 +223,20 @@ async def fetch_contact_cli_response(
|
||||
subscription.unsubscribe()
|
||||
|
||||
|
||||
async def prepare_authenticated_contact_connection(
|
||||
async def _attempt_server_login(
|
||||
mc,
|
||||
contact: Contact,
|
||||
password: str,
|
||||
*,
|
||||
label: str | None = None,
|
||||
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
contact_label: str,
|
||||
response_timeout: float,
|
||||
) -> RepeaterLoginResponse:
|
||||
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
|
||||
"""Send one login and wait for the reply.
|
||||
|
||||
Subscriptions are per-attempt because the resolving future can only be
|
||||
settled once — a retry needs a fresh pair.
|
||||
"""
|
||||
pubkey_prefix = contact.public_key[:12].lower()
|
||||
contact_label = label or get_server_contact_label(contact)
|
||||
loop = asyncio.get_running_loop()
|
||||
login_future = loop.create_future()
|
||||
|
||||
@@ -254,9 +266,6 @@ async def prepare_authenticated_contact_connection(
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
@@ -268,10 +277,7 @@ async def prepare_authenticated_contact_connection(
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
login_future,
|
||||
timeout=response_timeout,
|
||||
)
|
||||
return await asyncio.wait_for(login_future, timeout=response_timeout)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"No login response from %s %s within %.1fs",
|
||||
@@ -284,6 +290,105 @@ async def prepare_authenticated_contact_connection(
|
||||
authenticated=False,
|
||||
message=_login_timeout_message(contact_label),
|
||||
)
|
||||
finally:
|
||||
success_subscription.unsubscribe()
|
||||
failed_subscription.unsubscribe()
|
||||
|
||||
|
||||
async def prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact: Contact,
|
||||
password: str,
|
||||
*,
|
||||
label: str | None = None,
|
||||
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
) -> RepeaterLoginResponse:
|
||||
"""Prepare connection to a server-capable contact by adding it to the radio and logging in.
|
||||
|
||||
A login that draws no reply at all may mean the stored direct route has gone
|
||||
stale, so it escalates to one flood retry — mirroring the DM send path, which
|
||||
already resets the path before its final attempt. This is deliberately more
|
||||
than the reference clients do: firmware ``sendLogin`` and ``send_login_sync``
|
||||
are both single-shot, and firmware only clears a stale path when the *host*
|
||||
asks it to (``CMD_RESET_PATH``). Escalating is still the right call because
|
||||
the server side treats an inbound flood as its cue to relearn the return path
|
||||
(``simple_repeater``/``simple_room_server``: ``if (is_flood)
|
||||
client->out_path_len = OUT_PATH_UNKNOWN``), so a flood login is exactly what
|
||||
repairs a broken route in both directions.
|
||||
|
||||
Escalation is bounded to a single extra attempt and only fires when:
|
||||
- the first attempt timed out. An explicit ``LOGIN_FAILED`` means the
|
||||
server heard us and refused, so the route is fine and retrying would
|
||||
just hammer it with bad credentials. A send error is a local radio
|
||||
problem that a different route will not fix.
|
||||
- the first attempt actually used a route. If the contact was already on
|
||||
flood, the retry would be byte-identical for no gain.
|
||||
|
||||
``reset_path`` clears the route on the radio only; the stored contact route
|
||||
is left alone, so the next ``add_contact`` re-stages it. That matches the DM
|
||||
retry and keeps a single bad login from discarding a route that may be fine.
|
||||
"""
|
||||
contact_label = label or get_server_contact_label(contact)
|
||||
|
||||
try:
|
||||
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
response = await _attempt_server_login(
|
||||
mc,
|
||||
contact,
|
||||
password,
|
||||
contact_label=contact_label,
|
||||
response_timeout=response_timeout,
|
||||
)
|
||||
if response.status != "timeout":
|
||||
return response
|
||||
|
||||
if contact.effective_route_source == "flood":
|
||||
logger.debug(
|
||||
"Login to %s %s timed out on flood; no route to escalate from",
|
||||
contact_label,
|
||||
contact.public_key[:12],
|
||||
)
|
||||
return response
|
||||
|
||||
logger.info(
|
||||
"Login to %s %s timed out on the %s route; resetting path and retrying as flood",
|
||||
contact_label,
|
||||
contact.public_key[:12],
|
||||
contact.effective_route_source,
|
||||
)
|
||||
reset_result = await mc.commands.reset_path(contact.public_key)
|
||||
if reset_result is None:
|
||||
logger.warning(
|
||||
"No response from radio for reset_path to %s before flood login retry",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
return response
|
||||
if reset_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to reset path before flood login retry to %s: %s",
|
||||
contact.public_key[:12],
|
||||
reset_result.payload,
|
||||
)
|
||||
return response
|
||||
|
||||
# Deliberately no _ensure_on_radio here — re-adding the contact would
|
||||
# restore the very route we just cleared, and the retry would go direct.
|
||||
flood_response = await _attempt_server_login(
|
||||
mc,
|
||||
contact,
|
||||
password,
|
||||
contact_label=contact_label,
|
||||
response_timeout=response_timeout,
|
||||
)
|
||||
if flood_response.status == "timeout":
|
||||
return RepeaterLoginResponse(
|
||||
status="timeout",
|
||||
authenticated=False,
|
||||
message=_login_flood_retry_timeout_message(contact_label),
|
||||
)
|
||||
return flood_response
|
||||
except HTTPException as exc:
|
||||
logger.warning(
|
||||
"%s login setup failed for %s: %s",
|
||||
@@ -296,9 +401,6 @@ async def prepare_authenticated_contact_connection(
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
||||
)
|
||||
finally:
|
||||
success_subscription.unsubscribe()
|
||||
failed_subscription.unsubscribe()
|
||||
|
||||
|
||||
async def batch_cli_fetch(
|
||||
|
||||
+22
-3
@@ -41,9 +41,13 @@ frontend/src/
|
||||
├── themes.css # Color theme definitions
|
||||
├── contexts/
|
||||
│ ├── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
|
||||
│ ├── PathHopWidthContext.tsx # Browser-local path hop-width display preference
|
||||
│ ├── RichPayloadContext.tsx # Browser-local rich MeshCore payload rendering preference
|
||||
│ └── PushSubscriptionContext.tsx # Push subscription state context/provider
|
||||
├── lib/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── networkGraph/
|
||||
│ └── packetNetworkGraph.ts # Packet→network graph construction shared by visualizer surfaces
|
||||
├── stores/
|
||||
│ └── rawPacketStore.ts # Overheard packet stream + session stats, outside React
|
||||
├── hooks/
|
||||
@@ -62,6 +66,7 @@ frontend/src/
|
||||
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
|
||||
│ ├── usePushSubscription.ts # Web Push subscription lifecycle, per-conversation filters
|
||||
│ ├── useFaviconBadge.ts # Browser tab unread badge state
|
||||
│ ├── useEntranceSettled.ts # Defers entrance animation work until layout settles
|
||||
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
|
||||
@@ -83,6 +88,10 @@ frontend/src/
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
|
||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||
│ ├── meshcoreOpenPayloads.ts # Rich MeshCore Open payload detection/rendering helpers
|
||||
│ ├── textReplace.ts # Shared message text substitution helpers
|
||||
│ ├── pathHopWidthPreference.ts # LocalStorage persistence for hop-width display toggle
|
||||
│ ├── richPayloadPreference.ts # LocalStorage persistence for rich payload rendering toggle
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
@@ -144,7 +153,7 @@ frontend/src/
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsRadioAppSection.tsx # Radio-App Management: tracked telemetry, contact management, blocked lists
|
||||
│ │ ├── SettingsDatabaseSection.tsx # Database: DB size, storage cleanup, auto-decrypt
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
|
||||
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats (incl. region-scope adoption)
|
||||
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
|
||||
│ │ ├── ThemeSelector.tsx # Color theme picker
|
||||
│ │ └── BulkDeleteContactsModal.tsx # Bulk contact deletion dialog
|
||||
@@ -155,6 +164,7 @@ frontend/src/
|
||||
│ │ ├── RepeaterAclPane.tsx # Permission table
|
||||
│ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift
|
||||
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
|
||||
│ │ ├── RepeaterRegionsPane.tsx # Region hierarchy / flood-allowed region names
|
||||
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
|
||||
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
|
||||
│ │ ├── RepeaterTelemetryHistoryPane.tsx # Historical telemetry chart/table
|
||||
@@ -417,9 +427,9 @@ State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`.
|
||||
|
||||
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
|
||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
|
||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. The frontend sends exactly one request; the backend internally escalates a timed-out login to one flood retry (see `app/AGENTS.md` § "Server login route escalation"), so a single call may take up to two response windows. Do not add a client-side login retry loop on top — a `LOGIN_FAILED` result means the password was refused, not that the route needs another attempt.
|
||||
|
||||
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Regions, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. The Regions pane prefers the admin CLI hierarchy and falls back to the guest anon flood-allowed names, so its payload carries a `source` of `cli` or `anon`. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
|
||||
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
|
||||
|
||||
@@ -474,6 +484,15 @@ Key conventions documented in the reference:
|
||||
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
|
||||
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
|
||||
|
||||
### Region-scope adoption panel
|
||||
|
||||
`SettingsStatisticsSection.tsx` renders `stats.region_scope_24h` via `RegionScopeStatsPanel`. Two presentation rules exist because regional adoption is currently very sparse, and both are deliberate:
|
||||
|
||||
- **Fractions, not bare percentages.** "3 of 117" carries the sample size that "2.6%" hides.
|
||||
- **The traffic percentage is withheld** when the scoped count is at or below `false_positive_floor` (corrupt-capture noise) or when the share would round to `0.0%`. The floor caveat is always shown alongside a non-zero scoped count. The sender figure is never suppressed — it requires successful decryption and so carries no noise.
|
||||
|
||||
Traffic and sender figures use different denominators (all channels vs. decryptable-only) and are not expected to match.
|
||||
|
||||
## Security Posture (intentional)
|
||||
|
||||
- No authentication UI.
|
||||
|
||||
@@ -13,12 +13,76 @@ import {
|
||||
} from 'recharts';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { api } from '../../api';
|
||||
import type { StatisticsResponse } from '../../types';
|
||||
import type { RegionScopeStats, StatisticsResponse } from '../../types';
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regional flood-scope adoption. Deliberately shows fractions rather than bare
|
||||
* percentages: with adoption this sparse, "3 of 117" communicates the sample
|
||||
* size that "2.6%" hides.
|
||||
*/
|
||||
function RegionScopeStatsPanel({ stats }: { stats: RegionScopeStats }) {
|
||||
// Corrupt RF captures land in the packet table with random headers, some of
|
||||
// which claim to be region-scoped. At or below the measured floor there is
|
||||
// nothing to report but noise, so withhold the percentage and say so.
|
||||
const floor = stats.false_positive_floor;
|
||||
const withinNoise = stats.scoped_messages <= floor;
|
||||
// Real-world scoping is currently rare enough that the share rounds to "0.0%",
|
||||
// which reads as a broken widget. Below that resolution the fraction alone is
|
||||
// the honest presentation.
|
||||
const showTrafficPct = stats.total_messages > 0 && !withinNoise && stats.scoped_pct >= 0.05;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">Region Scope (24h)</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground mb-3">
|
||||
How much local traffic uses regional flood scoping. Traffic covers all channel messages
|
||||
heard, including channels you have no key for; senders only counts channels you can decrypt,
|
||||
so the two use different denominators and will not match.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">Scoped messages</span>
|
||||
<span className="font-medium text-right">
|
||||
{stats.scoped_messages.toLocaleString()} of {stats.total_messages.toLocaleString()}
|
||||
{showTrafficPct && (
|
||||
<span className="text-muted-foreground"> ({formatPercent(stats.scoped_pct)})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<span className="text-sm text-muted-foreground">Senders using regions</span>
|
||||
<span className="font-medium text-right">
|
||||
{stats.scoped_senders.toLocaleString()} of {stats.total_senders.toLocaleString()}
|
||||
{stats.total_senders > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
({formatPercent(stats.scoped_senders_pct)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{floor > 0 && stats.scoped_messages > 0 && (
|
||||
<p className="text-[0.8125rem] text-muted-foreground mt-2">
|
||||
{withinNoise
|
||||
? `Scoped message count is at or below the estimated false-positive floor (${floor.toFixed(0)}) from corrupt packet captures, so it is not evidence of regional adoption.`
|
||||
: `Includes an estimated ${floor.toFixed(0)} false positives from corrupt packet captures.`}{' '}
|
||||
The sender count is unaffected — it requires successful decryption.
|
||||
</p>
|
||||
)}
|
||||
{stats.total_messages === 0 && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
No channel messages heard in the last 24 hours.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
|
||||
|
||||
const TOOLTIP_STYLE = {
|
||||
@@ -404,6 +468,11 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Region Scope */}
|
||||
<RegionScopeStatsPanel stats={stats.region_scope_24h} />
|
||||
|
||||
{/* Busiest Channels */}
|
||||
{stats.busiest_channels_24h.length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -777,6 +777,15 @@ describe('SettingsModal', () => {
|
||||
double_byte_pct: 30,
|
||||
triple_byte_pct: 20,
|
||||
},
|
||||
region_scope_24h: {
|
||||
total_messages: 120,
|
||||
scoped_messages: 40,
|
||||
scoped_pct: 33.3,
|
||||
false_positive_floor: 2,
|
||||
total_senders: 12,
|
||||
scoped_senders: 3,
|
||||
scoped_senders_pct: 25,
|
||||
},
|
||||
packets_per_hour_72h: [
|
||||
{ timestamp: 1711792800, count: 12 },
|
||||
{ timestamp: 1711796400, count: 8 },
|
||||
@@ -824,6 +833,146 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
|
||||
// Fractions, not bare percentages — the sample size matters at this sparsity
|
||||
expect(screen.getByText(/40 of 120/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/3 of 12/)).toBeInTheDocument();
|
||||
// 40 scoped is well above the floor of 2, so the percentage is shown
|
||||
expect(screen.getByText(/33\.3%/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/at or below the estimated false-positive floor/)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('discloses the false-positive floor and withholds a sub-0.1% scoped share', async () => {
|
||||
const mockStats: StatisticsResponse = {
|
||||
busiest_channels_24h: [],
|
||||
contact_count: 0,
|
||||
repeater_count: 0,
|
||||
channel_count: 0,
|
||||
total_packets: 0,
|
||||
decrypted_packets: 0,
|
||||
undecrypted_packets: 0,
|
||||
total_dms: 0,
|
||||
total_channel_messages: 0,
|
||||
total_outgoing: 0,
|
||||
contacts_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
repeaters_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
known_channels_active: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 0,
|
||||
single_byte: 0,
|
||||
double_byte: 0,
|
||||
triple_byte: 0,
|
||||
single_byte_pct: 0,
|
||||
double_byte_pct: 0,
|
||||
triple_byte_pct: 0,
|
||||
},
|
||||
// Mirrors real-world data: 70 "scoped" packets against a measured floor of
|
||||
// 60 is corrupt-capture noise, not adoption.
|
||||
region_scope_24h: {
|
||||
total_messages: 391757,
|
||||
scoped_messages: 70,
|
||||
scoped_pct: 0.0179,
|
||||
false_positive_floor: 60.3,
|
||||
total_senders: 117,
|
||||
scoped_senders: 3,
|
||||
scoped_senders_pct: 2.56,
|
||||
},
|
||||
packets_per_hour_72h: [],
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 60,
|
||||
coverage_seconds: 0,
|
||||
latest_noise_floor_dbm: null,
|
||||
latest_timestamp: null,
|
||||
samples: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockStats), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
renderModal({ externalSidebarNav: true, desktopSection: 'statistics' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 70 scoped sits just above the 60.3 floor, so most of it is corrupt captures
|
||||
expect(screen.getByText(/Includes an estimated 60 false positives/)).toBeInTheDocument();
|
||||
// 0.0179% would render as a meaningless "0.0%", so the share is withheld
|
||||
expect(screen.queryByText(/0\.0%/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/70 of 391,757/)).toBeInTheDocument();
|
||||
// ...but the decryption-backed sender figure still stands
|
||||
expect(screen.getByText(/3 of 117/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2\.6%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports scoped traffic as noise when it is at or below the floor', async () => {
|
||||
const mockStats: StatisticsResponse = {
|
||||
busiest_channels_24h: [],
|
||||
contact_count: 0,
|
||||
repeater_count: 0,
|
||||
channel_count: 0,
|
||||
total_packets: 0,
|
||||
decrypted_packets: 0,
|
||||
undecrypted_packets: 0,
|
||||
total_dms: 0,
|
||||
total_channel_messages: 0,
|
||||
total_outgoing: 0,
|
||||
contacts_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
repeaters_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
known_channels_active: { last_hour: 0, last_24_hours: 0, last_week: 0 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 0,
|
||||
single_byte: 0,
|
||||
double_byte: 0,
|
||||
triple_byte: 0,
|
||||
single_byte_pct: 0,
|
||||
double_byte_pct: 0,
|
||||
triple_byte_pct: 0,
|
||||
},
|
||||
region_scope_24h: {
|
||||
total_messages: 5000,
|
||||
scoped_messages: 12,
|
||||
scoped_pct: 0.24,
|
||||
false_positive_floor: 20,
|
||||
total_senders: 40,
|
||||
scoped_senders: 0,
|
||||
scoped_senders_pct: 0,
|
||||
},
|
||||
packets_per_hour_72h: [],
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 60,
|
||||
coverage_seconds: 0,
|
||||
latest_noise_floor_dbm: null,
|
||||
latest_timestamp: null,
|
||||
samples: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify(mockStats), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
renderModal({ externalSidebarNav: true, desktopSection: 'statistics' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/at or below the estimated false-positive floor \(20\)/)
|
||||
).toBeInTheDocument();
|
||||
// Percentage withheld even though 0.24% would round visibly — it is noise
|
||||
expect(screen.queryByText(/0\.2%/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches statistics when expanded in mobile external-nav mode', async () => {
|
||||
@@ -850,6 +999,15 @@ describe('SettingsModal', () => {
|
||||
double_byte_pct: 30,
|
||||
triple_byte_pct: 20,
|
||||
},
|
||||
region_scope_24h: {
|
||||
total_messages: 0,
|
||||
scoped_messages: 0,
|
||||
scoped_pct: 0,
|
||||
false_positive_floor: 0,
|
||||
total_senders: 0,
|
||||
scoped_senders: 0,
|
||||
scoped_senders_pct: 0,
|
||||
},
|
||||
packets_per_hour_72h: [],
|
||||
noise_floor_24h: {
|
||||
sample_interval_seconds: 60,
|
||||
|
||||
@@ -674,6 +674,24 @@ interface PacketsPerHourBucket {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regional flood-scope adoption over the last 24h. Two views with different
|
||||
* denominators that will not agree — traffic spans all channels including
|
||||
* undecryptable ones (so it carries a false-positive floor from corrupt RF
|
||||
* captures), while senders requires decryption and is therefore noise-free but
|
||||
* limited to channels we hold keys for.
|
||||
*/
|
||||
export interface RegionScopeStats {
|
||||
total_messages: number;
|
||||
scoped_messages: number;
|
||||
scoped_pct: number;
|
||||
/** Estimated false positives in scoped_messages. At or below this = not adoption. */
|
||||
false_positive_floor: number;
|
||||
total_senders: number;
|
||||
scoped_senders: number;
|
||||
scoped_senders_pct: number;
|
||||
}
|
||||
|
||||
export interface StatisticsResponse {
|
||||
busiest_channels_24h: BusyChannel[];
|
||||
contact_count: number;
|
||||
@@ -697,6 +715,7 @@ export interface StatisticsResponse {
|
||||
double_byte_pct: number;
|
||||
triple_byte_pct: number;
|
||||
};
|
||||
region_scope_24h: RegionScopeStats;
|
||||
packets_per_hour_72h: PacketsPerHourBucket[];
|
||||
noise_floor_24h: NoiseFloorHistoryStats;
|
||||
}
|
||||
|
||||
@@ -818,6 +818,162 @@ class TestPrepareRepeaterConnection:
|
||||
assert "No login confirmation was heard from the repeater" in (response.message or "")
|
||||
|
||||
|
||||
def _make_routed_contact() -> Contact:
|
||||
"""A repeater with a learned direct route, so a login goes out direct."""
|
||||
contact = Contact(
|
||||
public_key=KEY_A,
|
||||
name="Repeater",
|
||||
type=2,
|
||||
direct_path="aabb",
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=0,
|
||||
)
|
||||
assert contact.effective_route_source == "direct"
|
||||
return contact
|
||||
|
||||
|
||||
class TestRepeaterLoginFloodEscalation:
|
||||
"""A login that draws no reply escalates to one flood retry.
|
||||
|
||||
Firmware's own ``sendLogin`` is single-shot, so this is RT going beyond the
|
||||
reference clients. It works because the *server* treats an inbound flood as
|
||||
its cue to relearn the return path.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_on_direct_route_resets_path_and_retries_as_flood(self):
|
||||
mc = _mock_mc()
|
||||
contact = _make_routed_contact()
|
||||
subscriptions: dict[EventType, tuple[object, object]] = {}
|
||||
attempts = 0
|
||||
|
||||
def _subscribe(event_type, callback, attribute_filters=None):
|
||||
subscriptions[event_type] = (callback, attribute_filters)
|
||||
return MagicMock(unsubscribe=MagicMock())
|
||||
|
||||
async def _send_login(*args, **kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
# First attempt (direct) is never answered; the flood retry lands.
|
||||
if attempts > 1:
|
||||
callback, _filters = subscriptions[EventType.LOGIN_SUCCESS]
|
||||
callback(_radio_result(EventType.LOGIN_SUCCESS, {"pubkey_prefix": KEY_A[:12]}))
|
||||
return _radio_result(EventType.MSG_SENT)
|
||||
|
||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||
mc.commands.send_login = AsyncMock(side_effect=_send_login)
|
||||
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
|
||||
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||
|
||||
assert response.status == "ok"
|
||||
assert response.authenticated is True
|
||||
assert attempts == 2
|
||||
mc.commands.reset_path.assert_awaited_once_with(KEY_A)
|
||||
# The contact must not be re-added between attempts — that would restore
|
||||
# the route we just cleared and send the retry direct again.
|
||||
assert mc.commands.add_contact.await_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flood_retry_timeout_reports_that_flood_was_tried(self):
|
||||
mc = _mock_mc()
|
||||
contact = _make_routed_contact()
|
||||
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
|
||||
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||
|
||||
assert response.status == "timeout"
|
||||
assert response.authenticated is False
|
||||
assert "retry sent as flood" in (response.message or "")
|
||||
assert mc.commands.send_login.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_escalation_when_contact_is_already_flood(self):
|
||||
mc = _mock_mc()
|
||||
contact = _make_contact() # no direct route -> already flooding
|
||||
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
|
||||
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||
|
||||
assert response.status == "timeout"
|
||||
# A flood retry would be byte-identical, so don't bother the repeater
|
||||
assert mc.commands.send_login.await_count == 1
|
||||
mc.commands.reset_path.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_login_does_not_escalate(self):
|
||||
"""LOGIN_FAILED means the route works and the password doesn't."""
|
||||
mc = _mock_mc()
|
||||
contact = _make_routed_contact()
|
||||
subscriptions: dict[EventType, tuple[object, object]] = {}
|
||||
|
||||
def _subscribe(event_type, callback, attribute_filters=None):
|
||||
subscriptions[event_type] = (callback, attribute_filters)
|
||||
return MagicMock(unsubscribe=MagicMock())
|
||||
|
||||
async def _send_login(*args, **kwargs):
|
||||
callback, _filters = subscriptions[EventType.LOGIN_FAILED]
|
||||
callback(_radio_result(EventType.LOGIN_FAILED, {"pubkey_prefix": KEY_A[:12]}))
|
||||
return _radio_result(EventType.MSG_SENT)
|
||||
|
||||
mc.subscribe = MagicMock(side_effect=_subscribe)
|
||||
mc.commands.send_login = AsyncMock(side_effect=_send_login)
|
||||
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
|
||||
response = await prepare_repeater_connection(mc, contact, "bad")
|
||||
|
||||
assert response.status == "error"
|
||||
assert mc.commands.send_login.await_count == 1
|
||||
mc.commands.reset_path.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_error_does_not_escalate(self):
|
||||
"""A local radio failure is not fixed by picking a different route."""
|
||||
mc = _mock_mc()
|
||||
contact = _make_routed_contact()
|
||||
mc.commands.send_login = AsyncMock(
|
||||
return_value=_radio_result(EventType.ERROR, {"err": "busy"})
|
||||
)
|
||||
mc.commands.reset_path = AsyncMock(return_value=_radio_result(EventType.OK))
|
||||
|
||||
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||
|
||||
assert response.status == "error"
|
||||
assert mc.commands.send_login.await_count == 1
|
||||
mc.commands.reset_path.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_reset_path_returns_original_timeout(self):
|
||||
mc = _mock_mc()
|
||||
contact = _make_routed_contact()
|
||||
mc.commands.reset_path = AsyncMock(
|
||||
return_value=_radio_result(EventType.ERROR, {"err": "nope"})
|
||||
)
|
||||
|
||||
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||
|
||||
assert response.status == "timeout"
|
||||
# Without a cleared path the retry would just go direct again
|
||||
assert mc.commands.send_login.await_count == 1
|
||||
assert "retry sent as flood" not in (response.message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reset_path_response_returns_original_timeout(self):
|
||||
mc = _mock_mc()
|
||||
contact = _make_routed_contact()
|
||||
mc.commands.reset_path = AsyncMock(return_value=None)
|
||||
|
||||
with patch("app.routers.repeaters.REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS", 0):
|
||||
response = await prepare_repeater_connection(mc, contact, "secret")
|
||||
|
||||
assert response.status == "timeout"
|
||||
assert mc.commands.send_login.await_count == 1
|
||||
|
||||
|
||||
class TestRepeaterStatus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_with_field_mapping(self, test_db):
|
||||
|
||||
+192
-2
@@ -43,6 +43,15 @@ class TestStatisticsEmpty:
|
||||
"double_byte_pct": 0.0,
|
||||
"triple_byte_pct": 0.0,
|
||||
}
|
||||
assert result["region_scope_24h"] == {
|
||||
"total_messages": 0,
|
||||
"scoped_messages": 0,
|
||||
"scoped_pct": 0.0,
|
||||
"false_positive_floor": 0.0,
|
||||
"total_senders": 0,
|
||||
"scoped_senders": 0,
|
||||
"scoped_senders_pct": 0.0,
|
||||
}
|
||||
assert result["packets_per_hour_72h"] == []
|
||||
|
||||
|
||||
@@ -378,10 +387,18 @@ class TestPathHashWidthStats:
|
||||
hash_size = hash_sizes.get(raw_packet)
|
||||
if hash_size is None:
|
||||
return None
|
||||
return SimpleNamespace(hash_size=hash_size)
|
||||
# The scan is shared with the region-scope bucketer, so the stub has
|
||||
# to carry the envelope fields that one reads too. Plain flood
|
||||
# GroupText keeps it out of the region counters' way.
|
||||
return SimpleNamespace(
|
||||
hash_size=hash_size,
|
||||
route_type=0x01,
|
||||
payload_type=0x05,
|
||||
transport_codes=None,
|
||||
)
|
||||
|
||||
with patch("app.path_utils.parse_packet_envelope", side_effect=fake_parse):
|
||||
breakdown = await StatisticsRepository._path_hash_width_24h()
|
||||
breakdown, _region_scope = await StatisticsRepository._packet_shape_24h()
|
||||
|
||||
assert breakdown["total_packets"] == 3
|
||||
assert breakdown["single_byte"] == 1
|
||||
@@ -389,6 +406,179 @@ class TestPathHashWidthStats:
|
||||
assert breakdown["triple_byte"] == 1
|
||||
|
||||
|
||||
class TestRegionScopeStats:
|
||||
"""Regional flood-scope adoption counters.
|
||||
|
||||
Packet fixtures are built by hand so the header bits are explicit:
|
||||
header = (payload_type << 2) | route_type, then a 4-byte transport-code
|
||||
block for TRANSPORT_* routes, then the packed path byte, then payload.
|
||||
"""
|
||||
|
||||
# GROUP_TEXT (0x05) flood (0x01) -> header 0x15; path byte 0x00 = 0 hops, 1-byte hashes
|
||||
UNSCOPED_GROUP_TEXT = bytes.fromhex("1500AA")
|
||||
# GROUP_TEXT (0x05) transport-flood (0x00) -> header 0x14, + codes AABB/0000
|
||||
SCOPED_GROUP_TEXT = bytes.fromhex("14AABB000000AA")
|
||||
# Undefined payload type 0x0C, transport-flood -> header 0x30. Corrupt by
|
||||
# definition, so it feeds the false-positive floor rather than the counts.
|
||||
SCOPED_UNDEFINED_TYPE = bytes.fromhex("30AABB000000AA")
|
||||
# GROUP_TEXT direct (0x02) -> header 0x16. Direct sends can never be scoped.
|
||||
DIRECT_GROUP_TEXT = bytes.fromhex("1600AA")
|
||||
|
||||
async def _insert_packet(self, conn, data: bytes, tag: bytes, timestamp: int):
|
||||
await conn.execute(
|
||||
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
|
||||
(timestamp, data, tag * 32),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counts_scoped_flood_group_text_only(self, test_db):
|
||||
"""Only flood-routed GroupText packets count; direct sends are excluded."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
await self._insert_packet(conn, self.SCOPED_GROUP_TEXT, b"\x11", now)
|
||||
await self._insert_packet(conn, self.UNSCOPED_GROUP_TEXT, b"\x22", now)
|
||||
await self._insert_packet(conn, self.DIRECT_GROUP_TEXT, b"\x33", now)
|
||||
# Outside the 24h window
|
||||
await self._insert_packet(conn, self.SCOPED_GROUP_TEXT, b"\x44", now - 90000)
|
||||
await conn.commit()
|
||||
|
||||
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
|
||||
|
||||
# Direct + stale packets excluded, so 2 in the denominator
|
||||
assert stats["total_messages"] == 2
|
||||
assert stats["scoped_messages"] == 1
|
||||
assert stats["scoped_pct"] == pytest.approx(50.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_undefined_payload_types_feed_false_positive_floor(self, test_db):
|
||||
"""Corrupt packets claiming an undefined type estimate the noise floor."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
await self._insert_packet(conn, self.UNSCOPED_GROUP_TEXT, b"\x11", now)
|
||||
# Three corrupt transport-routed packets across the undefined-type buckets
|
||||
for i, header in enumerate(("30", "34", "38")): # types 0x0C, 0x0D, 0x0E
|
||||
await self._insert_packet(
|
||||
conn, bytes.fromhex(f"{header}AABB000000AA"), bytes([0x20 + i]), now
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
|
||||
|
||||
# Garbage must not inflate the real counts...
|
||||
assert stats["total_messages"] == 1
|
||||
assert stats["scoped_messages"] == 0
|
||||
# ...but should surface as the floor: 3 packets over 3 undefined buckets
|
||||
assert stats["false_positive_floor"] == pytest.approx(1.0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_counts_distinct_senders_who_scoped(self, test_db):
|
||||
"""Sender adoption is per distinct sender, not per message."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
# One chatty scoping sender, one quiet scoping sender, one unscoped sender.
|
||||
# Traffic share would read 4/5; sender share should read 2/3.
|
||||
rows = [
|
||||
("alice_key", "Alice", 0xAABB),
|
||||
("alice_key", "Alice", 0xAABB),
|
||||
("alice_key", "Alice", 0xAABB),
|
||||
("bob_key", "Bob", 0xCCDD),
|
||||
("carol_key", "Carol", None),
|
||||
]
|
||||
for i, (sender_key, sender_name, code) in enumerate(rows):
|
||||
await conn.execute(
|
||||
"""INSERT INTO messages
|
||||
(type, conversation_key, text, received_at, outgoing,
|
||||
sender_key, sender_name, transport_code)
|
||||
VALUES ('CHAN', ?, ?, ?, 0, ?, ?, ?)""",
|
||||
("EE" * 16, f"msg{i}", now, sender_key, sender_name, code),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
|
||||
|
||||
assert stats["total_senders"] == 3
|
||||
assert stats["scoped_senders"] == 2
|
||||
assert stats["scoped_senders_pct"] == pytest.approx(200 / 3, rel=1e-3)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_scoping_falls_back_to_linked_raw_packet(self, test_db):
|
||||
"""Rows predating region tagging are resolved via their retained packet."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
async with conn.execute(
|
||||
"""INSERT INTO messages
|
||||
(type, conversation_key, text, received_at, outgoing, sender_key, sender_name)
|
||||
VALUES ('CHAN', ?, 'legacy', ?, 0, 'dave_key', 'Dave')""",
|
||||
("EE" * 16, now),
|
||||
) as cursor:
|
||||
message_id = cursor.lastrowid
|
||||
# transport_code is NULL, but the linked packet still shows the scoping
|
||||
await conn.execute(
|
||||
"""INSERT INTO raw_packets (timestamp, data, payload_hash, message_id)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(now, self.SCOPED_GROUP_TEXT, b"\x99" * 32, message_id),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
|
||||
|
||||
assert stats["total_senders"] == 1
|
||||
assert stats["scoped_senders"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_senders_fall_back_to_name_without_resolved_key(self, test_db):
|
||||
"""sender_key is only ~69% resolved, so name is the fallback identity."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
for i, code in enumerate((0xAABB, None)):
|
||||
await conn.execute(
|
||||
"""INSERT INTO messages
|
||||
(type, conversation_key, text, received_at, outgoing,
|
||||
sender_key, sender_name, transport_code)
|
||||
VALUES ('CHAN', ?, ?, ?, 0, NULL, ?, ?)""",
|
||||
("EE" * 16, f"msg{i}", now, f"NamedOnly{i}", code),
|
||||
)
|
||||
# No identity at all -> not counted in either side of the fraction
|
||||
await conn.execute(
|
||||
"""INSERT INTO messages
|
||||
(type, conversation_key, text, received_at, outgoing, sender_key, sender_name)
|
||||
VALUES ('CHAN', ?, 'anon', ?, 0, NULL, NULL)""",
|
||||
("EE" * 16, now),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
|
||||
|
||||
assert stats["total_senders"] == 2
|
||||
assert stats["scoped_senders"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outgoing_messages_excluded_from_sender_counts(self, test_db):
|
||||
"""Our own sends are not evidence of anyone else's adoption."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
await conn.execute(
|
||||
"""INSERT INTO messages
|
||||
(type, conversation_key, text, received_at, outgoing,
|
||||
sender_key, sender_name, transport_code)
|
||||
VALUES ('CHAN', ?, 'ours', ?, 1, 'me_key', 'Me', ?)""",
|
||||
("EE" * 16, now, 0xAABB),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
stats = (await StatisticsRepository.get_all())["region_scope_24h"]
|
||||
|
||||
assert stats["total_senders"] == 0
|
||||
assert stats["scoped_senders"] == 0
|
||||
assert stats["scoped_senders_pct"] == 0.0
|
||||
|
||||
|
||||
class TestPacketsPerHour:
|
||||
@pytest.mark.asyncio
|
||||
async def test_buckets_packets_by_hour(self, test_db):
|
||||
|
||||
Reference in New Issue
Block a user