Commit Graph

344 Commits

Author SHA1 Message Date
MarekWo
752c60f02d feat(v2): Import archive .msgs files + DB-based message history
Migration now imports all archive files (oldest first) in addition to the
live .msgs file, with deduplication. Archives endpoint and message history
now query SQLite by date instead of reading .msgs files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 13:05:33 +01:00
MarekWo
97a2014af2 feat(v2): Auto-migrate v1 .msgs data to SQLite on first startup
Reads the existing .msgs JSONL file and imports channel messages and DMs
into the v2 SQLite database. Runs automatically when device connects and
DB is empty. Handles sender parsing, pubkey resolution, and FK constraints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:32:28 +01:00
MarekWo
64860ba178 fix(v2): Parse sender name from channel message text format
Channel messages from meshcore arrive as "SenderName: message text".
The library doesn't provide sender name separately. Now parsing it
from the text (split on first colon), matching v1 parser behavior.

Also:
- Look up DM sender names from mc.contacts instead of event payload
- Fix SNR field name (uppercase 'SNR' from meshcore library)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:10:13 +01:00
MarekWo
65eb44d0ff fix(v2): Use event.payload instead of event.data throughout
The meshcore Event class has 'payload' not 'data'. All event handlers
were silently getting empty dicts, causing:
- Channel messages showing 'Unknown' sender
- Channel info not returning name/secret
- Sent message event data being lost

Also normalizes channel_name/channel_secret keys from CHANNEL_INFO
events and converts secret bytes to hex string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:01:42 +01:00
MarekWo
a7b9b74fa2 fix(v2): Use mc.commands for meshcore command methods
MeshCore library exposes command methods (get_channel, send_msg,
send_advert, etc.) on mc.commands, not directly on the MeshCore
instance. Updated all DeviceManager calls accordingly.

Fixes: channels not loading, message sending, advert, battery, etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:49:04 +01:00
MarekWo
1b142b26f2 fix(v2): Use Flask current_app for DeviceManager lookup in cli.py
The module-level 'from app.main import device_manager' was returning
None in Flask request context even though device_manager was set.
Now tries current_app.device_manager first (Flask app context),
falling back to module import for non-request contexts.

Fixes 500 errors on /api/contacts, /api/contacts/pending,
/api/contacts/detailed, and /api/channels endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:33:07 +01:00
MarekWo
adf17d2d54 fix(v2): Add connection retry logic and self_info null guard
- Add _connect_with_retry() with exponential backoff (10 attempts)
- Guard against self_info being None after meshcore library disconnects
  due to unresponsive device
- Prevents crash when device is busy (e.g. held by orphan container)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:23:05 +01:00
MarekWo
2e95bbf9b5 fix(v2): Serial port auto-detection and channel_messages query
- Add _detect_serial_port() to DeviceManager — resolves 'auto' to
  actual device via /dev/serial/by-id with common path fallbacks
- Make channel_idx optional in get_channel_messages() so status and
  channel-updates endpoints can query across all channels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:15:44 +01:00
MarekWo
e98acf6afa feat(v2): Add pkt_payload to DMs, update watchdog for single container
- Add pkt_payload column to direct_messages table for stable packet
  hash generation and Analyzer URL linking
- Update insert_direct_message() and DeviceManager to store pkt_payload
- Add test for DM pkt_payload storage (43 tests pass)
- Update watchdog to monitor only mc-webui (meshcore-bridge removed)
- USB reset trigger now fires for mc-webui container failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:01:43 +01:00
MarekWo
df8e2d2218 feat(v2): Route API endpoints through Database, remove bridge
- Update api.py: messages, contacts, DM endpoints read from SQLite DB
- Add DB fallback paths for parser.py backward compatibility
- Replace bridge echo registration with DeviceManager event handling
- Update status endpoint to use db.get_stats()
- Update channel updates/DM updates endpoints for DB queries
- Delete channel messages via DB instead of parser
- Remove meshcore-bridge/ directory (no longer needed in v2)
- Remove MC_BRIDGE_URL from config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:28:14 +01:00
MarekWo
badf67cf74 feat(v2): Rewrite main.py and cli.py for direct device communication
main.py: Initialize Database + DeviceManager in create_app(), replace
bridge-dependent startup code, simplified console command router.
cli.py: All functions now delegate to DeviceManager instead of HTTP
bridge calls. Same signatures preserved for api.py compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:23:59 +01:00
MarekWo
a8a0becb13 feat(v2): Complete DeviceManager with event handlers and commands
Event handlers: channel messages, DMs, ACKs, adverts, path updates,
new contacts, disconnection — all write to Database + emit SocketIO.
Command methods: send_channel_message, send_dm, get/delete contacts,
get/set/remove channels, send_advert, check_connection, battery,
manual_add_contacts, pending contacts approval.
Auto message fetching and initial contact sync on connect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:21:25 +01:00
MarekWo
8959261aca test(v2): Add 42 integration tests for Database class
Tests cover: schema init, WAL mode, device info, contacts CRUD
(with protection, GPS, upsert semantics), channels, channel messages
(limit/offset/filter), DMs with conversations, ACKs, echoes,
FTS5 search (channel+DM+combined), read status, muting,
backup/restore, cleanup, advertisements, and paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:04:28 +01:00
MarekWo
bd825f48c3 feat(v2): Single container Docker setup with direct USB access
Replace two-container bridge architecture with single container.
Dockerfile adds udev for serial device support.
docker-compose.yml: one service with cgroup rules for ttyUSB/ttyACM,
SQLite DB path, backup settings, optional TCP mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:02:11 +01:00
MarekWo
c9cf37e8d5 feat(v2): Add DeviceManager skeleton with connect/disconnect
Background thread runs meshcore async event loop. Supports both
serial and TCP transports. Flask routes bridge sync→async via
execute() method. Event subscriptions marked as TODO for Phase 1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:01:16 +01:00
MarekWo
68b14434ca feat(v2): Add Database class with full CRUD and backup
Sync SQLite wrapper with WAL mode, connection-per-call thread safety.
Methods for: device info, contacts (upsert/get/delete/protect),
channels, channel messages, DMs, ACKs, echoes, paths, advertisements,
read status, FTS5 search, stats, cleanup, and sqlite3.backup().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:59:39 +01:00
MarekWo
9f9b6e7ed7 feat(v2): Add SQLite schema with 10 tables, indexes and FTS5
Tables: device, contacts, channels, channel_messages, direct_messages,
acks, echoes, paths, advertisements, read_status.
Includes schema_version for migrations, FTS5 virtual tables with
auto-sync triggers for full-text search on messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:58:19 +01:00
MarekWo
ebfc3c9845 feat(v2): Add v2 config settings (DB, TCP, backup)
New environment variables for Phase 0:
- MC_DB_PATH: SQLite database location
- MC_TCP_HOST/MC_TCP_PORT: TCP transport (meshcore-proxy)
- MC_BACKUP_ENABLED/HOUR/RETENTION_DAYS: automated backup
- MC_AUTO_RECONNECT, MC_LOG_LEVEL: connection management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:57:08 +01:00
MarekWo
a0eb590baa chore(v2): Add meshcore dependency and gitignore docs/v2
- Add meshcore>=2.2.0 for direct device communication (Phase 0.1)
- Exclude docs/v2/ from git (local working notes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 06:56:32 +01:00
MarekWo
2254580f01 chore(v2): Initialize v2 branch with status tracking
- Create v2 branch for mc-webui direct device communication migration
- Add docs/v2/STATUS.md for development progress tracking
- Exclude PRD documents from git (local-only planning docs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:42:24 +01:00
MarekWo
39f4a71538 fix(dm): Fix PATH_UPDATE race condition and confirm all retry acks
The retry thread was removing pending_flood_acks immediately after
exhausting retries. PATH_UPDATE arriving even 1 second later would
find no pending entry to match, leaving the message undelivered.

Changes:
- Don't clean up pending_flood_acks in retry threads, keep entries
  alive for late PATH_UPDATE arrivals
- Add TTL-based cleanup (2 min) in _process_path_update
- When PATH_UPDATE confirms delivery, save synthetic ACK for the
  original ack code AND all retry group ack codes so the frontend
  ack_status polling finds a match regardless of which code it checks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:02:42 +01:00
MarekWo
66b553a8b5 fix(dm): Retry flood DM when initial send fails with device error
When sending a DM to a no-path contact, meshcli can return
"Error sending message" if the device-level flood send fails.
Previously this was treated as a terminal failure with no retry.

Now detects the error and starts a background flood retry thread
that re-attempts up to auto_retry_flood_only times (default 3),
waiting between attempts for a PATH_UPDATE to establish the route.
If a retry succeeds, registers for normal delivery tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:58:48 +01:00
MarekWo
2764e1c551 feat(dm): Add PATH-based delivery tracking for flood DMs
When sending DM to contacts without a known path (flood mode), ACK is
piggybacked inside the PATH response packet and not visible on stdout.
This adds PATH_UPDATE event parsing to confirm flood DM delivery.

Changes:
- Enable print_path_updates in meshcli init for PATH_UPDATE events
- Parse PATH echoes from json_log_rx -> .path.jsonl log (diagnostics)
- Parse PATH_UPDATE events to confirm flood DM delivery by public_key
- Replace "skip retry for no-path contacts" with proper flood retry
  (max 3 attempts, matching standard Meshcore app behavior)
- Split _retry_send into _retry_send_flood and _retry_send_direct
- Refactor _get_contact_path_len -> _get_contact_info (returns both
  out_path_len and public_key)
- Add GET /paths endpoint for diagnostics
- Add flood_only config param for auto-retry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 10:33:39 +01:00
MarekWo
6956cd5415 fix(dm): Skip retry for contacts without path (flood sends)
When a contact has no path set (out_path_len == -1), the initial msg
is already sent as flood. Retrying would spam the network with up to
8 flood messages in quick succession.

Now checks contact's out_path_len via .ci command before starting the
retry loop. If no path exists, logs and skips retry entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 21:34:53 +01:00
MarekWo
8ab19582cd fix(dm): Fix unread markers by deduping retries in all endpoints
Extracted dedup_retry_messages() helper (300s window, was 120s which
was too tight) and applied it in three places:
- GET /api/dm/messages - already had inline dedup, now uses helper
- get_dm_conversations() - fixes last_message_timestamp inflation
- GET /api/dm/updates - was missing dedup entirely, counted retries
  as unread messages (root cause of persistent unread markers)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:34:39 +01:00
MarekWo
c2acbb4ba1 fix(dm): Wait for msg JSON response and fix unread markers
Monitor: For .msg commands, keep waiting for expected_ack in response
instead of completing on silence timeout. Waits up to half of cmd_timeout
(5s) for the JSON to arrive before giving up.

Unread: Apply 120s time-window dedup to outgoing messages in conversation
listing, so retry messages don't inflate last_message_timestamp and cause
permanent unread markers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 10:19:28 +01:00
MarekWo
49159a888c fix(dm): Continue retry on command timeout and dedup retry messages
- Don't abort retry loop when msg command fails or times out - the device
  may be temporarily busy (especially during flood mode)
- Add 120s time-window dedup for outgoing messages with same text+recipient
  to prevent duplicate messages in chat when retry acks aren't tracked

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 09:58:05 +01:00
MarekWo
0367b38770 fix(dm): Ensure msg JSON response is captured before completing command
The 300ms response silence timeout was too short for .msg commands -
the prompt echo arrived quickly but the JSON with expected_ack could
arrive after a gap > 300ms, causing retry to abort mid-sequence.

- Add 1.5s minimum wait for .msg/.m commands in response monitor
- Don't abort retry loop when ack parsing fails (message was still sent)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:21:28 +01:00
MarekWo
37c2d3d51f fix(dm): Use .msg prefix for JSON output to enable auto-retry
The msg command in meshcli only outputs JSON (with expected_ack and
suggested_timeout) when json_output=True, triggered by the dot prefix
(e.g., .msg instead of msg). Without JSON output, the bridge couldn't
extract ack codes and retry was never triggered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 07:56:37 +01:00
MarekWo
c0f93029cd fix(dm): Fix auto-retry not triggering and increase retry limits
- Fix JSON parsing: msg command outputs multi-line pretty-printed JSON
  (indent=4), but parser tried line-by-line. Now tries full-text parse
  first, then line-by-line, then regex fallback.
- Change retry limits: 5 direct + 3 flood attempts (was 3 total)
- Separate max_attempts (direct) and max_flood parameters
- Add debug logging when ack extraction fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:39:58 +01:00
MarekWo
c37a7d3b23 feat(dm): Auto-retry for undelivered DM messages
Implement automatic retry for DM messages when ACK is not received,
similar to the MeshCore mobile app's Auto Retry feature. The bridge
monitors for ACK after each send and retries up to 3 times, switching
to flood routing after 2 failed direct attempts via reset_path.

- Bridge: background retry engine with configurable max_attempts,
  flood_after; retry group tracking to prevent duplicate messages
- Bridge: enhanced ACK status checks retry groups so delivery is
  detected even if only a retry attempt's ACK arrives
- Backend: filter retry SENT_MSG duplicates from message list
- Frontend: extended ACK polling window, auto-retry toggle in DM bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:23:32 +01:00
MarekWo
fd4818cfad fix(ui): Remove CSS rule that stacked channel buttons vertically
Remove nested @media (max-width: 400px) rule that forced btn-group
to flex-direction: column, causing buttons to stack on mobile.
Also remove now-unused .list-group-item small styles (channel keys
no longer shown).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 07:35:56 +01:00
MarekWo
71f292d843 feat(ui): Mark-all-read confirmation dialog and compact channel list
Add confirm() dialog before marking all messages as read, showing
list of unread channels with counts. Remove channel key/ID from
Manage Channels modal to save vertical space on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 07:27:35 +01:00
MarekWo
7a4f4d3161 feat(notifications): Channel mute toggle and mark-all-as-read bell button
Add ability to mute notifications for individual channels via Manage
Channels modal (bell/bell-slash toggle button). Muted channels are
excluded from unread badge counts, browser notifications, and app icon
badge. Bell icon click now marks all channels as read in bulk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:00:40 +01:00
MarekWo
ad478a8d47 feat(ui): Add @me filter button, DM filter push-down, and DM FAB toggle
- Add person icon button in filter bar that inserts the current device
  name into the search field, for filtering own messages
- DM filter bar already benefits from the CSS sibling push-down rule
  added in previous commit (same class names used)
- Add collapsible FAB toggle to DM view, same pattern as channel chat

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:05:47 +01:00
MarekWo
6310c41934 feat(ui): FAB toggle, filter bar layout fix, and filter @mentions
- Add collapsible FAB container with chevron toggle button to
  temporarily hide floating action buttons that overlap messages
- Make filter bar push messages down instead of overlaying the first
  matched message (CSS sibling selector adds padding-top)
- Add @mentions autocomplete to filter search bar - typing @ shows
  contact list dropdown, selecting inserts plain name (not @[] format)
  so all messages from/mentioning that user are found

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:47:00 +01:00
MarekWo
000c4f6884 fix: Make container port match FLASK_PORT for custom port configurations
Previously, the internal container port was hardcoded to 5000, so setting
FLASK_PORT to a different value would break the port mapping and healthcheck.

Credit: Tymo3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:01:12 +01:00
MarekWo
2f82c589c7 feat(watchdog): Hardware USB bus reset for stuck LoRa devices
Implement a smart auto-detection and low-level fcntl ioctl reset mechanism for LoRa USB devices. This 'last resort' recovery is triggered if the meshcore-bridge container fails to recover after 3 restarts within an 8-minute window. Includes updates to the installer, systemd service, and newly added README.

Co-Authored-By: Gemini CLI <noreply@google.com>
2026-02-22 20:15:27 +00:00
MarekWo
f1e5f39a4e fix: Reload echoes/acks after device name detection
When MC_DEVICE_NAME=auto, _load_echoes() runs with "auto.echoes.jsonl"
which doesn't exist. After actual device name is detected and paths
updated, the data was never reloaded from the correct file, leaving
incoming_paths and echo_counts empty. This caused missing path info
for all messages older than the current bridge session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:57:47 +01:00
MarekWo
bcdc014965 fix: Extend sent echo_counts retention from 1h to 7 days
Same 1-hour cleanup issue as incoming_paths: sent messages lost
their analyzer links after ~1 hour because echo_counts was pruned
on every new send. Now matches .echoes.jsonl 7-day retention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:24:57 +01:00
MarekWo
9ad3435609 fix: Always use attempt=0 payload for analyzer URL computation
The attempt loop (0-3) for matching incoming echo paths left
computed_payload at attempt=3 when no match was found, producing
wrong analyzer hashes. Combined with 1-hour incoming_paths cleanup
in bridge (vs 7-day .echoes.jsonl retention), this caused older
messages to lose both path info and correct analyzer links.

Two fixes:
- Compute base_payload at attempt=0 upfront for analyzer URL
- Extend incoming_paths memory cleanup from 1h to 7 days

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:15:12 +01:00
MarekWo
6d50391ea8 fix: Decode GPS coordinates as int32/1e6, not float
MeshCore encodes lat/lon as little-endian signed int32 divided by 1e6,
not as IEEE 754 floats. This caused all map pins to show at (0,0).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:45:43 +01:00
MarekWo
587bc8cb9f fix: Validate GPS coordinates from advert payloads
Discard NaN, Infinity, and out-of-range lat/lon values from
struct.unpack to prevent JSON parse errors in browser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:10:34 +01:00
MarekWo
247b11e1e9 feat: Enrich contacts cache with GPS coordinates and node type
- Extract lat/lon from advert payloads (struct unpack from binary)
- Store type_label and lat/lon in cache from device seed and adverts
- Show Map button for cache contacts with GPS coordinates
- Show colored type badge (CLI/REP/ROOM/SENS) for typed cache contacts
- Type filter now works for cache contacts with known type
- Change counter label from "known" to "cached"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:40:42 +01:00
MarekWo
a5e767e5bf fix: Replace sort buttons with dropdown for mobile-friendly contact filters
Replaces two sort toggle buttons with a single <select> dropdown (e-commerce style)
so all 3 filter/sort controls fit on mobile screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:19:56 +01:00
MarekWo
de0108d6aa feat: Add persistent contacts cache for @mention autocomplete
Contacts cache accumulates all known node names from device contacts
and adverts into a JSONL file, so @mentions work even after contacts
are removed from the device. Background thread scans adverts every
45s and parses advert payloads to extract public keys and node names.

Existing Contacts page now shows merged view with "Cache" badge for
contacts not on device, plus source filter (All/On device/Cache only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:13:36 +01:00
MarekWo
0a73556c78 fix: Use bi-clipboard-data icon for analyzer (bi-flask unavailable)
bi-flask was added in Bootstrap Icons 1.12+, but project uses 1.11.2.
Replace with bi-clipboard-data which is available and conveys
"data analysis".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:24:43 +01:00
MarekWo
5a7a9476f8 feat: Always show analyzer link for incoming msgs + flask icon
Generate analyzer_url from computed pkt_payload for all incoming
channel messages, not just those with echo path matches. This means
the analyzer button appears even when no route paths were captured.

Also change analyzer button icon from bi-search (magnifying glass)
to bi-flask (lab flask) to better convey "analysis/inspection".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:20:10 +01:00
MarekWo
68b2166445 fix: Use unstripped raw_text for pkt_payload computation
The parser's .strip() was removing trailing whitespace from message
text, but the encrypted radio payload includes those trailing spaces.
This caused pkt_payload mismatches for messages ending with spaces
(e.g., "Dzień dobry "). Use original unstripped text for raw_text.

Also add debug logging for unmatched messages to help diagnose
remaining edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:09:28 +01:00
MarekWo
28148d32d8 feat: Deterministic echo-to-message matching via pkt_payload computation
Replace unreliable timestamp-based heuristic (±10s window) with exact
cryptographic matching for incoming channel message routes. Compute
pkt_payload by reconstructing the AES-128-ECB encrypted packet from
message data (sender_timestamp, txt_type, text) + channel secret, then
match against echo data by exact key lookup.

Also accumulate ALL route paths per message (previously only last path
was kept due to dict overwrite), and display them in a multi-path popup
showing SNR and hops for each route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 07:29:49 +01:00