diff --git a/AGENTS.md b/AGENTS.md index cd7980b..246af97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,13 +110,13 @@ Raw packet handling uses two identities by design: Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference. -## Repeater Advert Path Memory +## Contact Advert Path Memory -To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per repeater in a dedicated table (`repeater_advert_paths`). +To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per contact in a dedicated table (`contact_advert_paths`). - This is independent of raw-packet payload deduplication. -- Paths are keyed per repeater + path, with `heard_count`, `first_seen`, and `last_seen`. -- Only the N most recent unique paths are retained per repeater (currently 10). +- Paths are keyed per contact + path, with `heard_count`, `first_seen`, and `last_seen`. +- Only the N most recent unique paths are retained per contact (currently 10). - See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data. ## Data Flow @@ -288,7 +288,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/contacts/{key}/repeater/radio-settings` | Fetch radio settings via CLI | | POST | `/api/contacts/{key}/repeater/advert-intervals` | Fetch advert intervals | | POST | `/api/contacts/{key}/repeater/owner-info` | Fetch owner info | -| POST | `/api/contacts/{key}/repeater/clock` | Fetch repeater clock | + | GET | `/api/channels` | List channels | | GET | `/api/channels/{key}` | Get channel by key | | POST | `/api/channels` | Create channel | diff --git a/app/AGENTS.md b/app/AGENTS.md index bf9e269..c76066b 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -134,7 +134,6 @@ app/ - `POST /contacts/{public_key}/repeater/radio-settings` - `POST /contacts/{public_key}/repeater/advert-intervals` - `POST /contacts/{public_key}/repeater/owner-info` -- `POST /contacts/{public_key}/repeater/clock` ### Channels - `GET /channels` diff --git a/app/models.py b/app/models.py index 0b712fe..8044d85 100644 --- a/app/models.py +++ b/app/models.py @@ -276,12 +276,6 @@ class RepeaterOwnerInfoResponse(BaseModel): guest_password: str | None = Field(default=None, description="Guest password") -class RepeaterClockResponse(BaseModel): - """Clock output from a repeater.""" - - clock_output: str | None = Field(default=None, description="Output from 'clock' command") - - class LppSensor(BaseModel): """A single CayenneLPP sensor reading from req_telemetry_sync.""" diff --git a/app/routers/contacts.py b/app/routers/contacts.py index f5b5f1c..1eb9627 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -24,7 +24,6 @@ from app.models import ( NeighborInfo, RepeaterAclResponse, RepeaterAdvertIntervalsResponse, - RepeaterClockResponse, RepeaterLoginRequest, RepeaterLoginResponse, RepeaterLppTelemetryResponse, @@ -481,21 +480,6 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse: return RepeaterOwnerInfoResponse(**results) -@router.post("/{public_key}/repeater/clock", response_model=RepeaterClockResponse) -async def repeater_clock(public_key: str) -> RepeaterClockResponse: - """Fetch clock output from a repeater.""" - require_connected() - contact = await _resolve_contact_or_404(public_key) - _require_repeater(contact) - - results = await _batch_cli_fetch( - contact, - "repeater_clock", - [("clock", "clock_output")], - ) - return RepeaterClockResponse(**results) - - @router.get("", response_model=list[Contact]) async def list_contacts( limit: int = Query(default=100, ge=1, le=1000), diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index b2cbd10..b76fb81 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -48,7 +48,10 @@ frontend/src/ │ ├── contactAvatar.ts # Avatar color derivation from public key │ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers │ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles -│ └── lastViewedConversation.ts # localStorage for last-viewed conversation +│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation +│ ├── contactMerge.ts # Merge WS contact updates into list +│ ├── localLabel.ts # Local label (text + color) in localStorage +│ └── radioPresets.ts # LoRa radio preset configurations ├── components/ │ ├── StatusBar.tsx │ ├── Sidebar.tsx @@ -69,6 +72,7 @@ frontend/src/ │ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths) │ ├── RepeaterDashboard.tsx # Repeater pane-based dashboard (telemetry, neighbors, ACL, etc.) │ ├── RepeaterLogin.tsx # Repeater login form (password + guest) +│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations │ └── ui/ # shadcn/ui primitives ├── types/ │ └── d3-force-3d.d.ts # Type declarations for d3-force-3d @@ -86,7 +90,10 @@ frontend/src/ ├── radioPresets.test.ts ├── rawPacketIdentity.test.ts ├── repeaterDashboard.test.tsx - ├── repeaterMode.test.ts + ├── repeaterFormatters.test.ts + ├── repeaterLogin.test.tsx + ├── repeaterMessageParsing.test.ts + ├── localLabel.test.ts ├── settingsModal.test.tsx ├── sidebar.test.tsx ├── unreadCounts.test.ts @@ -200,13 +207,30 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid - `last_advert_time` - `bots` +## Contact Info Pane + +Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/{key}/detail`: + +- Header: avatar, name, public key, type badge, on-radio badge +- Info grid: last seen, first heard, last contacted, distance, hops +- GPS location (clickable → map) +- Favorite toggle +- Name history ("Also Known As") — shown only when the contact has used multiple names +- Message stats: DM count, channel message count +- Most active rooms (clickable → navigate to channel) +- Advert observation rate +- Nearest repeaters (resolved from first-hop path prefixes) +- Recent advert paths + +State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot. + ## Repeater Dashboard For repeater contacts (`type=2`), App.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`. -**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info, Clock — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock). +**Dashboard panes** (after login): Telemetry, 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. "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`. diff --git a/frontend/src/components/AGENTS.md b/frontend/src/components/AGENTS.md index 2a7bd92..ddb9390 100644 --- a/frontend/src/components/AGENTS.md +++ b/frontend/src/components/AGENTS.md @@ -178,7 +178,7 @@ When only a 1-byte prefix is known (from packet path bytes), the node is marked **Problem:** When multiple repeaters share a 1-byte prefix, the visualizer can't tell which physical repeater a path hop refers to. -**Solution:** The backend tracks recent unique advertisement paths per repeater in `repeater_advert_paths` (see root `AGENTS.md` § "Repeater Advert Path Memory"). On mount (and when new contacts appear), the visualizer fetches this data via `GET /api/contacts/repeaters/advert-paths` and builds an index keyed by 12-char prefix. +**Solution:** The backend tracks recent unique advertisement paths per contact in `contact_advert_paths` (see root `AGENTS.md` § "Contact Advert Path Memory"). On mount (and when new contacts appear), the visualizer fetches this data via `GET /api/contacts/repeaters/advert-paths` and builds an index keyed by 12-char prefix. **Scoring:** `pickLikelyRepeaterByAdvertPath(candidates, nextPrefix)` scores each candidate repeater by how often its stored advert paths' `next_hop` matches the packet's actual next-hop prefix. It requires: diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index 6231c16..5b8e1e4 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -76,6 +76,7 @@ async def _insert_contact(public_key=KEY_A, name="Alice", on_radio=False, **over "last_seen": None, "on_radio": on_radio, "last_contacted": None, + "first_seen": None, } data.update(overrides) await ContactRepository.upsert(data) diff --git a/tests/test_repeater_routes.py b/tests/test_repeater_routes.py index 5106c88..e39f2f8 100644 --- a/tests/test_repeater_routes.py +++ b/tests/test_repeater_routes.py @@ -15,7 +15,6 @@ from app.routers.contacts import ( _fetch_repeater_response, repeater_acl, repeater_advert_intervals, - repeater_clock, repeater_login, repeater_lpp_telemetry, repeater_neighbors, @@ -84,6 +83,7 @@ async def _insert_contact(public_key: str, name: str = "Node", contact_type: int "last_seen": None, "on_radio": False, "last_contacted": None, + "first_seen": None, } ) @@ -1057,45 +1057,6 @@ class TestRepeaterOwnerInfo: assert response.guest_password is None -class TestRepeaterClock: - @pytest.mark.asyncio - async def test_success(self, test_db): - mc = _mock_mc() - await _insert_contact(KEY_A, name="Repeater", contact_type=2) - - mc.commands.get_msg = AsyncMock( - return_value=_radio_result( - EventType.CONTACT_MSG_RECV, - {"pubkey_prefix": KEY_A[:12], "text": "2026-02-25 12:00:00 UTC", "txt_type": 1}, - ) - ) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - patch(_MONOTONIC, side_effect=_advancing_clock()), - ): - response = await repeater_clock(KEY_A) - - assert response.clock_output == "2026-02-25 12:00:00 UTC" - - @pytest.mark.asyncio - async def test_timeout_returns_none(self, test_db): - mc = _mock_mc() - await _insert_contact(KEY_A, name="Repeater", contact_type=2) - mc.commands.get_msg = AsyncMock(return_value=_radio_result(EventType.NO_MORE_MSGS)) - - with ( - patch("app.routers.contacts.require_connected", return_value=mc), - patch.object(radio_manager, "_meshcore", mc), - patch(_MONOTONIC, side_effect=[0.0, 5.0, 11.0]), - patch("app.routers.contacts.asyncio.sleep", new_callable=AsyncMock), - ): - response = await repeater_clock(KEY_A) - - assert response.clock_output is None - - def _make_contact( public_key: str = KEY_A, name: str = "Repeater", contact_type: int = 2 ) -> Contact: