diff --git a/SCHEMAS.md b/SCHEMAS.md index 90a5478..7ec89fe 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -189,6 +189,11 @@ Group/broadcast messages on specific channels. - Payload type `4` location metadata (`appData.location.latitude/longitude`) is mapped to node `lat/lon` for map rendering. - This keeps advertisement persistence aligned with meshcore-packet-capture expectations (advertisement traffic only). +**Compatibility ingest note (envelope fields)**: +- The LetsMesh upload envelope carries `SNR` and `path` fields alongside the decoded packet payload. These are available on all packet types (messages, advertisements, traces, telemetry). +- The normalizer extracts `SNR` (normalized to lowercase `snr`) and `path` (converted to `path_len` via hop count) from the envelope and includes them in the normalized payload. +- Per-observer `snr` and `path_len` are stored in the `event_observers` junction table, allowing each observer to record its own signal strength and hop count. + **Compatibility ingest note (non-message structured events)**: - Decoded payload type `9` is normalized to `TRACE_DATA` (`traceTag`, flags, auth, path hashes, and SNR values). - Decoded payload type `11` (`Control/NodeDiscoverResp`) is normalized to `contact` events for node upsert parity. @@ -514,6 +519,22 @@ See [AGENTS.md](AGENTS.md) for webhook configuration details. --- +## API Response: Observer Info + +Events that support multi-observer tracking (messages, advertisements, trace paths, telemetry) include an `observers` array in API responses. Each observer entry contains: + +| Field | Type | Description | +|-------|------|-------------| +| `node_id` | string (UUID) | Observer node UUID | +| `public_key` | string (64 hex chars) | Observer node public key | +| `name` | string or null | Observer node advertised name | +| `tag_name` | string or null | Observer name from node tags | +| `snr` | number or null | Signal-to-noise ratio at this observer (dB) | +| `path_len` | integer or null | Hop count at this observer | +| `observed_at` | string (ISO 8601) | When this observer captured the event | + +--- + ## Event Flow 1. **Hardware/Mock MeshCore** → Generates raw events diff --git a/alembic/versions/20260426_1052_a10dbca883a2_add_path_len_to_event_observers.py b/alembic/versions/20260426_1052_a10dbca883a2_add_path_len_to_event_observers.py new file mode 100644 index 0000000..72b8d66 --- /dev/null +++ b/alembic/versions/20260426_1052_a10dbca883a2_add_path_len_to_event_observers.py @@ -0,0 +1,45 @@ +"""add path_len to event_observers + +Revision ID: a10dbca883a2 +Revises: b1c2d3e4f5a6 +Create Date: 2026-04-26 10:52:09.664958+00:00 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "a10dbca883a2" +down_revision: Union[str, None] = "b1c2d3e4f5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("event_observers", schema=None) as batch_op: + batch_op.add_column(sa.Column("path_len", sa.Integer(), nullable=True)) + batch_op.drop_constraint( + batch_op.f("uq_event_receivers_hash_node"), type_="unique" + ) + batch_op.create_unique_constraint( + "uq_event_observers_hash_node", ["event_hash", "observer_node_id"] + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("event_observers", schema=None) as batch_op: + batch_op.drop_constraint("uq_event_observers_hash_node", type_="unique") + batch_op.create_unique_constraint( + batch_op.f("uq_event_receivers_hash_node"), + ["event_hash", "observer_node_id"], + ) + batch_op.drop_column("path_len") + + # ### end Alembic commands ### diff --git a/docs/plans/20260426-1137-improve-snr-path-visibility/plan.md b/docs/plans/20260426-1137-improve-snr-path-visibility/plan.md new file mode 100644 index 0000000..59a67ac --- /dev/null +++ b/docs/plans/20260426-1137-improve-snr-path-visibility/plan.md @@ -0,0 +1,342 @@ +# Observer Detail Rows — Implementation Plan + +**Date:** 2026-04-26 +**Status:** Approved + +## Decisions + +1. **No backfill** — Historical data unchanged. Per-observer `path_len` only captured for new events. +2. **Canonical case: lowercase `snr`** — All code normalizes SNR references to lowercase. +3. **Trace observer pattern** — Per-observer `path_len` tracks the observer's hop count to the event source. + +## Terminology + +The original research used outdated names. This plan uses the correct codebase terminology: + +| Old Reference | Actual Codebase | +|---------------|-----------------| +| `event_receivers` | `event_observers` | +| `add_event_receiver()` | `add_event_observer()` | +| `_fetch_receivers_for_events()` | `_fetch_observers_for_events()` | +| `ReceiverInfo` | `ObserverInfo` | +| `receivers` (API field) | `observers` | + +## Verified Current State + +### Database (`event_observers` table) + +``` +event_observers +├── id UUID PK +├── event_type String(20) +├── event_hash String(32) +├── observer_node_id FK → nodes.id +├── snr Float (nullable) +├── observed_at DateTime +├── created_at DateTime +└── updated_at DateTime +``` + +**Missing:** `path_len` column. + +### Per-Event vs Per-Observer Fields + +| Field | Scope | Rationale | +|-------|-------|-----------| +| `snr` | **Per-observer** | Signal strength differs by observer location | +| `path_len` | **Per-observer** | Hop count differs by observer position in mesh topology | +| `observed_at` | **Per-observer** | Each observer sees the event at a different time | +| `snr_values` (trace) | **Per-event only** | Per-hop SNR along the trace path | +| `hop_count` (trace) | **Per-event only** | Total hops in the trace | + +### Handler Payload Extraction + +| Handler | SNR extraction | path_len extraction | +|---------|---------------|-------------------| +| `message.py` | `payload.get("SNR") or payload.get("snr")` | `payload.get("path_len")` | +| `advertisement.py` | None | None | +| `trace.py` | None | `payload.get("path_len")` | +| `telemetry.py` | None | None | + +Each handler has exactly 3 `add_event_observer()` call sites: +1. Duplicate path (existing event) +2. First observer (new event) +3. Race condition recovery + +All call sites need `path_len` parameter. + +### LetsMesh Normalizer + +`_build_letsmesh_advertisement_payload()` (handles decoded packet type 4) does NOT extract envelope `SNR` or `path`. The message payload method already does (lines 143-160). **Note:** The message method outputs `normalized_payload["SNR"]` (uppercase) at line 160, which contradicts Decision #2. This must be changed to lowercase `"snr"` alongside the advertisement fix. + +### API Routes + +| Route | Populates `observers`? | Notes | +|-------|----------------------|-------| +| `api/routes/messages.py` | Yes | Uses `_fetch_observers_for_events()` | +| `api/routes/advertisements.py` | Yes | Uses `_fetch_observers_for_events()` | +| `api/routes/trace_paths.py` | No — returns `[]` | Schema has field but never queries | +| `api/routes/telemetry.py` | No — returns `[]` | Same issue | + +`_fetch_observers_for_events()` is duplicated identically in `messages.py` and `advertisements.py`. + +### Frontend + +- **`components.js:444` `receiverIcons()`** — Dead code. Uses wrong property names (`receiver_node_name` / `receiver_node_public_key`). No page imports it. +- **`messages.js` and `advertisements.js`** — Render satellite dish icons with correct property names (`recv.tag_name`, `recv.name`, `recv.public_key`) but display name-only tooltips. Per-observer SNR/path_len is returned by API but never displayed. +- **No trace_paths.js or telemetry.js frontend pages exist.** +- **No expandable/collapsible row patterns** exist anywhere in the SPA. + +### Schema (`ObserverInfo`) + +```python +class ObserverInfo(BaseModel): + node_id: str + public_key: str + name: Optional[str] + tag_name: Optional[str] + snr: Optional[float] + observed_at: datetime +``` + +**Missing:** `path_len` field. + +--- + +## Implementation Plan + +### Phase 1: Database Schema — Add `path_len` to `event_observers` + +**File:** `src/meshcore_hub/common/models/event_observer.py` + +- Add column: `path_len: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)` +- Update `add_event_observer()` signature to accept `path_len: Optional[int] = None` +- Include `path_len` in `sqlite_insert().values()` call + +**Migration:** +```bash +source .venv/bin/activate +meshcore-hub db revision --autogenerate -m "add path_len to event_observers" +meshcore-hub db upgrade +``` + +### Phase 2: API Utility & Schema + +**New file:** `src/meshcore_hub/api/observer_utils.py` + +Move `_fetch_observers_for_events()` into a shared module. Update the query to also select `EventObserver.path_len`. + +```python +def _fetch_observers_for_events( + session: DbSession, + event_type: str, + event_hashes: list[str], +) -> dict[str, list[ObserverInfo]]: +``` + +The query selects: `EventObserver.event_hash`, `EventObserver.snr`, `EventObserver.path_len`, `EventObserver.observed_at`, `Node.id`, `Node.public_key`, `Node.name`. Also fetches `NodeTag` name tags. + +**Schema update:** `src/meshcore_hub/common/schemas/messages.py` + +Add to `ObserverInfo`: +```python +path_len: Optional[int] = Field(default=None, description="Hop count at this observer") +``` + +**Route updates:** + +| File | Change | +|------|--------| +| `api/routes/messages.py` | Remove local `_fetch_observers_for_events`, import from `observer_utils` | +| `api/routes/advertisements.py` | Same | +| `api/routes/trace_paths.py` | Import and call `_fetch_observers_for_events(session, "trace", event_hashes)` to populate `observers` | +| `api/routes/telemetry.py` | Import and call `_fetch_observers_for_events(session, "telemetry", event_hashes)` to populate `observers` | + +### Phase 3: LetsMesh Normalizer — Extract Envelope SNR & Path for Advertisements + +**File:** `src/meshcore_hub/collector/letsmesh_normalizer.py` + +In `_build_letsmesh_advertisement_payload()`, add after the `normalized_payload` dict initialization (line 574): + +```python +snr = self._parse_float(payload.get("SNR")) +if snr is None: + snr = self._parse_float(payload.get("snr")) +if snr is not None: + normalized_payload["snr"] = snr + +path_len = self._parse_path_length(payload.get("path")) +if path_len is not None: + normalized_payload["path_len"] = path_len +``` + +This follows the same pattern used in `_build_letsmesh_message_payload()` (lines 143-160), with the key difference being lowercase `"snr"` output. + +> **Inaccuracy corrected (review 2026-04-26):** The message normalizer at line 160 outputs `normalized_payload["SNR"]` (uppercase). Phase 4 below removes the handler's uppercase fallback (`payload.get("SNR") or ...`). Without also fixing the normalizer output, LetsMesh-routed messages would lose SNR data. The change below is added to enforce Decision #2 (canonical lowercase `snr`) consistently. + +In `_build_letsmesh_message_payload()`, change line 160: +```python +# Before: +normalized_payload["SNR"] = snr +# After: +normalized_payload["snr"] = snr +``` + +### Phase 4: Collector Handlers — Pass SNR & path_len + +**File:** `src/meshcore_hub/collector/handlers/message.py` + +- Change line 78: `payload.get("SNR") or payload.get("snr")` → `payload.get("snr")` +- `path_len` is already extracted (line 75) — just pass it to all 3 `add_event_observer()` call sites (lines 124, 158, 178) + +**File:** `src/meshcore_hub/collector/handlers/advertisement.py` + +- Add extraction: `snr = payload.get("snr")` +- Add extraction: `path_len = payload.get("path_len")` +- Pass both to all 3 `add_event_observer()` call sites (lines 120, 182, 203) + +**File:** `src/meshcore_hub/collector/handlers/trace.py` + +- Add extraction: `snr = payload.get("snr")` +- `path_len` is already extracted (line 38) — pass both to all 3 `add_event_observer()` call sites (lines 74, 106, 126) + +**File:** `src/meshcore_hub/collector/handlers/telemetry.py` + +- Add extraction: `snr = payload.get("snr")` +- Add extraction: `path_len = payload.get("path_len")` +- Pass both to all 3 `add_event_observer()` call sites (lines 87, 133, 154) + +### Phase 5: Frontend Components + +**File:** `src/meshcore_hub/web/static/js/spa/components.js` + +- **Remove** dead `receiverIcons()` function (lines 444-452) — uses wrong property names, no page imports it +- **Add** `observerDetailRow(observers, eventProperties)` component: + - Renders an expandable sub-table below the event row + - Observer columns: + - **Observer** — `tag_name || name || truncateKey(public_key, 12)`, linked to `/nodes/${public_key}` + - **SNR** — Formatted as "X.X dB" or "—" if null + - **Path** — Formatted as "N hops" or "—" if null + - **Received** — Relative time via `formatRelativeTime(observed_at)` + - `eventProperties` parameter for event-level context (e.g., trace `snr_values`) + - Toggle helper: click event row to show/hide `.observer-detail` row below it +- **Add** `observerIcons(observers)` — count badge with tooltip listing observer names + +**File:** `src/meshcore_hub/web/static/css/app.css` + +- `.observer-detail` expandable row styles (indented, compact sub-table) +- CSS transition for smooth expand/collapse (max-height animation) +- Responsive: desktop table vs mobile card layout + +### Phase 6: Frontend Pages + +**File:** `src/meshcore_hub/web/static/js/spa/pages/messages.js` + +Replace current satellite dish icon rendering with: +- Observer count badge in the Receivers column (clickable to expand) +- Expandable detail row showing per-observer: name, SNR, path_len, observed_at +- Both desktop table (~line 255) and mobile card (~line 206) views + +**File:** `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` + +Same pattern as messages. +- Desktop table (~line 135) +- Mobile card (~line 99) + +**Note:** No `trace_paths.js` frontend page exists. No frontend changes needed for trace paths — API changes in Phase 2 will surface observer data for future pages or API consumers. + +### Phase 7: Tests + +**`tests/test_collector/test_letsmesh_normalizer.py`** +- Test type 4 packet extracts `snr` (lowercase) and `path_len` from envelope +- Test both `"SNR"` and `"snr"` input casing normalizes to lowercase `"snr"` output +- Test message payload also outputs lowercase `"snr"` (verifies line 160 casing fix) + +**`tests/test_collector/test_handlers/test_advertisement.py`** +- Test handler with `snr` and `path_len` in payload → stored in `event_observers` + +**`tests/test_collector/test_handlers/test_message.py`** +- Test handler passes `path_len` to `add_event_observer()` +- Fix casing: change `"SNR": 15.5` → `"snr": 15.5` (line 21) and `"SNR": 8.5` → `"snr": 8.5` (line 102) + +**`tests/test_collector/test_handlers/test_trace.py`** +- Test handler with envelope `snr`/`path_len` → stored in `event_observers` + +**`tests/test_collector/test_handlers/test_telemetry.py`** +- Test handler with envelope `snr`/`path_len` → stored in `event_observers` + +**`tests/test_common/test_models.py`** +- Test `add_event_observer()` accepts and stores `path_len` +- Test backwards compatibility (`path_len=None` is default) + +**`tests/test_api/test_trace_paths.py`** +- Test `observers` list is populated (query returns data) + +**`tests/test_api/test_telemetry.py`** +- Test `observers` list is populated (query returns data) + +**Run commands:** +```bash +source .venv/bin/activate +pytest tests/test_collector/ -v +pytest tests/test_api/ -v +pytest tests/test_common/ -v +pre-commit run --all-files +``` + +### Phase 8: Documentation + +**`SCHEMAS.md`:** +- Document that `SNR` and `path` are LetsMesh envelope fields available on all packet types +- Update `ObserverInfo` description to include `path_len` + +**`AGENTS.md`:** +- Update `event_observers` table description to include `path_len` column + +--- + +## File Change Summary + +| # | File | Action | Phase | +|---|------|--------|-------| +| 1 | `common/models/event_observer.py` | Modify | 1 | +| 2 | `alembic/versions/*.py` | Create | 1 | +| 3 | `common/schemas/messages.py` | Modify | 2 | +| 4 | `api/observer_utils.py` | Create | 2 | +| 5 | `api/routes/messages.py` | Modify | 2 | +| 6 | `api/routes/advertisements.py` | Modify | 2 | +| 7 | `api/routes/trace_paths.py` | Modify | 2 | +| 8 | `api/routes/telemetry.py` | Modify | 2 | +| 9 | `collector/letsmesh_normalizer.py` | Modify | 3 | +| 10 | `collector/handlers/message.py` | Modify | 4 | +| 11 | `collector/handlers/advertisement.py` | Modify | 4 | +| 12 | `collector/handlers/trace.py` | Modify | 4 | +| 13 | `collector/handlers/telemetry.py` | Modify | 4 | +| 14 | `web/static/js/spa/components.js` | Modify | 5 | +| 15 | `web/static/css/app.css` | Modify | 5 | +| 16 | `web/static/js/spa/pages/messages.js` | Modify | 6 | +| 17 | `web/static/js/spa/pages/advertisements.js` | Modify | 6 | +| 18 | `SCHEMAS.md` | Modify | 8 | +| 19 | `AGENTS.md` | Modify | 8 | + +--- + +## Source Files Reference + +| File | Key Locations | +|------|--------------| +| `common/models/event_observer.py` | `EventObserver` model, `add_event_observer()` helper | +| `common/hash_utils.py` | Hash computation for deduplication | +| `common/schemas/messages.py` | `ObserverInfo`, `MessageRead`, `AdvertisementRead`, `TracePathRead`, `TelemetryRead` | +| `collector/letsmesh_normalizer.py` | `_build_letsmesh_advertisement_payload()` (line 544), `_build_letsmesh_message_payload()` (line 84) | +| `collector/handlers/advertisement.py` | 3x `add_event_observer()` at lines 120, 182, 203 | +| `collector/handlers/message.py` | 3x `add_event_observer()` at lines 124, 158, 178 | +| `collector/handlers/trace.py` | 3x `add_event_observer()` at lines 74, 106, 126 | +| `collector/handlers/telemetry.py` | 3x `add_event_observer()` at lines 87, 133, 154 | +| `api/routes/messages.py` | `_fetch_observers_for_events()` at line 28 | +| `api/routes/advertisements.py` | `_fetch_observers_for_events()` at line 42 | +| `api/routes/trace_paths.py` | Returns `[]` for observers | +| `api/routes/telemetry.py` | Returns `[]` for observers | +| `web/static/js/spa/components.js` | Dead `receiverIcons()` at line 444 | +| `web/static/js/spa/pages/messages.js` | Observer rendering at lines 206-216 (mobile), 255-267 (desktop) | +| `web/static/js/spa/pages/advertisements.js` | Observer rendering at lines 99-109 (mobile), 135-147 (desktop) | diff --git a/docs/plans/20260426-1137-improve-snr-path-visibility/tasks.md b/docs/plans/20260426-1137-improve-snr-path-visibility/tasks.md new file mode 100644 index 0000000..20ecad4 --- /dev/null +++ b/docs/plans/20260426-1137-improve-snr-path-visibility/tasks.md @@ -0,0 +1,127 @@ +# Observer Detail Rows — Task Checklist + +**Plan:** `docs/plans/20260426-1137-improve-snr-path-visibility/plan.md` +**Status:** Complete + +## Review Notes + +> **Plan accuracy issue found:** Phase 4 changes `message.py` handler to read only `payload.get("snr")` (lowercase), but the LetsMesh **message** normalizer (`letsmesh_normalizer.py:160`) still outputs `normalized_payload["SNR"]` (uppercase). Phase 3 only fixes the **advertisement** normalizer. An additional task (Phase 3.5) is included below to normalize the message normalizer output to lowercase `"snr"`, consistent with Decision #2. + +--- + +## Phase 1: Database Schema + +- [x] **1.1** Add `path_len: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)` to `EventObserver` model in `src/meshcore_hub/common/models/event_observer.py` +- [x] **1.2** Add `path_len: Optional[int] = None` parameter to `add_event_observer()` signature +- [x] **1.3** Include `path_len=path_len` in `sqlite_insert().values()` call +- [x] **1.4** Generate Alembic migration: `meshcore-hub db revision --autogenerate -m "add path_len to event_observers"` +- [x] **1.5** Review and adjust migration file +- [x] **1.6** Run migration: `meshcore-hub db upgrade` + +## Phase 2: API Utility & Schema + +- [x] **2.1** Add `path_len: Optional[int] = Field(default=None, description="Hop count at this observer")` to `ObserverInfo` schema in `src/meshcore_hub/common/schemas/messages.py` +- [x] **2.2** Create `src/meshcore_hub/api/observer_utils.py` — move shared `_fetch_observers_for_events()` function +- [x] **2.3** Update shared query to also select `EventObserver.path_len` +- [x] **2.4** Include `path_len=row.path_len` in `ObserverInfo()` construction within the shared function +- [x] **2.5** Update `api/routes/messages.py` — remove local `_fetch_observers_for_events`, import from `observer_utils`; also remove local `_get_tag_name` if it becomes unused +- [x] **2.6** Update `api/routes/advertisements.py` — same as 2.5 +- [x] **2.7** Update `api/routes/trace_paths.py` — import and call `_fetch_observers_for_events(session, "trace", event_hashes)` for both list and detail endpoints; include `"observers"` key in response dicts +- [x] **2.8** Update `api/routes/telemetry.py` — same pattern as 2.7 with `"telemetry"` event type + +## Phase 3: LetsMesh Normalizer — Advertisement SNR & Path + +- [x] **3.1** In `src/meshcore_hub/collector/letsmesh_normalizer.py`, in `_build_letsmesh_advertisement_payload()`, add SNR extraction after `normalized_payload` dict initialization (~line 574): `snr = self._parse_float(payload.get("SNR"))`, fallback to `payload.get("snr")`, store as `normalized_payload["snr"]` (lowercase) +- [x] **3.2** Add `path_len = self._parse_path_length(payload.get("path"))` extraction, store as `normalized_payload["path_len"]` +- [x] **3.3** Return the updated `normalized_payload` (already returned at line 627) + +## Phase 3.5: LetsMesh Normalizer — Message SNR Casing Fix + +> **Accuracy fix:** Not in original plan. Required to prevent SNR data loss for LetsMesh-routed messages after Phase 4 removes the uppercase fallback. + +- [x] **3.5.1** In `src/meshcore_hub/collector/letsmesh_normalizer.py`, change line 160: `normalized_payload["SNR"]` → `normalized_payload["snr"]` (lowercase, per Decision #2) + +## Phase 4: Collector Handlers + +- [x] **4.1** `src/meshcore_hub/collector/handlers/message.py` — change line 78: `payload.get("SNR") or payload.get("snr")` → `payload.get("snr")` +- [x] **4.2** `message.py` — pass `path_len=path_len` to all 3 `add_event_observer()` call sites (lines 124, 158, 178) +- [x] **4.3** `src/meshcore_hub/collector/handlers/advertisement.py` — add extraction: `snr = payload.get("snr")` and `path_len = payload.get("path_len")` +- [x] **4.4** `advertisement.py` — pass `snr=snr, path_len=path_len` to all 3 `add_event_observer()` call sites (lines 120, 182, 203); replace current `snr=None` +- [x] **4.5** `src/meshcore_hub/collector/handlers/trace.py` — add extraction: `snr = payload.get("snr")` +- [x] **4.6** `trace.py` — pass `snr=snr, path_len=path_len` to all 3 `add_event_observer()` call sites (lines 74, 106, 126); replace current `snr=None` +- [x] **4.7** `src/meshcore_hub/collector/handlers/telemetry.py` — add extraction: `snr = payload.get("snr")` and `path_len = payload.get("path_len")` +- [x] **4.8** `telemetry.py` — pass `snr=snr, path_len=path_len` to all 3 `add_event_observer()` call sites (lines 87, 133, 154); replace current `snr=None` + +## Phase 5: Frontend Components + +- [x] **5.1** Remove dead `receiverIcons()` function from `src/meshcore_hub/web/static/js/spa/components.js` (lines 444-452) +- [x] **5.2** Add `observerDetailRow(observers, eventProperties)` component to `components.js`: + - Renders expandable sub-table with columns: Observer (name/link), SNR (formatted dB), Path (formatted hops), Received (relative time) + - `eventProperties` param for event-level context (e.g., trace `snr_values`) + - Toggle helper: click event row to show/hide `.observer-detail` row +- [x] **5.3** Add `observerIcons(observers)` component — count badge with tooltip listing observer names +- [x] **5.4** Add `.observer-detail` expandable row styles to `src/meshcore_hub/web/static/css/app.css`: + - Indented, compact sub-table styling + - CSS transition for smooth expand/collapse (max-height animation) + - Responsive: desktop table vs mobile card layout + +## Phase 6: Frontend Pages + +- [x] **6.1** Update `src/meshcore_hub/web/static/js/spa/pages/messages.js`: + - Replace satellite dish icon rendering with observer count badge (clickable to expand) + - Add expandable detail row showing per-observer: name, SNR, path_len, observed_at + - Update both mobile card view (~line 206) and desktop table view (~line 255) +- [x] **6.2** Update `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`: + - Same pattern as messages + - Update both mobile card view (~line 99) and desktop table view (~line 135) + +## Phase 7: Tests + +- [x] **7.1** `tests/test_collector/test_letsmesh_normalizer.py` — test type 4 packet extracts `snr` (lowercase) and `path_len` from envelope +- [x] **7.2** `tests/test_collector/test_letsmesh_normalizer.py` — test both `"SNR"` and `"snr"` input casing normalizes to lowercase `"snr"` output +- [x] **7.3** `tests/test_collector/test_letsmesh_normalizer.py` — test message payload also outputs lowercase `"snr"` (verifies Phase 3.5 fix) +- [x] **7.4** `tests/test_collector/test_handlers/test_advertisement.py` — test handler with `snr` and `path_len` in payload → stored in `event_observers` +- [x] **7.5** `tests/test_collector/test_handlers/test_message.py` — test handler passes `path_len` to `add_event_observer()` +- [x] **7.6** `tests/test_collector/test_handlers/test_message.py` — fix casing: `"SNR": 15.5` → `"snr": 15.5` (line 21) and `"SNR": 8.5` → `"snr": 8.5` (line 102) +- [x] **7.7** `tests/test_collector/test_handlers/test_trace.py` — test handler with envelope `snr`/`path_len` → stored in `event_observers` +- [x] **7.8** `tests/test_collector/test_handlers/test_telemetry.py` — test handler with envelope `snr`/`path_len` → stored in `event_observers` +- [x] **7.9** `tests/test_common/test_models.py` — test `add_event_observer()` accepts and stores `path_len` +- [x] **7.10** `tests/test_common/test_models.py` — test backwards compatibility (`path_len=None` is default) +- [x] **7.11** `tests/test_api/test_trace_paths.py` — test `observers` list is populated (query returns data) +- [x] **7.12** `tests/test_api/test_telemetry.py` — test `observers` list is populated (query returns data) +- [x] **7.13** Run targeted tests: `pytest tests/test_collector/ -v` +- [x] **7.14** Run targeted tests: `pytest tests/test_api/ -v` +- [x] **7.15** Run targeted tests: `pytest tests/test_common/ -v` +- [x] **7.16** Run quality checks: `pre-commit run --all-files` + +## Phase 8: Documentation + +- [x] **8.1** Update `SCHEMAS.md` — document that `SNR` and `path` are LetsMesh envelope fields available on all packet types +- [x] **8.2** Update `SCHEMAS.md` — update `ObserverInfo` description to include `path_len` +- [x] **8.3** Update `AGENTS.md` — update `event_observers` table description to include `path_len` column + +--- + +## File Change Summary + +| # | File | Action | Phase(s) | +|---|------|--------|----------| +| 1 | `common/models/event_observer.py` | Modify | 1 | +| 2 | `alembic/versions/*.py` | Create | 1 | +| 3 | `common/schemas/messages.py` | Modify | 2 | +| 4 | `api/observer_utils.py` | Create | 2 | +| 5 | `api/routes/messages.py` | Modify | 2 | +| 6 | `api/routes/advertisements.py` | Modify | 2 | +| 7 | `api/routes/trace_paths.py` | Modify | 2 | +| 8 | `api/routes/telemetry.py` | Modify | 2 | +| 9 | `collector/letsmesh_normalizer.py` | Modify | 3, 3.5 | +| 10 | `collector/handlers/message.py` | Modify | 4 | +| 11 | `collector/handlers/advertisement.py` | Modify | 4 | +| 12 | `collector/handlers/trace.py` | Modify | 4 | +| 13 | `collector/handlers/telemetry.py` | Modify | 4 | +| 14 | `web/static/js/spa/components.js` | Modify | 5 | +| 15 | `web/static/css/app.css` | Modify | 5 | +| 16 | `web/static/js/spa/pages/messages.js` | Modify | 6 | +| 17 | `web/static/js/spa/pages/advertisements.js` | Modify | 6 | +| 18 | `SCHEMAS.md` | Modify | 8 | +| 19 | `AGENTS.md` | Modify | 8 | diff --git a/src/meshcore_hub/api/observer_utils.py b/src/meshcore_hub/api/observer_utils.py new file mode 100644 index 0000000..21a4a79 --- /dev/null +++ b/src/meshcore_hub/api/observer_utils.py @@ -0,0 +1,75 @@ +"""Shared utilities for fetching event observer data.""" + +from sqlalchemy import select + +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import EventObserver, Node, NodeTag +from meshcore_hub.common.schemas.messages import ObserverInfo + + +def fetch_observers_for_events( + session: DbSession, + event_type: str, + event_hashes: list[str], +) -> dict[str, list[ObserverInfo]]: + """Fetch observer info for a list of events by their hashes. + + Args: + session: Database session + event_type: Type of event ('message', 'advertisement', etc.) + event_hashes: List of event hashes to fetch observers for + + Returns: + Dict mapping event_hash to list of ObserverInfo objects + """ + if not event_hashes: + return {} + + query = ( + select( + EventObserver.event_hash, + EventObserver.snr, + EventObserver.path_len, + EventObserver.observed_at, + Node.id.label("node_id"), + Node.public_key, + Node.name, + ) + .join(Node, EventObserver.observer_node_id == Node.id) + .where(EventObserver.event_type == event_type) + .where(EventObserver.event_hash.in_(event_hashes)) + .order_by(EventObserver.observed_at) + ) + + results = session.execute(query).all() + + observers_by_hash: dict[str, list[ObserverInfo]] = {} + + node_ids = [r.node_id for r in results] + tag_names: dict[str, str] = {} + if node_ids: + tag_query = ( + select(NodeTag.node_id, NodeTag.value) + .where(NodeTag.node_id.in_(node_ids)) + .where(NodeTag.key == "name") + ) + for node_id, value in session.execute(tag_query).all(): + tag_names[node_id] = value + + for row in results: + if row.event_hash not in observers_by_hash: + observers_by_hash[row.event_hash] = [] + + observers_by_hash[row.event_hash].append( + ObserverInfo( + node_id=row.node_id, + public_key=row.public_key, + name=row.name, + tag_name=tag_names.get(row.node_id), + snr=row.snr, + path_len=row.path_len, + observed_at=row.observed_at, + ) + ) + + return observers_by_hash diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index a3d7b87..68d7548 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -9,11 +9,11 @@ from sqlalchemy.orm import aliased, selectinload from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import Advertisement, EventObserver, Node, NodeTag +from meshcore_hub.api.observer_utils import fetch_observers_for_events +from meshcore_hub.common.models import Advertisement, Node, NodeTag from meshcore_hub.common.schemas.messages import ( AdvertisementList, AdvertisementRead, - ObserverInfo, ) router = APIRouter() @@ -39,62 +39,6 @@ def _get_tag_description(node: Optional[Node]) -> Optional[str]: return None -def _fetch_observers_for_events( - session: DbSession, - event_type: str, - event_hashes: list[str], -) -> dict[str, list[ObserverInfo]]: - """Fetch receiver info for a list of events by their hashes.""" - if not event_hashes: - return {} - - query = ( - select( - EventObserver.event_hash, - EventObserver.snr, - EventObserver.observed_at, - Node.id.label("node_id"), - Node.public_key, - Node.name, - ) - .join(Node, EventObserver.observer_node_id == Node.id) - .where(EventObserver.event_type == event_type) - .where(EventObserver.event_hash.in_(event_hashes)) - .order_by(EventObserver.observed_at) - ) - - results = session.execute(query).all() - observers_by_hash: dict[str, list[ObserverInfo]] = {} - - node_ids = [r.node_id for r in results] - tag_names: dict[str, str] = {} - if node_ids: - tag_query = ( - select(NodeTag.node_id, NodeTag.value) - .where(NodeTag.node_id.in_(node_ids)) - .where(NodeTag.key == "name") - ) - for node_id, value in session.execute(tag_query).all(): - tag_names[node_id] = value - - for row in results: - if row.event_hash not in observers_by_hash: - observers_by_hash[row.event_hash] = [] - - observers_by_hash[row.event_hash].append( - ObserverInfo( - node_id=row.node_id, - public_key=row.public_key, - name=row.name, - tag_name=tag_names.get(row.node_id), - snr=row.snr, - observed_at=row.observed_at, - ) - ) - - return observers_by_hash - - @router.get("", response_model=AdvertisementList) async def list_advertisements( _: RequireRead, @@ -201,7 +145,7 @@ async def list_advertisements( # Fetch all observers for these advertisements event_hashes = [r[0].event_hash for r in results if r[0].event_hash] - observers_by_hash = _fetch_observers_for_events( + observers_by_hash = fetch_observers_for_events( session, "advertisement", event_hashes ) @@ -290,7 +234,7 @@ async def get_advertisement( # Fetch observers for this advertisement observers = [] if adv.event_hash: - observers_by_hash = _fetch_observers_for_events( + observers_by_hash = fetch_observers_for_events( session, "advertisement", [adv.event_hash] ) observers = observers_by_hash.get(adv.event_hash, []) diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index 29dea68..701cd8c 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -9,8 +9,9 @@ from sqlalchemy.orm import aliased, selectinload from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import EventObserver, Message, Node, NodeTag -from meshcore_hub.common.schemas.messages import MessageList, MessageRead, ObserverInfo +from meshcore_hub.api.observer_utils import fetch_observers_for_events +from meshcore_hub.common.models import Message, Node, NodeTag +from meshcore_hub.common.schemas.messages import MessageList, MessageRead router = APIRouter() @@ -25,75 +26,6 @@ def _get_tag_name(node: Optional[Node]) -> Optional[str]: return None -def _fetch_observers_for_events( - session: DbSession, - event_type: str, - event_hashes: list[str], -) -> dict[str, list[ObserverInfo]]: - """Fetch receiver info for a list of events by their hashes. - - Args: - session: Database session - event_type: Type of event ('message', 'advertisement', etc.) - event_hashes: List of event hashes to fetch observers for - - Returns: - Dict mapping event_hash to list of ObserverInfo objects - """ - if not event_hashes: - return {} - - # Query event_observers with receiver node info - query = ( - select( - EventObserver.event_hash, - EventObserver.snr, - EventObserver.observed_at, - Node.id.label("node_id"), - Node.public_key, - Node.name, - ) - .join(Node, EventObserver.observer_node_id == Node.id) - .where(EventObserver.event_type == event_type) - .where(EventObserver.event_hash.in_(event_hashes)) - .order_by(EventObserver.observed_at) - ) - - results = session.execute(query).all() - - # Group by event_hash - observers_by_hash: dict[str, list[ObserverInfo]] = {} - - # Get tag names for receiver nodes - node_ids = [r.node_id for r in results] - tag_names: dict[str, str] = {} - if node_ids: - tag_query = ( - select(NodeTag.node_id, NodeTag.value) - .where(NodeTag.node_id.in_(node_ids)) - .where(NodeTag.key == "name") - ) - for node_id, value in session.execute(tag_query).all(): - tag_names[node_id] = value - - for row in results: - if row.event_hash not in observers_by_hash: - observers_by_hash[row.event_hash] = [] - - observers_by_hash[row.event_hash].append( - ObserverInfo( - node_id=row.node_id, - public_key=row.public_key, - name=row.name, - tag_name=tag_names.get(row.node_id), - snr=row.snr, - observed_at=row.observed_at, - ) - ) - - return observers_by_hash - - @router.get("", response_model=MessageList) async def list_messages( _: RequireRead, @@ -197,7 +129,7 @@ async def list_messages( # Fetch all observers for these messages event_hashes = [r[0].event_hash for r in results if r[0].event_hash] - observers_by_hash = _fetch_observers_for_events(session, "message", event_hashes) + observers_by_hash = fetch_observers_for_events(session, "message", event_hashes) # Build response with sender info and observed_by items = [] @@ -269,7 +201,7 @@ async def get_message( # Fetch observers for this message observers = [] if message.event_hash: - observers_by_hash = _fetch_observers_for_events( + observers_by_hash = fetch_observers_for_events( session, "message", [message.event_hash] ) observers = observers_by_hash.get(message.event_hash, []) diff --git a/src/meshcore_hub/api/routes/telemetry.py b/src/meshcore_hub/api/routes/telemetry.py index e56743f..002678e 100644 --- a/src/meshcore_hub/api/routes/telemetry.py +++ b/src/meshcore_hub/api/routes/telemetry.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import aliased from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.api.observer_utils import fetch_observers_for_events from meshcore_hub.common.models import Node, Telemetry from meshcore_hub.common.schemas.messages import TelemetryList, TelemetryRead @@ -59,6 +60,10 @@ async def list_telemetry( # Execute results = session.execute(query).all() + # Fetch observers for these telemetry records + event_hashes = [tel.event_hash for tel, _ in results if tel.event_hash] + observers_by_hash = fetch_observers_for_events(session, "telemetry", event_hashes) + # Build response with observed_by items = [] for tel, observer_pk in results: @@ -71,6 +76,9 @@ async def list_telemetry( "parsed_data": tel.parsed_data, "received_at": tel.received_at, "created_at": tel.created_at, + "observers": ( + observers_by_hash.get(tel.event_hash, []) if tel.event_hash else [] + ), } items.append(TelemetryRead(**data)) @@ -101,6 +109,14 @@ async def get_telemetry( raise HTTPException(status_code=404, detail="Telemetry record not found") tel, observer_pk = result + + observers = [] + if tel.event_hash: + observers_by_hash = fetch_observers_for_events( + session, "telemetry", [tel.event_hash] + ) + observers = observers_by_hash.get(tel.event_hash, []) + data = { "id": tel.id, "observer_node_id": tel.observer_node_id, @@ -110,5 +126,6 @@ async def get_telemetry( "parsed_data": tel.parsed_data, "received_at": tel.received_at, "created_at": tel.created_at, + "observers": observers, } return TelemetryRead(**data) diff --git a/src/meshcore_hub/api/routes/trace_paths.py b/src/meshcore_hub/api/routes/trace_paths.py index e9c576d..b592157 100644 --- a/src/meshcore_hub/api/routes/trace_paths.py +++ b/src/meshcore_hub/api/routes/trace_paths.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import aliased from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.api.observer_utils import fetch_observers_for_events from meshcore_hub.common.models import Node, TracePath from meshcore_hub.common.schemas.messages import TracePathList, TracePathRead @@ -55,6 +56,10 @@ async def list_trace_paths( # Execute results = session.execute(query).all() + # Fetch observers for these trace paths + event_hashes = [tp.event_hash for tp, _ in results if tp.event_hash] + observers_by_hash = fetch_observers_for_events(session, "trace", event_hashes) + # Build response with observed_by items = [] for tp, observer_pk in results: @@ -71,6 +76,9 @@ async def list_trace_paths( "hop_count": tp.hop_count, "received_at": tp.received_at, "created_at": tp.created_at, + "observers": ( + observers_by_hash.get(tp.event_hash, []) if tp.event_hash else [] + ), } items.append(TracePathRead(**data)) @@ -101,6 +109,14 @@ async def get_trace_path( raise HTTPException(status_code=404, detail="Trace path not found") tp, observer_pk = result + + observers = [] + if tp.event_hash: + observers_by_hash = fetch_observers_for_events( + session, "trace", [tp.event_hash] + ) + observers = observers_by_hash.get(tp.event_hash, []) + data = { "id": tp.id, "observer_node_id": tp.observer_node_id, @@ -114,5 +130,6 @@ async def get_trace_path( "hop_count": tp.hop_count, "received_at": tp.received_at, "created_at": tp.created_at, + "observers": observers, } return TracePathRead(**data) diff --git a/src/meshcore_hub/collector/handlers/advertisement.py b/src/meshcore_hub/collector/handlers/advertisement.py index fb3d1e1..e92b07b 100644 --- a/src/meshcore_hub/collector/handlers/advertisement.py +++ b/src/meshcore_hub/collector/handlers/advertisement.py @@ -72,6 +72,9 @@ def handle_advertisement( lon = _coerce_float(lon) now = datetime.now(timezone.utc) + snr = payload.get("snr") + path_len = payload.get("path_len") + # Compute event hash for deduplication (30-second time bucket) event_hash = compute_advertisement_hash( public_key=adv_public_key, @@ -122,7 +125,8 @@ def handle_advertisement( event_type="advertisement", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, # Advertisements don't have SNR + snr=snr, + path_len=path_len, observed_at=now, ) if added: @@ -184,7 +188,8 @@ def handle_advertisement( event_type="advertisement", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) @@ -205,7 +210,8 @@ def handle_advertisement( event_type="advertisement", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) return diff --git a/src/meshcore_hub/collector/handlers/message.py b/src/meshcore_hub/collector/handlers/message.py index 8797145..8cb3ae5 100644 --- a/src/meshcore_hub/collector/handlers/message.py +++ b/src/meshcore_hub/collector/handlers/message.py @@ -75,7 +75,7 @@ def _handle_message( path_len = payload.get("path_len") txt_type = payload.get("txt_type") signature = payload.get("signature") - snr = payload.get("SNR") or payload.get("snr") + snr = payload.get("snr") # Parse sender timestamp sender_ts = payload.get("sender_timestamp") @@ -127,6 +127,7 @@ def _handle_message( event_hash=event_hash, observer_node_id=receiver_node.id, snr=snr, + path_len=path_len, observed_at=now, ) if added: @@ -161,6 +162,7 @@ def _handle_message( event_hash=event_hash, observer_node_id=receiver_node.id, snr=snr, + path_len=path_len, observed_at=now, ) @@ -181,6 +183,7 @@ def _handle_message( event_hash=event_hash, observer_node_id=receiver_node.id, snr=snr, + path_len=path_len, observed_at=now, ) return diff --git a/src/meshcore_hub/collector/handlers/telemetry.py b/src/meshcore_hub/collector/handlers/telemetry.py index 43b9a95..0883d27 100644 --- a/src/meshcore_hub/collector/handlers/telemetry.py +++ b/src/meshcore_hub/collector/handlers/telemetry.py @@ -37,6 +37,8 @@ def handle_telemetry( lpp_data = payload.get("lpp_data") parsed_data = payload.get("parsed_data") + snr = payload.get("snr") + path_len = payload.get("path_len") # Convert lpp_data to bytes if it's a string or list lpp_bytes = None @@ -89,7 +91,8 @@ def handle_telemetry( event_type="telemetry", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) if added: @@ -135,7 +138,8 @@ def handle_telemetry( event_type="telemetry", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) @@ -156,7 +160,8 @@ def handle_telemetry( event_type="telemetry", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) return diff --git a/src/meshcore_hub/collector/handlers/trace.py b/src/meshcore_hub/collector/handlers/trace.py index 98b233f..2b3c7e4 100644 --- a/src/meshcore_hub/collector/handlers/trace.py +++ b/src/meshcore_hub/collector/handlers/trace.py @@ -41,6 +41,7 @@ def handle_trace_data( path_hashes = payload.get("path_hashes") snr_values = payload.get("snr_values") hop_count = payload.get("hop_count") + snr = payload.get("snr") # Compute event hash for deduplication (initiator_tag is unique per trace) event_hash = compute_trace_hash(initiator_tag=initiator_tag) @@ -76,7 +77,8 @@ def handle_trace_data( event_type="trace", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, # Trace events don't have a single SNR value + snr=snr, + path_len=path_len, observed_at=now, ) if added: @@ -108,7 +110,8 @@ def handle_trace_data( event_type="trace", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) @@ -128,7 +131,8 @@ def handle_trace_data( event_type="trace", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) return diff --git a/src/meshcore_hub/collector/letsmesh_normalizer.py b/src/meshcore_hub/collector/letsmesh_normalizer.py index 416df7f..c292312 100644 --- a/src/meshcore_hub/collector/letsmesh_normalizer.py +++ b/src/meshcore_hub/collector/letsmesh_normalizer.py @@ -157,7 +157,8 @@ class LetsMeshNormalizer: if snr is None: snr = self._parse_float(payload.get("snr")) if snr is not None: - normalized_payload["SNR"] = snr + normalized_payload["snr"] = snr + normalized_payload.pop("SNR", None) decoded_sender = self._extract_letsmesh_decoder_sender( decoded_packet, @@ -575,6 +576,16 @@ class LetsMeshNormalizer: "public_key": public_key, } + snr = self._parse_float(payload.get("SNR")) + if snr is None: + snr = self._parse_float(payload.get("snr")) + if snr is not None: + normalized_payload["snr"] = snr + + path_len = self._parse_path_length(payload.get("path")) + if path_len is not None: + normalized_payload["path_len"] = path_len + app_data = decoded_payload.get("appData") if isinstance(app_data, dict): name = app_data.get("name") diff --git a/src/meshcore_hub/common/models/event_observer.py b/src/meshcore_hub/common/models/event_observer.py index 36fd1c0..14442d7 100644 --- a/src/meshcore_hub/common/models/event_observer.py +++ b/src/meshcore_hub/common/models/event_observer.py @@ -4,7 +4,15 @@ from datetime import datetime from typing import TYPE_CHECKING, Optional from uuid import uuid4 -from sqlalchemy import DateTime, Float, ForeignKey, Index, String, UniqueConstraint +from sqlalchemy import ( + DateTime, + Float, + ForeignKey, + Integer, + Index, + String, + UniqueConstraint, +) from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.orm import Mapped, Session, mapped_column, relationship @@ -27,6 +35,7 @@ class EventObserver(Base, UUIDMixin, TimestampMixin): event_hash: Hash identifying the unique event (links to event tables) observer_node_id: FK to the node that observed this event snr: Signal-to-noise ratio at this observer (if available) + path_len: Hop count at this observer (if available) observed_at: When this specific observer captured the event created_at: Record creation timestamp updated_at: Record update timestamp @@ -53,6 +62,10 @@ class EventObserver(Base, UUIDMixin, TimestampMixin): Float, nullable=True, ) + path_len: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) observed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utc_now, @@ -85,6 +98,7 @@ def add_event_observer( event_hash: str, observer_node_id: str, snr: Optional[float] = None, + path_len: Optional[int] = None, observed_at: Optional[datetime] = None, ) -> bool: """Add an observer to an event, handling duplicates gracefully. @@ -97,6 +111,7 @@ def add_event_observer( event_hash: Hash identifying the unique event observer_node_id: UUID of the observer node snr: Signal-to-noise ratio at this observer (optional) + path_len: Hop count at this observer (optional) observed_at: When this observer captured the event (defaults to now) Returns: @@ -114,6 +129,7 @@ def add_event_observer( event_hash=event_hash, observer_node_id=observer_node_id, snr=snr, + path_len=path_len, observed_at=now, created_at=now, updated_at=now, diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index 7e84b38..c551d19 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -16,6 +16,9 @@ class ObserverInfo(BaseModel): snr: Optional[float] = Field( default=None, description="Signal-to-noise ratio at this observer" ) + path_len: Optional[int] = Field( + default=None, description="Hop count at this observer" + ) observed_at: datetime = Field( ..., description="When this observer captured the event" ) diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index b65bfd9..5a20813 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -348,3 +348,41 @@ #header-map .leaflet-control { z-index: auto !important; } + +/* ========================================================================== + Observer Detail Rows + Expandable sub-table showing per-observer signal/path data. + ========================================================================== */ + +.observer-detail .observer-detail-content { + padding: 0.5rem 1rem 0.5rem 2rem; +} + +.observer-detail table { + margin: 0; +} + +.observer-badge { + transition: background-color 0.15s ease; +} + +.observer-badge:hover { + background-color: color-mix(in oklch, var(--color-base-content) 15%, transparent); +} + +.observer-badge-group { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +/* Mobile card observer details */ +.observer-detail-card { + padding: 0.5rem 0.75rem; + margin-top: 0.25rem; +} + +.observer-detail-card table { + margin: 0; + font-size: 0.8rem; +} diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 4376c63..aa18d3c 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -437,18 +437,83 @@ export function timezoneIndicator() { } /** - * Render receiver node icons with tooltips. - * @param {Array} receivers + * Render an observer count badge with tooltip listing observer names. + * @param {Array} observers - Array of observer objects * @returns {TemplateResult|nothing} */ -export function receiverIcons(receivers) { - if (!receivers || receivers.length === 0) return nothing; - return html`${receivers.map(r => { - const name = r.receiver_node_name || truncateKey(r.receiver_node_public_key || '', 8); - const time = formatRelativeTime(r.received_at); - const tooltip = time ? `${name} (${time})` : name; - return html`\u{1F4E1}`; - })}`; +export function observerIcons(observers) { + if (!observers || observers.length === 0) return nothing; + const names = observers.map(o => o.tag_name || o.name || truncateKey(o.public_key, 8)); + const tooltip = names.join(', '); + return html`\u{1F4E1}${observers.length}`; +} + +/** + * Render an expandable observer detail row. + * Shows per-observer: name, SNR, path_len, observed_at. + * @param {Array} observers - Array of observer objects + * @param {Object} [eventProperties] - Event-level context (unused, for future use) + * @returns {TemplateResult|nothing} + */ +export function observerDetailRow(observers, eventProperties, options = {}) { + if (!observers || observers.length === 0) return nothing; + const showPath = !options.hidePath; + return html` + + +
+ + + + + + ${showPath ? html`` : nothing} + + + + + ${observers.map(o => { + const displayName = o.tag_name || o.name || truncateKey(o.public_key, 12); + const snrDisplay = o.snr != null ? `${Number(o.snr).toFixed(1)} dB` : '\u2014'; + const pathDisplay = o.path_len != null ? `${o.path_len} hop${o.path_len !== 1 ? 's' : ''}` : '\u2014'; + const timeDisplay = formatRelativeTime(o.observed_at); + return html` + + + + ${showPath ? html`` : nothing} + + + `; + })} + +
ObserverSNRPathReceived
\u{1F4E1} ${displayName}${snrDisplay}${pathDisplay}${timeDisplay}
+
+ + + `; +} + +/** + * Toggle observer detail row visibility when clicking an event row. + * @param {Event} event - Click event + */ +export function toggleObserverDetail(event) { + const row = event.currentTarget; + const detailRow = row.nextElementSibling; + if (detailRow && detailRow.classList.contains('observer-detail')) { + detailRow.classList.toggle('hidden'); + } +} + +export function toggleCardObserverDetail(event) { + event.stopPropagation(); + event.preventDefault(); + const card = event.currentTarget.closest('.card'); + if (card) { + const detail = card.querySelector('.observer-detail-card'); + if (detail) detail.classList.toggle('hidden'); + } } // --- Form Helpers --- diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index f35cd2f..0c18aee 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -1,9 +1,10 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, + getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, truncateKey, errorAlert, - pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay + pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, + observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; @@ -98,15 +99,9 @@ ${content}`, container); const adDescription = ad.node_tag_description; let receiversBlock = nothing; if (ad.observers && ad.observers.length >= 1) { - receiversBlock = html`
- ${ad.observers.map(recv => { - const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); - return html`\u{1F4E1}`; - })} -
`; + receiversBlock = html`${observerIcons(ad.observers)}`; } else if (ad.observed_by) { - const recvTitle = ad.observer_tag_name || ad.observer_name || truncateKey(ad.observed_by, 12); - receiversBlock = html`\u{1F4E1}`; + receiversBlock = html`\u{1F4E1}`; } return html`
@@ -123,6 +118,25 @@ ${content}`, container); ${receiversBlock}
+ ${ad.observers && ad.observers.length > 0 ? html` + + ` : nothing}
`; }); @@ -134,19 +148,13 @@ ${content}`, container); const adDescription = ad.node_tag_description; let receiversBlock; if (ad.observers && ad.observers.length >= 1) { - receiversBlock = html`
- ${ad.observers.map(recv => { - const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); - return html`\u{1F4E1}`; - })} -
`; + receiversBlock = html`${observerIcons(ad.observers)}`; } else if (ad.observed_by) { - const recvTitle = ad.observer_tag_name || ad.observer_name || truncateKey(ad.observed_by, 12); - receiversBlock = html`\u{1F4E1}`; + receiversBlock = html`\u{1F4E1}`; } else { receiversBlock = html`-`; } - return html` + return html` ${renderNodeDisplay({ @@ -165,7 +173,7 @@ ${content}`, container); ${formatDateTime(ad.received_at)} ${receiversBlock} - `; + ${observerDetailRow(ad.observers || [], null, { hidePath: true })}`; }); const paginationBlock = pagination(page, totalPages, '/advertisements', { @@ -203,7 +211,7 @@ ${content}`, container); ${t('entities.node')} ${t('common.public_key')} ${t('common.time')} - ${t('common.receivers')} + ${t('common.observers')} diff --git a/src/meshcore_hub/web/static/js/spa/pages/messages.js b/src/meshcore_hub/web/static/js/spa/pages/messages.js index 78ebcc4..fed6bc6 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/messages.js +++ b/src/meshcore_hub/web/static/js/spa/pages/messages.js @@ -1,11 +1,12 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, + getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, getChannelLabelsMap, resolveChannelLabel, truncateKey, errorAlert, pagination, timezoneIndicator, - createFilterHandler, autoSubmit, submitOnEnter + createFilterHandler, autoSubmit, submitOnEnter, + observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; @@ -205,15 +206,9 @@ ${content}`, container); : sender; let receiversBlock = nothing; if (msg.observers && msg.observers.length >= 1) { - receiversBlock = html`
- ${msg.observers.map(recv => { - const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); - return html`\u{1F4E1}`; - })} -
`; + receiversBlock = html`${observerIcons(msg.observers)}`; } else if (msg.observed_by) { - const recvTitle = msg.observer_tag_name || msg.observer_name || truncateKey(msg.observed_by, 12); - receiversBlock = html`\u{1F4E1}`; + receiversBlock = html`\u{1F4E1}`; } return html`
@@ -236,6 +231,27 @@ ${content}`, container);

${displayMessage}

+ ${msg.observers && msg.observers.length > 0 ? html` + + ` : nothing} `; }); @@ -254,19 +270,13 @@ ${content}`, container); : sender; let receiversBlock; if (msg.observers && msg.observers.length >= 1) { - receiversBlock = html`
- ${msg.observers.map(recv => { - const recvName = recv.tag_name || recv.name || truncateKey(recv.public_key, 12); - return html`\u{1F4E1}`; - })} -
`; + receiversBlock = html`${observerIcons(msg.observers)}`; } else if (msg.observed_by) { - const recvTitle = msg.observer_tag_name || msg.observer_name || truncateKey(msg.observed_by, 12); - receiversBlock = html`\u{1F4E1}`; + receiversBlock = html`\u{1F4E1}`; } else { receiversBlock = html`-`; } - return html` + return html` ${typeIcon} ${formatDateTime(msg.received_at)} @@ -274,7 +284,7 @@ ${content}`, container); ${displayMessage} ${receiversBlock} - `; + ${observerDetailRow(msg.observers || [])}`; }); const paginationBlock = pagination(page, totalPages, '/messages', { @@ -326,7 +336,7 @@ ${content}`, container); ${t('common.time')} ${t('common.from')} ${t('entities.message')} - ${t('common.receivers')} + ${t('common.observers')} diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 0d846c1..60481e9 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -94,7 +94,7 @@ "public_key": "Public Key", "received": "Received", "received_by": "Received By", - "receivers": "Receivers", + "observers": "Observers", "from": "From", "close": "close", "unnamed": "Unnamed", diff --git a/tests/test_api/test_telemetry.py b/tests/test_api/test_telemetry.py index 9d7ba1a..e081b79 100644 --- a/tests/test_api/test_telemetry.py +++ b/tests/test_api/test_telemetry.py @@ -118,3 +118,61 @@ class TestListTelemetryFilters: assert response.status_code == 200 data = response.json() assert len(data["items"]) == 1 + + +class TestTelemetryObservers: + """Tests for observer data in telemetry API responses.""" + + def test_telemetry_observers_populated(self, client_no_auth, api_db_session): + """Test that telemetry list returns observers with data.""" + from meshcore_hub.common.hash_utils import compute_telemetry_hash + from meshcore_hub.common.models import EventObserver, Node, Telemetry + + observer_node = Node( + public_key="z" * 64, + name="TelObserver", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add(observer_node) + api_db_session.flush() + + now = datetime.now(timezone.utc) + event_hash = compute_telemetry_hash( + node_public_key="tel123tel123tel123tel123tel123tel", + parsed_data={"temperature": 22.0}, + received_at=now, + ) + telemetry = Telemetry( + node_public_key="tel123tel123tel123tel123tel123tel", + parsed_data={"temperature": 22.0}, + received_at=now, + observer_node_id=observer_node.id, + event_hash=event_hash, + ) + api_db_session.add(telemetry) + api_db_session.flush() + + ev_obs = EventObserver( + event_type="telemetry", + event_hash=event_hash, + observer_node_id=observer_node.id, + snr=8.5, + path_len=2, + observed_at=now, + ) + api_db_session.add(ev_obs) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/telemetry") + assert response.status_code == 200 + data = response.json() + items = data["items"] + assert len(items) >= 1 + tel_item = next( + i + for i in items + if i["node_public_key"] == "tel123tel123tel123tel123tel123tel" + ) + assert len(tel_item["observers"]) == 1 + assert tel_item["observers"][0]["snr"] == 8.5 + assert tel_item["observers"][0]["path_len"] == 2 diff --git a/tests/test_api/test_trace_paths.py b/tests/test_api/test_trace_paths.py index fd34344..97f57c7 100644 --- a/tests/test_api/test_trace_paths.py +++ b/tests/test_api/test_trace_paths.py @@ -153,3 +153,53 @@ class TestListTracePathsFilters: assert response.status_code == 200 data = response.json() assert len(data["items"]) == 1 + + +class TestTracePathObservers: + """Tests for observer data in trace path API responses.""" + + def test_trace_path_observers_populated(self, client_no_auth, api_db_session): + """Test that trace path list returns observers with data.""" + from meshcore_hub.common.hash_utils import compute_trace_hash + from meshcore_hub.common.models import EventObserver, Node, TracePath + + observer_node = Node( + public_key="z" * 64, + name="TraceObserver", + first_seen=datetime.now(timezone.utc), + ) + api_db_session.add(observer_node) + api_db_session.flush() + + event_hash = compute_trace_hash(initiator_tag=44444) + trace = TracePath( + initiator_tag=44444, + path_hashes=["aa", "bb"], + hop_count=2, + received_at=datetime.now(timezone.utc), + observer_node_id=observer_node.id, + event_hash=event_hash, + ) + api_db_session.add(trace) + api_db_session.flush() + + ev_obs = EventObserver( + event_type="trace", + event_hash=event_hash, + observer_node_id=observer_node.id, + snr=12.0, + path_len=3, + observed_at=datetime.now(timezone.utc), + ) + api_db_session.add(ev_obs) + api_db_session.commit() + + response = client_no_auth.get("/api/v1/trace-paths") + assert response.status_code == 200 + data = response.json() + items = data["items"] + assert len(items) >= 1 + trace_item = next(i for i in items if i["initiator_tag"] == 44444) + assert len(trace_item["observers"]) == 1 + assert trace_item["observers"][0]["snr"] == 12.0 + assert trace_item["observers"][0]["path_len"] == 3 diff --git a/tests/test_collector/test_handlers/test_advertisement.py b/tests/test_collector/test_handlers/test_advertisement.py index 14041a3..cbfd584 100644 --- a/tests/test_collector/test_handlers/test_advertisement.py +++ b/tests/test_collector/test_handlers/test_advertisement.py @@ -199,3 +199,20 @@ class TestHandleAdvertisement: select(Node).where(Node.public_key == receiver_pk) ).scalar_one_or_none() assert receiver is not None + + def test_advertisement_stores_snr_and_path_len(self, db_manager, db_session): + """Advertisement handler stores snr and path_len in event_observers.""" + payload = { + "public_key": "a" * 64, + "name": "TestNode", + "adv_type": "chat", + "snr": 12.5, + "path_len": 3, + } + + handle_advertisement("b" * 64, "advertisement", payload, db_manager) + + observer = db_session.execute(select(EventObserver)).scalar_one_or_none() + assert observer is not None + assert observer.snr == 12.5 + assert observer.path_len == 3 diff --git a/tests/test_collector/test_handlers/test_message.py b/tests/test_collector/test_handlers/test_message.py index afeea23..ddb1407 100644 --- a/tests/test_collector/test_handlers/test_message.py +++ b/tests/test_collector/test_handlers/test_message.py @@ -18,7 +18,7 @@ class TestHandleContactMessage: "pubkey_prefix": "01ab2186c4d5", "text": "Hello World!", "path_len": 3, - "SNR": 15.5, + "snr": 15.5, } handle_contact_message("a" * 64, "contact_msg_recv", payload, db_manager) @@ -99,7 +99,7 @@ class TestHandleChannelMessage: "channel_idx": 4, "text": "Channel broadcast", "path_len": 10, - "SNR": 8.5, + "snr": 8.5, } handle_channel_message("a" * 64, "channel_msg_recv", payload, db_manager) @@ -158,3 +158,19 @@ class TestHandleChannelMessage: msg = db_session.execute(select(Message)).scalar_one() assert msg.signature == "abcdef1234567890" + + def test_message_handler_passes_path_len_to_observer(self, db_manager, db_session): + """Message handler passes path_len to add_event_observer.""" + payload = { + "pubkey_prefix": "01ab2186c4d5", + "text": "Path test", + "path_len": 5, + "snr": 10.0, + } + + handle_contact_message("a" * 64, "contact_msg_recv", payload, db_manager) + + observer = db_session.execute(select(EventObserver)).scalar_one_or_none() + assert observer is not None + assert observer.path_len == 5 + assert observer.snr == 10.0 diff --git a/tests/test_collector/test_handlers/test_telemetry.py b/tests/test_collector/test_handlers/test_telemetry.py index b993bca..eaf3300 100644 --- a/tests/test_collector/test_handlers/test_telemetry.py +++ b/tests/test_collector/test_handlers/test_telemetry.py @@ -126,3 +126,18 @@ class TestHandleTelemetry: telemetry = db_session.execute(select(Telemetry)).scalar_one() assert telemetry.node_public_key == "b" * 64 + + def test_telemetry_stores_snr_and_path_len(self, db_manager, db_session): + """Telemetry handler stores snr and path_len in event_observers.""" + payload = { + "node_public_key": "b" * 64, + "parsed_data": {"temperature": 20.0}, + "snr": 7.5, + "path_len": 2, + } + + handle_telemetry("a" * 64, "telemetry_response", payload, db_manager) + + observer = db_session.execute(select(EventObserver)).scalar_one() + assert observer.snr == 7.5 + assert observer.path_len == 2 diff --git a/tests/test_collector/test_handlers/test_trace.py b/tests/test_collector/test_handlers/test_trace.py index 2126614..541045c 100644 --- a/tests/test_collector/test_handlers/test_trace.py +++ b/tests/test_collector/test_handlers/test_trace.py @@ -88,3 +88,18 @@ class TestHandleTraceData: observers = db_session.execute(select(EventObserver)).scalars().all() assert len(observers) == 1 assert observers[0].event_type == "trace" + + def test_trace_stores_snr_and_path_len(self, db_manager, db_session): + """Trace handler stores snr and path_len in event_observers.""" + payload = { + "initiator_tag": 88888, + "path_len": 4, + "snr": 11.5, + "hop_count": 2, + } + + handle_trace_data("a" * 64, "trace_data", payload, db_manager) + + observer = db_session.execute(select(EventObserver)).scalar_one() + assert observer.snr == 11.5 + assert observer.path_len == 4 diff --git a/tests/test_collector/test_letsmesh_normalizer.py b/tests/test_collector/test_letsmesh_normalizer.py index 57d1f84..64b2d75 100644 --- a/tests/test_collector/test_letsmesh_normalizer.py +++ b/tests/test_collector/test_letsmesh_normalizer.py @@ -1,4 +1,6 @@ -"""Tests for LetsMesh normalizer _normalize_hash_list method.""" +"""Tests for LetsMesh normalizer.""" + +from unittest.mock import MagicMock from meshcore_hub.collector.letsmesh_normalizer import LetsMeshNormalizer @@ -66,3 +68,102 @@ class TestNormalizeHashList: """Single-character strings are rejected (minimum is 2).""" result = LetsMeshNormalizer._normalize_hash_list(["a", "4a"]) assert result == ["4A"] + + +class TestAdvertisementSnrAndPath: + """Tests for SNR and path_len extraction in advertisement payloads.""" + + def _make_normalizer(self) -> LetsMeshNormalizer: + norm = LetsMeshNormalizer() + norm._letsmesh_decoder = MagicMock() + norm._include_test_channel = False + return norm + + def _make_decoded_type4(self) -> dict: + return { + "payloadType": 4, + "payload": { + "decoded": { + "publicKey": "b" * 64, + }, + }, + } + + def test_advertisement_extracts_snr_from_uppercase_key(self) -> None: + """Advertisement payload with uppercase SNR normalizes to lowercase snr.""" + norm = self._make_normalizer() + result = norm._build_letsmesh_advertisement_payload( + {"SNR": 12.5, "path": "91CBC3"}, + decoded_packet=self._make_decoded_type4(), + ) + assert result is not None + assert result["snr"] == 12.5 + + def test_advertisement_extracts_snr_from_lowercase_key(self) -> None: + """Advertisement payload with lowercase snr extracts it.""" + norm = self._make_normalizer() + result = norm._build_letsmesh_advertisement_payload( + {"snr": 9.0}, + decoded_packet=self._make_decoded_type4(), + ) + assert result is not None + assert result["snr"] == 9.0 + + def test_advertisement_extracts_path_len(self) -> None: + """Advertisement payload with path extracts path_len.""" + norm = self._make_normalizer() + result = norm._build_letsmesh_advertisement_payload( + {"path": "91CBC3"}, + decoded_packet=self._make_decoded_type4(), + ) + assert result is not None + assert result.get("path_len") is not None + + def test_output_has_lowercase_snr_key_only(self) -> None: + """Output contains lowercase snr key, never uppercase SNR.""" + norm = self._make_normalizer() + result = norm._build_letsmesh_advertisement_payload( + {"SNR": 8.0}, + decoded_packet=self._make_decoded_type4(), + ) + assert result is not None + assert "snr" in result + assert "SNR" not in result + + +class TestMessageSnrCasing: + """Tests for message payload SNR casing (lowercase output).""" + + def _make_normalizer(self) -> LetsMeshNormalizer: + norm = LetsMeshNormalizer() + norm._letsmesh_decoder = MagicMock() + norm._include_test_channel = True + return norm + + def _make_decoded_type5(self) -> dict: + return { + "payloadType": 5, + "payload": { + "decoded": { + "text": "hello channel", + "channel": {"hash": "A" * 24}, + }, + }, + } + + def test_message_outputs_lowercase_snr(self) -> None: + """Message payload outputs lowercase snr key, not uppercase SNR.""" + norm = self._make_normalizer() + result = norm._build_letsmesh_message_payload( + { + "packet_type": "5", + "hash": "ABCDEF1234", + "SNR": "12.5", + "path": "91CBC3", + }, + decoded_packet=self._make_decoded_type5(), + ) + assert result is not None + _, payload = result + assert "snr" in payload + assert "SNR" not in payload diff --git a/tests/test_collector/test_subscriber.py b/tests/test_collector/test_subscriber.py index a8dd00f..885c166 100644 --- a/tests/test_collector/test_subscriber.py +++ b/tests/test_collector/test_subscriber.py @@ -240,7 +240,7 @@ class TestSubscriber: assert payload["text"] == "hello channel" assert payload["txt_type"] == 5 assert "sender_timestamp" not in payload - assert payload["SNR"] == 12.5 + assert payload["snr"] == 12.5 assert payload["path_len"] == 3 def test_letsmesh_packet_without_decrypted_text_is_not_shown_as_message( diff --git a/tests/test_common/test_models.py b/tests/test_common/test_models.py index 2810c4c..dff3a6e 100644 --- a/tests/test_common/test_models.py +++ b/tests/test_common/test_models.py @@ -1,7 +1,7 @@ """Tests for database models.""" import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, select from sqlalchemy.orm import sessionmaker from meshcore_hub.common.models import ( @@ -13,6 +13,8 @@ from meshcore_hub.common.models import ( TracePath, Telemetry, EventLog, + EventObserver, + add_event_observer, ) @@ -216,3 +218,45 @@ class TestEventLogModel: assert event.event_type == "BATTERY" assert event.payload is not None assert event.payload["battery_percentage"] == 75 + + +class TestEventObserverModel: + """Tests for EventObserver model and add_event_observer helper.""" + + def test_add_event_observer_stores_path_len(self, db_session) -> None: + """add_event_observer accepts and stores path_len.""" + node = Node(public_key="a" * 64, name="Observer") + db_session.add(node) + db_session.commit() + + result = add_event_observer( + session=db_session, + event_type="message", + event_hash="abcdef12", + observer_node_id=node.id, + snr=10.5, + path_len=3, + ) + assert result is True + + observer = db_session.execute(select(EventObserver)).scalar_one() + assert observer.snr == 10.5 + assert observer.path_len == 3 + + def test_add_event_observer_path_len_defaults_none(self, db_session) -> None: + """add_event_observer defaults path_len to None when not provided.""" + node = Node(public_key="b" * 64, name="Observer2") + db_session.add(node) + db_session.commit() + + result = add_event_observer( + session=db_session, + event_type="trace", + event_hash="12345678", + observer_node_id=node.id, + ) + assert result is True + + observer = db_session.execute(select(EventObserver)).scalar_one() + assert observer.path_len is None + assert observer.snr is None