71 Commits

Author SHA1 Message Date
Rightup 438a02242d feat: flood suppression logic to differentiate rebroadcasts based on hop count 2026-07-13 15:07:45 +01:00
Rightup 4fec619e36 feat:update ui with flood drop reason chart. 2026-07-13 14:08:57 +01:00
Rightup 8ba818c921 feat: implement redundant flood retransmission suppression to reduce collisions 2026-07-13 14:08:01 +01:00
Rightup 5d75cb9ad4 feat: add manual cad tests to cad UI 2026-07-12 22:35:57 +01:00
Rightup ad87ca3db2 feat: enhance CAD configuration with dynamic symbol number handling and update OpenAPI documentation 2026-07-12 22:34:51 +01:00
Rightup 4990322cef feat: implement dynamic serving of frontend static assets and enhance error handling for unraisable exceptions 2026-07-12 22:34:23 +01:00
Rightup 0485023143 feat: add CAD symbol number configuration with validation and logging 2026-07-12 22:30:50 +01:00
Rightup fac5b1bdce feat: add CAD calibration session configuration and manual check endpoint 2026-07-11 07:41:00 +01:00
Lloyd 86a2ac3083 feat: enhance CAD calibration thresholds and sensitivity scoring 2026-07-10 16:46:23 +01:00
Lloyd 25db2d19fc feat:add LBT reporting chart/heat map UI 2026-07-10 13:37:42 +01:00
Lloyd b2eb45b199 feat: Add LBT diagnostics endpoint with correlation analysis
- Implemented `lbt_diagnostics` API endpoint to return aggregated Listen Before Talk (LBT) diagnostics aligned with RF metrics.
- Introduced methods for calculating Pearson correlation coefficients and auto-bucket sizing for diagnostics.
- Enhanced data aggregation logic in `StorageCollector` for LBT diagnostics.
- Updated OpenAPI specification to include new endpoint and response schemas.
- Added comprehensive unit tests for LBT diagnostics, including validation of correlation calculations and data integrity.
2026-07-10 13:36:49 +01:00
Lloyd 95500ece09 feat:add restore config to first time setup 2026-07-09 14:30:39 +01:00
Lloyd fa046a1c5f feat: allow unauthenticated config import during first-run setup 2026-07-09 14:25:08 +01:00
Rightup ff53b2520b fix:typo in openhop 2026-07-08 22:47:18 +01:00
Rightup 11552c467f feat:add auto logout for websockets 2026-07-08 22:25:57 +01:00
Rightup 9f75c0460d feat:update ui term-cli help and add default region ui to region keys 2026-07-08 17:27:00 +01:00
Rightup 1906f576bb feat: add region/default-scope / cli commands update
standardize default region on mesh.default_region (remove legacy region_default_scope usage)
add/align CLI support for owner.info, path.hash.mode, and loop.detect with validation
wire UI terminal get/set + autocomplete/help for owner.info, path.hash.mode, loop.detect
extend update_radio_config to persist owner_info for UI set owner.info
add and use shared packet utility for advert creation + default-region transport scoping
refactor repeater and room-server advert paths to reuse shared packet logic
2026-07-08 17:25:35 +01:00
Rightup 92310235ae fix: correct model name in MeshCoreToMqttPusher class 2026-07-08 15:34:12 +01:00
agessaman e22514882f perf: force timestamp range scan for windowed packet-stats GROUP BYs
The GROUP BY type and GROUP BY drop_reason sub-queries in
get_packet_stats (and get_packet_type_stats) filter by a time window but
let the planner pick idx_packets_type / idx_packets_transmitted to get
grouping for free. It then heap-checks the timestamp filter across the
entire table, turning a bounded window into a full scan: on a 1.5M-row
packets table the 24h stats load spent ~4.8s (type) + ~2.3s
(drop_reason) instead of ~0.1s each.

Pin these to idx_packets_timestamp with INDEXED BY so they range-scan the
window (~50k rows) and group via a small temp b-tree. Verified on the
live DB: 4.80s -> 0.10s and 2.32s -> 0.10s. Unlike a covering index this
adds no write-path cost on the packet-insert hot path.
2026-07-08 10:13:28 +01:00
agessaman 676e2cea30 perf: add covering index for airtime chart queries
The airtime/utilization chart queries (get_airtime_data and
get_airtime_buckets) range-scan and order packets by timestamp,
selecting only timestamp/length/payload_length/transmitted. On a large
packets table this forced a full scan of the row heap, saturating slow
storage (e.g. a Pi SD card): each dashboard poll took longer than the
client timeout, aborted polls stacked, and sustained I/O starved the
transmit queue.

Add a covering index on packets(timestamp, length, payload_length,
transmitted) so these queries run index-only, dropping the read from the
full row heap to just the index range. Verified via EXPLAIN QUERY PLAN
(COVERING INDEX idx_packets_airtime). Additive and idempotent.
2026-07-08 10:13:28 +01:00
agessaman d8bc448f82 fix: update dependencies for platform-specific installation of openhop_core 2026-07-08 10:13:28 +01:00
Björkan 4a124782ae Replace favicon with openhop favicon 2026-07-08 10:12:47 +01:00
Will Dillon d1a9230ab5 feat: allow API key authentication in WebSocket handler
The PacketWebSocket.opened() method only accepts JWT tokens for
authentication. API keys (used via X-API-Key header for REST endpoints)
are silently rejected, forcing WS clients to obtain a JWT through a
separate login flow.

Add fallback to token_manager.verify_token() when JWT verification
fails or when an X-API-Key header is present. This lets API token
holders connect directly to the WebSocket without needing a JWT.

Auth priority:
1. JWT in ?token= query parameter (existing behavior)
2. API key in X-API-Key header
3. API key in ?token= query parameter (falls through from JWT reject)
2026-07-08 10:12:47 +01:00
Vashiru 667169c7ff feat: Bump to 168 hours to match docs.meshcore.io 2026-07-08 10:12:47 +01:00
Vashiru 17f8bcca2f feat: Allow setting max. flood interval to 50 2026-07-08 10:12:46 +01:00
Rightup 05d1e391d6 Merge branch 'dev' of https://github.com/rightup/pyMC_Repeater into dev 2026-07-08 07:25:56 +01:00
Rightup 630f2cfa4e Merge branch 'pr-343' into dev 2026-07-08 07:25:07 +01:00
Yellowcooln 143c7633bf Merge pull request #344 from yellowcooln/fix/docker-console-cache-bust
fix: refresh bundled Console in Docker builds
2026-07-07 21:44:37 -04:00
yellowcooln c14d8b2226 fix: refresh bundled console in docker builds
Invalidate the Docker cache layer that downloads the bundled Console release when the latest release changes, and allow manual builds to pin a Console tag.

Co-Authored-By: Warp <agent@warp.dev>
2026-07-07 21:35:13 -04:00
agessaman 4a055be8df fix(router): try both companion and server on colliding dest hash
On-air dest hashes are one byte, so a companion and a room-server (or
repeater) identity can share one. The login and text dispatch used
`if dest_hash in companion_bridges: ... elif login_helper/text_helper`,
so a companion silently shadowed a same-hash room server: its login
never reached the room-server handler and failed with Invalid HMAC
(e.g. Lake Stevens pubkey f55d.. / hash 0xf5 colliding with a companion).

Offer the packet to both handlers when both are registered at the hash;
decryption disambiguates — the key owner replies, the other fails HMAC
and no-ops. Non-colliding paths are unchanged. Applied to both the
LoginServerHandler and TextMessageHandler branches.

Adds regression tests covering collision (both invoked), companion-only
(server helper skipped), and server-without-companion for each path.
2026-07-07 14:51:59 -07:00
Rightup 9177d1aacc feat: add repeat on/off to status for mqtt 2026-07-07 22:41:32 +01:00
Rightup 1a0823b6d7 fix: rename radio settings entry for rak6421-13300x-slot2 to rak6421-13300x-slot2-hw-mod 2026-07-07 22:40:15 +01:00
Rightup 90d63d61de Merge branch 'pr-340' into dev 2026-07-07 22:34:41 +01:00
Rastislav Vysoky f15884565e Merge branch 'dev' into patch-1 2026-07-07 23:02:33 +02:00
Lloyd 7d30dd4247 Merge pull request #342 from agessaman/companion/post-refactor-cleanup
Companion/post refactor cleanup
2026-07-07 21:03:59 +01:00
agessaman 954150b2d8 merge: reconcile companion cleanup with fix-general-tidy
Merge the maintainer's fix-general-tidy branch (neighbor discovery,
keygen, API endpoints, web-asset rebuild, HTTP server config/control
commands, and an independent #286 room-server push/ACK/guest fix) into
the companion cleanup branch.

Both branches fixed #286 in parallel with byte-identical push-ACK CRC
logic. In the six overlapping files the maintainer's implementation is
kept (ACL replay-detection/session helpers, encoded path-len with legacy
fallback, expected_crc/ack_timeout_s injector API, dispatcher ACK
helpers); this branch's unique companion work is preserved on top
(sender_prefix persistence + migration, boot-state hardening /
CompanionStateLoadError, MessageQueue.max_size, older-core fallbacks).

Conflict resolution took the maintainer's side across the overlap, then
fixed two integration seams the merge introduced and updated this
branch's tests to the maintainer's API:
- room_server: timeout used undefined `hops`; aligned to `path_len`.
- packet_router: PATH helper was invoked twice (maintainer's
  unconditional call plus this branch's conditional local-identity
  call); dropped the now-redundant conditional block.

Pin openhop_core to @dev (was @feature/publish-workflow-message-handling)
so this can merge to the repeater's dev; core dev carries the required
sender_prefix and PathUtils.is_valid_path_len APIs.

Full suite green (1040 passed) against openhop_core dev and
refactor/companion-housekeeping; ruff clean.
2026-07-07 12:56:43 -07:00
Rightup 43a112ce7c feat: add HTTP server configuration and control commands 2026-07-07 20:38:22 +01:00
agessaman b2e45c2038 fix: resolve room-server push ACKs through the dispatcher
Room server pushes waited on dispatcher.wait_for_ack, but nothing in the
repeater could ever resolve it, so every push timed out and re-pushed on
the backoff schedule (issue #286's duplicate floods — worst for virtual
companions on the same instance, whose ACKs never even cross the air):

- no core AckHandler is registered (all RX lands in the router fallback),
  so received ACK CRCs were never fed to dispatcher ACK matching
- inject_packet waited on packet.get_crc(), a packet-hash CRC that no ACK
  sender produces; the crypto CRC only the room server knows was ignored
- PATH returns (how a flood-received DM is ACKed) were decrypted by
  PathHelper, but it read the encoded path_len wire byte as a raw count —
  an empty 3-byte-hash path (0x80) parsed as a 128-byte truncated
  payload — and the router's PATH branch never ran PathHelper for
  room/repeater destinations when any companion bridge existed

Fixes:
- router ACK branch feeds discrete ACK CRCs (RF and locally injected) to
  dispatcher._register_ack_received
- PATH packets addressed to a local server identity are processed by
  PathHelper regardless of companion bridges; PathHelper decodes the
  encoded path_len via PathUtils, keeps the encoded byte in out_path_len,
  and registers the embedded ACK via ack_received_fn
- inject_packet accepts expected_ack_crc/ack_timeout; the room server
  passes its crypto CRC and a hop-count-based timeout (the encoded byte
  would have produced a ~4-minute direct-push wait)

Verified live on a real mesh: push to a same-instance virtual companion
resolves via the PATH-embedded ACK (encoded path_len 0x80) and push to a
firmware client resolves via the discrete RF ACK, no re-push loops.

Fixes #286
Fixes #341
2026-07-07 11:17:54 -07:00
agessaman 9c6ea0151e fix: compute signed-post push ACK over the full signed span
push_post_to_client sent posts as TXT_TYPE_SIGNED_PLAIN (plaintext =
timestamp + flags + author_prefix + text) but computed its expected ACK
over the plain-DM span (timestamp + attempt byte + text). Signed-plain
receivers (firmware BaseChatMesh::onPeerDataRecv, and openhop_core's
text handler since it gained signed-message ACKs) reply with
sha256(decrypted[0 : 9 + strlen(text)] || client_pubkey)[:4], so the
server never recognized any push ACK: every push timed out and the same
posts were re-pushed forever (issue #286's repeating 'Push timed out'
log and duplicate unread floods).

Hash the exact plaintext span that is encrypted and sent, plus the
client pubkey. Verified against openhop_core's TextMessageHandler: the
ACK it emits for a pushed post now matches the stored pending_ack_crc.

Fixes #286
2026-07-07 10:35:28 -07:00
agessaman 0404b3ab44 fix: add blank-password read-only guests to the room ACL
A blank-password login replied success (read-only guest) without ever
creating an ACL entry or storing the ECDH shared secret. The client app
believed it was logged in, but the room server's text handler and sync
loop only see ACL members: the client's posts were dropped without a
delivery ACK (send shows failed) and posts were never pushed to it.
Rooms with only an admin password configured were fully affected since
every guest login is blank-password.

Add the guest to the ACL with guest permissions, the shared secret, and
sync_since (mirroring the password path), reject when the ACL is full,
and refresh activity timestamps on repeat blank logins.

Refs #286
2026-07-07 10:35:01 -07:00
agessaman 3a29061e24 fix: tolerate openhop_core without QueuedMessage.sender_prefix
Installed openhop_core releases predating the sender_prefix change
(paired with fd43d86) reject the kwarg, so restoring queued messages
raised TypeError and aborted companion init. Detect support via the
QueuedMessage signature and drop persisted prefixes with a warning on
older cores instead of failing the boot.
2026-07-07 07:53:49 -07:00
agessaman e6d4b68d01 fix: fail companion init loudly when persisted state cannot be loaded
A transient SQLite error during boot made companion_load_channels/
contacts/messages swallow the exception and return [], which is
indistinguishable from "no data". The Public-channel backfill then ran
over the empty store, so clients saw their channels wiped and later
saves could overwrite the persisted state.

- companion_load_{contacts,channels,messages} now return None on error
  vs [] for genuinely empty, and log the companion hash
- add companion_count_{channels,messages} helpers
- extract shared _restore_companion_state used by both the boot and
  hot-reload companion paths; each load is cross-checked against the
  table's row count for the hash, retried once after a short delay,
  and raises CompanionStateLoadError if it still cannot load, aborting
  companion init (and the Public backfill) instead of starting empty
- log restored row counts per companion; log when the channel store
  rejects a persisted channel (unchecked channels.set return)
- trim_companion_contacts_to_fit refuses to trim on a failed load
2026-07-07 07:43:27 -07:00
agessaman fd43d86ea8 fix: persist sender_prefix for signed room posts
TXT_TYPE_SIGNED_PLAIN room posts carry a 4-byte author pubkey prefix
(QueuedMessage.sender_prefix, added in openhop_core). The SQLite
persistence path dropped it, so posts replayed from persistence showed
a zero-padded author prefix in the app frame while posts synced from
the live in-memory queue were correct.

- add sender_prefix column (hex text, default '') to companion_messages
  with an ALTER TABLE migration for existing databases
- store/return the prefix in companion_push/pop/load_messages
- pass sender_prefix when rebuilding QueuedMessage from persistence in
  the frame server and the startup preload paths
- add round-trip, default, migration, and rebuild tests
2026-07-06 22:46:49 -07:00
agessaman 700a38b8c1 refactor: use public max_size property for offline-queue retention
Prefer MessageQueue.max_size (new public property in openhop_core) over
the private _max_size attribute, keeping a getattr fallback for older
cores that predate the property.
2026-07-06 16:38:45 -07:00
Rastislav Vysoky 141126a9a6 Clean up radio-settings.json by removing unused fields
Removed invalid settings from rak6421 and station g3
2026-07-06 21:50:15 +02:00
Lloyd e52637535d Merge pull request #337 from Littleaton/radio-fix-comma
Radio fix comma
2026-07-06 16:12:40 +01:00
Littleaton 0e26ed3cb3 fied extra comma 2026-07-06 09:06:55 -06:00
Rightup 1a2bf9e4fd feat:update ui with repeater discovery 2026-07-04 23:48:32 +01:00
Rightup 8b3894d2d5 fix: remove duplicate import statements in test files 2026-07-04 23:41:51 +01:00
Rightup daec7f0ebc feat: implement neighbor discovery session management and API endpoints 2026-07-04 23:41:33 +01:00
Lloyd a5c85dd9d9 Merge pull request #326 from Littleaton/dev
updated working zebra duo config radio 0 / 1 / Rak6421 pi hat
2026-07-04 22:27:33 +01:00
Lloyd ff43d85b85 Merge pull request #333 from mdspann/issue305
Add timezone to MQTT packet data
2026-07-04 22:19:49 +01:00
Rightup 229d71459f fix: improve upsert_client_sync to prevent over write. 2026-07-04 18:41:55 +01:00
mike 047ad19fd3 Add timezone to MQTT packet data 2026-07-03 15:07:26 -07:00
Rightup 05449987c1 feat: update packet detail screen to lookup repeater names 2026-07-03 21:21:20 +01:00
Rightup ae6ac1bd05 update packet table desktop/mobile with dupe groups 2026-07-02 21:47:51 +01:00
Rightup ae68112377 - Refactored ACL authentication flow with shared helpers for replay detection and session updates.
- Changed blank-password login behavior to create/manage guest sessions (when read-only is enabled), including replay protection and session timestamp tracking.
- Preserved and centralized `sync_since` handling during successful auth/session updates.
- Updated login handling to return explicit `(success, permissions)` and reset room-server sync guard state in SQLite on successful room-server login.
- Updated identity/banner output in mesh CLI from `pyMC_*` to `openHop_*` and adjusted default version fallback.
- Extended path handling to support encoded path-length semantics via `PathUtils`, with legacy fallback support for older/malformed packets.
- Added bundled ACK extraction from PATH payload extras and callback-based ACK propagation.
- Hardened room-server push logic: max-failure early skip, corrected expected ACK CRC calculation, path encoding compatibility, persisted sync/last-activity fields, and passed expected CRC + timeout into packet injection.
- Enhanced packet router ACK flow with dispatcher ACK registration helper, support for multipart ACK wrapper handling, configurable ACK wait timeout/CRC in injection, and ensured PATH helper processing always runs.
- Updated daemon wiring to pass ACK callback into `PathHelper`,
- make dispatcher dedupe configurable (prevent dbl deduping), and only register duplicate logging hook when dedupe is enabled.
- Expanded tests to cover guest blank-password replay tracking, updated room-server injector ACK expectations, and new duplicate-logging-hook gating behavior.
2026-07-02 16:32:32 +01:00
Rightup 34747d2610 feat: add packet retrieval by ID endpoint and corresponding database methods 2026-07-02 16:18:54 +01:00
yellowcooln 0561803eeb merge: sync dev with main 2026-07-02 09:23:56 -04:00
Yellowcooln a88ac21e61 Merge pull request #330 from yellowcooln/dev
Bundle Console UI into Docker image
2026-07-02 09:19:23 -04:00
yellowcooln f3d3129d4a fix: route fork Docker image publishes 2026-07-01 23:34:04 -04:00
yellowcooln 4a7720e7ae fix: route fork Docker image publishes 2026-07-01 23:15:19 -04:00
yellowcooln add7011c21 feat: bundle PyMC Console in Docker image 2026-07-01 23:05:28 -04:00
Littleaton b84479de0c fixed rak6421 config. 2026-06-30 14:11:55 -06:00
Littleaton baa89e836c updated working zebra duo config radio 0 / 1 2026-06-30 13:37:38 -06:00
Lloyd 823308aa3f fix: update drop reason handling in PacketRouter and RepeaterHandler 2026-06-29 15:48:19 +01:00
Lloyd a964062bf6 Merge branch 'pr-323' into dev 2026-06-29 15:22:16 +01:00
Lloyd 9be74efeb0 Merge remote-tracking branch 'origin/feat-std-key-formats' into dev 2026-06-29 15:21:05 +01:00
Lloyd 38efb05eeb fix: expose effective drop reason to PacketRouter 2026-06-29 15:18:39 +01:00
theshaun 172c8cc016 Initialise MQTT handler only when at least one broker is configured to save some lil cpu and logging 2026-06-28 18:01:49 +10:00
Lloyd 60357f5808 Merge pull request #320 from openhop-dev/dev 2026-06-26 23:07:51 +01:00
140 changed files with 11555 additions and 5829 deletions
+32 -4
View File
@@ -10,14 +10,18 @@ on:
image_repository:
description: "Docker image repository to publish to"
required: false
default: "openhop/openhop-repeater"
default: ""
pymc_console_version:
description: "PyMC Console release to bundle (latest or a tag such as v0.9.333)"
required: false
default: "latest"
jobs:
docker:
if: |
github.event_name == 'workflow_dispatch' ||
github.repository == 'openhop-dev/openhop_repeater' ||
github.repository == 'yellowcooln/openhop_repeater'
github.repository_owner == 'yellowcooln'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -58,8 +62,8 @@ jobs:
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.image_repository }}" ]; then
image_repository="${{ inputs.image_repository }}"
elif [ "${{ github.repository }}" = "yellowcooln/openhop_repeater" ]; then
image_repository="yellowcooln/openhop-repeater"
elif [ "${{ github.repository_owner }}" = "yellowcooln" ]; then
image_repository="yellowcooln/pymc-repeater"
else
image_repository="openhop/openhop-repeater"
fi
@@ -67,6 +71,28 @@ jobs:
echo "Using image repository: ${image_repository}"
echo "image_repository=${image_repository}" >> "$GITHUB_OUTPUT"
- name: Resolve bundled Console release
id: console_release
shell: bash
env:
REQUESTED_CONSOLE_VERSION: ${{ inputs.pymc_console_version || 'latest' }}
run: |
set -euo pipefail
if [ "${REQUESTED_CONSOLE_VERSION}" = "latest" ]; then
release_json="$(curl -fsSL https://api.github.com/repos/Treehouse-00/pymc_console-dist/releases/latest)"
tag="$(jq -r '.tag_name' <<<"${release_json}")"
published_at="$(jq -r '.published_at' <<<"${release_json}")"
cache_bust="${tag}@${published_at}"
else
tag="${REQUESTED_CONSOLE_VERSION}"
cache_bust="${REQUESTED_CONSOLE_VERSION}"
fi
echo "Bundling PyMC Console ${tag}"
echo "version=${REQUESTED_CONSOLE_VERSION}" >> "$GITHUB_OUTPUT"
echo "cache_bust=${cache_bust}" >> "$GITHUB_OUTPUT"
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
@@ -86,6 +112,8 @@ jobs:
push: true
build-args: |
PACKAGE_VERSION=${{ steps.package_version.outputs.version }}
PYMC_CONSOLE_VERSION=${{ steps.console_release.outputs.version }}
PYMC_CONSOLE_CACHE_BUST=${{ steps.console_release.outputs.cache_bust }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
+12 -1
View File
@@ -428,6 +428,15 @@ that source as a directory, which breaks startup.
docker compose up -d
```
The Docker image bundles the optional PyMC Console frontend at
`/opt/pymc_console/web/html`. Local builds with `docker-compose.build.yml`
download the newest PyMC Console release by default. Set `PYMC_CONSOLE_VERSION`
in `.env` to pin a release tag such as `v0.9.329`.
After the container starts, open Web Settings and choose `PyMC Console` as the
web frontend if you want the repeater to serve that UI. The existing
`/api/check_pymc_console` endpoint should report that the Console path exists.
### Example `docker-compose.yml`
```yaml
@@ -511,7 +520,9 @@ pre-commit run --all-files
```
Hardware support for LoRa radio drivers is included in the base installation
through `openhop_core[hardware]`.
through `openhop_core[hardware]` on Linux. On other platforms (e.g. macOS),
`openhop_core` is installed without the hardware extra, since `spidev` and
similar packages only build against the Linux SPI kernel headers.
Pre-commit hooks will automatically:
- Lint and auto-fix Python issues with Ruff
+48
View File
@@ -311,6 +311,10 @@ mesh:
# true = allow unscoped flooding, false = deny flooding globally
unscoped_flood_allow: true
# Legacy alias for unscoped flood policy. Keep in sync with unscoped_flood_allow.
# true = allow unscoped flooding, false = deny
global_flood_allow: true
# Path hash mode for flood packets (0-hop): per-hop hash size in path encoding
# 0 = 1-byte hashes (legacy), 1 = 2-byte, 2 = 3-byte. Must match mesh convention.
# Affects originated adverts and any other flood packets sent by the repeater.
@@ -320,6 +324,28 @@ mesh:
# off = disabled, minimal = allow up to 3 self-hashes, moderate = allow up to 1, strict = allow 0
loop_detect: minimal
# Default flood scope for locally-originated flood adverts.
# Use null or "<null>" via CLI command "region default <null>" to clear.
default_region: null
# Redundant flood retransmission suppression (rebroadcast cancellation).
# While our own flood retransmission waits out its collision-avoidance delay,
# other repeaters often rebroadcast the same packet first. When enabled,
# hearing flood_suppression_threshold rebroadcast copies cancels our pending
# TX; the packet is logged with drop reason
# "Redundant flood retransmission (rebroadcast heard)" instead of being sent.
# Reduces redundant airtime and collisions on busy networks.
# Only copies whose path actually grew (another repeater appended its path
# hash) count as rebroadcasts. Same-depth copies — e.g. the origin resending
# the same packet — are ignored so normal path flooding (our TX appending our
# hash to the path) is never cancelled without evidence of propagation.
flood_suppression_enabled: false
# Rebroadcast copies heard (while our TX is pending) that trigger suppression.
# Minimum 1. Raise to 2+ if your repeater provides critical coverage and you
# only want to skip TX when the packet is clearly well-propagated.
flood_suppression_threshold: 1
# Multiple Identity Configuration (Optional)
# Define additional identities for the repeater to manage
# Each identity operates independently with its own key pair and configuration
@@ -611,6 +637,28 @@ logging:
# Log format
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# HTTP server configuration (web UI + API)
http:
# Enable/disable the embedded HTTP server at runtime.
# Changes are applied live via ConfigManager (no daemon restart required).
enabled: true
# Listen address and port for the web UI/API server.
host: "0.0.0.0"
port: 8000
# CherryPy worker/thread tuning.
# thread_pool is the initial worker count (minimum enforced by server: 2).
thread_pool: 8
# thread_pool_max is the max worker count (always >= thread_pool).
thread_pool_max: 16
# Socket tuning.
# socket_timeout closes idle sockets so worker threads can be reused (min: 15 seconds).
socket_timeout: 65
# socket_queue_size controls pending TCP connection backlog (min: 10).
socket_queue_size: 100
# Web interface configuration
web:
# Enable Cross-Origin Resource Sharing (CORS) headers
+1
View File
@@ -10,3 +10,4 @@ services:
DIALOUT_GID: ${DIALOUT_GID:-20}
GPIO_GID: ${GPIO_GID:-986}
SPI_GID: ${SPI_GID:-989}
PYMC_CONSOLE_VERSION: ${PYMC_CONSOLE_VERSION:-latest}
+21 -1
View File
@@ -10,10 +10,14 @@ ARG GPIO_GID=986
ARG SPI_GID=989
ARG TARGETARCH
ARG YQ_VERSION=v4.40.5
ARG PYMC_CONSOLE_REPO=Treehouse-00/pymc_console-dist
ARG PYMC_CONSOLE_VERSION=latest
ARG PYMC_CONSOLE_CACHE_BUST=default
ENV INSTALL_DIR=/opt/openhop_repeater \
CONFIG_DIR=/etc/openhop_repeater \
DATA_DIR=/var/lib/openhop_repeater \
PYMC_CONSOLE_WEB_DIR=/opt/pymc_console/web/html \
HOME_DIR=/home/${USER} \
PATH=/home/${USER}/.local/bin:${PATH} \
PYTHONUNBUFFERED=1 \
@@ -49,6 +53,22 @@ RUN arch="${TARGETARCH:-}" \
&& wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" \
&& chmod +x /usr/local/bin/yq
# Bundle the optional PyMC Console frontend so the web UI can select it without
# requiring a host bind mount. Use PYMC_CONSOLE_VERSION=latest to pull the
# newest release at image build time, or pin a tag such as v0.9.329.
RUN set -eux; \
echo "Bundling PyMC Console cache key: ${PYMC_CONSOLE_CACHE_BUST}"; \
mkdir -p "${PYMC_CONSOLE_WEB_DIR}"; \
if [ "${PYMC_CONSOLE_VERSION}" = "latest" ]; then \
console_url="https://github.com/${PYMC_CONSOLE_REPO}/releases/latest/download/pymc-ui-latest.tar.gz"; \
else \
console_url="https://github.com/${PYMC_CONSOLE_REPO}/releases/download/${PYMC_CONSOLE_VERSION}/pymc-ui-${PYMC_CONSOLE_VERSION}.tar.gz"; \
fi; \
wget -qO /tmp/pymc-console.tar.gz "${console_url}"; \
tar -xzf /tmp/pymc-console.tar.gz -C "${PYMC_CONSOLE_WEB_DIR}"; \
rm /tmp/pymc-console.tar.gz; \
test -f "${PYMC_CONSOLE_WEB_DIR}/index.html"
# Create the group and user in order to run without root privileges
RUN groupadd --gid "$PGID" "$GROUP" \
&& (getent group dialout >/dev/null || groupadd --gid "$DIALOUT_GID" dialout) \
@@ -59,7 +79,7 @@ RUN groupadd --gid "$PGID" "$GROUP" \
# Create runtime directories
RUN mkdir -p ${INSTALL_DIR} ${CONFIG_DIR} ${DATA_DIR} \
&& chown -R "$USER":"$GROUP" ${INSTALL_DIR} ${CONFIG_DIR} ${DATA_DIR} ${HOME_DIR}
&& chown -R "$USER":"$GROUP" ${INSTALL_DIR} ${CONFIG_DIR} ${DATA_DIR} ${HOME_DIR} /opt/pymc_console
WORKDIR ${INSTALL_DIR}
+6
View File
@@ -29,3 +29,9 @@ SPI_GID=989
# image yourself instead of pulling OPENHOP_REPEATER_IMAGE.
PUID=15888
PGID=15888
# Local build only. The Docker image bundles PyMC Console under
# /opt/pymc_console/web/html so it is available in Web Settings without a host
# bind mount. Use "latest" to pull the newest pyMC Console release at build
# time, or pin a tag such as v0.9.329.
PYMC_CONSOLE_VERSION=latest
+3 -2
View File
@@ -29,7 +29,8 @@ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [
"openhop_core[hardware]",
"openhop_core[hardware] @ git+https://github.com/openhop-dev/openhop_core.git@dev ; sys_platform == 'linux'",
"openhop_core @ git+https://github.com/openhop-dev/openhop_core.git@dev ; sys_platform != 'linux'",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
"paho-mqtt>=1.6.0",
@@ -45,7 +46,7 @@ dependencies = [
[project.optional-dependencies]
# SX1262/SPI support (Linux only; required for Raspberry Pi HATs)
hardware = [
"openhop_core[hardware]",
"openhop_core[hardware] @ git+https://github.com/openhop-dev/openhop_core.git@dev",
]
# RRD metrics (Performance Metrics chart); system librrd required (e.g. apt install rrdtool)
rrd = [
+41 -23
View File
@@ -133,35 +133,39 @@
"zebra-duo-hat-r0": {
"name": "ZebraHatDuo-R0-1W",
"bus_id": 0,
"busy_pin": 23,
"cs_id": 0,
"cs_pin": -1,
"reset_pin": 18,
"busy_pin": 23,
"irq_pin": 24,
"txen_pin": -1,
"dio3_tcxo_voltage": 1.8,
"en_pin": -1,
"irq_pin": 18,
"is_waveshare": false,
"reset_pin": 24,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 18,
"use_dio3_tcxo": true,
"txen_pin": -1,
"txled_pin": -1,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"preamble_length": 32
},
"zebra-duo-hat-r1": {
"name": "ZebraHatDuo-R1-1W",
"bus_id": 0,
"busy_pin": 27,
"cs_id": 1,
"cs_pin": -1,
"reset_pin": 17,
"busy_pin": 27,
"dio3_tcxo_voltage": 1.8,
"en_pin": -1,
"irq_pin": 22,
"txen_pin": -1,
"is_waveshare": false,
"reset_pin": 17,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 18,
"use_dio3_tcxo": true,
"txen_pin": -1,
"txled_pin": -1,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"preamble_length": 32
},
"femtofox-1W-SX": {
@@ -312,9 +316,7 @@
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
"preamble_length": 32
},
"rak6421-13300x-slot2": {
"name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2",
@@ -333,9 +335,27 @@
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
"preamble_length": 32
},
"rak6421-13300x-slot2-hw-mod": {
"name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2",
"description": "Use this radio settings preset if you have modded your pi hat to work with dual radios see https://discord.com/channels/1495203904898728149/1512614022913200159 .",
"bus_id": 0,
"cs_id": 1,
"cs_pin": -1,
"reset_pin": 27,
"busy_pin": 19,
"irq_pin": 18,
"txen_pin": -1,
"rxen_pin": -1,
"en_pins": [26, 23],
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32
},
"ultrapeaterzero-e22": {
"name": "Zindello Industries UltraPeaterZero E22",
@@ -389,9 +409,7 @@
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
"preamble_length": 32
},
"pymc_usb": {
"name": "pymc_usb modem (USB-CDC)",
@@ -418,4 +436,4 @@
"preamble_length": 32
}
}
}
}
+11 -2
View File
@@ -61,13 +61,18 @@ class CompanionFrameServer(_BaseFrameServer):
async def _persist_companion_message(self, msg_dict: dict) -> None:
"""Persist message to SQLite and pop from bridge queue.
The bridge's ``offline_queue_size`` (``message_queue._max_size``) doubles
The bridge's ``offline_queue_size`` (``message_queue.max_size``) doubles
as the SQLite retention limit: 0 disables offline storage entirely, so the
message is dropped instead of persisted.
"""
if not self.sqlite_handler:
return
retention = getattr(self.bridge.message_queue, "_max_size", None)
# Older cores predate the public max_size property.
retention = getattr(
self.bridge.message_queue,
"max_size",
getattr(self.bridge.message_queue, "_max_size", None),
)
if retention == 0:
self.bridge.message_queue.pop_last()
return
@@ -86,6 +91,9 @@ class CompanionFrameServer(_BaseFrameServer):
msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash)
if not msg_dict:
return None
sender_prefix = msg_dict.get("sender_prefix", b"")
if isinstance(sender_prefix, str):
sender_prefix = bytes.fromhex(sender_prefix) if sender_prefix else b""
return QueuedMessage(
sender_key=msg_dict.get("sender_key", b""),
txt_type=msg_dict.get("txt_type", 0),
@@ -94,6 +102,7 @@ class CompanionFrameServer(_BaseFrameServer):
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
sender_prefix=sender_prefix,
)
# -----------------------------------------------------------------
+13 -1
View File
@@ -42,6 +42,14 @@ class CompanionContactCapacityError(Exception):
)
class CompanionStateLoadError(Exception):
"""Persisted companion state exists in SQLite but could not be loaded.
Raised at companion init so the companion fails loudly instead of starting
with an empty store (which would present to clients as wiped channels or
contacts and let subsequent saves overwrite the persisted state)."""
def normalize_companion_identity_key(identity_key: str) -> str:
"""Strip whitespace and remove optional 0x prefix so fromhex() is consistent across installs."""
s = identity_key.strip()
@@ -207,11 +215,15 @@ def trim_companion_contacts_to_fit(
Raises:
ValueError: favourites alone exceed ``max_contacts`` (cannot trim).
RuntimeError: persisting the trimmed contact list failed.
RuntimeError: loading or persisting the contact list failed.
"""
if sqlite_handler is None:
return 0
contacts = sqlite_handler.companion_load_contacts(companion_hash)
if contacts is None:
raise RuntimeError(
f"Failed to load persisted contacts for {companion_hash}; refusing to trim"
)
keep, removed = select_companion_contacts_to_trim(contacts, max_contacts)
if not removed:
return 0
+7
View File
@@ -230,6 +230,13 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
if "mesh" not in config:
config["mesh"] = {}
if "http" not in config:
config["http"] = {
"enabled": True,
"host": "0.0.0.0",
"port": 8000,
}
if "glass" not in config:
config["glass"] = {
"enabled": False,
+52 -2
View File
@@ -1,7 +1,8 @@
import logging
import os
from typing import Any, Dict, List, Optional
import yaml
from typing import Optional, Dict, Any, List
logger = logging.getLogger("ConfigManager")
@@ -145,6 +146,52 @@ class ConfigManager:
logger.error(f"Failed to apply live radio config: {e}", exc_info=True)
return False
@staticmethod
def _parse_bool(value: Any, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _apply_live_http_config(self) -> bool:
if not self.daemon:
logger.warning("Daemon not available for HTTP live update")
return False
http_server = getattr(self.daemon, "http_server", None)
if http_server is None:
# Early in daemon lifecycle, there is nothing to control yet.
logger.info("HTTP server not initialized yet; skipping live HTTP update")
return True
http_cfg = self.config.get("http", {}) if isinstance(self.config, dict) else {}
enabled = self._parse_bool(http_cfg.get("enabled", True), default=True)
host = str(http_cfg.get("host", "0.0.0.0") or "0.0.0.0")
try:
port = int(http_cfg.get("port", 8000))
except (TypeError, ValueError):
logger.warning("Invalid http.port=%r, falling back to 8000", http_cfg.get("port"))
port = 8000
# Keep runtime server settings aligned with config before start/restart.
http_server.host = host
http_server.port = port
from repeater.service_utils import start_http_server, stop_http_server
if enabled:
success, message = start_http_server(self.daemon)
else:
success, message = stop_http_server(self.daemon)
if success:
logger.info("Applied live HTTP config: %s", message)
else:
logger.warning("Failed live HTTP config apply: %s", message)
return success
def save_to_file(self) -> bool:
"""
Save current config to YAML file.
@@ -193,7 +240,7 @@ class ConfigManager:
# Default sections to update if not specified
if sections is None:
sections = ["repeater", "delays", "radio", "acl", "identities", "glass"]
sections = ["repeater", "delays", "radio", "acl", "identities", "glass", "http"]
# Update each section
for section in sections:
@@ -247,6 +294,9 @@ class ConfigManager:
if "radio" in sections:
live_update_ok = self._apply_live_radio_config() and live_update_ok
if "http" in sections:
live_update_ok = self._apply_live_http_config() and live_update_ok
return live_update_ok
except Exception as e:
+6 -1
View File
@@ -617,6 +617,7 @@ class MeshCoreToMqttPusher:
jwt_expiry_minutes: int = 10,
stats_provider: Optional[Callable[[], dict]] = None,
):
self.config = config
# Store local identity and get public key
self.local_identity = local_identity
public_key = local_identity.get_public_key().hex().upper()
@@ -965,15 +966,19 @@ class MeshCoreToMqttPusher:
else:
live_stats = {"uptime_secs": 0, "packets_sent": 0, "packets_received": 0}
mode = str(self.config.get("repeater", {}).get("mode", "forward")).strip().lower()
repeat_state = "on" if mode == "forward" else "off"
status = {
"status": state,
"timestamp": datetime.now(timezone.utc).isoformat(),
"origin": origin or self.node_name,
"origin_id": self.public_key,
"model": "PyMC-Repeater",
"model": "openHop-Repeater",
"firmware_version": self.app_version,
"radio": radio_config or self.radio_config,
"client_version": f"openhop_repeater/{self.app_version}",
"repeat": repeat_state,
"stats": {**live_stats, "errors": 0, "queue_len": 0, **(extra_stats or {})},
}
+697 -34
View File
@@ -1,6 +1,7 @@
import base64
import json
import logging
import math
import secrets
import sqlite3
import threading
@@ -191,6 +192,14 @@ class SQLiteHandler:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_packets_transmitted ON packets(transmitted)"
)
# Covering index for the airtime/utilization charts. get_airtime_data
# and get_airtime_buckets range-scan and order by timestamp, selecting
# only these columns; keeping them all in the index lets SQLite serve
# the query index-only, avoiding a full scan of the (large) row heap.
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_packets_airtime "
"ON packets(timestamp, length, payload_length, transmitted)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_adverts_timestamp ON adverts(timestamp)"
)
@@ -426,6 +435,7 @@ class SQLiteHandler:
is_channel INTEGER NOT NULL DEFAULT 0,
channel_idx INTEGER NOT NULL DEFAULT 0,
path_len INTEGER NOT NULL DEFAULT 0,
sender_prefix TEXT NOT NULL DEFAULT '',
packet_hash TEXT,
created_at REAL NOT NULL
)
@@ -581,6 +591,30 @@ class SQLiteHandler:
)
logger.info(f"Migration '{migration_name}' applied successfully")
# Migration 10: Add sender_prefix column (hex text) to
# companion_messages. TXT_TYPE_SIGNED_PLAIN room posts carry a
# 4-byte author pubkey prefix; without it, posts replayed from
# SQLite show a zero-padded author in the app frame.
migration_name = "add_sender_prefix_to_companion_messages"
existing = conn.execute(
"SELECT migration_name FROM migrations WHERE migration_name = ?",
(migration_name,),
).fetchone()
if not existing:
cursor = conn.execute("PRAGMA table_info(companion_messages)")
columns = [column[1] for column in cursor.fetchall()]
if "sender_prefix" not in columns:
conn.execute(
"ALTER TABLE companion_messages "
"ADD COLUMN sender_prefix TEXT NOT NULL DEFAULT ''"
)
logger.info("Added sender_prefix column to companion_messages table")
conn.execute(
"INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)",
(migration_name, time.time()),
)
logger.info(f"Migration '{migration_name}' applied successfully")
conn.commit()
except Exception as e:
@@ -671,7 +705,7 @@ class SQLiteHandler:
except Exception:
fwd_path_val = str(fwd_path)
conn.execute(
cursor = conn.execute(
"""
INSERT INTO packets (
timestamp, type, route, length, rssi, snr, score,
@@ -714,6 +748,7 @@ class SQLiteHandler:
),
)
self._invalidate_hot_caches()
return cursor.lastrowid
except Exception as e:
logger.error(f"Failed to store packet in SQLite: {e}")
@@ -910,6 +945,524 @@ class SQLiteHandler:
logger.error(f"Failed to get policy event counts: {e}")
return []
def get_lbt_diagnostics(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 300,
severe_attempt_threshold: int = 4,
) -> dict:
"""Return aggregated LBT diagnostics for TX-path packets.
LBT metadata in packets is persisted as "extra attempts/backoffs" where:
- lbt_attempts == 0 means first CAD/LBT check was clear
- total attempts/checks ~= lbt_attempts + 1
This method avoids returning raw packet rows and instead returns
bucketed aggregates + summary metrics for efficient dashboard refreshes.
"""
def _weighted_percentile(attempt_counts: dict, q: float) -> Optional[float]:
total = sum(int(v) for v in attempt_counts.values())
if total <= 0:
return None
q = max(0.0, min(1.0, float(q)))
# Use nearest-rank percentile so p95 on sparse samples doesn't
# systematically under-report tail attempts.
rank = max(1, int(math.ceil(total * q)))
running = 0
for attempt in sorted(int(k) for k in attempt_counts.keys()):
running += int(attempt_counts.get(attempt, 0))
if running >= rank:
return float(attempt)
return float(max(int(k) for k in attempt_counts.keys()))
def _packet_type_name(pkt_type: int) -> str:
try:
from openhop_core.protocol.utils import PAYLOAD_TYPES as _PT
labels = {
"REQ": "Request",
"RESPONSE": "Response",
"TXT_MSG": "Plain Text Message",
"ACK": "Acknowledgment",
"ADVERT": "Node Advertisement",
"GRP_TXT": "Group Text Message",
"GRP_DATA": "Group Datagram",
"ANON_REQ": "Anonymous Request",
"PATH": "Returned Path",
"TRACE": "Trace",
"MULTIPART": "Multi-part Packet",
"CONTROL": "Control",
"RAW_CUSTOM": "Custom Packet",
}
code = _PT.get(pkt_type)
if not code:
return (
f"Reserved Type {pkt_type}" if 0 <= pkt_type <= 15 else f"Type {pkt_type}"
)
return f"{labels.get(code, code.replace('_', ' ').title())} ({code})"
except Exception:
return f"Reserved Type {pkt_type}" if 0 <= pkt_type <= 15 else f"Type {pkt_type}"
try:
bucket_seconds = max(60, min(int(bucket_seconds), 3600))
severe_attempt_threshold = max(2, int(severe_attempt_threshold))
if end_timestamp < start_timestamp:
start_timestamp, end_timestamp = end_timestamp, start_timestamp
tx_filter = "(transmitted = 1 OR lbt_attempts > 0 OR drop_reason LIKE 'TX failed%')"
with self._connect() as conn:
conn.row_factory = sqlite3.Row
aggregate_rows = conn.execute(
f"""
WITH tx_packets AS (
SELECT
CAST(timestamp / ? AS INTEGER) * ? AS bucket_ts,
CASE
WHEN lbt_attempts IS NULL OR lbt_attempts < 0 THEN 1
ELSE lbt_attempts + 1
END AS attempts_total,
CASE WHEN transmitted = 1 THEN 1 ELSE 0 END AS tx_success,
CASE
WHEN transmitted = 0 AND drop_reason LIKE 'TX failed%' THEN 1
ELSE 0
END AS failed_tx,
CASE WHEN COALESCE(lbt_channel_busy, 0) = 1 THEN 1 ELSE 0 END AS busy
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp >= ?
AND timestamp <= ?
AND {tx_filter}
)
SELECT
bucket_ts,
COUNT(*) AS transmissions,
SUM(attempts_total) AS total_attempts,
SUM(CASE WHEN attempts_total = 1 THEN 1 ELSE 0 END) AS attempts_1,
SUM(CASE WHEN attempts_total = 2 THEN 1 ELSE 0 END) AS attempts_2,
SUM(CASE WHEN attempts_total = 3 THEN 1 ELSE 0 END) AS attempts_3,
SUM(CASE WHEN attempts_total >= 4 THEN 1 ELSE 0 END) AS attempts_4_plus,
SUM(CASE WHEN attempts_total > 1 THEN 1 ELSE 0 END) AS retry_packets,
SUM(CASE WHEN tx_success = 1 AND attempts_total = 1 THEN 1 ELSE 0 END) AS first_attempt_success,
SUM(failed_tx) AS failed_transmissions,
SUM(busy) AS busy_channel_events,
SUM(CASE WHEN attempts_total >= ? THEN 1 ELSE 0 END) AS severe_contention_count,
MAX(attempts_total) AS max_attempts
FROM tx_packets
GROUP BY bucket_ts
ORDER BY bucket_ts ASC
""",
(
bucket_seconds,
bucket_seconds,
float(start_timestamp),
float(end_timestamp),
severe_attempt_threshold,
),
).fetchall()
dist_rows = conn.execute(
f"""
WITH tx_packets AS (
SELECT
CAST(timestamp / ? AS INTEGER) * ? AS bucket_ts,
CASE
WHEN lbt_attempts IS NULL OR lbt_attempts < 0 THEN 1
ELSE lbt_attempts + 1
END AS attempts_total
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp >= ?
AND timestamp <= ?
AND {tx_filter}
)
SELECT bucket_ts, attempts_total, COUNT(*) AS cnt
FROM tx_packets
GROUP BY bucket_ts, attempts_total
ORDER BY bucket_ts ASC, attempts_total ASC
""",
(
bucket_seconds,
bucket_seconds,
float(start_timestamp),
float(end_timestamp),
),
).fetchall()
type_rows = conn.execute(
f"""
WITH tx_packets AS (
SELECT
CAST(timestamp / ? AS INTEGER) * ? AS bucket_ts,
type AS packet_type,
CASE
WHEN lbt_attempts IS NULL OR lbt_attempts < 0 THEN 1
ELSE lbt_attempts + 1
END AS attempts_total,
CASE WHEN transmitted = 1 THEN 1 ELSE 0 END AS tx_success,
CASE
WHEN transmitted = 0 AND drop_reason LIKE 'TX failed%' THEN 1
ELSE 0
END AS failed_tx
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp >= ?
AND timestamp <= ?
AND {tx_filter}
)
SELECT
bucket_ts,
packet_type,
COUNT(*) AS transmissions,
SUM(attempts_total) AS total_attempts,
SUM(CASE WHEN attempts_total = 1 THEN 1 ELSE 0 END) AS attempts_1,
SUM(CASE WHEN attempts_total = 2 THEN 1 ELSE 0 END) AS attempts_2,
SUM(CASE WHEN attempts_total = 3 THEN 1 ELSE 0 END) AS attempts_3,
SUM(CASE WHEN attempts_total >= 4 THEN 1 ELSE 0 END) AS attempts_4_plus,
SUM(CASE WHEN attempts_total > 1 THEN 1 ELSE 0 END) AS retry_packets,
SUM(CASE WHEN tx_success = 1 AND attempts_total = 1 THEN 1 ELSE 0 END) AS first_attempt_success,
SUM(failed_tx) AS failed_transmissions,
SUM(CASE WHEN attempts_total >= ? THEN 1 ELSE 0 END) AS severe_contention_count,
MAX(attempts_total) AS max_attempts
FROM tx_packets
GROUP BY bucket_ts, packet_type
ORDER BY bucket_ts ASC, packet_type ASC
""",
(
bucket_seconds,
bucket_seconds,
float(start_timestamp),
float(end_timestamp),
severe_attempt_threshold,
),
).fetchall()
dist_by_bucket: dict = {}
overall_dist: dict = {}
for row in dist_rows:
bucket_ts = int(row["bucket_ts"])
attempt = int(row["attempts_total"])
count = int(row["cnt"])
bucket_dist = dist_by_bucket.setdefault(bucket_ts, {})
bucket_dist[attempt] = bucket_dist.get(attempt, 0) + count
overall_dist[attempt] = overall_dist.get(attempt, 0) + count
bucket_map: dict = {}
start_bucket = int(float(start_timestamp) // bucket_seconds) * bucket_seconds
end_bucket = int(float(end_timestamp) // bucket_seconds) * bucket_seconds
for bucket_ts in range(start_bucket, end_bucket + 1, bucket_seconds):
bucket_map[bucket_ts] = {
"timestamp": bucket_ts,
"transmissions": 0,
"total_attempts": 0,
"attempts_1": 0,
"attempts_2": 0,
"attempts_3": 0,
"attempts_4_plus": 0,
"retry_packets": 0,
"first_attempt_success": 0,
"failed_transmissions": 0,
"busy_channel_events": 0,
"severe_contention_count": 0,
"max_attempts": 0,
}
for row in aggregate_rows:
bucket_ts = int(row["bucket_ts"])
if bucket_ts not in bucket_map:
bucket_map[bucket_ts] = {
"timestamp": bucket_ts,
"transmissions": 0,
"total_attempts": 0,
"attempts_1": 0,
"attempts_2": 0,
"attempts_3": 0,
"attempts_4_plus": 0,
"retry_packets": 0,
"first_attempt_success": 0,
"failed_transmissions": 0,
"busy_channel_events": 0,
"severe_contention_count": 0,
"max_attempts": 0,
}
bucket_map[bucket_ts].update(
{
"transmissions": int(row["transmissions"] or 0),
"total_attempts": int(row["total_attempts"] or 0),
"attempts_1": int(row["attempts_1"] or 0),
"attempts_2": int(row["attempts_2"] or 0),
"attempts_3": int(row["attempts_3"] or 0),
"attempts_4_plus": int(row["attempts_4_plus"] or 0),
"retry_packets": int(row["retry_packets"] or 0),
"first_attempt_success": int(row["first_attempt_success"] or 0),
"failed_transmissions": int(row["failed_transmissions"] or 0),
"busy_channel_events": int(row["busy_channel_events"] or 0),
"severe_contention_count": int(row["severe_contention_count"] or 0),
"max_attempts": int(row["max_attempts"] or 0),
}
)
buckets = []
for bucket_ts in sorted(bucket_map.keys()):
bucket = bucket_map[bucket_ts]
transmissions = int(bucket["transmissions"])
total_attempts = int(bucket["total_attempts"])
attempts_3_plus = int(bucket["attempts_3"] + bucket["attempts_4_plus"])
median_attempts = _weighted_percentile(dist_by_bucket.get(bucket_ts, {}), 0.5)
p95_attempts = _weighted_percentile(dist_by_bucket.get(bucket_ts, {}), 0.95)
retry_rate_pct = None
first_attempt_success_rate_pct = None
avg_attempts = None
attempts_3_plus_pct = None
attempts_4_plus_pct = None
severe_contention_pct = None
if transmissions > 0:
retry_rate_pct = (bucket["retry_packets"] * 100.0) / transmissions
first_attempt_success_rate_pct = (
bucket["first_attempt_success"] * 100.0
) / transmissions
avg_attempts = total_attempts / transmissions
attempts_3_plus_pct = (attempts_3_plus * 100.0) / transmissions
attempts_4_plus_pct = (bucket["attempts_4_plus"] * 100.0) / transmissions
severe_contention_pct = (
bucket["severe_contention_count"] * 100.0
) / transmissions
buckets.append(
{
"timestamp": bucket_ts,
"transmissions": transmissions,
"total_attempts": total_attempts,
"first_attempt_success": int(bucket["first_attempt_success"]),
"retry_packets": int(bucket["retry_packets"]),
"retry_rate_pct": retry_rate_pct,
"first_attempt_success_rate_pct": first_attempt_success_rate_pct,
"avg_attempts": avg_attempts,
"median_attempts": median_attempts,
"p95_attempts": p95_attempts,
"max_attempts": int(bucket["max_attempts"]),
"attempts_1": int(bucket["attempts_1"]),
"attempts_2": int(bucket["attempts_2"]),
"attempts_3": int(bucket["attempts_3"]),
"attempts_4_plus": int(bucket["attempts_4_plus"]),
"attempts_3_plus": int(attempts_3_plus),
"attempts_3_plus_pct": attempts_3_plus_pct,
"attempts_4_plus_pct": attempts_4_plus_pct,
"failed_transmissions": int(bucket["failed_transmissions"]),
"busy_channel_events": int(bucket["busy_channel_events"]),
"severe_contention_count": int(bucket["severe_contention_count"]),
"severe_contention_pct": severe_contention_pct,
}
)
total_transmissions = int(sum(b["transmissions"] for b in buckets))
total_attempts = int(sum(b["total_attempts"] for b in buckets))
first_attempt_success = int(sum(b["first_attempt_success"] for b in buckets))
retry_packets = int(sum(b["retry_packets"] for b in buckets))
attempts_1 = int(sum(b["attempts_1"] for b in buckets))
attempts_2 = int(sum(b["attempts_2"] for b in buckets))
attempts_3 = int(sum(b["attempts_3"] for b in buckets))
attempts_4_plus = int(sum(b["attempts_4_plus"] for b in buckets))
attempts_3_plus = int(attempts_3 + attempts_4_plus)
failed_transmissions = int(sum(b["failed_transmissions"] for b in buckets))
busy_channel_events = int(sum(b["busy_channel_events"] for b in buckets))
severe_contention_count = int(sum(b["severe_contention_count"] for b in buckets))
max_attempts = int(max([b["max_attempts"] for b in buckets], default=0))
retry_rate_pct = None
first_attempt_success_rate_pct = None
avg_attempts = None
attempts_3_plus_pct = None
attempts_4_plus_pct = None
severe_contention_pct = None
if total_transmissions > 0:
retry_rate_pct = (retry_packets * 100.0) / total_transmissions
first_attempt_success_rate_pct = (
first_attempt_success * 100.0
) / total_transmissions
avg_attempts = total_attempts / total_transmissions
attempts_3_plus_pct = (attempts_3_plus * 100.0) / total_transmissions
attempts_4_plus_pct = (attempts_4_plus * 100.0) / total_transmissions
severe_contention_pct = (severe_contention_count * 100.0) / total_transmissions
worst_bucket = None
scored_buckets = [
b
for b in buckets
if int(b.get("transmissions", 0)) > 0 and b.get("retry_rate_pct") is not None
]
if scored_buckets:
worst = max(
scored_buckets, key=lambda item: float(item.get("retry_rate_pct") or 0.0)
)
worst_bucket = {
"timestamp": int(worst["timestamp"]),
"retry_rate_pct": float(worst.get("retry_rate_pct") or 0.0),
"attempts_3_plus_pct": float(worst.get("attempts_3_plus_pct") or 0.0),
"max_attempts": int(worst.get("max_attempts") or 0),
"transmissions": int(worst.get("transmissions") or 0),
}
summary = {
"total_transmissions": total_transmissions,
"total_attempts": total_attempts,
"first_attempt_success": first_attempt_success,
"retry_packets": retry_packets,
"retry_rate_pct": retry_rate_pct,
"first_attempt_success_rate_pct": first_attempt_success_rate_pct,
"avg_attempts": avg_attempts,
"median_attempts": _weighted_percentile(overall_dist, 0.5),
"p95_attempts": _weighted_percentile(overall_dist, 0.95),
"max_attempts": max_attempts,
"attempts_1": attempts_1,
"attempts_2": attempts_2,
"attempts_3": attempts_3,
"attempts_4_plus": attempts_4_plus,
"attempts_3_plus": attempts_3_plus,
"attempts_3_plus_pct": attempts_3_plus_pct,
"attempts_4_plus_pct": attempts_4_plus_pct,
"failed_transmissions": failed_transmissions,
"busy_channel_events": busy_channel_events,
"severe_contention_count": severe_contention_count,
"severe_contention_pct": severe_contention_pct,
"severe_attempt_threshold": severe_attempt_threshold,
"has_lbt_data": total_transmissions > 0,
"worst_bucket": worst_bucket,
}
packet_type_totals: dict = {}
packet_type_buckets = []
for row in type_rows:
bucket_ts = int(row["bucket_ts"])
packet_type = int(row["packet_type"] if row["packet_type"] is not None else -1)
transmissions = int(row["transmissions"] or 0)
total_attempts_for_type = int(row["total_attempts"] or 0)
attempts_3_plus = int((row["attempts_3"] or 0) + (row["attempts_4_plus"] or 0))
retry_rate_pct_for_type = None
first_attempt_success_rate_pct_for_type = None
avg_attempts_for_type = None
attempts_3_plus_pct_for_type = None
if transmissions > 0:
retry_rate_pct_for_type = (
int(row["retry_packets"] or 0) * 100.0
) / transmissions
first_attempt_success_rate_pct_for_type = (
int(row["first_attempt_success"] or 0) * 100.0
) / transmissions
avg_attempts_for_type = total_attempts_for_type / transmissions
attempts_3_plus_pct_for_type = (attempts_3_plus * 100.0) / transmissions
packet_type_buckets.append(
{
"timestamp": bucket_ts,
"packet_type": packet_type,
"packet_type_label": _packet_type_name(packet_type),
"transmissions": transmissions,
"total_attempts": total_attempts_for_type,
"first_attempt_success": int(row["first_attempt_success"] or 0),
"retry_packets": int(row["retry_packets"] or 0),
"retry_rate_pct": retry_rate_pct_for_type,
"first_attempt_success_rate_pct": first_attempt_success_rate_pct_for_type,
"avg_attempts": avg_attempts_for_type,
"attempts_1": int(row["attempts_1"] or 0),
"attempts_2": int(row["attempts_2"] or 0),
"attempts_3": int(row["attempts_3"] or 0),
"attempts_4_plus": int(row["attempts_4_plus"] or 0),
"attempts_3_plus": attempts_3_plus,
"attempts_3_plus_pct": attempts_3_plus_pct_for_type,
"max_attempts": int(row["max_attempts"] or 0),
"failed_transmissions": int(row["failed_transmissions"] or 0),
"severe_contention_count": int(row["severe_contention_count"] or 0),
}
)
total_entry = packet_type_totals.setdefault(
packet_type,
{
"packet_type": packet_type,
"packet_type_label": _packet_type_name(packet_type),
"transmissions": 0,
"retry_packets": 0,
},
)
total_entry["transmissions"] += transmissions
total_entry["retry_packets"] += int(row["retry_packets"] or 0)
packet_types = []
for pkt_type in sorted(
packet_type_totals.keys(),
key=lambda key: packet_type_totals[key]["transmissions"],
reverse=True,
):
entry = packet_type_totals[pkt_type]
transmissions = int(entry["transmissions"])
retry_rate_pct_for_type = None
if transmissions > 0:
retry_rate_pct_for_type = (int(entry["retry_packets"]) * 100.0) / transmissions
packet_types.append(
{
"packet_type": int(entry["packet_type"]),
"packet_type_label": str(entry["packet_type_label"]),
"transmissions": transmissions,
"retry_packets": int(entry["retry_packets"]),
"retry_rate_pct": retry_rate_pct_for_type,
}
)
return {
"start_time": int(start_timestamp),
"end_time": int(end_timestamp),
"bucket_seconds": bucket_seconds,
"summary": summary,
"buckets": buckets,
"packet_types": packet_types,
"packet_type_buckets": packet_type_buckets,
}
except Exception as e:
logger.error(f"Failed to get LBT diagnostics: {e}")
return {
"start_time": int(start_timestamp),
"end_time": int(end_timestamp),
"bucket_seconds": max(60, min(int(bucket_seconds), 3600)),
"summary": {
"total_transmissions": 0,
"total_attempts": 0,
"first_attempt_success": 0,
"retry_packets": 0,
"retry_rate_pct": None,
"first_attempt_success_rate_pct": None,
"avg_attempts": None,
"median_attempts": None,
"p95_attempts": None,
"max_attempts": 0,
"attempts_1": 0,
"attempts_2": 0,
"attempts_3": 0,
"attempts_4_plus": 0,
"attempts_3_plus": 0,
"attempts_3_plus_pct": None,
"attempts_4_plus_pct": None,
"failed_transmissions": 0,
"busy_channel_events": 0,
"severe_contention_count": 0,
"severe_contention_pct": None,
"severe_attempt_threshold": max(2, int(severe_attempt_threshold)),
"has_lbt_data": False,
"worst_bucket": None,
},
"buckets": [],
"packet_types": [],
"packet_type_buckets": [],
}
def get_packet_stats(self, hours: int = 24) -> dict:
try:
now = time.time()
@@ -939,10 +1492,16 @@ class SQLiteHandler:
(cutoff,),
).fetchone()
# INDEXED BY forces the timestamp range scan. Without it the
# planner picks idx_packets_type / idx_packets_transmitted to get
# grouping for free, then heap-checks the timestamp filter across
# the entire table — turning a bounded window into a full scan
# (~5s vs ~0.1s at 1.5M rows). A small temp b-tree over the
# windowed rows is far cheaper.
types = conn.execute(
"""
SELECT type, COUNT(*) as count
FROM packets
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp > ?
GROUP BY type
ORDER BY count DESC
@@ -953,7 +1512,7 @@ class SQLiteHandler:
drop_reasons = conn.execute(
"""
SELECT drop_reason, COUNT(*) as count
FROM packets
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp > ? AND transmitted = 0 AND drop_reason IS NOT NULL
GROUP BY drop_reason
ORDER BY count DESC
@@ -992,6 +1551,7 @@ class SQLiteHandler:
packets = conn.execute(
"""
SELECT
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
transport_codes, payload, payload_length,
@@ -1044,6 +1604,7 @@ class SQLiteHandler:
base_query = """
SELECT
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
transport_codes, payload, payload_length,
@@ -1176,6 +1737,7 @@ class SQLiteHandler:
packet = conn.execute(
"""
SELECT
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
header, transport_codes, payload, payload_length,
@@ -1193,6 +1755,32 @@ class SQLiteHandler:
logger.error(f"Failed to get packet by hash: {e}")
return None
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
try:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
packet = conn.execute(
"""
SELECT
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
header, transport_codes, payload, payload_length,
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
FROM packets
WHERE id = ?
""",
(packet_id,),
).fetchone()
return dict(packet) if packet else None
except Exception as e:
logger.error(f"Failed to get packet by id: {e}")
return None
def get_packet_type_stats(self, hours: int = 24) -> dict:
try:
now = time.time()
@@ -1251,10 +1839,12 @@ class SQLiteHandler:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
# See get_packet_stats: force the timestamp range scan so the
# windowed GROUP BY doesn't degrade into a full-table scan.
type_rows = conn.execute(
"""
SELECT type, COUNT(*) as count
FROM packets
FROM packets INDEXED BY idx_packets_timestamp
WHERE timestamp > ?
GROUP BY type
""",
@@ -2072,11 +2662,29 @@ class SQLiteHandler:
try:
with self._connect() as conn:
cursor = conn.execute("DELETE FROM adverts WHERE id = ?", (advert_id,))
self._neighbors_cache = {"timestamp": 0.0, "value": None}
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Failed to delete advert: {e}")
return False
def delete_neighbors_by_pubkey_prefix(self, pubkey_prefix: Optional[str]) -> int:
"""Delete neighbor adverts by pubkey prefix (or all when prefix is None)."""
try:
with self._connect() as conn:
if pubkey_prefix is None:
cursor = conn.execute("DELETE FROM adverts")
else:
cursor = conn.execute(
"DELETE FROM adverts WHERE lower(pubkey) LIKE ?",
(f"{pubkey_prefix.lower()}%",),
)
self._neighbors_cache = {"timestamp": 0.0, "value": None}
return int(cursor.rowcount)
except Exception as e:
logger.error(f"Failed to delete neighbors by prefix: {e}")
raise
# ------------------------------------------------------------------
# Room Server Methods
# ------------------------------------------------------------------
@@ -2139,29 +2747,32 @@ class SQLiteHandler:
return []
def upsert_client_sync(self, room_hash: str, client_pubkey: str, **kwargs) -> bool:
"""Insert or update client sync state using single upsert operation."""
"""Insert or update client sync state without clobbering unspecified fields."""
try:
with self._connect() as conn:
now = time.time()
kwargs["updated_at"] = now
update_fields = dict(kwargs)
update_fields["updated_at"] = now
# Set defaults for insert path
kwargs.setdefault("sync_since", 0)
kwargs.setdefault("pending_ack_crc", 0)
kwargs.setdefault("push_post_timestamp", 0)
kwargs.setdefault("ack_timeout_time", 0)
kwargs.setdefault("push_failures", 0)
kwargs.setdefault("last_activity", now)
# INSERT must satisfy NOT NULL columns (last_activity), while
# ON CONFLICT updates should only touch supplied fields.
insert_fields = dict(update_fields)
if insert_fields.get("last_activity") is None:
insert_fields["last_activity"] = now
columns = ["room_hash", "client_pubkey"] + list(kwargs.keys())
columns = ["room_hash", "client_pubkey"] + list(insert_fields.keys())
placeholders = ["?"] * len(columns)
values = [room_hash, client_pubkey] + list(kwargs.values())
values = [room_hash, client_pubkey] + list(insert_fields.values())
# Use INSERT OR REPLACE for single atomic upsert
# Update only supplied columns on conflict so partial updates don't
# reset counters/state such as push_failures.
update_set = ", ".join(f"{col}=excluded.{col}" for col in update_fields.keys())
conn.execute(
f"""
INSERT OR REPLACE INTO room_client_sync ({", ".join(columns)})
INSERT INTO room_client_sync ({", ".join(columns)})
VALUES ({", ".join(placeholders)})
ON CONFLICT(room_hash, client_pubkey)
DO UPDATE SET {update_set}
""",
values,
)
@@ -2367,8 +2978,12 @@ class SQLiteHandler:
logger.error(f"Failed to count companion contacts: {e}")
return 0
def companion_load_contacts(self, companion_hash: str) -> List[Dict]:
"""Load contacts for a companion from storage."""
def companion_load_contacts(self, companion_hash: str) -> Optional[List[Dict]]:
"""Load contacts for a companion from storage.
Returns [] when the companion has no persisted contacts, or None when
the load failed callers must not treat a failed load as "no data".
"""
try:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
@@ -2382,8 +2997,8 @@ class SQLiteHandler:
)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Failed to load companion contacts: {e}")
return []
logger.error(f"Failed to load companion contacts for {companion_hash}: {e}")
return None
def companion_save_contacts(self, companion_hash: str, contacts: List[Dict]) -> bool:
"""Replace all contacts for a companion in storage using batch insert."""
@@ -2594,8 +3209,26 @@ class SQLiteHandler:
logger.error(f"Failed to save companion prefs: {e}")
return False
def companion_load_channels(self, companion_hash: str) -> List[Dict]:
"""Load channels for a companion from storage."""
def companion_count_channels(self, companion_hash: str) -> int:
"""Return the number of persisted channels for a companion."""
try:
with self._connect() as conn:
cursor = conn.execute(
"SELECT COUNT(*) FROM companion_channels WHERE companion_hash = ?",
(companion_hash,),
)
row = cursor.fetchone()
return int(row[0]) if row else 0
except Exception as e:
logger.error(f"Failed to count companion channels: {e}")
return 0
def companion_load_channels(self, companion_hash: str) -> Optional[List[Dict]]:
"""Load channels for a companion from storage.
Returns [] when the companion has no persisted channels, or None when
the load failed callers must not treat a failed load as "no data".
"""
try:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
@@ -2608,8 +3241,8 @@ class SQLiteHandler:
)
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Failed to load companion channels: {e}")
return []
logger.error(f"Failed to load companion channels for {companion_hash}: {e}")
return None
def companion_save_channels(self, companion_hash: str, channels: List[Dict]) -> bool:
"""Replace all channels for a companion in storage using batch insert."""
@@ -2645,23 +3278,47 @@ class SQLiteHandler:
logger.error(f"Failed to save companion channels: {e}")
return False
def companion_load_messages(self, companion_hash: str, limit: int = 100) -> List[Dict]:
"""Load queued messages for a companion (oldest first for queue order)."""
def companion_count_messages(self, companion_hash: str) -> int:
"""Return the number of persisted queued messages for a companion."""
try:
with self._connect() as conn:
cursor = conn.execute(
"SELECT COUNT(*) FROM companion_messages WHERE companion_hash = ?",
(companion_hash,),
)
row = cursor.fetchone()
return int(row[0]) if row else 0
except Exception as e:
logger.error(f"Failed to count companion messages: {e}")
return 0
def companion_load_messages(
self, companion_hash: str, limit: int = 100
) -> Optional[List[Dict]]:
"""Load queued messages for a companion (oldest first for queue order).
Returns [] when the companion has no persisted messages, or None when
the load failed callers must not treat a failed load as "no data".
"""
try:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
SELECT sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len
SELECT sender_key, txt_type, timestamp, text, is_channel, channel_idx,
path_len, sender_prefix
FROM companion_messages WHERE companion_hash = ?
ORDER BY created_at ASC LIMIT ?
""",
(companion_hash, limit),
)
return [dict(row) for row in cursor.fetchall()]
rows = [dict(row) for row in cursor.fetchall()]
for msg in rows:
msg["sender_prefix"] = bytes.fromhex(msg.get("sender_prefix") or "")
return rows
except Exception as e:
logger.error(f"Failed to load companion messages: {e}")
return []
logger.error(f"Failed to load companion messages for {companion_hash}: {e}")
return None
def companion_push_message(
self, companion_hash: str, msg: Dict, max_messages: Optional[int] = None
@@ -2683,13 +3340,16 @@ class SQLiteHandler:
if isinstance(packet_hash, bytes):
packet_hash = packet_hash.decode("utf-8", errors="replace") if packet_hash else None
sender_key = msg.get("sender_key", b"")
sender_prefix = msg.get("sender_prefix", b"")
if not isinstance(sender_prefix, str):
sender_prefix = bytes(sender_prefix or b"").hex()
with self._connect() as conn:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO companion_messages
(companion_hash, sender_key, txt_type, timestamp, text,
is_channel, channel_idx, path_len, packet_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_channel, channel_idx, path_len, sender_prefix, packet_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
companion_hash,
@@ -2700,6 +3360,7 @@ class SQLiteHandler:
int(msg.get("is_channel", False)),
msg.get("channel_idx", 0),
msg.get("path_len", 0),
sender_prefix,
packet_hash,
time.time(),
),
@@ -2732,7 +3393,8 @@ class SQLiteHandler:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
SELECT id, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len
SELECT id, sender_key, txt_type, timestamp, text, is_channel, channel_idx,
path_len, sender_prefix
FROM companion_messages WHERE companion_hash = ?
ORDER BY created_at ASC LIMIT 1
""",
@@ -2742,6 +3404,7 @@ class SQLiteHandler:
if not row:
return None
msg = dict(row)
msg["sender_prefix"] = bytes.fromhex(msg.get("sender_prefix") or "")
conn.execute("DELETE FROM companion_messages WHERE id = ?", (msg["id"],))
conn.commit()
return {k: v for k, v in msg.items() if k != "id"}
+33 -5
View File
@@ -39,11 +39,15 @@ class StorageCollector:
self.sqlite_handler = SQLiteHandler(self.storage_dir)
self.rrd_handler = RRDToolHandler(self.storage_dir)
# Initialize MQTT handler if configured
# Initialize MQTT handler only when at least one broker is configured
self.mqtt_handler = None
if (
config.get("mqtt_brokers", {}) or config.get("letsmesh", {}) or config.get("mqtt", {})
) and local_identity:
mqtt_brokers_config = config.get("mqtt_brokers", {}) or {}
letsmesh_config = config.get("letsmesh", {}) or {}
mqtt_config = config.get("mqtt", {}) or {}
has_brokers_configured = (
bool(mqtt_brokers_config.get("brokers")) or bool(letsmesh_config) or bool(mqtt_config)
)
if has_brokers_configured and local_identity:
try:
# Pass local_identity directly (supports both standard and firmware keys)
self.mqtt_handler = MeshCoreToMqttPusher(
@@ -58,6 +62,8 @@ class StorageCollector:
except Exception as e:
logger.error(f"Failed to initialize MQTT handler: {e}")
self.mqtt_handler = None
else:
logger.info("MQTT handler disabled - no brokers configured")
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
@@ -209,7 +215,9 @@ class StorageCollector:
def _record_packet_blocking(self, packet_record: dict, skip_mqtt: bool):
"""Store, aggregate, update metrics, and publish one packet (writer thread)."""
self.sqlite_handler.store_packet(packet_record)
packet_id = self.sqlite_handler.store_packet(packet_record)
if packet_id is not None:
packet_record["id"] = packet_id
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
self._publish_packet_sync(packet_record, skip_mqtt)
@@ -372,6 +380,20 @@ class StorageCollector:
bucket_seconds=bucket_seconds,
)
def get_lbt_diagnostics(
self,
start_timestamp: float,
end_timestamp: float,
bucket_seconds: int = 300,
severe_attempt_threshold: int = 4,
) -> dict:
return self.sqlite_handler.get_lbt_diagnostics(
start_timestamp=start_timestamp,
end_timestamp=end_timestamp,
bucket_seconds=bucket_seconds,
severe_attempt_threshold=severe_attempt_threshold,
)
def get_packet_stats(self, hours: int = 24) -> dict:
return self.sqlite_handler.get_packet_stats(hours)
@@ -416,6 +438,9 @@ class StorageCollector:
def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_hash(packet_hash)
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_id(packet_id)
def get_rrd_data(
self,
start_time: Optional[int] = None,
@@ -540,6 +565,9 @@ class StorageCollector:
def delete_advert(self, advert_id: int) -> bool:
return self.sqlite_handler.delete_advert(advert_id)
def delete_neighbors_by_pubkey_prefix(self, pubkey_prefix: str | None) -> int:
return self.sqlite_handler.delete_neighbors_by_pubkey_prefix(pubkey_prefix)
def get_hardware_stats(self) -> Optional[dict]:
"""Get current hardware statistics"""
try:
+2 -2
View File
@@ -1,7 +1,7 @@
"""Storage utility classes and functions for data acquisition."""
from dataclasses import asdict, dataclass
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional
@@ -57,7 +57,7 @@ class PacketRecord:
# Extract timestamp and format date/time
timestamp = packet_record.get("timestamp", 0)
dt = datetime.fromtimestamp(timestamp)
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
# Format route type (1=Flood->F, 2=Direct->D, etc)
route_map = {1: "F", 2: "D"}
+69 -25
View File
@@ -24,15 +24,15 @@ _connected_clients = set()
PING_INTERVAL = 30 # seconds
_heartbeat_thread = None
_heartbeat_running = False
_websocket_plugin = None
class PacketWebSocket(WebSocket):
def opened(self):
"""Called when a WebSocket connection is established"""
# Authenticate using JWT provided as query parameter (token=)
jwt_handler = cherrypy.config.get("jwt_handler")
token_manager = cherrypy.config.get("token_manager")
# Get query string from environ
qs = ""
if hasattr(self, "environ"):
qs = self.environ.get("QUERY_STRING", "")
@@ -41,38 +41,55 @@ class PacketWebSocket(WebSocket):
token = params.get("token", [None])[0]
client_id = params.get("client_id", [None])[0]
api_key = self.environ.get("HTTP_X_API_KEY", "") if hasattr(self, "environ") else ""
if not jwt_handler:
logger.warning("WebSocket connection rejected: no JWT handler configured")
self.close(code=1011, reason="server configuration error")
return
if not token:
if not token and not api_key:
logger.warning("WebSocket connection rejected: missing token")
self.close(code=1008, reason="unauthorized")
return
try:
payload = jwt_handler.verify_jwt(token)
if not payload:
logger.warning("WebSocket connection rejected: invalid token")
self.close(code=1008, reason="unauthorized")
return
except Exception as e:
logger.warning(f"WebSocket auth error: {e}")
self.close(code=1008, reason="unauthorized")
return
if token:
try:
payload = jwt_handler.verify_jwt(token)
if payload:
if (
client_id
and payload.get("client_id")
and payload.get("client_id") != client_id
):
logger.warning("WebSocket connection rejected: client_id mismatch")
self.close(code=1008, reason="unauthorized")
return
self.user = payload.get("sub")
_connected_clients.add(self)
logger.info(
f"WebSocket connected ({self.user or 'unknown user'}). Total clients: {len(_connected_clients)}"
)
return
except Exception as e:
logger.warning(f"WebSocket JWT auth error: {e}")
if client_id and payload.get("client_id") and payload.get("client_id") != client_id:
logger.warning("WebSocket connection rejected: client_id mismatch")
self.close(code=1008, reason="unauthorized")
return
api_token = api_key or token
if api_token and token_manager:
try:
token_info = token_manager.verify_token(api_token)
if token_info:
self.user = f"api_token:{token_info.get('name', 'unknown')}"
_connected_clients.add(self)
logger.info(
f"WebSocket connected (API token: {token_info.get('name', 'unknown')}). Total clients: {len(_connected_clients)}"
)
return
except Exception as e:
logger.warning(f"WebSocket API key auth error: {e}")
# Auth success - store user and add to connected clients
self.user = payload.get("sub") # type: ignore[attr-defined]
_connected_clients.add(self)
logger.info(
f"WebSocket connected ({self.user or 'unknown user'}). Total clients: {len(_connected_clients)}"
)
logger.warning("WebSocket connection rejected: no valid authentication")
self.close(code=1008, reason="unauthorized")
def closed(self, code, reason=None):
"""Called when a WebSocket connection is closed"""
@@ -152,9 +169,20 @@ def _heartbeat_loop():
def init_websocket():
"""Initialize WebSocket plugin and start heartbeat"""
global _heartbeat_thread, _heartbeat_running
global _heartbeat_thread, _heartbeat_running, _websocket_plugin
WebSocketPlugin(cherrypy.engine).subscribe()
# Re-initialize plugin safely across CherryPy stop/start cycles.
# ws4py's manager thread cannot be started twice, so always tear down
# any previously subscribed plugin instance before creating a new one.
if _websocket_plugin is not None:
try:
_websocket_plugin.unsubscribe()
except Exception as e:
logger.debug(f"WebSocket plugin unsubscribe during init failed: {e}")
_websocket_plugin = None
_websocket_plugin = WebSocketPlugin(cherrypy.engine)
_websocket_plugin.subscribe()
cherrypy.tools.websocket = WebSocketTool()
# Start heartbeat thread
@@ -165,3 +193,19 @@ def init_websocket():
logger.info(f"WebSocket initialized with {PING_INTERVAL}s heartbeat")
else:
logger.info("WebSocket initialized")
def shutdown_websocket():
"""Stop websocket heartbeat and unsubscribe plugin for clean restart."""
global _heartbeat_running, _heartbeat_thread, _websocket_plugin
_heartbeat_running = False
_heartbeat_thread = None
_connected_clients.clear()
if _websocket_plugin is not None:
try:
_websocket_plugin.unsubscribe()
except Exception as e:
logger.debug(f"WebSocket plugin unsubscribe failed: {e}")
_websocket_plugin = None
+230 -64
View File
@@ -43,6 +43,11 @@ LOOP_DETECT_MAX_COUNTERS = {
LOOP_DETECT_STRICT: 1,
}
# Sentinel returned by schedule_retransmit's task when a pending flood TX was
# cancelled by the redundant flood retransmission check (rebroadcasts of the
# same packet were heard before our own TX slot fired).
TX_RESULT_SUPPRESSED = "suppressed"
class RepeaterHandler(BaseHandler):
@staticmethod
@@ -85,6 +90,10 @@ class RepeaterHandler(BaseHandler):
self.loop_detect_mode = self._normalize_loop_detect_mode(
config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
)
# Redundant flood retransmission suppression (rebroadcast cancellation).
self._load_flood_suppression_config()
# pkt_hash (full upper hex) -> {"cancel_event": asyncio.Event, "dup_count": int}
self._pending_flood_tx = {}
radio = dispatcher.radio if dispatcher else None
if radio:
@@ -118,6 +127,7 @@ class RepeaterHandler(BaseHandler):
self.sent_direct_count = 0
self.flood_dup_count = 0
self.direct_dup_count = 0
self.flood_suppressed_count = 0
# Storage collector for persistent packet logging
try:
@@ -238,6 +248,7 @@ class RepeaterHandler(BaseHandler):
snr = metadata.get("snr", 0.0)
rssi = metadata.get("rssi", 0)
transmitted = False
tx_suppressed = False
tx_delay_ms = 0.0
drop_reason = None
lbt_attempts = 0
@@ -330,8 +341,17 @@ class RepeaterHandler(BaseHandler):
self.dropped_count += 1
drop_reason = "Duty cycle limit"
else:
suppressible = not local_transmission and route_type in (
ROUTE_TYPE_FLOOD,
ROUTE_TYPE_TRANSPORT_FLOOD,
)
tx_task = await self.schedule_retransmit(
fwd_pkt, delay, airtime_ms, local_transmission=local_transmission
fwd_pkt,
delay,
airtime_ms,
local_transmission=local_transmission,
packet_hash=pkt_hash_full,
suppressible=suppressible,
)
try:
tx_success = await tx_task
@@ -340,7 +360,13 @@ class RepeaterHandler(BaseHandler):
drop_reason = "TX failed"
logger.warning(f"Local TX failed: {e}")
raise
if not tx_success:
if tx_success == TX_RESULT_SUPPRESSED:
transmitted = False
tx_suppressed = True
drop_reason = "Redundant flood retransmission (rebroadcast heard)"
self.flood_suppressed_count += 1
self.dropped_count += 1
elif not tx_success:
transmitted = False
drop_reason = "TX failed"
self.dropped_count += 1
@@ -388,8 +414,8 @@ class RepeaterHandler(BaseHandler):
f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}"
)
# Check if this is a duplicate
is_dupe = pkt_hash_full in self.seen_packets and not transmitted
# Check if this is a duplicate (a suppressed TX is not a duplicate of itself)
is_dupe = pkt_hash_full in self.seen_packets and not transmitted and not tx_suppressed
# Set drop reason for duplicates and count flood vs direct dups
if is_dupe and drop_reason is None:
@@ -400,6 +426,14 @@ class RepeaterHandler(BaseHandler):
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
self.direct_dup_count += 1
# Expose effective drop reason to PacketRouter.
# Some Packet implementations are slot-based and cannot accept dynamic attrs.
metadata["_repeater_drop_reason"] = drop_reason
try:
setattr(packet, "_repeater_drop_reason", drop_reason)
except (AttributeError, TypeError):
pass
display_hashes = (
original_path_hashes if original_path_hashes else packet.get_path_hashes_hex()
)
@@ -535,9 +569,14 @@ class RepeaterHandler(BaseHandler):
"""
self.rx_count += 1
route_type = packet.header & PH_ROUTE_MASK
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
self.recv_flood_count += 1
self.flood_dup_count += 1
# Rebroadcast copy heard — may cancel our own pending flood TX.
# Pass the copy's hop count so same-depth copies (no path growth)
# are not mistaken for rebroadcasts.
self._note_flood_duplicate(pkt_hash_full, hop_count=self._safe_hop_count(packet))
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
self.recv_direct_count += 1
self.direct_dup_count += 1
@@ -565,7 +604,7 @@ class RepeaterHandler(BaseHandler):
transmitted=False,
drop_reason="Duplicate",
is_duplicate=True,
packet_hash=packet.calculate_packet_hash().hex().upper(),
packet_hash=pkt_hash_full,
)
if self.storage:
@@ -765,6 +804,68 @@ class RepeaterHandler(BaseHandler):
if len(self.seen_packets) > self.max_cache_size:
self.seen_packets.popitem(last=False)
def _load_flood_suppression_config(self) -> None:
"""Load redundant-flood-retransmission suppression settings from config."""
mesh_cfg = self.config.get("mesh", {})
self.flood_suppression_enabled = bool(mesh_cfg.get("flood_suppression_enabled", False))
try:
threshold = int(mesh_cfg.get("flood_suppression_threshold", 1))
except (TypeError, ValueError):
threshold = 1
self.flood_suppression_threshold = max(1, threshold)
@staticmethod
def _safe_hop_count(packet: Packet) -> Optional[int]:
"""Return the packet's path hop count, or None when it cannot be read."""
try:
return packet.get_path_hash_count()
except Exception:
return None
def _note_flood_duplicate(
self, pkt_hash: Optional[str], hop_count: Optional[int] = None
) -> None:
"""Count a rebroadcast copy heard while our own flood TX is pending.
Redundant flood retransmission check: once the configured number of
rebroadcast copies has been heard, the pending retransmission is
cancelled before it reaches the radio.
The packet hash covers only payload type + payload (path excluded), so
every copy of a flood packet matches regardless of hop depth including
the origin resending the same packet. Only copies whose path actually
grew (hop_count >= the depth of our own forwarded copy) prove another
repeater relayed the packet and appended its path hash. Same-depth
copies (e.g. the origin's retry with an empty path) are ignored so
normal path flooding which relies on our TX adding our hash to the
path is not cancelled without evidence of propagation.
"""
if not self.flood_suppression_enabled or not pkt_hash:
return
entry = self._pending_flood_tx.get(pkt_hash)
if entry is None or entry["cancel_event"].is_set():
return
min_hops = entry.get("min_hops")
if min_hops is not None and hop_count is not None and hop_count < min_hops:
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"Flood suppression: ignoring same-depth copy of packet %s "
"(hops=%d < %d) — not a rebroadcast",
pkt_hash[:16],
hop_count,
min_hops,
)
return
entry["dup_count"] += 1
if entry["dup_count"] >= self.flood_suppression_threshold:
entry["cancel_event"].set()
logger.info(
"Redundant flood retransmission check: heard %d rebroadcast(s) of "
"packet %s — cancelling our pending TX",
entry["dup_count"],
pkt_hash[:16],
)
def validate_packet(self, packet: Packet) -> Tuple[bool, str]:
if not packet or not packet.payload:
@@ -944,6 +1045,13 @@ class RepeaterHandler(BaseHandler):
# Suppress duplicates — pass pre-computed hash to avoid a second SHA-256.
if self.is_duplicate(packet, packet_hash=packet_hash):
# Rebroadcast copy heard (dispatcher dedupe disabled path) — may
# cancel our own pending flood TX for this packet. Hop count guards
# against same-depth copies (no path growth) triggering suppression.
self._note_flood_duplicate(
packet_hash or packet.calculate_packet_hash().hex().upper(),
hop_count=self._safe_hop_count(packet),
)
packet.drop_reason = "Duplicate"
return None
@@ -1135,77 +1243,131 @@ class RepeaterHandler(BaseHandler):
delay: float,
airtime_ms: float = 0.0,
local_transmission: bool = False,
packet_hash: Optional[str] = None,
suppressible: bool = False,
):
"""Schedule a packet retransmission with delay and return the task.
If local_transmission is True and the first send fails, retry once after
a short delay (handles transient radio/LBT failures).
When suppressible is True (relayed flood packets) and flood suppression
is enabled, the pending TX is registered under packet_hash so that
rebroadcast copies heard during the delay cancel it (redundant flood
retransmission check). The task then resolves to TX_RESULT_SUPPRESSED
instead of True/False.
"""
suppression_entry = None
if suppressible and self.flood_suppression_enabled and packet_hash:
# min_hops = depth of our forwarded copy (original + our appended
# hash). Only duplicate copies at this depth or deeper prove another
# repeater relayed the packet (the packet hash excludes the path, so
# the origin's own retry would otherwise match and cancel our TX).
suppression_entry = {
"cancel_event": asyncio.Event(),
"dup_count": 0,
"min_hops": self._safe_hop_count(fwd_pkt),
}
self._pending_flood_tx[packet_hash] = suppression_entry
def _suppression_triggered() -> bool:
return suppression_entry is not None and suppression_entry["cancel_event"].is_set()
def _log_suppressed(stage: str) -> None:
logger.info(
"TX prevented (%s): redundant flood retransmission check — "
"%d rebroadcast(s) of packet %s heard while TX was pending",
stage,
suppression_entry["dup_count"],
(packet_hash or "")[:16],
)
async def delayed_send():
await asyncio.sleep(delay)
try:
await asyncio.sleep(delay)
# Each attempt gets its own lock acquisition so the 1-second retry
# backoff (local_transmission only) happens OUTSIDE the lock.
# Holding _tx_lock across asyncio.sleep(1.0) would block every other
# queued TX task for the full backoff period.
#
# Loop runs once for relayed packets, twice for local_transmission:
# attempt 0 — initial try (no pre-sleep)
# attempt 1 — retry after 1s backoff outside the lock
for attempt in range(2 if local_transmission else 1):
if attempt > 0:
# Back-off OUTSIDE the lock — other tasks can transmit here.
logger.info("Retrying local TX in 1s (lock released during backoff)...")
await asyncio.sleep(1.0)
# Redundant flood retransmission check — rebroadcasts may have
# arrived while this task slept through its collision-avoidance
# delay. Skip the radio entirely if the packet is already covered.
if _suppression_triggered():
_log_suppressed("pre-send")
return TX_RESULT_SUPPRESSED
async with self._tx_lock:
# ── Authoritative duty-cycle gate ──────────────────────────
# The upfront can_transmit() call in __call__ is advisory: it
# avoids scheduling packets obviously over budget, but cannot
# prevent a race between tasks whose delay timers expire nearly
# simultaneously. Both pass the advisory check before either
# records airtime, then both attempt to transmit.
#
# Inside _tx_lock only one task runs at a time. The check and
# record_tx() are effectively atomic — no TOCTOU window.
# Re-checked every attempt because airtime state may change
# while we wait for the lock or sleep through backoff.
if airtime_ms > 0:
can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms)
if not can_tx_now:
logger.warning(
"Packet dropped at TX time: duty-cycle exceeded (airtime=%.1fms)",
airtime_ms,
)
return False
# Each attempt gets its own lock acquisition so the 1-second retry
# backoff (local_transmission only) happens OUTSIDE the lock.
# Holding _tx_lock across asyncio.sleep(1.0) would block every other
# queued TX task for the full backoff period.
#
# Loop runs once for relayed packets, twice for local_transmission:
# attempt 0 — initial try (no pre-sleep)
# attempt 1 — retry after 1s backoff outside the lock
for attempt in range(2 if local_transmission else 1):
if attempt > 0:
# Back-off OUTSIDE the lock — other tasks can transmit here.
logger.info("Retrying local TX in 1s (lock released during backoff)...")
await asyncio.sleep(1.0)
try:
sent = await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
if not sent:
logger.warning(
"Retransmit failed (attempt %d): dispatcher returned false",
attempt + 1,
)
if local_transmission and attempt == 0:
continue
return False
self._record_packet_sent(fwd_pkt)
async with self._tx_lock:
# Re-check after acquiring the lock: rebroadcasts may have
# arrived while another task held the radio.
if _suppression_triggered():
_log_suppressed("at-lock")
return TX_RESULT_SUPPRESSED
# ── Authoritative duty-cycle gate ──────────────────────────
# The upfront can_transmit() call in __call__ is advisory: it
# avoids scheduling packets obviously over budget, but cannot
# prevent a race between tasks whose delay timers expire nearly
# simultaneously. Both pass the advisory check before either
# records airtime, then both attempt to transmit.
#
# Inside _tx_lock only one task runs at a time. The check and
# record_tx() are effectively atomic — no TOCTOU window.
# Re-checked every attempt because airtime state may change
# while we wait for the lock or sleep through backoff.
if airtime_ms > 0:
self.airtime_mgr.record_tx(airtime_ms)
packet_size = fwd_pkt.get_raw_length()
logger.info(
f"Retransmitted packet ({packet_size} bytes, "
f"{airtime_ms:.1f}ms airtime)"
)
return True
except Exception as e:
logger.error(f"Retransmit failed (attempt {attempt + 1}): {e}")
if local_transmission and attempt == 0:
pass # release lock, outer loop sleeps, then retries
else:
raise
return False
can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms)
if not can_tx_now:
logger.warning(
"Packet dropped at TX time: duty-cycle exceeded (airtime=%.1fms)",
airtime_ms,
)
return False
try:
sent = await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
if not sent:
logger.warning(
"Retransmit failed (attempt %d): dispatcher returned false",
attempt + 1,
)
if local_transmission and attempt == 0:
continue
return False
self._record_packet_sent(fwd_pkt)
if airtime_ms > 0:
self.airtime_mgr.record_tx(airtime_ms)
packet_size = fwd_pkt.get_raw_length()
logger.info(
f"Retransmitted packet ({packet_size} bytes, "
f"{airtime_ms:.1f}ms airtime)"
)
return True
except Exception as e:
logger.error(f"Retransmit failed (attempt {attempt + 1}): {e}")
if local_transmission and attempt == 0:
pass # release lock, outer loop sleeps, then retries
else:
raise
return False
finally:
# Deregister the pending TX regardless of outcome so the registry
# never grows beyond in-flight retransmissions.
if (
suppression_entry is not None
and self._pending_flood_tx.get(packet_hash) is suppression_entry
):
self._pending_flood_tx.pop(packet_hash, None)
return asyncio.create_task(delayed_send())
@@ -1279,6 +1441,7 @@ class RepeaterHandler(BaseHandler):
"sent_direct_count": self.sent_direct_count,
"flood_dup_count": self.flood_dup_count,
"direct_dup_count": self.direct_dup_count,
"flood_suppressed_count": self.flood_suppressed_count,
"rx_per_hour": rx_per_hour,
"forwarded_per_hour": forwarded_per_hour,
"recent_packets": list(self.recent_packets),
@@ -1324,6 +1487,8 @@ class RepeaterHandler(BaseHandler):
self.config.get("mesh", {}).get("global_flood_allow", True),
),
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
"flood_suppression_enabled": self.flood_suppression_enabled,
"flood_suppression_threshold": self.flood_suppression_threshold,
},
"mqtt_brokers": self.config.get("mqtt_brokers", {}),
},
@@ -1457,6 +1622,7 @@ class RepeaterHandler(BaseHandler):
self.loop_detect_mode = self._normalize_loop_detect_mode(
self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
)
self._load_flood_suppression_config()
# Note: Radio config changes require restart as they affect hardware
# Note: Airtime manager has its own config reference that gets updated
+41 -19
View File
@@ -48,6 +48,30 @@ class ACL:
self.allow_read_only = allow_read_only
self.clients: Dict[bytes, ClientInfo] = {}
def _is_replay(self, client: ClientInfo, timestamp: int) -> bool:
if timestamp <= client.last_timestamp:
logger.warning(
f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}"
)
return True
return False
def _touch_client_session(
self,
client: ClientInfo,
shared_secret: bytes,
timestamp: int,
sync_since: int = None,
) -> None:
now = int(time.time())
client.last_timestamp = timestamp
client.last_activity = now
client.last_login_success = now
client.shared_secret = shared_secret
if sync_since is not None:
client.sync_since = sync_since
logger.debug(f"Stored sync_since={sync_since} for client")
def authenticate_client(
self,
client_identity: Identity,
@@ -106,13 +130,23 @@ class ACL:
if not password:
client = self.clients.get(pub_key)
if client is None:
if self.allow_read_only:
logger.info("Blank password, allowing read-only guest access")
return True, PERM_ACL_GUEST
else:
if not self.allow_read_only:
logger.info("Blank password, sender not in ACL and read-only disabled")
return False, 0
logger.info(f"ACL-based login for {pub_key[:6].hex()}...")
if len(self.clients) >= self.max_clients:
logger.warning("ACL full, cannot add client")
return False, 0
client = ClientInfo(client_identity, PERM_ACL_GUEST)
self.clients[pub_key] = client
logger.info("Blank password, allowing read-only guest access")
else:
logger.info(f"ACL-based login for {pub_key[:6].hex()}...")
if self._is_replay(client, timestamp):
return False, 0
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
if (client.permissions & PERM_ACL_ROLE_MASK) == 0:
client.permissions |= PERM_ACL_GUEST
return True, client.permissions
permissions = 0
@@ -140,23 +174,11 @@ class ACL:
self.clients[pub_key] = client
logger.info(f"Added new client {pub_key[:6].hex()}...")
if timestamp <= client.last_timestamp:
logger.warning(
f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}"
)
if self._is_replay(client, timestamp):
return False, 0
client.last_timestamp = timestamp
client.last_activity = int(time.time())
client.last_login_success = int(time.time())
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
client.permissions &= ~PERM_ACL_ROLE_MASK
client.permissions |= permissions
client.shared_secret = shared_secret
# Store sync_since for room server clients
if sync_since is not None:
client.sync_since = sync_since
logger.debug(f"Stored sync_since={sync_since} for client")
logger.info(f"Login success! Permissions: {'ADMIN' if client.is_admin() else 'GUEST'}")
return True, client.permissions
+259
View File
@@ -8,6 +8,10 @@ allowing other nodes to discover repeaters on the mesh network.
import asyncio
import logging
import secrets
import threading
import time
import uuid
from typing import Any, Callable, Optional
from openhop_core.node.handlers.control import ControlHandler
@@ -22,6 +26,15 @@ logger = logging.getLogger("DiscoveryHelper")
# 60s (firmware pending_discover_until = futureMillis(60000)).
DEFAULT_DISCOVERY_RESPONSE_JITTER_MS = 2000
DEFAULT_DISCOVERY_TIMEOUT_SECONDS = 10.0
DISCOVERY_EVENT_BACKLOG_LIMIT = 512
NODE_TYPE_NAMES = {
1: "Chat Node",
2: "Repeater",
3: "Room Server",
}
class DiscoveryHelper:
"""Helper class for processing discovery requests in the repeater."""
@@ -60,11 +73,257 @@ class DiscoveryHelper:
debug_log_fn=debug_log_fn,
)
self._pending_tasks = set()
self._sessions: dict[str, dict[str, Any]] = {}
self._sessions_lock = threading.Lock()
# Set up the request callback
self.control_handler.set_request_callback(self._on_discovery_request)
logger.debug("Discovery handler initialized")
def create_session(
self,
*,
timeout: float = DEFAULT_DISCOVERY_TIMEOUT_SECONDS,
filter_mask: int,
since: int = 0,
prefix_only: bool = False,
result_enricher: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Create a new discovery session and return its public metadata."""
session_id = uuid.uuid4().hex
tag = secrets.randbits(32)
created_at = time.time()
session = {
"session_id": session_id,
"tag": tag,
"timeout": max(1.0, float(timeout)),
"filter_mask": int(filter_mask) & 0xFF,
"since": max(0, int(since)),
"prefix_only": bool(prefix_only),
"created_at": created_at,
"started_at": None,
"completed_at": None,
"status": "created",
"results": {},
"events": [],
"next_event_id": 1,
"error": None,
"result_enricher": result_enricher,
}
with self._sessions_lock:
self._sessions[session_id] = session
return self.get_session_snapshot(session_id) or {}
def get_session_snapshot(self, session_id: str) -> Optional[dict[str, Any]]:
"""Return a public snapshot for a discovery session."""
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return None
return self._public_session_snapshot(session)
def get_events_since(self, session_id: str, last_event_id: int = 0) -> Optional[dict[str, Any]]:
"""Return all session events newer than last_event_id."""
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return None
events = [event for event in session["events"] if event["id"] > last_event_id]
return {
"events": events,
"status": session["status"],
"completed": session["status"] in {"completed", "timed_out", "error", "cancelled"},
"latest_event_id": session["next_event_id"] - 1,
}
async def execute_session(self, session_id: str) -> None:
"""Send a discovery request and stream responses into the session."""
session = self._get_session(session_id)
if not session:
raise ValueError(f"Unknown discovery session: {session_id}")
if session["status"] != "created":
return
session["started_at"] = time.time()
session["status"] = "running"
self._emit_event(
session_id,
"started",
{
"session_id": session_id,
"tag": session["tag"],
"timeout": session["timeout"],
"filter_mask": session["filter_mask"],
"since": session["since"],
"prefix_only": session["prefix_only"],
"started_at": session["started_at"],
},
)
try:
from openhop_core.protocol.packet_builder import PacketBuilder
packet = PacketBuilder.create_discovery_request(
tag=session["tag"],
filter_mask=session["filter_mask"],
since=session["since"],
prefix_only=session["prefix_only"],
)
def _response_callback(response_data: dict[str, Any]) -> None:
self._record_response(session_id, response_data)
self.control_handler.set_response_callback(session["tag"], _response_callback)
if not self.packet_injector:
raise RuntimeError("No packet injector available")
success = await self.packet_injector(packet, wait_for_ack=False)
if not success:
raise RuntimeError("Failed to send discovery request")
logger.info(
"Discovery request sent for session %s tag 0x%08X filter=0x%02X",
session_id,
session["tag"],
session["filter_mask"],
)
await asyncio.sleep(session["timeout"])
self._finish_session(session_id, "completed")
except asyncio.CancelledError:
self._finish_session(session_id, "cancelled")
raise
except Exception as e:
logger.error("Discovery session %s failed: %s", session_id, e, exc_info=True)
self._finish_session(session_id, "error", error=str(e))
finally:
self.control_handler.clear_response_callback(session["tag"])
def start_session_task(self, session_id: str) -> None:
"""Schedule a discovery session on the current event loop."""
task = asyncio.create_task(self.execute_session(session_id))
self._track_task(task)
def cleanup_sessions(self, max_age_seconds: int = 120) -> None:
"""Remove old completed sessions to keep memory bounded."""
cutoff = time.time() - max_age_seconds
with self._sessions_lock:
stale_ids = [
session_id
for session_id, session in self._sessions.items()
if session["status"] in {"completed", "timed_out", "error", "cancelled"}
and (session.get("completed_at") or session.get("created_at", 0)) < cutoff
]
for session_id in stale_ids:
self._sessions.pop(session_id, None)
def _get_session(self, session_id: str) -> Optional[dict[str, Any]]:
with self._sessions_lock:
return self._sessions.get(session_id)
def _record_response(self, session_id: str, response_data: dict[str, Any]) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session or session["status"] != "running":
return
result = dict(response_data)
result["node_type_name"] = NODE_TYPE_NAMES.get(
result.get("node_type"), f"Unknown({result.get('node_type', 0)})"
)
result["discovered_at"] = time.time()
enricher = session.get("result_enricher")
if enricher:
try:
result = enricher(result)
except Exception as e:
logger.debug("Discovery result enrichment failed: %s", e)
result_key = str(result.get("pub_key") or "")
if not result_key:
return
existing = session["results"].get(result_key)
session["results"][result_key] = result
payload = {
"session_id": session_id,
"tag": session["tag"],
"result": result,
"is_update": existing is not None,
"count": len(session["results"]),
}
self._append_event_unlocked(session, "discovery_result", payload)
def _finish_session(self, session_id: str, status: str, error: Optional[str] = None) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session or session["status"] in {"completed", "timed_out", "error", "cancelled"}:
return
session["completed_at"] = time.time()
session["status"] = status
session["error"] = error
payload = {
"session_id": session_id,
"tag": session["tag"],
"status": status,
"error": error,
"count": len(session["results"]),
"duration_ms": round(
(
(session["completed_at"] or session["created_at"])
- (session["started_at"] or session["created_at"])
)
* 1000,
2,
),
"completed_at": session["completed_at"],
"results": list(session["results"].values()),
}
event_type = "error" if status == "error" else "completed"
self._append_event_unlocked(session, event_type, payload)
def _emit_event(self, session_id: str, event_type: str, payload: dict[str, Any]) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return
self._append_event_unlocked(session, event_type, payload)
def _append_event_unlocked(
self, session: dict[str, Any], event_type: str, payload: dict[str, Any]
) -> None:
event_id = session["next_event_id"]
session["next_event_id"] += 1
session["events"].append(
{
"id": event_id,
"event": event_type,
"data": payload,
}
)
if len(session["events"]) > DISCOVERY_EVENT_BACKLOG_LIMIT:
session["events"] = session["events"][-DISCOVERY_EVENT_BACKLOG_LIMIT:]
def _public_session_snapshot(self, session: dict[str, Any]) -> dict[str, Any]:
return {
"session_id": session["session_id"],
"tag": session["tag"],
"status": session["status"],
"timeout": session["timeout"],
"filter_mask": session["filter_mask"],
"since": session["since"],
"prefix_only": session["prefix_only"],
"created_at": session["created_at"],
"started_at": session["started_at"],
"completed_at": session["completed_at"],
"count": len(session["results"]),
"error": session["error"],
}
def _track_task(self, task: asyncio.Task) -> None:
self._pending_tasks.add(task)
+21 -1
View File
@@ -122,7 +122,7 @@ class LoginHelper:
def auth_callback_with_context(
client_identity, shared_secret, password, timestamp, sync_since=None
):
return identity_acl.authenticate_client(
success, permissions = identity_acl.authenticate_client(
client_identity=client_identity,
shared_secret=shared_secret,
password=password,
@@ -132,6 +132,26 @@ class LoginHelper:
target_identity_name=name,
target_identity_config=config,
)
if success and identity_type == "room_server" and self.sqlite_handler is not None:
try:
sync_kwargs = {}
if sync_since is not None:
sync_kwargs["sync_since"] = sync_since
self.sqlite_handler.upsert_client_sync(
room_hash=f"0x{hash_byte:02X}",
client_pubkey=client_identity.get_public_key().hex(),
pending_ack_crc=0,
push_post_timestamp=0,
ack_timeout_time=0,
push_failures=0,
last_activity=time.time(),
**sync_kwargs,
)
except Exception as e:
logger.warning(
f"Failed to reset room sync guard state after login for hash=0x{hash_byte:02X}: {e}"
)
return success, permissions
handler = LoginServerHandler(
local_identity=identity,
+577 -27
View File
@@ -2,7 +2,6 @@ import logging
from pathlib import Path
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__)
@@ -38,6 +37,112 @@ class MeshCLI:
# Get repeater config shortcut
self.repeater_config = config.get("repeater", {})
self.mesh_config = config.setdefault("mesh", {})
def _get_node_name(self) -> str:
"""Return the configured node name, preferring the newer key when present."""
return self.repeater_config.get("node_name") or self.repeater_config.get("name", "Unknown")
def _set_node_name(self, value: str) -> None:
"""Persist node name to both legacy and current config keys for compatibility."""
self.repeater_config["node_name"] = value
self.repeater_config["name"] = value
def _get_local_pubkey_hex(self) -> Optional[str]:
"""Return local node public key (hex) when available."""
try:
if self.identity and hasattr(self.identity, "get_public_key"):
pubkey = self.identity.get_public_key()
if isinstance(pubkey, (bytes, bytearray)):
return bytes(pubkey).hex().lower()
if isinstance(pubkey, str):
normalized = pubkey.strip().lower()
if normalized.startswith("0x"):
normalized = normalized[2:]
if normalized:
return normalized
except Exception as exc:
logger.debug("Unable to read local identity pubkey: %s", exc)
key = self.repeater_config.get("identity_key")
if isinstance(key, (bytes, bytearray)):
return bytes(key).hex().lower()
if isinstance(key, str):
normalized = key.strip().lower()
if normalized.startswith("0x"):
normalized = normalized[2:]
if normalized and all(ch in "0123456789abcdef" for ch in normalized):
return normalized
return None
def _is_local_pubkey(self, pubkey_hex: str) -> bool:
"""Return True when a discovery result pubkey matches the local node."""
candidate = (pubkey_hex or "").strip().lower()
if not candidate:
return False
if candidate.startswith("0x"):
candidate = candidate[2:]
local_pubkey = self._get_local_pubkey_hex()
if not local_pubkey:
return False
# Prefix-only discovery may return fewer bytes than full identity pubkey.
return local_pubkey.startswith(candidate) or candidate.startswith(local_pubkey)
def _auto_add_discovery_result(self, result: Dict[str, Any]) -> Dict[str, Any]:
"""Persist discovered neighbors automatically, excluding this node itself."""
enriched = dict(result)
pub_key = str(enriched.get("pub_key") or "").strip().lower()
if not pub_key:
return enriched
if self._is_local_pubkey(pub_key):
enriched["is_self"] = True
enriched["known_neighbor"] = True
return enriched
if not self.storage_handler:
return enriched
record_advert = getattr(self.storage_handler, "record_advert", None)
if not callable(record_advert):
return enriched
try:
import time
node_type = int(enriched.get("node_type", 0) or 0)
contact_type = {
1: "Chat Node",
2: "Repeater",
3: "Room Server",
}.get(node_type, "Unknown")
rssi = enriched.get("rssi")
snr = enriched.get("response_snr", enriched.get("snr"))
advert_record = {
"timestamp": time.time(),
"pubkey": pub_key,
"node_name": enriched.get("node_name"),
"is_repeater": node_type == 2,
"route_type": 2,
"contact_type": contact_type,
"latitude": None,
"longitude": None,
"rssi": int(rssi) if rssi is not None else None,
"snr": float(snr) if snr is not None else None,
"is_new_neighbor": True,
"zero_hop": True,
}
record_advert(advert_record)
enriched["known_neighbor"] = True
enriched["auto_added"] = True
except Exception as exc:
logger.debug("Auto-add discovery result failed for %s: %s", pub_key, exc)
return enriched
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
@@ -81,6 +186,10 @@ class MeshCLI:
return self._cmd_clock(command)
elif command.startswith("time "):
return self._cmd_time(command)
elif command == "http start":
return self._cmd_http_start()
elif command == "http stop":
return self._cmd_http_stop()
elif command == "start ota":
return "Error: OTA not supported in Python repeater"
elif command.startswith("password "):
@@ -116,6 +225,8 @@ class MeshCLI:
return self._cmd_neighbors()
elif command.startswith("neighbor.remove "):
return self._cmd_neighbor_remove(command)
elif command.startswith("discover.neighbors"):
return self._cmd_discover_neighbors(command)
# Temporary radio params
elif command.startswith("tempradio "):
@@ -149,13 +260,15 @@ class MeshCLI:
return self._help_detail(parts[1])
lines = [
"=== pyMC CLI Commands ===",
"=== openHop CLI Commands ===",
"",
"System:",
" reboot Restart the repeater service",
" advert Send self advertisement",
" clock Show current UTC time",
" clock sync Sync clock (no-op, uses system time)",
" http start Start the HTTP server",
" http stop Stop the HTTP server",
" ver Show version info",
" password <pw> Change admin password",
" clear stats Clear statistics",
@@ -169,11 +282,14 @@ class MeshCLI:
" get repeat Repeat mode (on/off)",
" get lat / get lon GPS coordinates",
" get role Identity role",
" get owner.info Owner info text",
" get guest.password Guest password",
" get allow.read.only Read-only access setting",
" get advert.interval Advert interval (minutes)",
" get flood.advert.interval Flood advert interval (hours)",
" get flood.max Max flood hops",
" get path.hash.mode Flood advert path hash mode (0-2)",
" get loop.detect Flood loop detection mode",
" get rxdelay RX delay base",
" get txdelay TX delay factor",
" get direct.txdelay Direct TX delay factor",
@@ -187,6 +303,7 @@ class MeshCLI:
"Other:",
" neighbors List neighbors",
" neighbor.remove <key> Remove neighbor by pubkey",
" discover.neighbors Send zero-hop neighbor discovery",
" tempradio <freq> <bw> <sf> <cr> <timeout_mins>",
" setperm <pubkey> <perm> Set ACL permissions",
" log start|stop|erase Logging control",
@@ -211,10 +328,13 @@ class MeshCLI:
" set lat <deg> Latitude\n"
" set lon <deg> Longitude\n"
" set guest.password <pw> Guest password\n"
" set owner.info <text> Owner info text\n"
" set allow.read.only on|off Read-only access\n"
" set advert.interval <min> 60-240 minutes\n"
" set flood.advert.interval <hr> 3-48 hours\n"
" set flood.advert.interval <hr> 3-168 hours\n"
" set flood.max <hops> Max flood hops (max 64)\n"
" set path.hash.mode <0-2> Path hash mode (0=1B,1=2B,2=3B)\n"
" set loop.detect <off|minimal|moderate|strict> Flood loop detection\n"
" set rxdelay <val> RX delay base (>=0)\n"
" set txdelay <val> TX delay factor (>=0)\n"
" set direct.txdelay <val> Direct TX delay (>=0)\n"
@@ -226,6 +346,9 @@ class MeshCLI:
"reboot": "Restart the repeater service via systemd.",
"advert": "Trigger a self-advertisement flood packet.",
"clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).",
"http": "http start|stop - Control the HTTP server.",
"http start": "Start the HTTP server.",
"http stop": "Stop the HTTP server.",
"ver": "Show repeater version and identity type.",
"password": "password <new_password> \u2014 Change the admin password.",
"tempradio": (
@@ -234,6 +357,7 @@ class MeshCLI:
" freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8"
),
"neighbors": "List known neighbor nodes from the routing table.",
"discover.neighbors": "Send a neighbor discovery request.",
"setperm": "setperm <pubkey_hex> <permission_int> \u2014 Set ACL permissions for a node.",
"log": "log start|stop|erase \u2014 Control logging.",
}
@@ -296,6 +420,26 @@ class MeshCLI:
"""Set time - not supported in Python (use system time)."""
return "Error: Time setting not supported (system time is used)"
def _cmd_http_start(self) -> str:
"""Start HTTP server."""
from repeater.service_utils import start_http_server
daemon_instance = getattr(self.config_manager, "daemon", None)
success, message = start_http_server(daemon_instance)
if success:
return f"OK - {message}"
return f"Error: {message}"
def _cmd_http_stop(self) -> str:
"""Stop HTTP server."""
from repeater.service_utils import stop_http_server
daemon_instance = getattr(self.config_manager, "daemon", None)
success, message = stop_http_server(daemon_instance)
if success:
return f"OK - {message}"
return f"Error: {message}"
def _cmd_password(self, command: str) -> str:
"""Change admin password."""
new_password = command[9:].strip()
@@ -329,8 +473,8 @@ class MeshCLI:
def _cmd_version(self) -> str:
"""Get version information."""
role = "room_server" if self.identity_type == "room_server" else "repeater"
version = self.config.get("version", "1.0.0")
return f"pyMC_{role} v{version}"
version = self.config.get("version", "13")
return f"openHop_{role} v{version}"
# ==================== Get Commands ====================
@@ -344,8 +488,7 @@ class MeshCLI:
return f"> {af}"
elif param == "name":
name = self.repeater_config.get("name", "Unknown")
return f"> {name}"
return f"> {self._get_node_name()}"
elif param == "repeat":
mode = self.repeater_config.get("mode", "forward")
@@ -398,6 +541,10 @@ class MeshCLI:
guest_pw = self.config.get("security", {}).get("guest_password", "")
return f"> {guest_pw}"
elif param == "owner.info":
owner_info = self.repeater_config.get("owner_info", "")
return f"> {owner_info}"
elif param == "allow.read.only":
allow = self.config.get("security", {}).get("allow_read_only", False)
return f"> {'on' if allow else 'off'}"
@@ -414,6 +561,14 @@ class MeshCLI:
max_flood = self.repeater_config.get("max_flood_hops", 64)
return f"> {max_flood}"
elif param == "path.hash.mode":
path_hash_mode = self.mesh_config.get("path_hash_mode", 0)
return f"> {path_hash_mode}"
elif param == "loop.detect":
loop_detect = self.mesh_config.get("loop_detect", "off")
return f"> {loop_detect}"
elif param == "rxdelay":
delay = self.repeater_config.get("rx_delay_base", 0.0)
return f"> {delay}"
@@ -459,7 +614,7 @@ class MeshCLI:
return "OK"
elif key == "name":
self.repeater_config["node_name"] = value
self._set_node_name(value)
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
@@ -523,6 +678,12 @@ class MeshCLI:
self.config_manager.live_update_daemon(["security"])
return "OK"
elif key == "owner.info":
self.repeater_config["owner_info"] = value.replace("|", "\n")
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "allow.read.only":
if "security" not in self.config:
self.config["security"] = {}
@@ -542,8 +703,8 @@ class MeshCLI:
elif key == "flood.advert.interval":
hours = int(value)
if (hours > 0 and hours < 3) or hours > 48:
return "Error: interval range is 3-48 hours"
if (hours > 0 and hours < 3) or hours > 168:
return "Error: interval range is 3-168 hours"
self.repeater_config["flood_advert_interval_hours"] = hours
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["repeater"])
@@ -558,6 +719,24 @@ class MeshCLI:
self.config_manager.live_update_daemon(["repeater"])
return "OK"
elif key == "path.hash.mode":
mode = int(value)
if mode not in (0, 1, 2):
return "Error: path.hash.mode must be 0, 1, or 2"
self.mesh_config["path_hash_mode"] = mode
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["mesh"])
return "OK"
elif key == "loop.detect":
mode = str(value).strip().lower()
if mode not in ("off", "minimal", "moderate", "strict"):
return "Error: loop.detect must be off, minimal, moderate, or strict"
self.mesh_config["loop_detect"] = mode
saved, _ = self.config_manager.save_to_file()
self.config_manager.live_update_daemon(["mesh"])
return "OK"
elif key == "rxdelay":
delay = float(value)
if delay < 0:
@@ -637,22 +816,336 @@ class MeshCLI:
# ==================== Region Commands ====================
def _cmd_region(self, command: str) -> str:
"""Handle region commands."""
"""Handle region commands with MeshCore-compatible response shapes."""
parts = command.split()
if len(parts) == 1:
return "Error: Region commands not implemented in Python repeater"
return self._region_export_tree()
subcommand = parts[1]
if subcommand == "load":
return "Error: Region commands not implemented"
elif subcommand == "save":
return "Error: Region commands not implemented"
elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"):
return "Error: Region commands not implemented"
else:
return "Err - ??"
return "Err - region load not supported"
if subcommand == "def":
return "Err - region def not supported"
if subcommand == "save":
return self._region_save()
if subcommand == "allowf" and len(parts) >= 3:
return self._region_set_flood(parts[2], allow=True)
if subcommand == "denyf" and len(parts) >= 3:
return self._region_set_flood(parts[2], allow=False)
if subcommand == "get" and len(parts) >= 3:
return self._region_get(parts[2])
if subcommand == "home":
if len(parts) >= 3:
return self._region_home_set(parts[2])
return self._region_home_get()
if subcommand == "default":
if len(parts) >= 3:
return self._region_default_set(parts[2])
return self._region_default_get()
if subcommand == "put" and len(parts) >= 3:
parent = parts[3] if len(parts) >= 4 else "*"
return self._region_put(parts[2], parent)
if subcommand == "remove" and len(parts) >= 3:
return self._region_remove(parts[2])
if subcommand == "list" and len(parts) >= 3:
return self._region_list(parts[2])
return "Err - ??"
def _region_storage_available(self) -> bool:
return bool(
self.storage_handler
and hasattr(self.storage_handler, "get_transport_keys")
and callable(getattr(self.storage_handler, "get_transport_keys"))
)
def _region_load_records(self) -> list[dict]:
if not self._region_storage_available():
return []
records = self.storage_handler.get_transport_keys()
return records if isinstance(records, list) else []
@staticmethod
def _region_display_name(raw_name: str) -> str:
name = str(raw_name or "").strip()
if name.startswith("#"):
return name[1:]
return name
def _region_find_prefix(self, query: str) -> Optional[dict]:
q = str(query or "").strip()
if not q:
return None
if q == "*":
return {
"id": 0,
"name": "*",
"display_name": "*",
"flood_policy": "allow" if self._region_unscoped_allow() else "deny",
"parent_id": None,
}
q_lower = q.lower()
for rec in self._region_load_records():
display = self._region_display_name(rec.get("name", ""))
if display.lower().startswith(q_lower):
return {**rec, "display_name": display}
return None
def _region_find_exact(self, query: str) -> Optional[dict]:
q = str(query or "").strip()
if not q:
return None
if q == "*":
return {
"id": 0,
"name": "*",
"display_name": "*",
"flood_policy": "allow" if self._region_unscoped_allow() else "deny",
"parent_id": None,
}
q_lower = q.lower()
for rec in self._region_load_records():
display = self._region_display_name(rec.get("name", ""))
if display.lower() == q_lower:
return {**rec, "display_name": display}
return None
def _region_unscoped_allow(self) -> bool:
return bool(
self.mesh_config.get(
"unscoped_flood_allow",
self.mesh_config.get("global_flood_allow", True),
)
)
def _region_set_unscoped_allow(self, allow: bool) -> bool:
self.mesh_config["unscoped_flood_allow"] = bool(allow)
self.mesh_config["global_flood_allow"] = bool(allow)
save_result = self.config_manager.save_to_file()
saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result)
self.config_manager.live_update_daemon(["mesh"])
return bool(saved)
def _region_get_default_name(self) -> Optional[str]:
default_name = self.mesh_config.get("default_region")
text = str(default_name).strip() if default_name is not None else ""
return text or None
def _region_set_default_name(self, value: Optional[str]) -> bool:
self.mesh_config["default_region"] = value
save_result = self.config_manager.save_to_file()
saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result)
self.config_manager.live_update_daemon(["mesh"])
return bool(saved)
def _region_export_tree(self) -> str:
records = self._region_load_records()
children_by_parent: Dict[int, list[dict]] = {}
for rec in records:
parent_id = rec.get("parent_id")
parent_key = int(parent_id) if isinstance(parent_id, int) and parent_id > 0 else 0
children_by_parent.setdefault(parent_key, []).append(rec)
for parent_list in children_by_parent.values():
parent_list.sort(key=lambda r: str(r.get("name", "")).lower())
home_name = str(self.repeater_config.get("region_home") or "").strip().lower()
lines: list[str] = []
def append_line(indent: int, display_name: str, flood_policy: str):
home_mark = "^" if home_name and display_name.lower() == home_name else ""
flood_mark = " F" if flood_policy == "allow" else ""
lines.append(f"{' ' * indent}{display_name}{home_mark}{flood_mark}")
append_line(0, "*", "allow" if self._region_unscoped_allow() else "deny")
def walk(parent_id: int, indent: int):
for rec in children_by_parent.get(parent_id, []):
display_name = self._region_display_name(rec.get("name", ""))
append_line(indent, display_name, str(rec.get("flood_policy", "deny")))
walk(int(rec.get("id", 0)), indent + 1)
walk(0, 1)
return "\n".join(lines)
def _region_save(self) -> str:
save_result = self.config_manager.save_to_file()
saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result)
return "OK" if saved else "Err - save failed"
def _region_set_flood(self, name_prefix: str, allow: bool) -> str:
region = self._region_find_prefix(name_prefix)
if not region:
return "Err - unknown region"
if region.get("id") == 0:
return "OK" if self._region_set_unscoped_allow(allow) else "Err - save failed"
update_fn = getattr(self.storage_handler, "update_transport_key", None)
if not callable(update_fn):
return "Error: Region commands not supported by storage backend"
ok = update_fn(int(region["id"]), flood_policy="allow" if allow else "deny")
return "OK" if ok else "Err - unknown region"
def _region_get(self, name_prefix: str) -> str:
region = self._region_find_prefix(name_prefix)
if not region:
return "Err - unknown region"
display_name = str(
region.get("display_name") or self._region_display_name(region.get("name", ""))
)
flood_suffix = "F" if region.get("flood_policy") == "allow" else ""
parent_name = None
parent_id = region.get("parent_id")
if isinstance(parent_id, int) and parent_id > 0:
for rec in self._region_load_records():
if int(rec.get("id", -1)) == parent_id:
parent_name = self._region_display_name(rec.get("name", ""))
break
if parent_name:
return f" {display_name} ({parent_name}) {flood_suffix}".rstrip()
return f" {display_name} {flood_suffix}".rstrip()
def _region_home_get(self) -> str:
home = str(self.repeater_config.get("region_home") or "").strip()
return f" home is {home or '*'}"
def _region_home_set(self, name_prefix: str) -> str:
region = self._region_find_prefix(name_prefix)
if not region:
return "Err - unknown region"
display_name = str(region.get("display_name") or "*")
self.repeater_config["region_home"] = display_name
save_result = self.config_manager.save_to_file()
saved = save_result[0] if isinstance(save_result, tuple) else bool(save_result)
return f" home is now {display_name}" if saved else "Err - save failed"
def _region_default_get(self) -> str:
default_region = self._region_get_default_name()
if default_region is None:
return " default scope is <null>"
return f" default scope is {default_region}"
def _region_default_set(self, value: str) -> str:
text = str(value or "").strip()
if text == "<null>":
saved = self._region_set_default_name(None)
return " default scope is now <null>" if saved else "Err - save failed"
region = self._region_find_prefix(text)
if region:
display_name = str(region.get("display_name") or text)
if region.get("id") not in (None, 0):
update_fn = getattr(self.storage_handler, "update_transport_key", None)
if callable(update_fn):
update_fn(int(region["id"]), flood_policy="allow")
saved = self._region_set_default_name(display_name)
return f" default scope is now {display_name}" if saved else "Err - save failed"
put_result = self._region_put(text, "*")
if not put_result.startswith("OK"):
return "Err - region table full"
saved = self._region_set_default_name(text)
return f" default scope is now {text}" if saved else "Err - save failed"
def _region_put(self, name: str, parent_name: str) -> str:
region_name = str(name or "").strip()
if not region_name:
return "Err - unable to put"
parent = self._region_find_prefix(parent_name)
if not parent:
return "Err - unknown parent"
parent_id = int(parent.get("id", 0))
parent_storage_id = None if parent_id == 0 else parent_id
existing = self._region_find_exact(region_name)
if existing and existing.get("id") != 0:
update_fn = getattr(self.storage_handler, "update_transport_key", None)
if not callable(update_fn):
return "Err - unable to put"
ok = update_fn(
int(existing["id"]),
flood_policy="allow",
parent_id=parent_storage_id,
)
return "OK - (flood allowed)" if ok else "Err - unable to put"
create_fn = getattr(self.storage_handler, "create_transport_key", None)
if not callable(create_fn):
return "Err - unable to put"
key_id = create_fn(
region_name,
"allow",
None,
parent_storage_id,
None,
)
return "OK - (flood allowed)" if key_id else "Err - unable to put"
def _region_remove(self, name: str) -> str:
region = self._region_find_exact(name)
if not region or region.get("id") == 0:
return "Err - not found"
region_id = int(region["id"])
for rec in self._region_load_records():
if int(rec.get("parent_id") or 0) == region_id:
return "Err - not empty"
delete_fn = getattr(self.storage_handler, "delete_transport_key", None)
if not callable(delete_fn):
return "Err - not found"
ok = delete_fn(region_id)
if not ok:
return "Err - not found"
removed_name = str(region.get("display_name") or "")
if (
str(self.repeater_config.get("region_home") or "").strip().lower()
== removed_name.lower()
):
self.repeater_config["region_home"] = ""
default_name = self._region_get_default_name()
if str(default_name or "").strip().lower() == removed_name.lower():
self.mesh_config["default_region"] = None
return "OK"
def _region_list(self, filter_name: str) -> str:
mode = str(filter_name or "").strip().lower()
if mode not in ("allowed", "denied"):
return "Err - use 'allowed' or 'denied'"
names: list[str] = []
unscoped_allowed = self._region_unscoped_allow()
if (mode == "allowed" and unscoped_allowed) or (mode == "denied" and not unscoped_allowed):
names.append("*")
records = sorted(
self._region_load_records(),
key=lambda r: self._region_display_name(r.get("name", "")).lower(),
)
for rec in records:
flood_policy = str(rec.get("flood_policy", "deny")).lower()
allowed = flood_policy == "allow"
if (mode == "allowed" and allowed) or (mode == "denied" and not allowed):
names.append(self._region_display_name(rec.get("name", "")))
return ",".join(names) if names else "-none-"
# ==================== Neighbor Commands ====================
@@ -667,15 +1160,15 @@ class MeshCLI:
if not neighbors:
return "No neighbors discovered yet"
# Filter to only show repeaters and zero hop nodes
# Match MeshCore behavior: show only zero-hop repeaters.
filtered_neighbors = {
pubkey: info
for pubkey, info in neighbors.items()
if info.get("is_repeater", False) or info.get("zero_hop", False)
if info.get("is_repeater", False) and info.get("zero_hop", False)
}
if not filtered_neighbors:
return "No repeaters or zero hop neighbors discovered yet"
return "No zero hop repeaters discovered yet"
# Format output similar to C++ version
# Format: "<pubkey_prefix> heard Xs ago"
@@ -703,14 +1196,71 @@ class MeshCLI:
def _cmd_neighbor_remove(self, command: str) -> str:
"""Remove a neighbor."""
pubkey_hex = command[16:].strip()
raw_suffix = command[16:]
pubkey_hex = raw_suffix.strip()
if not pubkey_hex:
# Keep MeshCore parity: plain empty is invalid, whitespace-only means remove all.
if raw_suffix == "":
return "ERR: Missing pubkey"
# TODO: Remove neighbor from routing table
logger.info(f"neighbor.remove: {pubkey_hex}")
return "Error: Not yet implemented"
if not self.storage_handler:
return "Error: Storage not available"
delete_fn = getattr(self.storage_handler, "delete_neighbors_by_pubkey_prefix", None)
if not callable(delete_fn):
return "Error: neighbor.remove not supported by storage backend"
try:
if pubkey_hex == "":
delete_fn(None)
return "OK"
if any(ch not in "0123456789abcdefABCDEF" for ch in pubkey_hex):
return "ERR: bad pubkey"
delete_fn(pubkey_hex)
return "OK"
except Exception as e:
logger.error(f"neighbor.remove failed: {e}", exc_info=True)
return f"Error: {e}"
def _cmd_discover_neighbors(self, command: str) -> str:
"""Send a discovery request for nearby repeaters."""
sub = command[18:]
if sub.strip():
return "Err - discover.neighbors has no options"
daemon_instance = getattr(self.config_manager, "daemon", None)
discovery_helper = getattr(daemon_instance, "discovery_helper", None)
if not discovery_helper:
return "Error: Discovery helper not available"
import asyncio
loop = self._event_loop
if loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is None or not loop.is_running():
return "Error: Event loop not available"
try:
discovery_helper.cleanup_sessions()
session = discovery_helper.create_session(
timeout=5,
filter_mask=(1 << 2),
since=0,
prefix_only=False,
result_enricher=self._auto_add_discovery_result,
)
loop.call_soon_threadsafe(discovery_helper.start_session_task, session["session_id"])
return "OK - Discover sent"
except Exception as e:
logger.error(f"discover.neighbors failed: {e}", exc_info=True)
return f"Error: {e}"
# ==================== Temporary Radio Commands ====================
+52 -9
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
import time
@@ -5,14 +6,31 @@ logger = logging.getLogger("PathHelper")
class PathHelper:
def __init__(self, acl_dict=None, log_fn=None):
def __init__(self, acl_dict=None, log_fn=None, ack_received_callback=None):
self.acl_dict = acl_dict or {}
self.log_fn = log_fn or logger.info
self.ack_received_callback = ack_received_callback
async def _register_ack_crc(self, ack_crc: int) -> None:
"""Propagate an ACK CRC to the configured callback."""
if ack_crc is None:
return
callback = self.ack_received_callback
if callback is None:
return
try:
result = callback(ack_crc)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.debug(f"ACK callback failed for CRC {ack_crc:08X}: {e}")
async def process_path_packet(self, packet):
from openhop_core.protocol.constants import PAYLOAD_TYPE_ACK
from openhop_core.protocol.crypto import CryptoUtils
from openhop_core.protocol.packet_utils import PathUtils
try:
if len(packet.payload) < 2:
@@ -60,30 +78,55 @@ class PathHelper:
return False
# Parse decrypted PATH data
# Format: path_len(1) + path[path_len] + extra_type(1) + extra[...]
# Format: path_len(1) + path[path_byte_len] + extra_type(1) + extra[...]
if len(decrypted) < 1:
logger.debug("Decrypted PATH data too short")
return False
path_len_byte = decrypted[0]
if PathUtils.is_valid_path_len(path_len_byte):
path_byte_len = PathUtils.get_path_byte_len(path_len_byte)
path_hops = PathUtils.get_path_hash_count(path_len_byte)
else:
# Legacy fallback for malformed/old packets: treat first byte as raw path bytes.
path_byte_len = path_len_byte
path_hops = path_byte_len
path_len = decrypted[0]
if len(decrypted) < 1 + path_len:
if len(decrypted) < 1 + path_byte_len:
logger.debug(
f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}"
f"PATH data truncated: need {1 + path_byte_len} bytes, got {len(decrypted)}"
)
return False
path_data = decrypted[1 : 1 + path_len]
path_data = decrypted[1 : 1 + path_byte_len]
# Update client's out_path (same as C++ memcpy)
# Update client's out_path (same as C++ memcpy); out_path_len keeps
# the encoded byte so direct sends put it on the wire as-is.
client.out_path = bytearray(path_data)
client.out_path_len = path_len
client.out_path_len = (
path_len_byte if PathUtils.is_valid_path_len(path_len_byte) else path_byte_len
)
client.last_activity = int(time.time())
logger.info(
f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: "
f"path_len={path_len}, path={[hex(b) for b in path_data]}"
f"path_len_byte=0x{path_len_byte:02X}, hops={path_hops}, "
f"path={[hex(b) for b in path_data]}"
)
# Handle bundled ACK in PATH extra section.
ack_crc = None
extra_start = 1 + path_byte_len
if len(decrypted) > extra_start:
extra_type = decrypted[extra_start] & 0x0F
extra_payload = decrypted[extra_start + 1 :]
if extra_type == PAYLOAD_TYPE_ACK and len(extra_payload) >= 4:
ack_crc = int.from_bytes(extra_payload[:4], "little")
logger.info(
f"PATH bundled ACK extracted for client 0x{src_hash:02X}: CRC={ack_crc:08X}"
)
if ack_crc is not None:
await self._register_ack_crc(ack_crc)
# Don't mark as do_not_retransmit - let it forward normally
return False
-700
View File
@@ -1,700 +0,0 @@
"""
Mesh CLI Handler
Handles administrative commands sent to repeaters and room servers via TXT_MSG packets.
Only users with admin permissions (via ACL) can execute these commands.
"""
import logging
from pathlib import Path
from typing import Any, Callable, Dict
logger = logging.getLogger(__name__)
class MeshCLI:
"""
CLI command handler for mesh node administration (repeaters and room servers).
Commands follow the format: XX|command params
where XX is an optional sequence number that gets echoed in the reply.
"""
def __init__(
self,
config_path: str,
config: Dict[str, Any],
save_config_callback: Callable,
identity_type: str = "repeater",
enable_regions: bool = True,
):
"""
Initialize the CLI handler.
Args:
config_path: Path to the config.yaml file
config: Current configuration dictionary
save_config_callback: Callback to save config changes
identity_type: Type of identity ('repeater' or 'room_server')
enable_regions: Whether to enable region commands (only for repeaters)
"""
self.config_path = Path(config_path)
self.config = config
self.save_config = save_config_callback
self.identity_type = identity_type
self.enable_regions = enable_regions
# Get repeater config shortcut
self.repeater_config = config.get("repeater", {})
def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str:
"""
Handle an incoming command from a client.
Args:
sender_pubkey: Public key of sender
command: Command string (may include XX| prefix)
is_admin: Whether sender has admin permissions
Returns:
Reply string to send back to sender
"""
# Check admin permission first
if not is_admin:
return "Error: Admin permission required"
logger.debug(f"handle_command received: '{command}' (len={len(command)})")
# Extract optional sequence prefix (XX|)
prefix = ""
if len(command) > 4 and command[2] == "|":
prefix = command[:3]
command = command[3:]
logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'")
# Strip leading/trailing whitespace
command = command.strip()
logger.debug(f"After strip: '{command}'")
# Route to appropriate handler
reply = self._route_command(command)
# Add prefix back to reply if present
if prefix:
return prefix + reply
return reply
def _route_command(self, command: str) -> str:
"""Route command to appropriate handler method."""
# Help
if command == "help" or command.startswith("help "):
return self._cmd_help(command)
# System commands
elif command == "reboot":
return self._cmd_reboot()
elif command == "advert":
return self._cmd_advert()
elif command.startswith("clock"):
return self._cmd_clock(command)
elif command.startswith("time "):
return self._cmd_time(command)
elif command == "start ota":
return "Error: OTA not supported in Python repeater"
elif command.startswith("password "):
return self._cmd_password(command)
elif command == "clear stats":
return self._cmd_clear_stats()
elif command == "ver":
return self._cmd_version()
# Get commands
elif command.startswith("get "):
return self._cmd_get(command[4:])
# Set commands
elif command.startswith("set "):
return self._cmd_set(command[4:])
# ACL commands
elif command.startswith("setperm "):
return self._cmd_setperm(command)
elif command == "get acl":
return "Error: Use 'get acl' via serial console only"
# Region commands (repeaters only)
elif command.startswith("region"):
if self.enable_regions:
return self._cmd_region(command)
else:
return "Error: Region commands not available for room servers"
# Neighbor commands
elif command == "neighbors":
return self._cmd_neighbors()
elif command.startswith("neighbor.remove "):
return self._cmd_neighbor_remove(command)
# Temporary radio params
elif command.startswith("tempradio "):
return self._cmd_tempradio(command)
# Sensor commands
elif command.startswith("sensor "):
return "Error: Sensor commands not implemented in Python repeater"
# GPS commands
elif command.startswith("gps"):
return "Error: GPS commands not implemented in Python repeater"
# Logging commands
elif command.startswith("log "):
return self._cmd_log(command)
# Statistics commands
elif command.startswith("stats-"):
return "Error: Stats commands not fully implemented yet"
else:
return "Unknown command"
# ==================== Help Command ====================
def _cmd_help(self, command: str) -> str:
"""Show available commands or detailed help for a specific command."""
parts = command.split(None, 1)
if len(parts) == 2:
return self._help_detail(parts[1])
lines = [
"=== pyMC CLI Commands ===",
"",
"System:",
" reboot Restart the repeater service",
" advert Send self advertisement",
" clock Show current UTC time",
" clock sync Sync clock (no-op, uses system time)",
" ver Show version info",
" password <pw> Change admin password",
" clear stats Clear statistics",
"",
"Get:",
" get name Node name",
" get radio Radio params (freq,bw,sf,cr)",
" get freq Frequency (MHz)",
" get tx TX power",
" get af Airtime factor",
" get repeat Repeat mode (on/off)",
" get lat / get lon GPS coordinates",
" get role Identity role",
" get guest.password Guest password",
" get allow.read.only Read-only access setting",
" get advert.interval Advert interval (minutes)",
" get flood.advert.interval Flood advert interval (hours)",
" get flood.max Max flood hops",
" get rxdelay RX delay base",
" get txdelay TX delay factor",
" get direct.txdelay Direct TX delay factor",
" get multi.acks Multi-ack count",
" get int.thresh Interference threshold",
" get agc.reset.interval AGC reset interval",
"",
"Set: (use 'help set' for details)",
" set <param> <value>",
"",
"Other:",
" neighbors List neighbors",
" neighbor.remove <key> Remove neighbor by pubkey",
" tempradio <freq> <bw> <sf> <cr> <timeout_mins>",
" setperm <pubkey> <perm> Set ACL permissions",
" log start|stop|erase Logging control",
]
if self.enable_regions:
lines.append(" region ... Region commands")
lines += ["", "Type 'help <command>' for details on a specific command."]
return "\n".join(lines)
def _help_detail(self, topic: str) -> str:
"""Return detailed help for a specific command topic."""
topic = topic.strip()
details = {
"set": (
"Set commands — set <param> <value>:\n"
" set name <name> Set node name\n"
" set radio <f> <bw> <sf> <cr> Set radio (restart required)\n"
" set freq <mhz> Set frequency (restart required)\n"
" set tx <power> Set TX power\n"
" set af <factor> Airtime factor\n"
" set repeat on|off Enable/disable repeating\n"
" set lat <deg> Latitude\n"
" set lon <deg> Longitude\n"
" set guest.password <pw> Guest password\n"
" set allow.read.only on|off Read-only access\n"
" set advert.interval <min> 60-240 minutes\n"
" set flood.advert.interval <hr> 3-48 hours\n"
" set flood.max <hops> Max flood hops (max 64)\n"
" set rxdelay <val> RX delay base (>=0)\n"
" set txdelay <val> TX delay factor (>=0)\n"
" set direct.txdelay <val> Direct TX delay (>=0)\n"
" set multi.acks <n> Multi-ack count\n"
" set int.thresh <dbm> Interference threshold\n"
" set agc.reset.interval <n> AGC reset (rounded to x4)"
),
"get": "Get commands — type 'help' to see all 'get' parameters.",
"reboot": "Restart the repeater service via systemd.",
"advert": "Trigger a self-advertisement flood packet.",
"clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).",
"ver": "Show repeater version and identity type.",
"password": "password <new_password> — Change the admin password.",
"tempradio": (
"tempradio <freq_mhz> <bw_khz> <sf> <cr> <timeout_mins>\n"
" Apply temporary radio parameters that revert after timeout.\n"
" freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8"
),
"neighbors": "List known neighbor nodes from the routing table.",
"setperm": "setperm <pubkey_hex> <permission_int> — Set ACL permissions for a node.",
"log": "log start|stop|erase — Control logging.",
}
return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.")
# ==================== System Commands ==
def _cmd_reboot(self) -> str:
"""Reboot the repeater process."""
from repeater.service_utils import restart_service
logger.warning("Reboot command received via repeater CLI")
success, message = restart_service()
if success:
return f"OK - {message}"
else:
return f"Error: {message}"
def _cmd_advert(self) -> str:
"""Send self advertisement."""
logger.info("Advert command received")
# TODO: Trigger advertisement through packet handler
return "Error: Not yet implemented"
def _cmd_clock(self, command: str) -> str:
"""Handle clock commands."""
if command == "clock":
# Display current time
import datetime
dt = datetime.datetime.now(datetime.timezone.utc)
return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC"
elif command == "clock sync":
# Clock sync happens automatically via sender_timestamp in protocol
return "OK - clock sync not needed (system time used)"
else:
return "Unknown clock command"
def _cmd_time(self, command: str) -> str:
"""Set time - not supported in Python (use system time)."""
return "Error: Time setting not supported (system time is used)"
def _cmd_password(self, command: str) -> str:
"""Change admin password."""
new_password = command[9:].strip()
if not new_password:
return "Error: Password cannot be empty"
# Update security config
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["password"] = new_password
# Save config
try:
self.save_config()
return f"password now: {new_password}"
except Exception as e:
logger.error(f"Failed to save password: {e}")
return "Error: Failed to save password"
def _cmd_clear_stats(self) -> str:
"""Clear statistics."""
# TODO: Implement stats clearing
return "Error: Not yet implemented"
def _cmd_version(self) -> str:
"""Get version information."""
role = "room_server" if self.identity_type == "room_server" else "repeater"
version = self.config.get("version", "1.0.0")
return f"pyMC_{role} v{version}"
# ==================== Get Commands ====================
def _cmd_get(self, param: str) -> str:
"""Handle get commands."""
param = param.strip()
logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})")
if param == "af":
af = self.repeater_config.get("airtime_factor", 1.0)
return f"> {af}"
elif param == "name":
name = self.repeater_config.get("name", "Unknown")
return f"> {name}"
elif param == "repeat":
mode = self.repeater_config.get("mode", "forward")
return f"> {'on' if mode == 'forward' else 'off'}"
elif param == "lat":
lat = self.repeater_config.get("latitude", 0.0)
return f"> {lat}"
elif param == "lon":
lon = self.repeater_config.get("longitude", 0.0)
return f"> {lon}"
elif param == "radio":
radio = self.config.get("radio", {})
freq_hz = radio.get("frequency", 915000000)
bw_hz = radio.get("bandwidth", 125000)
sf = radio.get("spreading_factor", 7)
cr = radio.get("coding_rate", 5)
# Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output)
freq_mhz = freq_hz / 1_000_000.0
bw_khz = bw_hz / 1_000.0
return f"> {freq_mhz},{bw_khz},{sf},{cr}"
elif param == "freq":
freq_hz = self.config.get("radio", {}).get("frequency", 915000000)
freq_mhz = freq_hz / 1_000_000.0
return f"> {freq_mhz}"
elif param == "tx":
power = self.config.get("radio", {}).get("tx_power", 20)
return f"> {power}"
elif param == "public.key":
# TODO: Get from identity
return "Error: Not yet implemented"
elif param == "role":
role = "room_server" if self.identity_type == "room_server" else "repeater"
return f"> {role}"
elif param == "guest.password":
guest_pw = self.config.get("security", {}).get("guest_password", "")
return f"> {guest_pw}"
elif param == "allow.read.only":
allow = self.config.get("security", {}).get("allow_read_only", False)
return f"> {'on' if allow else 'off'}"
elif param == "advert.interval":
interval = self.repeater_config.get("advert_interval_minutes", 120)
return f"> {interval}"
elif param == "flood.advert.interval":
interval = self.repeater_config.get("flood_advert_interval_hours", 24)
return f"> {interval}"
elif param == "flood.max":
max_flood = self.repeater_config.get("max_flood_hops", 64)
return f"> {max_flood}"
elif param == "rxdelay":
delay = self.repeater_config.get("rx_delay_base", 0.0)
return f"> {delay}"
elif param == "txdelay":
delay = self.repeater_config.get("tx_delay_factor", 1.0)
return f"> {delay}"
elif param == "direct.txdelay":
delay = self.repeater_config.get("direct_tx_delay_factor", 0.5)
return f"> {delay}"
elif param == "multi.acks":
acks = self.repeater_config.get("multi_acks", 0)
return f"> {acks}"
elif param == "int.thresh":
thresh = self.repeater_config.get("interference_threshold", -120)
return f"> {thresh}"
elif param == "agc.reset.interval":
interval = self.repeater_config.get("agc_reset_interval", 0)
return f"> {interval}"
else:
return f"??: {param}"
# ==================== Set Commands ====================
def _cmd_set(self, param: str) -> str:
"""Handle set commands."""
parts = param.split(None, 1)
if len(parts) < 2:
return "Error: Missing value"
key, value = parts[0], parts[1]
try:
if key == "af":
self.repeater_config["airtime_factor"] = float(value)
self.save_config()
return "OK"
elif key == "name":
self.repeater_config["name"] = value
self.save_config()
return "OK"
elif key == "repeat":
self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor"
self.save_config()
return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}"
elif key == "lat":
self.repeater_config["latitude"] = float(value)
self.save_config()
return "OK"
elif key == "lon":
self.repeater_config["longitude"] = float(value)
self.save_config()
return "OK"
elif key == "radio":
# Format: freq bw sf cr
radio_parts = value.split()
if len(radio_parts) != 4:
return "Error: Expected freq bw sf cr"
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["frequency"] = float(radio_parts[0])
self.config["radio"]["bandwidth"] = float(radio_parts[1])
self.config["radio"]["spreading_factor"] = int(radio_parts[2])
self.config["radio"]["coding_rate"] = int(radio_parts[3])
self.save_config()
return "OK - restart repeater to apply"
elif key == "freq":
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["frequency"] = float(value)
self.save_config()
return "OK - restart repeater to apply"
elif key == "tx":
if "radio" not in self.config:
self.config["radio"] = {}
self.config["radio"]["tx_power"] = int(value)
self.save_config()
return "OK"
elif key == "guest.password":
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["guest_password"] = value
self.save_config()
return "OK"
elif key == "allow.read.only":
if "security" not in self.config:
self.config["security"] = {}
self.config["security"]["allow_read_only"] = value.lower() == "on"
self.save_config()
return "OK"
elif key == "advert.interval":
mins = int(value)
if mins > 0 and (mins < 60 or mins > 240):
return "Error: interval range is 60-240 minutes"
self.repeater_config["advert_interval_minutes"] = mins
self.save_config()
return "OK"
elif key == "flood.advert.interval":
hours = int(value)
if (hours > 0 and hours < 3) or hours > 48:
return "Error: interval range is 3-48 hours"
self.repeater_config["flood_advert_interval_hours"] = hours
self.save_config()
return "OK"
elif key == "flood.max":
max_val = int(value)
if max_val > 64:
return "Error: max 64"
self.repeater_config["max_flood_hops"] = max_val
self.save_config()
return "OK"
elif key == "rxdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config["rx_delay_base"] = delay
self.save_config()
return "OK"
elif key == "txdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config["tx_delay_factor"] = delay
self.save_config()
return "OK"
elif key == "direct.txdelay":
delay = float(value)
if delay < 0:
return "Error: cannot be negative"
self.repeater_config["direct_tx_delay_factor"] = delay
self.save_config()
return "OK"
elif key == "multi.acks":
self.repeater_config["multi_acks"] = int(value)
self.save_config()
return "OK"
elif key == "int.thresh":
self.repeater_config["interference_threshold"] = int(value)
self.save_config()
return "OK"
elif key == "agc.reset.interval":
interval = int(value)
# Round to nearest multiple of 4
rounded = (interval // 4) * 4
self.repeater_config["agc_reset_interval"] = rounded
self.save_config()
return f"OK - interval rounded to {rounded}"
else:
return f"unknown config: {key}"
except ValueError as e:
return f"Error: invalid value - {e}"
except Exception as e:
logger.error(f"Set command error: {e}")
return f"Error: {e}"
# ==================== ACL Commands ====================
def _cmd_setperm(self, command: str) -> str:
"""Set permissions for a public key."""
# Format: setperm {pubkey-hex} {permissions-int}
parts = command[8:].split()
if len(parts) < 2:
return "Err - bad params"
pubkey_hex = parts[0]
try:
permissions = int(parts[1])
except ValueError:
return "Err - invalid permissions"
# TODO: Apply permissions via ACL
logger.info(f"setperm command: {pubkey_hex} -> {permissions}")
return "Error: Not yet implemented - use config file"
# ==================== Region Commands ====================
def _cmd_region(self, command: str) -> str:
"""Handle region commands."""
parts = command.split()
if len(parts) == 1:
return "Error: Region commands not implemented in Python repeater"
subcommand = parts[1]
if subcommand == "load":
return "Error: Region commands not implemented"
elif subcommand == "save":
return "Error: Region commands not implemented"
elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"):
return "Error: Region commands not implemented"
else:
return "Err - ??"
# ==================== Neighbor Commands ====================
def _cmd_neighbors(self) -> str:
"""List neighbors."""
# TODO: Get neighbors from routing table
return "Error: Not yet implemented"
def _cmd_neighbor_remove(self, command: str) -> str:
"""Remove a neighbor."""
pubkey_hex = command[16:].strip()
if not pubkey_hex:
return "ERR: Missing pubkey"
# TODO: Remove neighbor from routing table
logger.info(f"neighbor.remove: {pubkey_hex}")
return "Error: Not yet implemented"
# ==================== Temporary Radio Commands ====================
def _cmd_tempradio(self, command: str) -> str:
"""Apply temporary radio parameters."""
# Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins}
parts = command[10:].split()
if len(parts) < 5:
return "Error: Expected freq bw sf cr timeout_mins"
try:
freq = float(parts[0])
bw = float(parts[1])
sf = int(parts[2])
cr = int(parts[3])
timeout_mins = int(parts[4])
# Validate
if not (300.0 <= freq <= 2500.0):
return "Error: invalid frequency"
if not (7.0 <= bw <= 500.0):
return "Error: invalid bandwidth"
if not (5 <= sf <= 12):
return "Error: invalid spreading factor"
if not (5 <= cr <= 8):
return "Error: invalid coding rate"
if timeout_mins <= 0:
return "Error: invalid timeout"
# TODO: Apply temporary radio parameters
logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min")
return "Error: Not yet implemented"
except ValueError:
return "Error, invalid params"
# ==================== Logging Commands ====================
def _cmd_log(self, command: str) -> str:
"""Handle log commands."""
if command == "log start":
# TODO: Enable logging
return "Error: Not yet implemented"
elif command == "log stop":
# TODO: Disable logging
return "Error: Not yet implemented"
elif command == "log erase":
# TODO: Clear log file
return "Error: Not yet implemented"
elif command == "log":
return "Error: Use journalctl to view logs"
else:
return "Unknown log command"
# Backward compatibility alias
RepeaterCLI = MeshCLI
+47 -14
View File
@@ -6,6 +6,7 @@ from typing import Dict
from openhop_core.protocol import CryptoUtils, PacketBuilder
from openhop_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG
from openhop_core.protocol.packet_utils import PathUtils
logger = logging.getLogger("RoomServer")
@@ -328,6 +329,12 @@ class RoomServer:
if sync_state:
failures = sync_state.get("push_failures", 0)
if failures >= MAX_PUSH_FAILURES:
logger.debug(
f"Room '{self.room_name}': Client 0x{client_info.id.get_public_key()[0]:02X} "
f"at max failures ({failures}), skipping push"
)
return False
if failures > 0:
# Apply exponential backoff
backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1)
@@ -356,11 +363,9 @@ class RoomServer:
plaintext = (
timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes
)
# Calculate expected ACK (same algorithm as openhop_core)
attempt = 0
pack_data = PacketBuilder._pack_timestamp_data(timestamp, attempt, message_bytes)
ack_hash = CryptoUtils.sha256(pack_data + client_info.id.get_public_key())[:4]
# Calculate expected ACK (MeshCore signed text):
# sha256(timestamp + flags + author_prefix + text + recipient_pubkey)[:4]
ack_hash = CryptoUtils.sha256(plaintext + client_info.id.get_public_key())[:4]
expected_ack_crc = int.from_bytes(ack_hash, "little")
# Determine routing based on stored out_path
@@ -376,31 +381,59 @@ class RoomServer:
route_type=route_type,
)
# Add stored path for direct routing
# Add stored path for direct routing. out_path_len is the encoded
# wire byte (bits 0-5 = hash count, bits 6-7 = hash size - 1);
# out_path already holds exactly the path bytes.
if route_type == "direct" and len(client_info.out_path) > 0:
packet.path = bytearray(client_info.out_path[: client_info.out_path_len])
packet.path_len = client_info.out_path_len
if PathUtils.is_valid_path_len(client_info.out_path_len):
path_byte_len = PathUtils.get_path_byte_len(client_info.out_path_len)
packet.path = bytearray(client_info.out_path[:path_byte_len])
packet.path_len = client_info.out_path_len
else:
# Legacy fallback: treat stored path as 1-byte-hop path and clamp to
# valid encoded range (0-63 hops).
legacy_hops = min(len(client_info.out_path), 63)
packet.path = bytearray(client_info.out_path[:legacy_hops])
packet.path_len = legacy_hops
# Calculate ACK timeout
# Calculate ACK timeout from the HOP count, not the encoded byte
# (0x80 = empty 3-byte-hash path would otherwise give a ~4min wait)
if route_type == "flood":
ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0
else:
path_len = client_info.out_path_len if client_info.out_path_len >= 0 else 0
if PathUtils.is_valid_path_len(client_info.out_path_len):
path_len = PathUtils.get_path_hash_count(client_info.out_path_len)
else:
path_len = min(len(client_info.out_path), 63)
ack_timeout = (
PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)
) / 1000.0
# Update client sync state with pending ACK
current_sync_since = (
sync_state.get("sync_since", 0)
if sync_state
else getattr(client_info, "sync_since", 0)
)
self.db.upsert_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_info.id.get_public_key().hex(),
sync_since=current_sync_since,
pending_ack_crc=expected_ack_crc,
push_post_timestamp=post["post_timestamp"],
ack_timeout_time=time.time() + ack_timeout,
last_activity=time.time(),
)
# Send packet (dispatcher will track ACK automatically)
# Send and wait for the client's delivery ACK. The injector must be
# told the crypto ACK CRC we computed above — its default
# (packet.get_crc()) is a packet-hash CRC no client ever sends.
# This blocks for the entire transmission duration (0.5-9 seconds)
success = await self.packet_injector(packet, wait_for_ack=True)
success = await self.packet_injector(
packet,
wait_for_ack=True,
expected_crc=expected_ack_crc,
ack_timeout_s=ack_timeout,
)
# SAFETY: Release transmission lock AFTER send completes
self.global_limiter.release()
@@ -460,7 +493,7 @@ class RoomServer:
pending_ack_crc=0,
)
if failures >= 3:
if failures >= MAX_PUSH_FAILURES:
logger.warning(
f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} "
f"has {failures} consecutive failures"
@@ -625,7 +658,7 @@ class RoomServer:
)
continue
if push_failures >= 3:
if push_failures >= MAX_PUSH_FAILURES:
logger.debug(
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)"
)
+3
View File
@@ -18,6 +18,7 @@ from .room_server import RoomServer
logger = logging.getLogger("TextHelper")
# Text message type flags
TXT_TYPE_PLAIN = 0x00
TXT_TYPE_CLI_DATA = 0x01
@@ -457,12 +458,14 @@ class TextHelper:
"advert",
"clock",
"time ",
"http ",
"password ",
"clear ",
"ver",
"board",
"neighbors",
"neighbor.",
"discover.",
"tempradio ",
"setperm ",
"region",
+238 -122
View File
@@ -1,5 +1,6 @@
import asyncio
import functools
import inspect
import logging
import os
import signal
@@ -9,6 +10,7 @@ import time
from repeater.companion.utils import (
CompanionContactCapacityError,
CompanionStateLoadError,
effective_max_contacts,
enforce_companion_contact_capacity,
format_companion_bridge_limits,
@@ -33,10 +35,48 @@ from repeater.handler_helpers import (
from repeater.identity_manager import IdentityManager
from repeater.packet_router import PacketRouter
from repeater.sensors import SensorManager
from repeater.utils_packet import create_scoped_advert_packet
from repeater.web.http_server import HTTPStatsServer, _log_buffer
logger = logging.getLogger("RepeaterDaemon")
_COMPANION_LOAD_RETRY_DELAY_SEC = 0.5
async def _load_companion_rows_verified(
loader, counter, kind: str, companion_hash_str: str, name: str, **loader_kwargs
):
"""Load persisted companion rows, cross-checking empty results against the table.
A transient SQLite error at boot must not present as "no data" the
companion would start with an empty store and later saves would overwrite
the persisted state. Retries once after a short delay when the load failed
(loader returned None) or returned empty while the table has rows for this
companion; raises CompanionStateLoadError if it still cannot load.
Returns (rows, stored_count).
"""
stored = 0
for attempt in (1, 2):
rows = loader(companion_hash_str, **loader_kwargs)
stored = counter(companion_hash_str)
if rows is not None and (rows or stored == 0):
return rows, stored
if attempt == 1:
logger.warning(
"Companion %s ('%s'): %s load %s but table has %d row(s); retrying once",
companion_hash_str,
name,
kind,
"failed" if rows is None else "returned empty",
stored,
)
await asyncio.sleep(_COMPANION_LOAD_RETRY_DELAY_SEC)
raise CompanionStateLoadError(
f"Companion {companion_hash_str} ('{name}'): could not load persisted {kind} "
f"(table has {stored} row(s)); refusing to start with an empty store"
)
class RepeaterDaemon:
def __init__(self, config: dict, radio=None):
@@ -143,10 +183,26 @@ class RepeaterDaemon:
cad_config = self.config.get("radio", {}).get("cad", {})
peak_threshold = cad_config.get("peak_threshold", 23)
min_threshold = cad_config.get("min_threshold", 11)
symbol_num = cad_config.get("symbol_num", 2)
try:
symbol_num = int(symbol_num)
except (TypeError, ValueError):
symbol_num = 2
if symbol_num not in {1, 2, 4, 8, 16}:
logger.warning(
"Invalid CAD symbol_num in config (%s); defaulting to 2",
symbol_num,
)
symbol_num = 2
self.radio.set_custom_cad_thresholds(peak=peak_threshold, min_val=min_threshold)
if hasattr(self.radio, "set_custom_cad_symbol_num"):
self.radio.set_custom_cad_symbol_num(symbol_num)
logger.info(
f"CAD thresholds set from config: peak={peak_threshold}, min={min_threshold}"
"CAD settings set from config: peak=%s, min=%s, symbols=%s",
peak_threshold,
min_threshold,
symbol_num,
)
else:
logger.warning("Radio does not support CAD configuration")
@@ -178,8 +234,12 @@ class RepeaterDaemon:
from openhop_core import LocalIdentity
from openhop_core.node.dispatcher import Dispatcher
self.dispatcher = Dispatcher(self.radio)
dedupe_enabled = bool(
self.config.get("repeater", {}).get("dispatcher_dedupe_enabled", False)
)
self.dispatcher = Dispatcher(self.radio, dedupe_enabled=dedupe_enabled)
logger.info("Dispatcher initialized")
logger.info("Dispatcher dedupe enabled: %s", dedupe_enabled)
# Initialize Identity Manager for additional identities (e.g., room servers)
self.identity_manager = IdentityManager(self.config)
@@ -373,6 +433,11 @@ class RepeaterDaemon:
self.path_helper = PathHelper(
acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs
log_fn=logger.info,
ack_received_callback=(
self.dispatcher._register_ack_received
if self.dispatcher and hasattr(self.dispatcher, "_register_ack_received")
else None
),
)
logger.info("PATH packet processing helper initialized")
@@ -403,9 +468,7 @@ class RepeaterDaemon:
n,
)
# Subscribe to parsed packets (pre-dedup) so duplicate path variants
# still appear in the web UI even though the Dispatcher blocks them.
self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging)
self._register_duplicate_logging_hook(dedupe_enabled)
# When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv)
self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions
@@ -494,7 +557,6 @@ class RepeaterDaemon:
async def _load_companion_identities(self) -> None:
"""Load companion identities from config and create CompanionBridge + frame server for each."""
from openhop_core import LocalIdentity
from openhop_core.companion.models import Channel
from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge
@@ -613,60 +675,13 @@ class RepeaterDaemon:
**bridge_kwargs,
)
# Load contacts from SQLite
# Restore persisted state (contacts/channels/messages) from SQLite.
# Raises CompanionStateLoadError instead of continuing with an
# empty store when persisted rows exist but cannot be loaded.
if sqlite_handler:
contact_rows = sqlite_handler.companion_load_contacts(companion_hash_str)
if contact_rows:
records = []
for row in contact_rows:
d = dict(row)
d["public_key"] = d.pop("pubkey", d.get("public_key", b""))
records.append(d)
bridge.contacts.load_from_dicts(records)
# Load channels from SQLite (normalize secret to 32 bytes to match
# CompanionBase.set_channel and GroupTextHandler/PacketBuilder)
channel_rows = sqlite_handler.companion_load_channels(companion_hash_str)
for row in channel_rows:
s = row.get("secret", b"")
if isinstance(s, bytes):
raw = s
elif isinstance(s, (bytearray, memoryview)):
raw = bytes(s)
elif s:
raw = bytes.fromhex(s if isinstance(s, str) else str(s))
else:
raw = b""
if len(raw) < 32:
raw = raw + b"\x00" * (32 - len(raw))
elif len(raw) > 32:
raw = raw[:32]
ch = Channel(name=row.get("name", ""), secret=raw)
bridge.channels.set(row.get("channel_idx", 0), ch)
# Preload queued messages from SQLite into bridge, bounded by
# offline_queue_size (0 disables offline storage entirely).
retention = getattr(bridge.message_queue, "_max_size", None)
if retention != 0:
for msg_dict in sqlite_handler.companion_load_messages(
companion_hash_str, limit=retention or 100
):
from openhop_core.companion.models import QueuedMessage
sk = msg_dict.get("sender_key", b"")
if isinstance(sk, str):
sk = bytes.fromhex(sk)
bridge.message_queue.push(
QueuedMessage(
sender_key=sk,
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
)
)
await self._restore_companion_state(
sqlite_handler, bridge, companion_hash_str, name
)
# Ensure public channel (0) exists with default key for new companions
from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET
@@ -708,9 +723,132 @@ class RepeaterDaemon:
except CompanionContactCapacityError as e:
logger.error("%s", e)
except CompanionStateLoadError as e:
logger.error("Companion init aborted: %s", e)
except Exception as e:
logger.error(f"Failed to load companion '{name}': {e}", exc_info=True)
async def _restore_companion_state(
self, sqlite_handler, bridge, companion_hash_str: str, name: str
) -> None:
"""Restore persisted contacts/channels/messages from SQLite into a bridge.
Each load is cross-checked against the table's row count for this
companion and retried once on mismatch; raises CompanionStateLoadError
when persisted rows exist but cannot be loaded, so the companion fails
init loudly instead of starting with an empty store.
"""
from openhop_core.companion.models import Channel, QueuedMessage
contact_rows, contact_count = await _load_companion_rows_verified(
sqlite_handler.companion_load_contacts,
sqlite_handler.companion_count_contacts,
"contacts",
companion_hash_str,
name,
)
if contact_rows:
records = []
for row in contact_rows:
d = dict(row)
d["public_key"] = d.pop("pubkey", d.get("public_key", b""))
records.append(d)
bridge.contacts.load_from_dicts(records)
# Load channels (normalize secret to 32 bytes to match
# CompanionBase.set_channel and GroupTextHandler/PacketBuilder)
channel_rows, channel_count = await _load_companion_rows_verified(
sqlite_handler.companion_load_channels,
sqlite_handler.companion_count_channels,
"channels",
companion_hash_str,
name,
)
for row in channel_rows:
s = row.get("secret", b"")
if isinstance(s, bytes):
raw = s
elif isinstance(s, (bytearray, memoryview)):
raw = bytes(s)
elif s:
raw = bytes.fromhex(s if isinstance(s, str) else str(s))
else:
raw = b""
if len(raw) < 32:
raw = raw + b"\x00" * (32 - len(raw))
elif len(raw) > 32:
raw = raw[:32]
idx = row.get("channel_idx", 0)
ch = Channel(name=row.get("name", ""), secret=raw)
if not bridge.channels.set(idx, ch):
logger.error(
"Companion %s ('%s'): channel store rejected persisted channel "
"idx=%r name=%r (index out of range?)",
companion_hash_str,
name,
idx,
row.get("name", ""),
)
# Preload queued messages, bounded by offline_queue_size (0 disables
# offline storage entirely).
loaded_messages = 0
message_count = 0
retention = getattr(bridge.message_queue, "max_size", None)
if retention != 0:
message_rows, message_count = await _load_companion_rows_verified(
sqlite_handler.companion_load_messages,
sqlite_handler.companion_count_messages,
"messages",
companion_hash_str,
name,
limit=retention or 100,
)
loaded_messages = len(message_rows)
# openhop_core < the sender_prefix change (paired with fd43d86) has no
# QueuedMessage.sender_prefix; drop the prefix there instead of failing init.
supports_sender_prefix = "sender_prefix" in inspect.signature(QueuedMessage).parameters
if message_rows and not supports_sender_prefix:
logger.warning(
"Companion %s ('%s'): installed openhop_core QueuedMessage has no "
"sender_prefix field; persisted sender prefixes will be dropped "
"(update openhop_core to restore signed room-post authors)",
companion_hash_str,
name,
)
for msg_dict in message_rows:
sk = msg_dict.get("sender_key", b"")
if isinstance(sk, str):
sk = bytes.fromhex(sk)
sp = msg_dict.get("sender_prefix", b"")
if isinstance(sp, str):
sp = bytes.fromhex(sp) if sp else b""
msg_kwargs = dict(
sender_key=sk,
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
)
if supports_sender_prefix:
msg_kwargs["sender_prefix"] = sp
bridge.message_queue.push(QueuedMessage(**msg_kwargs))
logger.info(
"Companion %s ('%s'): restored %d/%d contact(s), %d/%d channel(s), "
"%d/%d message(s) from SQLite",
companion_hash_str,
name,
len(contact_rows),
contact_count,
len(channel_rows),
channel_count,
loaded_messages,
message_count,
)
async def add_companion_from_config(self, comp_config: dict) -> None:
"""
Load a single companion from config and register it (hot-reload).
@@ -718,7 +856,6 @@ class RepeaterDaemon:
and registers with identity_manager. Raises on error.
"""
from openhop_core import LocalIdentity
from openhop_core.companion.models import Channel
from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge
from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET
@@ -804,55 +941,10 @@ class RepeaterDaemon:
**bridge_kwargs,
)
# Restore persisted state; raises CompanionStateLoadError when persisted
# rows exist but cannot be loaded (hot-reload callers surface the error).
if sqlite_handler:
contact_rows = sqlite_handler.companion_load_contacts(companion_hash_str)
if contact_rows:
records = []
for row in contact_rows:
d = dict(row)
d["public_key"] = d.pop("pubkey", d.get("public_key", b""))
records.append(d)
bridge.contacts.load_from_dicts(records)
channel_rows = sqlite_handler.companion_load_channels(companion_hash_str)
for row in channel_rows:
s = row.get("secret", b"")
if isinstance(s, bytes):
raw = s
elif isinstance(s, (bytearray, memoryview)):
raw = bytes(s)
elif s:
raw = bytes.fromhex(s if isinstance(s, str) else str(s))
else:
raw = b""
if len(raw) < 32:
raw = raw + b"\x00" * (32 - len(raw))
elif len(raw) > 32:
raw = raw[:32]
ch = Channel(name=row.get("name", ""), secret=raw)
bridge.channels.set(row.get("channel_idx", 0), ch)
retention = getattr(bridge.message_queue, "_max_size", None)
if retention != 0:
for msg_dict in sqlite_handler.companion_load_messages(
companion_hash_str, limit=retention or 100
):
from openhop_core.companion.models import QueuedMessage
sk = msg_dict.get("sender_key", b"")
if isinstance(sk, str):
sk = bytes.fromhex(sk)
bridge.message_queue.push(
QueuedMessage(
sender_key=sk,
txt_type=msg_dict.get("txt_type", 0),
timestamp=msg_dict.get("timestamp", 0),
text=msg_dict.get("text", ""),
is_channel=bool(msg_dict.get("is_channel", False)),
channel_idx=msg_dict.get("channel_idx", 0),
path_len=msg_dict.get("path_len", 0),
)
)
await self._restore_companion_state(sqlite_handler, bridge, companion_hash_str, name)
if bridge.get_channel(0) is None:
bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET)
@@ -909,6 +1001,14 @@ class RepeaterDaemon:
except Exception as e:
logger.debug("Push RX raw to companion: %s", e)
def _register_duplicate_logging_hook(self, dedupe_enabled: bool) -> None:
"""Register pre-dedup duplicate logging only when dispatcher dedupe is active."""
if not self.dispatcher or not dedupe_enabled:
return
# When dispatcher dedupe is disabled, duplicates still flow through
# router -> repeater_handler and are already recorded there.
self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging)
def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None:
"""Record duplicate packets for UI visibility.
@@ -1139,7 +1239,6 @@ class RepeaterDaemon:
return False
try:
from openhop_core.protocol import PacketBuilder
from openhop_core.protocol.constants import (
ADVERT_FLAG_HAS_NAME,
ADVERT_FLAG_IS_REPEATER,
@@ -1160,15 +1259,16 @@ class RepeaterDaemon:
flags = ADVERT_FLAG_IS_REPEATER | ADVERT_FLAG_HAS_NAME
packet = PacketBuilder.create_advert(
mesh_config = self.config.get("mesh", {})
default_region = mesh_config.get("default_region")
packet, scoped_region_name = create_scoped_advert_packet(
local_identity=self.local_identity,
name=node_name,
lat=latitude,
lon=longitude,
feature1=0,
feature2=0,
node_name=node_name,
latitude=latitude,
longitude=longitude,
flags=flags,
route_type="flood",
default_region=default_region,
scope_label="advert",
)
# Send via dispatcher
@@ -1187,6 +1287,8 @@ class RepeaterDaemon:
longitude,
location_source,
)
if scoped_region_name:
logger.info("Advert scoped to default region '%s'", scoped_region_name)
return True
except Exception as e:
@@ -1378,8 +1480,19 @@ class RepeaterDaemon:
await self.initialize()
# Start HTTP stats server
http_port = self.config.get("http", {}).get("port", 8000)
http_host = self.config.get("http", {}).get("host", "0.0.0.0") # nosec B104
http_config = self.config.get("http", {})
http_port = http_config.get("port", 8000)
http_host = http_config.get("host", "0.0.0.0") # nosec B104
http_enabled_raw = http_config.get("enabled", True)
if isinstance(http_enabled_raw, str):
http_enabled = http_enabled_raw.strip().lower() in (
"1",
"true",
"yes",
"on",
)
else:
http_enabled = bool(http_enabled_raw)
node_name = self.config.get("repeater", {}).get("node_name", "Repeater")
@@ -1408,10 +1521,13 @@ class RepeaterDaemon:
config_path=getattr(self, "config_path", "/etc/openhop_repeater/config.yaml"),
)
try:
self.http_server.start()
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
if http_enabled:
try:
self.http_server.start()
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
else:
logger.info("HTTP server startup skipped (http.enabled=false)")
# Run dispatcher (handles RX/TX via openhop_core)
try:
+93 -18
View File
@@ -8,6 +8,7 @@ from openhop_core.node.handlers.control import ControlHandler
from openhop_core.node.handlers.group_text import GroupTextHandler
from openhop_core.node.handlers.login_response import LoginResponseHandler
from openhop_core.node.handlers.login_server import LoginServerHandler
from openhop_core.node.handlers.multipart import MultipartAckHandler
from openhop_core.node.handlers.path import PathHandler
from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler
from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler
@@ -261,6 +262,16 @@ class PacketRouter:
except Exception as e:
logger.debug("Record for UI failed: %s", e)
async def _register_ack_with_dispatcher(self, ack_crc: int, context: str) -> None:
"""Best-effort ACK CRC registration with the dispatcher waiter path."""
dispatcher = getattr(self.daemon, "dispatcher", None)
if dispatcher is None or not hasattr(dispatcher, "_register_ack_received"):
return
try:
await dispatcher._register_ack_received(ack_crc)
except Exception as e:
logger.debug("Dispatcher %s registration error: %s", context, e)
async def enqueue(self, packet):
"""Add packet to router queue."""
if self.queue.full():
@@ -271,7 +282,14 @@ class PacketRouter:
pass
await self.queue.put(packet)
async def inject_packet(self, packet, wait_for_ack: bool = False, origin_hash=None):
async def inject_packet(
self,
packet,
wait_for_ack: bool = False,
expected_crc=None,
origin_hash=None,
ack_timeout_s: float = 5.0,
):
try:
metadata = {
"rssi": getattr(packet, "rssi", 0),
@@ -327,11 +345,20 @@ class PacketRouter:
dispatcher = getattr(self.daemon, "dispatcher", None)
if dispatcher and hasattr(dispatcher, "wait_for_ack"):
try:
expected_crc = packet.get_crc()
ack_ok = await dispatcher.wait_for_ack(expected_crc, timeout=5.0)
wait_crc = (
expected_crc if expected_crc is not None else packet.get_crc()
)
wait_timeout = (
float(ack_timeout_s)
if isinstance(ack_timeout_s, (int, float)) and ack_timeout_s > 0
else 5.0
)
ack_ok = await dispatcher.wait_for_ack(wait_crc, timeout=wait_timeout)
if not ack_ok:
logger.warning(
"Injected packet ACK timeout (crc=%08X)", expected_crc
"Injected packet ACK timeout (crc=%08X, timeout=%.1fs)",
wait_crc,
wait_timeout,
)
return False
except Exception as e:
@@ -447,22 +474,40 @@ class PacketRouter:
logger.debug(f"Companion bridge advert error: {e}")
elif payload_type == LoginServerHandler.payload_type():
# Route to companion if dest is a companion; else to login_helper (for logging into this repeater).
# When dest is remote (not handled), pass to engine so DIRECT/FLOOD ANON_REQ can be forwarded.
# Our own injected ANON_REQ is suppressed by the engine's duplicate (mark_seen) check.
# Route to the local identity that owns this login. The on-air dest
# hash is only one byte, so a companion and a room-server identity
# can share it; decryption is the only real disambiguator. When both
# are registered under the same hash, offer the packet to both — the
# owner whose key decrypts replies, the other fails HMAC and no-ops.
# When dest is remote (not handled), login_helper passes it to the
# engine so DIRECT/FLOOD ANON_REQ can be forwarded. Our own injected
# ANON_REQ is suppressed by the engine's duplicate (mark_seen) check.
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if dest_hash is not None and dest_hash in companion_bridges:
login_helper = self.daemon.login_helper
login_handlers = getattr(login_helper, "handlers", {}) if login_helper else {}
has_companion = dest_hash is not None and dest_hash in companion_bridges
has_room_server = dest_hash is not None and dest_hash in login_handlers
if has_companion:
await companion_bridges[dest_hash].process_received_packet(packet)
processed_by_injection = True
elif self.daemon.login_helper:
handled = await self.daemon.login_helper.process_login_packet(packet)
# Offer to login_helper when a room-server identity shares this hash
# (collision) or when no local companion claims it at all (normal
# repeater/room-server login + remote-forward handling).
if login_helper and (has_room_server or not has_companion):
handled = await login_helper.process_login_packet(packet)
if handled:
processed_by_injection = True
if processed_by_injection:
self._record_for_ui(packet, metadata)
elif payload_type == AckHandler.payload_type():
# Ensure ACK CRC reaches dispatcher waiter path even when only router fallback is active.
if len(getattr(packet, "payload", b"")) >= 4:
ack_crc = int.from_bytes(packet.payload[:4], "little")
await self._register_ack_with_dispatcher(ack_crc, "ACK")
# ACK has no dest in payload (4-byte CRC only); deliver to all bridges so sender sees send_confirmed.
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
@@ -472,20 +517,50 @@ class PacketRouter:
except Exception as e:
logger.debug(f"Companion bridge ACK error: {e}")
elif payload_type == MultipartAckHandler.payload_type():
# MULTIPART ACK wrapper: low nibble of first byte is embedded payload type.
if (
len(getattr(packet, "payload", b"")) >= 5
and (packet.payload[0] & 0x0F) == AckHandler.payload_type()
):
ack_crc = int.from_bytes(packet.payload[1:5], "little")
await self._register_ack_with_dispatcher(ack_crc, "multi-ACK")
elif payload_type == TextMessageHandler.payload_type():
# Same one-byte dest-hash collision handling as the login path above:
# a companion and a room-server text identity can share a hash, and
# only decryption tells them apart. Offer to both when both are
# registered so a companion never shadows a room-server message.
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if dest_hash is not None and dest_hash in companion_bridges:
text_helper = self.daemon.text_helper
text_handlers = getattr(text_helper, "handlers", {}) if text_helper else {}
has_companion = dest_hash is not None and dest_hash in companion_bridges
has_text_identity = dest_hash is not None and dest_hash in text_handlers
if has_companion:
await companion_bridges[dest_hash].process_received_packet(packet)
processed_by_injection = True
self._record_for_ui(packet, metadata)
elif self.daemon.text_helper:
handled = await self.daemon.text_helper.process_text_packet(packet)
if text_helper and (has_text_identity or not has_companion):
handled = await text_helper.process_text_packet(packet)
if handled:
processed_by_injection = True
self._record_for_ui(packet, metadata)
if processed_by_injection:
self._record_for_ui(packet, metadata)
elif payload_type == PathHandler.payload_type():
# Always let PathHelper inspect/decrypt PATH first so out_path and bundled ACK state
# are updated even when companion routing fan-out also happens for this packet.
if self.daemon.path_helper:
try:
await self.daemon.path_helper.process_path_packet(packet)
except Exception as e:
logger.debug(f"Path helper processing error: {e}")
# The unconditional call above already covers PATH addressed to a
# local server identity (room server/repeater), so its out_path and
# any embedded ACK are handled before bridge delivery — the
# all-bridges branch below no longer swallows path returns for them.
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
if dest_hash is not None and dest_hash in companion_bridges:
@@ -506,8 +581,6 @@ class PacketRouter:
len(companion_bridges),
)
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
elif self.daemon.path_helper:
await self.daemon.path_helper.process_path_packet(packet)
elif payload_type == LoginResponseHandler.payload_type():
# PAYLOAD_TYPE_RESPONSE (0x01): payload is dest_hash(1)+src_hash(1)+encrypted.
@@ -620,7 +693,9 @@ class PacketRouter:
if self.daemon.repeater_handler and not processed_by_injection:
sent = await self.daemon.repeater_handler(packet, metadata)
if sent is False:
drop_reason = getattr(packet, "_repeater_drop_reason", None)
drop_reason = metadata.get("_repeater_drop_reason")
if not isinstance(drop_reason, str):
drop_reason = getattr(packet, "_repeater_drop_reason", None)
if not isinstance(drop_reason, str):
drop_reason = _drop_reason_from_recent_packets(
self.daemon.repeater_handler, packet
+54
View File
@@ -188,3 +188,57 @@ def restart_service() -> Tuple[bool, str]:
except Exception as e:
logger.error(f"Error executing sudo restart: {e}")
return False, f"Restart command failed: {str(e)}"
def _is_cherrypy_engine_running() -> Optional[bool]:
"""Return CherryPy engine running state when available."""
try:
import cherrypy
from cherrypy.process import wspbus
state = cherrypy.engine.state
return state in (wspbus.states.STARTING, wspbus.states.STARTED)
except Exception:
return None
def stop_http_server(daemon_instance) -> Tuple[bool, str]:
"""Stop the in-process HTTP stats server."""
if not daemon_instance:
return False, "Daemon instance not available"
http_server = getattr(daemon_instance, "http_server", None)
if not http_server:
return False, "HTTP server not initialized"
running = _is_cherrypy_engine_running()
if running is False:
return True, "HTTP server already stopped"
try:
http_server.stop()
return True, "HTTP server stopped"
except Exception as exc:
logger.error(f"Failed to stop HTTP server: {exc}", exc_info=True)
return False, f"Failed to stop HTTP server: {exc}"
def start_http_server(daemon_instance) -> Tuple[bool, str]:
"""Start the in-process HTTP stats server."""
if not daemon_instance:
return False, "Daemon instance not available"
http_server = getattr(daemon_instance, "http_server", None)
if not http_server:
return False, "HTTP server not initialized"
running = _is_cherrypy_engine_running()
if running is True:
return True, "HTTP server already running"
try:
http_server.start()
return True, "HTTP server started"
except Exception as exc:
logger.error(f"Failed to start HTTP server: {exc}", exc_info=True)
return False, f"Failed to start HTTP server: {exc}"
+61
View File
@@ -0,0 +1,61 @@
import logging
from typing import Optional, Tuple
from openhop_core.protocol import PacketBuilder
from openhop_core.protocol.constants import ROUTE_TYPE_TRANSPORT_FLOOD
logger = logging.getLogger("RepeaterPacketUtils")
def create_scoped_advert_packet(
*,
local_identity,
node_name: str,
latitude: float,
longitude: float,
flags: int,
default_region,
scope_label: str,
) -> Tuple[object, Optional[str]]:
"""Create a flood advert packet and apply default-region transport scope when configured."""
packet = PacketBuilder.create_advert(
local_identity=local_identity,
name=node_name,
lat=latitude,
lon=longitude,
feature1=0,
feature2=0,
flags=flags,
route_type="flood",
)
scoped_region_name = _apply_default_region_scope(
packet=packet,
default_region=default_region,
scope_label=scope_label,
)
return packet, scoped_region_name
def _apply_default_region_scope(*, packet, default_region, scope_label: str) -> Optional[str]:
"""Apply transport-flood scoping for a default region if provided."""
region_name = str(default_region).strip() if default_region not in (None, "") else ""
if not region_name:
return None
try:
from openhop_core.protocol.transport_keys import calc_transport_code, get_auto_key_for
region_key = get_auto_key_for(region_name)
packet.transport_codes[0] = calc_transport_code(region_key, packet)
packet.transport_codes[1] = 0 # reserved for home region
packet.header = (packet.header & ~0x03) | ROUTE_TYPE_TRANSPORT_FLOOD
return region_name
except Exception as scope_err:
logger.warning(
"Failed to apply default region scope '%s' to %s; sending unscoped flood: %s",
region_name,
scope_label,
scope_err,
)
return None
File diff suppressed because it is too large Load Diff
+20
View File
@@ -39,6 +39,26 @@ def require_auth(func):
else:
logger.warning("Invalid or expired JWT token")
request_params = getattr(cherrypy.request, "params", None)
if request_params is None:
request_params = {}
query_token = request_params.get("token")
if query_token:
payload = jwt_handler.verify_jwt(query_token)
if payload:
cherrypy.request.user = {
"username": payload["sub"],
"client_id": payload["client_id"],
"auth_type": "jwt_query",
}
if hasattr(cherrypy.request, "params") and "token" in cherrypy.request.params:
del cherrypy.request.params["token"]
return func(*args, **kwargs)
else:
logger.warning("Invalid or expired JWT query token")
# Try API token authentication
api_key = cherrypy.request.headers.get("X-API-Key", "")
if api_key:
+760 -158
View File
@@ -1,9 +1,8 @@
import asyncio
import logging
import random
import threading
import time
from typing import Any, Dict
from typing import Any, Dict, Optional, Tuple
logger = logging.getLogger("HTTPServer")
@@ -18,90 +17,353 @@ class CADCalibrationEngine:
self.progress = {"current": 0, "total": 0}
self.clients = set() # SSE clients
self.calibration_thread = None
self.session_config: dict[str, Any] = {}
def get_test_ranges(self, spreading_factor: int):
"""Get CAD test ranges"""
# Higher values = less sensitive, lower values = more sensitive
# Test from LESS sensitive to MORE sensitive to find the sweet spot
sf_ranges = {
7: (range(22, 30, 1), range(12, 20, 1)),
8: (range(22, 30, 1), range(12, 20, 1)),
9: (range(24, 32, 1), range(14, 22, 1)),
10: (range(26, 34, 1), range(16, 24, 1)),
11: (range(28, 36, 1), range(18, 26, 1)),
12: (range(30, 38, 1), range(20, 28, 1)),
@staticmethod
def _default_thresholds_for_sf(spreading_factor: int) -> tuple[int, int]:
defaults = {
7: (22, 10),
8: (22, 10),
9: (24, 10),
10: (25, 10),
11: (26, 10),
12: (30, 10),
}
return sf_ranges.get(spreading_factor, sf_ranges[8])
return defaults.get(spreading_factor, defaults[8])
@staticmethod
def _normalize_int(value: Any, default: int, minimum: int, maximum: int) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
parsed = default
return max(minimum, min(maximum, parsed))
@staticmethod
def _normalize_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "y", "on"}:
return True
if lowered in {"0", "false", "no", "n", "off"}:
return False
return default
def _get_radio_runtime_config(self, radio) -> dict[str, Any]:
config = getattr(self.daemon_instance, "config", {}) if self.daemon_instance else {}
radio_cfg = config.get("radio", {})
frequency = getattr(radio, "frequency", radio_cfg.get("frequency"))
spreading_factor = getattr(radio, "spreading_factor", radio_cfg.get("spreading_factor", 8))
bandwidth = getattr(radio, "bandwidth", radio_cfg.get("bandwidth", 125000))
coding_rate = getattr(radio, "coding_rate", radio_cfg.get("coding_rate", 5))
try:
spreading_factor = int(spreading_factor)
except (TypeError, ValueError):
spreading_factor = 8
try:
bandwidth = int(bandwidth)
except (TypeError, ValueError):
bandwidth = 125000
try:
coding_rate = int(coding_rate)
except (TypeError, ValueError):
coding_rate = 5
det_peak, det_min = self._default_thresholds_for_sf(spreading_factor)
if hasattr(radio, "_get_thresholds_for_current_settings"):
try:
det_peak, det_min = radio._get_thresholds_for_current_settings()
except Exception:
logger.debug("Failed to read runtime CAD thresholds from radio", exc_info=True)
return {
"frequency": frequency,
"spreading_factor": spreading_factor,
"bandwidth": bandwidth,
"coding_rate": coding_rate,
"current_cad_peak": int(det_peak),
"current_cad_min": int(det_min),
}
def get_test_ranges(self, spreading_factor: int, base_peak: int, base_min: int):
"""Get a small practical CAD test range around current/default values."""
semtech_peak, semtech_min = self._default_thresholds_for_sf(spreading_factor)
center_peak = int(base_peak if base_peak is not None else semtech_peak)
center_min = int(base_min if base_min is not None else semtech_min)
peak_candidates = {
center_peak - 2,
center_peak - 1,
center_peak,
center_peak + 1,
center_peak + 2,
semtech_peak,
}
min_candidates = {center_min - 1, center_min, center_min + 1, semtech_min}
peak_values = sorted(v for v in peak_candidates if 1 <= v <= 255)
min_values = sorted(v for v in min_candidates if 1 <= v <= 255)
return peak_values, min_values
@staticmethod
def _build_stepped_range(
lower: int, upper: int, step: int, anchor: Optional[int] = None
) -> list[int]:
values = list(range(lower, upper + 1, max(1, step)))
values.extend([lower, upper])
if anchor is not None:
values.append(anchor)
return sorted({v for v in values if lower <= v <= upper})
def _rank_results_for_search(
self, results: list[dict], known_signal_present: bool, sf: int
) -> list[dict]:
semtech_peak, semtech_min = self._default_thresholds_for_sf(sf)
if known_signal_present:
required_detection_rate = 85.0
def _known_signal_sort_key(r: dict):
detection_rate = float(r.get("detection_rate", 0.0) or 0.0)
instability = int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0)
peak = int(r.get("det_peak", semtech_peak))
min_val = int(r.get("det_min", semtech_min))
attempts = int(r.get("attempts", 0) or r.get("samples", 0) or 0)
aggressiveness_penalty = max(0, semtech_peak - peak) + (
2 * max(0, semtech_min - min_val)
)
is_stable = instability == 0
meets_detection_floor = detection_rate >= required_detection_rate
qualification_tier = (
2 if (is_stable and meets_detection_floor) else (1 if is_stable else 0)
)
return (
qualification_tier,
-aggressiveness_penalty,
min_val,
peak,
detection_rate,
-instability,
attempts,
int(r.get("detections", 0) or 0),
)
return sorted(
results,
key=_known_signal_sort_key,
reverse=True,
)
return sorted(
results,
key=lambda r: (
r.get("timeouts", 0) + r.get("errors", 0),
abs(r.get("detection_rate", 0.0)),
abs(r.get("det_peak", semtech_peak) - semtech_peak),
abs(r.get("det_min", semtech_min) - semtech_min),
),
)
@staticmethod
def _search_objective_value(result: dict, known_signal_present: bool) -> float:
if known_signal_present:
return float(result.get("detection_rate", 0.0)) - (
float(result.get("timeouts", 0) + result.get("errors", 0)) * 5.0
)
return -(
float(result.get("timeouts", 0) + result.get("errors", 0)) * 100.0
+ abs(float(result.get("detection_rate", 0.0)))
)
@staticmethod
def _merge_cad_result_samples(base: dict, extra: dict) -> dict:
merged = dict(base)
attempts = int(base.get("attempts", 0) or 0) + int(extra.get("attempts", 0) or 0)
detections = int(base.get("detections", 0) or 0) + int(extra.get("detections", 0) or 0)
non_detections = int(base.get("non_detections", 0) or 0) + int(
extra.get("non_detections", 0) or 0
)
timeouts = int(base.get("timeouts", 0) or 0) + int(extra.get("timeouts", 0) or 0)
errors = int(base.get("errors", 0) or 0) + int(extra.get("errors", 0) or 0)
cad_done_count = int(base.get("cad_done_count", 0) or 0) + int(
extra.get("cad_done_count", 0) or 0
)
merged.update(
{
"samples": attempts,
"attempts": attempts,
"detections": detections,
"non_detections": non_detections,
"timeouts": timeouts,
"errors": errors,
"cad_done_count": cad_done_count,
"detection_rate": (detections / attempts) * 100 if attempts > 0 else 0.0,
}
)
return merged
def _build_zoom_candidates(
self,
centers: list[dict],
peak_radius: int,
min_radius: int,
*,
peak_limit: tuple[int, int] = (1, 255),
min_limit: tuple[int, int] = (1, 255),
) -> list[tuple[int, int]]:
candidates: set[tuple[int, int]] = set()
for center in centers:
cp = int(center.get("det_peak", 22))
cm = int(center.get("det_min", 10))
peak_lower = max(peak_limit[0], cp - peak_radius)
peak_upper = min(peak_limit[1], cp + peak_radius)
min_lower = max(min_limit[0], cm - min_radius)
min_upper = min(min_limit[1], cm + min_radius)
for peak in range(peak_lower, peak_upper + 1):
for min_val in range(min_lower, min_upper + 1):
candidates.add((peak, min_val))
return sorted(candidates)
async def test_cad_config(
self, radio, det_peak: int, det_min: int, samples: int = 20
self,
radio,
det_peak: int,
det_min: int,
samples: int = 20,
cad_symbol_num: int = 2,
cad_timeout_seconds: float = 0.5,
) -> Dict[str, Any]:
detections = 0
baseline_detections = 0
non_detections = 0
timeouts = 0
errors = 0
cad_done_count = 0
attempts = 0
# First, get baseline with very insensitive settings (should detect nothing)
baseline_samples = 5
for _ in range(baseline_samples):
for _ in range(samples):
attempts += 1
try:
# Use very high thresholds that should detect nothing
baseline_result = await radio.perform_cad(det_peak=35, det_min=25, timeout=0.3)
if baseline_result:
baseline_detections += 1
result = await radio.perform_cad(
det_peak=det_peak,
det_min=det_min,
timeout=cad_timeout_seconds,
calibration=True,
cad_symbol_num=cad_symbol_num,
)
except Exception as exc:
logger.debug(f"CAD baseline sample failed: {exc}")
await asyncio.sleep(0.1) # 100ms between baseline samples
logger.debug("CAD sample exception for peak=%s min=%s: %s", det_peak, det_min, exc)
errors += 1
await asyncio.sleep(0.02)
continue
# Wait before actual test
await asyncio.sleep(0.5)
if not isinstance(result, dict):
result = {"detected": bool(result), "cad_done": True}
# Now test the actual configuration
for i in range(samples):
try:
result = await radio.perform_cad(det_peak=det_peak, det_min=det_min, timeout=0.3)
if result:
if result.get("error"):
errors += 1
elif result.get("timeout"):
timeouts += 1
else:
if bool(result.get("cad_done", False)):
cad_done_count += 1
if bool(result.get("detected", False)):
detections += 1
except Exception as exc:
logger.debug(f"CAD sample failed for det_peak={det_peak} det_min={det_min}: {exc}")
else:
non_detections += 1
# Variable delay to avoid sampling artifacts
delay = 0.05 + (i % 3) * 0.05 # 50ms, 100ms, 150ms rotation
await asyncio.sleep(delay)
await asyncio.sleep(0.02)
# Calculate adjusted detection rate
baseline_rate = (baseline_detections / baseline_samples) * 100
detection_rate = (detections / samples) * 100
# Subtract baseline noise
adjusted_rate = max(0, detection_rate - baseline_rate)
detection_rate = (detections / attempts) * 100 if attempts > 0 else 0.0
return {
"det_peak": det_peak,
"det_min": det_min,
"samples": samples,
"samples": attempts,
"attempts": attempts,
"detections": detections,
"non_detections": non_detections,
"timeouts": timeouts,
"errors": errors,
"cad_done_count": cad_done_count,
"cad_symbol_num": cad_symbol_num,
"detection_rate": detection_rate,
"baseline_rate": baseline_rate,
"adjusted_rate": adjusted_rate, # This is the useful metric
"sensitivity_score": self._calculate_sensitivity_score(
det_peak, det_min, adjusted_rate
),
}
def _calculate_sensitivity_score(
self, det_peak: int, det_min: int, adjusted_rate: float
) -> float:
def _select_recommended_result(
self, results: list[dict], known_signal_present: bool, sf: int
) -> Tuple[Optional[dict], str]:
if not results:
return None, "No calibration results collected."
# Ideal detection rate is around 10-30% for good sensitivity without false positives
ideal_rate = 20.0
rate_penalty = abs(adjusted_rate - ideal_rate) / ideal_rate
semtech_peak, semtech_min = self._default_thresholds_for_sf(sf)
# Prefer moderate sensitivity settings (not too extreme)
sensitivity_penalty = (abs(det_peak - 25) + abs(det_min - 15)) / 20.0
if known_signal_present:
required_detection_rate = 95.0
# Lower penalty = higher score
score = max(0, 100 - (rate_penalty * 50) - (sensitivity_penalty * 20))
return score
def _known_signal_sort_key(r: dict):
detection_rate = float(r.get("detection_rate", 0.0) or 0.0)
instability = int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0)
peak = int(r.get("det_peak", semtech_peak))
min_val = int(r.get("det_min", semtech_min))
attempts = int(r.get("attempts", 0) or r.get("samples", 0) or 0)
aggressiveness_penalty = max(0, semtech_peak - peak) + (
2 * max(0, semtech_min - min_val)
)
is_stable = instability == 0
meets_detection_floor = detection_rate >= required_detection_rate
qualification_tier = (
2 if (is_stable and meets_detection_floor) else (1 if is_stable else 0)
)
return (
qualification_tier,
-aggressiveness_penalty,
min_val,
peak,
detection_rate,
-instability,
attempts,
int(r.get("detections", 0) or 0),
)
ranked = sorted(
results,
key=_known_signal_sort_key,
reverse=True,
)
met_required = any(
(float(r.get("detection_rate", 0.0) or 0.0) >= required_detection_rate)
and (int(r.get("timeouts", 0) or 0) + int(r.get("errors", 0) or 0) == 0)
for r in results
)
return (
ranked[0],
(
"Recommended using known-signal qualification-first selection "
f"(require ≥{required_detection_rate:.0f}% detection with zero timeouts/errors, "
"then choose the least-sensitive stable setting that meets it)."
if met_required
else "No candidate met the strict known-signal qualification floor; "
"selected the most stable least-sensitive fallback from available results."
),
)
ranked = sorted(
results,
key=lambda r: (
r.get("timeouts", 0) + r.get("errors", 0),
abs(r.get("detection_rate", 0.0)),
abs(r.get("det_peak", semtech_peak) - semtech_peak),
abs(r.get("det_min", semtech_min) - semtech_min),
),
)
return (
ranked[0],
"Recommended from no-known-signal run (minimize false CAD_DETECTED and instability). Validation with a known compatible LoRa transmission is still required.",
)
def broadcast_to_clients(self, data):
@@ -134,125 +396,437 @@ class CADCalibrationEngine:
)
return
# Get spreading factor from daemon instance
config = getattr(self.daemon_instance, "config", {})
radio_config = config.get("radio", {})
sf = radio_config.get("spreading_factor", 8)
# Get test ranges
peak_range, min_range = self.get_test_ranges(sf)
total_tests = len(peak_range) * len(min_range)
self.progress = {"current": 0, "total": total_tests}
self.broadcast_to_clients(
{
"type": "status",
"message": f"Starting calibration: SF{sf}, {total_tests} tests",
"test_ranges": {
"peak_min": min(peak_range),
"peak_max": max(peak_range),
"min_min": min(min_range),
"min_max": max(min_range),
"spreading_factor": sf,
"total_tests": total_tests,
},
}
)
runtime_cfg = self._get_radio_runtime_config(radio)
sf = runtime_cfg["spreading_factor"]
base_peak = runtime_cfg["current_cad_peak"]
base_min = runtime_cfg["current_cad_min"]
known_signal_present = bool(self.session_config.get("known_signal_present", False))
cad_symbol_num = int(self.session_config.get("cad_symbol_num", 2))
cad_timeout_seconds = float(self.session_config.get("cad_timeout_seconds", 0.5))
current = 0
self.progress = {"current": 0, "total": 0}
peak_list = list(peak_range)
min_list = list(min_range)
# Create all test combinations
test_combinations = []
for det_peak in peak_list:
for det_min in min_list:
test_combinations.append((det_peak, det_min))
# Sort by distance from center for center-out pattern
peak_center = (max(peak_list) + min(peak_list)) / 2
min_center = (max(min_list) + min(min_list)) / 2
def distance_from_center(combo):
peak, min_val = combo
return ((peak - peak_center) ** 2 + (min_val - min_center) ** 2) ** 0.5
# Sort by distance from center
test_combinations.sort(key=distance_from_center)
# Randomize within bands for better coverage
band_size = max(1, len(test_combinations) // 8) # Create 8 bands
randomized_combinations = []
for i in range(0, len(test_combinations), band_size):
band = test_combinations[i : i + band_size]
random.shuffle(band) # Randomize within each band
randomized_combinations.extend(band)
# Run calibration in event loop with center-out randomized pattern
if self.event_loop:
for det_peak, det_min in randomized_combinations:
if not self.running:
break
if known_signal_present:
semtech_peak, semtech_min = self._default_thresholds_for_sf(sf)
required_detection_rate = 85.0
evaluation_samples = max(30, min(60, samples * 3))
max_escalation_steps = min(12, max(0, semtech_peak - 1))
candidate_pairs = [
(max(1, semtech_peak - step), semtech_min)
for step in range(max_escalation_steps + 1)
]
self.progress["total"] = len(candidate_pairs)
current += 1
self.progress["current"] = current
# Update progress
self.broadcast_to_clients(
{
"type": "progress",
"current": current,
"total": total_tests,
"peak": det_peak,
"min": det_min,
"type": "status",
"message": (
"Calibration stage 1/2 (default baseline): testing Semtech default "
"thresholds first; escalate only if required."
),
"test_ranges": {
"peak_min": candidate_pairs[-1][0],
"peak_max": candidate_pairs[0][0],
"min_min": semtech_min,
"min_max": semtech_min,
"spreading_factor": sf,
"bandwidth": runtime_cfg["bandwidth"],
"frequency": runtime_cfg["frequency"],
"current_peak": base_peak,
"current_min": base_min,
"cad_symbol_num": cad_symbol_num,
"known_signal_present": known_signal_present,
"total_tests": len(candidate_pairs),
"pass_index": 1,
"max_passes": 2,
"stage": "default-anchor",
},
}
)
# Run the test
future = asyncio.run_coroutine_threadsafe(
self.test_cad_config(radio, det_peak, det_min, samples), self.event_loop
)
qualified_candidate: Optional[dict] = None
for index, (det_peak, det_min) in enumerate(candidate_pairs, start=1):
if not self.running:
break
try:
result = future.result(timeout=30) # 30 second timeout per test
current = index
self.progress["current"] = current
self.broadcast_to_clients(
{
"type": "progress",
"current": current,
"total": len(candidate_pairs),
"det_peak": det_peak,
"det_min": det_min,
"known_signal_present": known_signal_present,
"pass_index": 1,
"max_passes": 2,
"stage": "default-anchor",
}
)
# Store result
key = f"{det_peak}-{det_min}"
self.results[key] = result
future = asyncio.run_coroutine_threadsafe(
self.test_cad_config(
radio,
det_peak,
det_min,
samples=evaluation_samples,
cad_symbol_num=cad_symbol_num,
cad_timeout_seconds=cad_timeout_seconds,
),
self.event_loop,
)
# Send result to clients
self.broadcast_to_clients({"type": "result", **result})
except Exception as e:
logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}")
try:
result = future.result(timeout=45)
self.results[f"{det_peak}-{det_min}"] = result
self.broadcast_to_clients(
{
"type": "result",
"pass_index": 1,
"stage": "default-anchor",
**result,
}
)
# Delay between tests
if self.running and delay_ms > 0:
time.sleep(delay_ms / 1000.0)
instability = int(result.get("timeouts", 0) or 0) + int(
result.get("errors", 0) or 0
)
detection_rate = float(result.get("detection_rate", 0.0) or 0.0)
if instability == 0 and detection_rate >= required_detection_rate:
qualified_candidate = result
self.broadcast_to_clients(
{
"type": "status",
"message": (
f"Qualification met at P{det_peak}/M{det_min} "
f"(rate {detection_rate:.1f}%, stable). "
"Stopping escalation at first qualifying candidate."
),
}
)
break
except Exception as e:
logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}")
if self.running and delay_ms > 0:
time.sleep(delay_ms / 1000.0)
if self.running and self.results and qualified_candidate is None:
self.broadcast_to_clients(
{
"type": "status",
"message": (
"No candidate met strict qualification floor "
f"(≥{required_detection_rate:.0f}% with zero timeouts/errors). "
"Using least-sensitive stable fallback from tested candidates."
),
}
)
else:
# Quiet-mode keeps the previous coarse-to-fine search behaviour.
coarse_peak_lower = max(1, int(base_peak) - 12)
coarse_peak_upper = min(255, int(base_peak) + 12)
coarse_min_lower = max(1, int(base_min) - 5)
coarse_min_upper = min(255, int(base_min) + 5)
max_total_tests = 84
estimated_total = 0
self.progress = {"current": 0, "total": estimated_total}
stage_definitions: list[dict[str, Any]] = [
{
"stage_key": "coarse",
"label": "coarse scan",
"builder": lambda: [
(peak, min_val)
for peak in self._build_stepped_range(
coarse_peak_lower, coarse_peak_upper, 4, anchor=int(base_peak)
)
for min_val in self._build_stepped_range(
coarse_min_lower, coarse_min_upper, 2, anchor=int(base_min)
)
],
},
{
"stage_key": "zoom1",
"label": "zoom refinement 1",
"builder": lambda: self._build_zoom_candidates(
self._rank_results_for_search(
list(self.results.values()), known_signal_present, sf
)[:3],
peak_radius=4,
min_radius=2,
),
},
{
"stage_key": "zoom2",
"label": "zoom refinement 2",
"builder": lambda: self._build_zoom_candidates(
self._rank_results_for_search(
list(self.results.values()), known_signal_present, sf
)[:2],
peak_radius=2,
min_radius=1,
),
},
{
"stage_key": "fine",
"label": "fine polish",
"builder": lambda: self._build_zoom_candidates(
self._rank_results_for_search(
list(self.results.values()), known_signal_present, sf
)[:1],
peak_radius=1,
min_radius=1,
),
},
]
best_score_before_stage: Optional[float] = None
for stage_index, stage in enumerate(stage_definitions, start=1):
if not self.running or current >= max_total_tests:
break
raw_candidates: list[tuple[int, int]] = stage["builder"]()
candidates = [
candidate
for candidate in raw_candidates
if f"{candidate[0]}-{candidate[1]}" not in self.results
]
remaining_budget = max_total_tests - current
candidates = candidates[:remaining_budget]
if not candidates:
continue
estimated_total = max(
self.progress.get("total", 0), current + len(candidates)
)
self.progress["total"] = estimated_total
peak_values = [candidate[0] for candidate in candidates]
min_values = [candidate[1] for candidate in candidates]
self.broadcast_to_clients(
{
"type": "status",
"message": (
f"Calibration stage {stage_index}/{len(stage_definitions)} "
f"({stage['label']}): testing {len(candidates)} combinations"
),
"test_ranges": {
"peak_min": min(peak_values),
"peak_max": max(peak_values),
"min_min": min(min_values),
"min_max": max(min_values),
"spreading_factor": sf,
"bandwidth": runtime_cfg["bandwidth"],
"frequency": runtime_cfg["frequency"],
"current_peak": base_peak,
"current_min": base_min,
"cad_symbol_num": cad_symbol_num,
"known_signal_present": known_signal_present,
"total_tests": estimated_total,
"pass_index": stage_index,
"max_passes": len(stage_definitions),
"stage": stage["stage_key"],
},
}
)
for det_peak, det_min in candidates:
if not self.running:
break
current += 1
self.progress["current"] = current
self.broadcast_to_clients(
{
"type": "progress",
"current": current,
"total": estimated_total,
"det_peak": det_peak,
"det_min": det_min,
"known_signal_present": known_signal_present,
"pass_index": stage_index,
"max_passes": len(stage_definitions),
"stage": stage["stage_key"],
}
)
future = asyncio.run_coroutine_threadsafe(
self.test_cad_config(
radio,
det_peak,
det_min,
samples=samples,
cad_symbol_num=cad_symbol_num,
cad_timeout_seconds=cad_timeout_seconds,
),
self.event_loop,
)
try:
result = future.result(timeout=30)
self.results[f"{det_peak}-{det_min}"] = result
self.broadcast_to_clients(
{
"type": "result",
"pass_index": stage_index,
"stage": stage["stage_key"],
**result,
}
)
except Exception as e:
logger.error(
f"CAD test failed for peak={det_peak}, min={det_min}: {e}"
)
if self.running and delay_ms > 0:
time.sleep(delay_ms / 1000.0)
if not self.running or not self.results:
break
ranked_results = self._rank_results_for_search(
list(self.results.values()), known_signal_present, sf
)
best_score_after_stage = self._search_objective_value(
ranked_results[0], known_signal_present
)
min_improvement = 1.0
if (
stage_index >= 2
and best_score_before_stage is not None
and (best_score_after_stage - best_score_before_stage) < min_improvement
):
self.broadcast_to_clients(
{
"type": "status",
"message": (
f"Calibration converged after {stage['label']} "
f"(improvement < {min_improvement:.0f}%)."
),
}
)
break
best_score_before_stage = best_score_after_stage
# Adaptive confidence pass remains for quiet-mode only.
if self.running and self.results:
ranked_for_verify = self._rank_results_for_search(
list(self.results.values()), known_signal_present, sf
)
finalist_count = min(3, len(ranked_for_verify))
target_samples = max(30, min(60, samples * 3))
extra_samples = max(0, target_samples - samples)
if finalist_count > 0 and extra_samples > 0:
self.broadcast_to_clients(
{
"type": "status",
"message": (
f"Verification pass: re-testing top {finalist_count} "
f"candidates with +{extra_samples} samples each."
),
}
)
for index, candidate in enumerate(
ranked_for_verify[:finalist_count], start=1
):
if not self.running:
break
det_peak = int(candidate.get("det_peak", 22))
det_min = int(candidate.get("det_min", 10))
self.broadcast_to_clients(
{
"type": "status",
"message": (
f"Verification {index}/{finalist_count}: "
f"P{det_peak}/M{det_min}"
),
}
)
future = asyncio.run_coroutine_threadsafe(
self.test_cad_config(
radio,
det_peak,
det_min,
samples=extra_samples,
cad_symbol_num=cad_symbol_num,
cad_timeout_seconds=cad_timeout_seconds,
),
self.event_loop,
)
try:
verification_result = future.result(timeout=30)
result_key = f"{det_peak}-{det_min}"
base_result = self.results.get(result_key, candidate)
merged_result = self._merge_cad_result_samples(
base_result, verification_result
)
self.results[result_key] = merged_result
self.broadcast_to_clients(
{
"type": "result",
"stage": "verification",
**merged_result,
}
)
except Exception as e:
logger.error(
"CAD finalist verification failed for peak=%s, min=%s: %s",
det_peak,
det_min,
e,
)
if self.running:
# Find best result based on sensitivity score (not just detection rate)
best_result = None
recommended_result = None
recommendation_reason = "No recommendation generated."
signal_activity_observed = False
known_signal_effective = known_signal_present
quiet_mode_invalid = False
quiet_mode_invalid_reason = ""
aggregate_detection_rate = 0.0
qualification = (
"Known compatible LoRa signal present during calibration."
if known_signal_present
else "No known compatible LoRa signal confirmed during calibration."
)
if self.results:
# Find result with highest sensitivity score (best balance)
best_result = max(
self.results.values(), key=lambda x: x.get("sensitivity_score", 0)
all_results = list(self.results.values())
best_result = max(all_results, key=lambda x: x.get("detection_rate", 0.0))
recommended_result, recommendation_reason = self._select_recommended_result(
all_results, known_signal_present=known_signal_present, sf=sf
)
# Also find result with ideal adjusted detection rate (10-30%)
ideal_results = [
r for r in self.results.values() if 10 <= r.get("adjusted_rate", 0) <= 30
]
if ideal_results:
# Among ideal results, pick the one with best sensitivity score
recommended_result = max(
ideal_results, key=lambda x: x.get("sensitivity_score", 0)
total_attempts = sum(int(r.get("attempts", 0) or 0) for r in all_results)
total_detections = sum(int(r.get("detections", 0) or 0) for r in all_results)
best_rate = float(best_result.get("detection_rate", 0.0) or 0.0)
aggregate_detection_rate = (
(float(total_detections) / float(total_attempts)) * 100.0
if total_attempts > 0
else 0.0
)
min_detection_floor = max(5, int(total_attempts * 0.03))
signal_activity_observed = (
total_detections >= min_detection_floor and best_rate >= 15.0
)
known_signal_effective = known_signal_present or signal_activity_observed
if not known_signal_present and (
signal_activity_observed or aggregate_detection_rate > 10.0
):
quiet_mode_invalid = True
quiet_mode_invalid_reason = (
"Quiet-mode run observed significant channel activity "
f"(aggregate CAD detection {aggregate_detection_rate:.1f}%). "
"Re-run quiet baseline during a truly idle channel."
)
if not known_signal_present and signal_activity_observed:
qualification = (
"Signal activity was observed during quiet-mode calibration, "
"but known-signal mode was not explicitly enabled."
)
else:
recommended_result = best_result
self.broadcast_to_clients(
{
@@ -262,6 +836,14 @@ class CADCalibrationEngine:
{
"best": best_result,
"recommended": recommended_result,
"recommendation_reason": recommendation_reason,
"known_signal_present": known_signal_present,
"signal_activity_observed": signal_activity_observed,
"known_signal_effective": known_signal_effective,
"quiet_mode_invalid": quiet_mode_invalid,
"quiet_mode_invalid_reason": quiet_mode_invalid_reason,
"aggregate_detection_rate": aggregate_detection_rate,
"qualification": qualification,
"total_tests": len(self.results),
}
if best_result
@@ -282,6 +864,26 @@ class CADCalibrationEngine:
if self.running:
return False
samples = self._normalize_int(samples, default=8, minimum=1, maximum=64)
delay_ms = self._normalize_int(delay_ms, default=100, minimum=0, maximum=2000)
known_signal_present = self._normalize_bool(
self.session_config.get("known_signal_present", False), default=False
)
cad_symbol_num = self._normalize_int(
self.session_config.get("cad_symbol_num", 2), default=2, minimum=1, maximum=16
)
if cad_symbol_num not in {1, 2, 4, 8, 16}:
cad_symbol_num = 2
cad_timeout_ms = self._normalize_int(
self.session_config.get("cad_timeout_ms", 500), default=500, minimum=50, maximum=5000
)
self.session_config = {
"known_signal_present": known_signal_present,
"cad_symbol_num": cad_symbol_num,
"cad_timeout_ms": cad_timeout_ms,
"cad_timeout_seconds": cad_timeout_ms / 1000.0,
}
self.running = True
self.results.clear()
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.glass-card[data-v-4267adba]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}
.glass-card[data-v-c88467e1]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.ml-0[data-v-1b1421f8]{margin-left:0}.ml-4[data-v-1b1421f8]{margin-left:1rem}.ml-8[data-v-1b1421f8]{margin-left:2rem}.ml-12[data-v-1b1421f8]{margin-left:3rem}.ml-16[data-v-1b1421f8]{margin-left:4rem}.ml-20[data-v-1b1421f8]{margin-left:5rem}.ml-24[data-v-1b1421f8]{margin-left:6rem}.ml-28[data-v-1b1421f8]{margin-left:7rem}.ml-32[data-v-1b1421f8]{margin-left:8rem}.dropdown-enter-active[data-v-45cb296d],.dropdown-leave-active[data-v-45cb296d]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-45cb296d],.dropdown-leave-to[data-v-45cb296d]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-00e540ed],.expand-leave-active[data-v-00e540ed]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-00e540ed],.expand-leave-to[data-v-00e540ed]{opacity:0;max-height:0}.expand-enter-to[data-v-00e540ed],.expand-leave-from[data-v-00e540ed]{opacity:1;max-height:2000px}
@@ -0,0 +1 @@
.ml-0[data-v-164d2832]{margin-left:0}.ml-4[data-v-164d2832]{margin-left:1rem}.ml-8[data-v-164d2832]{margin-left:2rem}.ml-12[data-v-164d2832]{margin-left:3rem}.ml-16[data-v-164d2832]{margin-left:4rem}.ml-20[data-v-164d2832]{margin-left:5rem}.ml-24[data-v-164d2832]{margin-left:6rem}.ml-28[data-v-164d2832]{margin-left:7rem}.ml-32[data-v-164d2832]{margin-left:8rem}.dropdown-enter-active[data-v-e0c27f45],.dropdown-leave-active[data-v-e0c27f45]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-e0c27f45],.dropdown-leave-to[data-v-e0c27f45]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-5bf02bea],.expand-leave-active[data-v-5bf02bea]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-5bf02bea],.expand-leave-to[data-v-5bf02bea]{opacity:0;max-height:0}.expand-enter-to[data-v-5bf02bea],.expand-leave-from[data-v-5bf02bea]{opacity:1;max-height:2000px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`flex items-center justify-between mb-4`},f={class:`text-xl font-semibold text-content-primary`},p={class:`mb-6`},m={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`text-content-secondary dark:text-content-primary/opacity-heavy text-base leading-relaxed`},v={class:`flex gap-3`},y=r({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(r,{emit:y}){let b=r,x=y,S={danger:`bg-accent-red/opacity-light dark:bg-accent-red/opacity-medium border-accent-red/opacity-medium text-accent-red`,warning:`bg-accent-amber/opacity-light dark:bg-accent-amber/opacity-medium border-accent-amber/opacity-medium text-accent-amber`,info:`bg-primary/opacity-medium border-primary/opacity-medium text-primary`},C={danger:`bg-accent-red/opacity-light hover:bg-accent-red/opacity-light`,warning:`bg-accent-amber/opacity-light hover:bg-accent-amber/opacity-light`,info:`bg-primary/opacity-light hover:bg-primary/opacity-light`};return(r,y)=>(e(),n(a,{to:`body`},[b.show?(e(),c(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`h3`,f,t(b.title),1),s(`button`,{onClick:y[0]||=e=>x(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...y[4]||=[s(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),s(`div`,p,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(e(),c(`svg`,m,[...y[5]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):b.variant===`warning`?(e(),c(`svg`,h,[...y[6]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(e(),c(`svg`,g,[...y[7]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),s(`p`,_,t(b.message),1)]),s(`div`,v,[s(`button`,{onClick:y[1]||=e=>x(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/opacity-subtle hover:bg-stroke-subtle dark:hover:bg-white/opacity-light text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/opacity-light`},t(b.cancelText),1),s(`button`,{onClick:y[2]||=e=>x(`confirm`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},t(b.confirmText),3)])])])):o(``,!0)]))}});export{y as t};
@@ -1 +0,0 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`flex items-center justify-between mb-4`},f={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},p={class:`mb-6`},m={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},v={class:`flex gap-3`},y=r({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(r,{emit:y}){let b=r,x=y,S={danger:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,warning:`bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},C={danger:`bg-red-500 hover:bg-red-600`,warning:`bg-yellow-500 hover:bg-yellow-600`,info:`bg-blue-500 hover:bg-blue-600`};return(r,y)=>(e(),n(a,{to:`body`},[b.show?(e(),c(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`h3`,f,t(b.title),1),s(`button`,{onClick:y[0]||=e=>x(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...y[4]||=[s(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),s(`div`,p,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(e(),c(`svg`,m,[...y[5]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):b.variant===`warning`?(e(),c(`svg`,h,[...y[6]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z`},null,-1)]])):(e(),c(`svg`,g,[...y[7]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),s(`p`,_,t(b.message),1)]),s(`div`,v,[s(`button`,{onClick:y[1]||=e=>x(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10`},t(b.cancelText),1),s(`button`,{onClick:y[2]||=e=>x(`confirm`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},t(b.confirmText),3)])])])):o(``,!0)]))}});export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.globe-stage[data-v-9debe317]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, color-mix(in srgb, var(--color-surface) 14%, transparent), transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-9debe317]:active{cursor:grabbing}.globe-stage canvas[data-v-9debe317]{width:100%;height:330px;display:block}.globe-tooltip[data-v-9debe317]{z-index:2;border:1px solid var(--color-glass-border);background:color-mix(in srgb, var(--color-surface-elevated) 92%, transparent);min-width:154px;color:var(--color-heading);box-shadow:var(--color-glass-shadow);pointer-events:none;border-radius:12px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px))}.tooltip-title[data-v-9debe317]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-9debe317]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-9debe317]{color:var(--color-text-muted)}.tooltip-value[data-v-9debe317]{color:var(--color-heading);text-align:right;font-weight:700}.globe-fallback[data-v-9debe317]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-9debe317]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-9debe317]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-9debe317]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-9debe317]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-9debe317]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-9debe317]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-9debe317]{opacity:.35}.page-tabs[data-v-9debe317]{border-bottom:1px solid var(--color-border-subtle);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-9debe317]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-9debe317]:hover{color:var(--color-text-primary)}.page-tab-active[data-v-9debe317]{color:var(--color-primary);border-bottom-color:var(--color-primary)}.inner-tabs[data-v-9debe317]{border:1px solid var(--color-border-subtle);background:color-mix(in srgb, var(--color-surface) 22%, transparent);border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-9debe317]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-9debe317]:hover{color:var(--color-text-primary)}.inner-tab-active[data-v-9debe317]{background:var(--color-primary);color:var(--color-heading)}.accordion-header[data-v-9debe317]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-9debe317]:hover{background:color-mix(in srgb, var(--color-surface) 22%, transparent)}.accordion-chevron[data-v-9debe317]{color:var(--color-text-muted);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-9debe317]{transform:rotate(180deg)}.accordion-body[data-v-9debe317]{padding:0 20px 16px}
.globe-stage[data-v-8578fd70]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, color-mix(in srgb, var(--color-surface) 14%, transparent), transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-8578fd70]:active{cursor:grabbing}.globe-stage canvas[data-v-8578fd70]{width:100%;height:330px;display:block}.globe-tooltip[data-v-8578fd70]{z-index:2;border:1px solid var(--color-glass-border);background:color-mix(in srgb, var(--color-surface-elevated) 92%, transparent);min-width:154px;color:var(--color-heading);box-shadow:var(--color-glass-shadow);pointer-events:none;border-radius:12px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px))}.tooltip-title[data-v-8578fd70]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-8578fd70]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-8578fd70]{color:var(--color-text-muted)}.tooltip-value[data-v-8578fd70]{color:var(--color-heading);text-align:right;font-weight:700}.globe-fallback[data-v-8578fd70]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-8578fd70]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-8578fd70]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-8578fd70]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-8578fd70]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-8578fd70]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-8578fd70]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-8578fd70]{opacity:.35}.page-tabs[data-v-8578fd70]{border-bottom:1px solid var(--color-border-subtle);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-8578fd70]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-8578fd70]:hover{color:var(--color-text-primary)}.page-tab-active[data-v-8578fd70]{color:var(--color-primary);border-bottom-color:var(--color-primary)}.inner-tabs[data-v-8578fd70]{border:1px solid var(--color-border-subtle);background:color-mix(in srgb, var(--color-surface) 22%, transparent);border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-8578fd70]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-8578fd70]:hover{color:var(--color-text-primary)}.inner-tab-active[data-v-8578fd70]{background:var(--color-primary);color:var(--color-heading)}.accordion-header[data-v-8578fd70]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-8578fd70]:hover{background:color-mix(in srgb, var(--color-surface) 22%, transparent)}.accordion-chevron[data-v-8578fd70]{color:var(--color-text-muted);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-8578fd70]{transform:rotate(180deg)}.accordion-body[data-v-8578fd70]{padding:0 20px 16px}
@@ -0,0 +1 @@
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/opacity-light rounded-[15px] p-8"><h1 class="text-content-primary text-2xl font-semibold mb-6"> Help &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary text-xl font-medium mb-3"> Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/openhop-dev/openhop-repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/opacity-medium hover:bg-primary/opacity-medium border border-primary/opacity-heavy text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
@@ -1 +0,0 @@
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6"> Help &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/20 hover:bg-primary/30 border border-primary/50 text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.bg-gradient-light[data-v-87cdf9e4]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 25%, transparent), color-mix(in srgb, var(--color-secondary) 15%, transparent))}.bg-gradient-dark[data-v-87cdf9e4]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-secondary) 8%, transparent))}.login-card[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(40px)saturate(180%);box-shadow:var(--color-glass-shadow);background:color-mix(in srgb, var(--color-surface) 85%, transparent)}.dark .login-card[data-v-87cdf9e4]{background:color-mix(in srgb, var(--color-surface-elevated) 80%, transparent)}.input-glass[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(20px);background:color-mix(in srgb, var(--color-surface) 90%, transparent);border:1px solid var(--color-border)}.dark .input-glass[data-v-87cdf9e4]{background:color-mix(in srgb, var(--color-surface-elevated) 55%, transparent);border-color:var(--color-border-subtle)}.input-glass[data-v-87cdf9e4]:focus{background:var(--color-surface)}.dark .input-glass[data-v-87cdf9e4]:focus{background:color-mix(in srgb, var(--color-surface-elevated) 70%, transparent)}.input-glass[data-v-87cdf9e4]:focus{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 0 20px color-mix(in srgb, var(--color-accent-cyan) 15%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.input-glow[data-v-87cdf9e4]{opacity:0;box-shadow:inset 0 1px 0 color-mix(in srgb, var(--color-surface) 35%, transparent);transition:opacity .3s}.input-glass:focus+.input-glow[data-v-87cdf9e4]{opacity:1;box-shadow:0 0 20px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-87cdf9e4]:before{content:"";background:linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--color-accent-cyan) 30%, transparent) 50%, transparent 100%);-webkit-mask:linear-gradient(var(--color-surface) 0 0) content-box, linear-gradient(var(--color-surface) 0 0);-webkit-mask-composite:xor;border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-composite:xor;mask-composite:exclude}.button-glass[data-v-87cdf9e4]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-87cdf9e4]{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 4px 16px color-mix(in srgb, var(--color-background) 35%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-87cdf9e4]:hover:not(:disabled){box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 40%, transparent), 0 0 30px color-mix(in srgb, var(--color-accent-cyan) 30%, transparent), 0 4px 20px color-mix(in srgb, var(--color-background) 45%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 55%, transparent)}@keyframes float-87cdf9e4{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-87cdf9e4{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-87cdf9e4{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-87cdf9e4{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-87cdf9e4]{animation:8s ease-in-out infinite pulse-slow-87cdf9e4}.animate-pulse-slower[data-v-87cdf9e4]{animation:10s ease-in-out infinite pulse-slower-87cdf9e4}.animate-pulse-slowest[data-v-87cdf9e4]{animation:12s ease-in-out infinite pulse-slowest-87cdf9e4}@keyframes shake-87cdf9e4{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-87cdf9e4]{animation:.5s ease-in-out shake-87cdf9e4}.form-group[data-v-87cdf9e4]{position:relative}.form-group:hover label[data-v-87cdf9e4]{color:var(--color-accent-cyan);transition:color .3s}
.bg-gradient-light[data-v-eec727a3]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 25%, transparent), color-mix(in srgb, var(--color-secondary) 15%, transparent))}.bg-gradient-dark[data-v-eec727a3]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-secondary) 8%, transparent))}.login-card[data-v-eec727a3]{-webkit-backdrop-filter:blur(40px)saturate(180%);box-shadow:var(--color-glass-shadow);background:color-mix(in srgb, var(--color-surface) 85%, transparent)}.dark .login-card[data-v-eec727a3]{background:color-mix(in srgb, var(--color-surface-elevated) 80%, transparent)}.input-glass[data-v-eec727a3]{-webkit-backdrop-filter:blur(20px);background:color-mix(in srgb, var(--color-surface) 90%, transparent);border:1px solid var(--color-border)}.dark .input-glass[data-v-eec727a3]{background:color-mix(in srgb, var(--color-surface-elevated) 55%, transparent);border-color:var(--color-border-subtle)}.input-glass[data-v-eec727a3]:focus{background:var(--color-surface)}.dark .input-glass[data-v-eec727a3]:focus{background:color-mix(in srgb, var(--color-surface-elevated) 70%, transparent)}.input-glass[data-v-eec727a3]:focus{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 0 20px color-mix(in srgb, var(--color-accent-cyan) 15%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.input-glow[data-v-eec727a3]{opacity:0;box-shadow:inset 0 1px 0 color-mix(in srgb, var(--color-surface) 35%, transparent);transition:opacity .3s}.input-glass:focus+.input-glow[data-v-eec727a3]{opacity:1;box-shadow:0 0 20px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-eec727a3]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-eec727a3]:before{content:"";background:linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--color-accent-cyan) 30%, transparent) 50%, transparent 100%);-webkit-mask:linear-gradient(var(--color-surface) 0 0) content-box, linear-gradient(var(--color-surface) 0 0);-webkit-mask-composite:xor;border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-composite:xor;mask-composite:exclude}.button-glass[data-v-eec727a3]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-eec727a3]{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 4px 16px color-mix(in srgb, var(--color-background) 35%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-eec727a3]:hover:not(:disabled){box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 40%, transparent), 0 0 30px color-mix(in srgb, var(--color-accent-cyan) 30%, transparent), 0 4px 20px color-mix(in srgb, var(--color-background) 45%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 55%, transparent)}@keyframes float-eec727a3{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-eec727a3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-eec727a3{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-eec727a3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-eec727a3]{animation:8s ease-in-out infinite pulse-slow-eec727a3}.animate-pulse-slower[data-v-eec727a3]{animation:10s ease-in-out infinite pulse-slower-eec727a3}.animate-pulse-slowest[data-v-eec727a3]{animation:12s ease-in-out infinite pulse-slowest-eec727a3}@keyframes shake-eec727a3{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-eec727a3]{animation:.5s ease-in-out shake-eec727a3}.form-group[data-v-eec727a3]{position:relative}.form-group:hover label[data-v-eec727a3]{color:var(--color-accent-cyan);transition:color .3s}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`mb-6`},f={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={class:`text-content-secondary dark:text-content-primary/opacity-heavy text-base leading-relaxed`},g={class:`flex`},_=r({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(r,{emit:_}){let v=r,y=_,b={success:`bg-accent-green/opacity-light dark:bg-accent-green/opacity-medium border-accent-green/opacity-heavy dark:border-accent-green/opacity-medium text-accent-green`,error:`bg-accent-red/opacity-light dark:bg-accent-red/opacity-medium border-accent-red/opacity-medium text-accent-red`,info:`bg-primary/opacity-medium border-primary/opacity-medium text-primary`},x={success:`bg-accent-green/opacity-light hover:bg-accent-green/opacity-light`,error:`bg-accent-red/opacity-light hover:bg-accent-red/opacity-light`,info:`bg-primary/opacity-light hover:bg-primary/opacity-light`};return(r,_)=>(e(),n(a,{to:`body`},[v.show?(e(),c(`div`,{key:0,onClick:_[1]||=l(e=>y(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,b[v.variant]])},[v.variant===`success`?(e(),c(`svg`,f,[..._[2]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):v.variant===`error`?(e(),c(`svg`,p,[..._[3]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(e(),c(`svg`,m,[..._[4]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),s(`p`,h,t(v.message),1)]),s(`div`,g,[s(`button`,{onClick:_[0]||=e=>y(`close`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[v.variant]])},` OK `,2)])])])):o(``,!0)]))}});export{_ as t};
@@ -1 +0,0 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`mb-6`},f={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},g={class:`flex`},_=r({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(r,{emit:_}){let v=r,y=_,b={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},x={success:`bg-green-500 hover:bg-green-600`,error:`bg-red-500 hover:bg-red-600`,info:`bg-blue-500 hover:bg-blue-600`};return(r,_)=>(e(),n(a,{to:`body`},[v.show?(e(),c(`div`,{key:0,onClick:_[1]||=l(e=>y(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,b[v.variant]])},[v.variant===`success`?(e(),c(`svg`,f,[..._[2]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):v.variant===`error`?(e(),c(`svg`,p,[..._[3]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(e(),c(`svg`,m,[..._[4]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]]))],2),s(`p`,h,t(v.message),1)]),s(`div`,g,[s(`button`,{onClick:_[0]||=e=>y(`close`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[v.variant]])},` OK `,2)])])])):o(``,!0)]))}});export{_ as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.modal-enter-active[data-v-e95d3629]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-e95d3629]{transition:all .2s ease-in}.modal-enter-from[data-v-e95d3629]{opacity:0;transform:scale(.95)translateY(-10px)}.modal-leave-to[data-v-e95d3629]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-e95d3629]{scrollbar-width:thin;scrollbar-color:color-mix(in srgb, var(--color-surface) 35%, transparent) transparent}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-track{background:color-mix(in srgb, var(--color-surface) 12%, transparent);border-radius:3px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-thumb{background:color-mix(in srgb, var(--color-surface) 35%, transparent);border-radius:3px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--color-surface) 45%, transparent)}.glass-card[data-v-e95d3629]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.modal-enter-active[data-v-1921baee]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-1921baee]{transition:all .2s ease-in}.modal-enter-from[data-v-1921baee]{opacity:0;transform:scale(.95)translateY(-10px)}.modal-leave-to[data-v-1921baee]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-1921baee]{scrollbar-width:thin;scrollbar-color:color-mix(in srgb, var(--color-surface) 35%, transparent) transparent}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-track{background:color-mix(in srgb, var(--color-surface) 12%, transparent);border-radius:3px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-thumb{background:color-mix(in srgb, var(--color-surface) 35%, transparent);border-radius:3px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--color-surface) 45%, transparent)}.glass-card[data-v-1921baee]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.fade-enter-active[data-v-1709062a],.fade-leave-active[data-v-1709062a]{transition:opacity .2s}.fade-enter-from[data-v-1709062a],.fade-leave-to[data-v-1709062a]{opacity:0}
@@ -1 +0,0 @@
.fade-enter-active[data-v-2abc6745],.fade-leave-active[data-v-2abc6745]{transition:opacity .2s}.fade-enter-from[data-v-2abc6745],.fade-leave-to[data-v-2abc6745]{opacity:0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-BwYDm56e.js";import{t as d}from"./index-Cijj_ZXo.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading dark:text-white`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading dark:text-white`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading dark:text-white`},w={key:0,class:`text-sm`},T={class:`ml-2 text-red-600 dark:text-red-300`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/10`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading dark:text-white`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-xl sm:text-2xl font-semibold text-content-heading dark:text-white`},`Sensors`),c(`p`,{class:`mt-1 text-sm text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/10 px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/10 p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300`:`bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/5 dark:bg-white/5`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/10`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
@@ -0,0 +1 @@
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-xkq2menr.js";import{t as d}from"./index-BVAZTGr4.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading`},w={key:0,class:`text-sm`},T={class:`ml-2 text-accent-red`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-xl sm:text-2xl font-semibold text-content-heading`},`Sensors`),c(`p`,{class:`mt-1 text-sm text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/opacity-light px-3 py-2 text-sm hover:bg-black/opacity-light dark:hover:bg-white/opacity-light`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-accent-green/opacity-light text-accent-green dark:bg-accent-green/opacity-medium dark:text-accent-green`:`bg-accent-red/opacity-light text-accent-red dark:bg-accent-red/opacity-medium dark:text-accent-red`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/opacity-light dark:bg-white/opacity-subtle`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/opacity-light`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.glass-card[data-v-f90711e2]{background:color-mix(in srgb, var(--color-surface) 45%, transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-border-subtle)}.setup-dialog[data-v-f90711e2]{box-shadow:var(--color-glass-shadow)}.modal-enter-active[data-v-f90711e2],.modal-leave-active[data-v-f90711e2]{transition:opacity .3s}.modal-enter-from[data-v-f90711e2],.modal-leave-to[data-v-f90711e2]{opacity:0}.modal-enter-active .glass-card[data-v-f90711e2],.modal-leave-active .glass-card[data-v-f90711e2]{transition:transform .3s}.modal-enter-from .glass-card[data-v-f90711e2],.modal-leave-to .glass-card[data-v-f90711e2]{transform:scale(.9)}.slide-enter-active[data-v-f90711e2],.slide-leave-active[data-v-f90711e2]{transition:all .3s}.slide-enter-from[data-v-f90711e2],.slide-leave-to[data-v-f90711e2]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-f90711e2{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px)scale(1.05)rotate(-24.22deg)}}@keyframes float-slower-f90711e2{0%,to{opacity:.75;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px)scale(1.08)rotate(-24.22deg)}}@keyframes float-slowest-f90711e2{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px)scale(1.1)rotate(-24.22deg)}}.animate-pulse-slow[data-v-f90711e2]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-f90711e2}.animate-pulse-slower[data-v-f90711e2]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-f90711e2}.animate-pulse-slowest[data-v-f90711e2]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-f90711e2}
@@ -1 +0,0 @@
.glass-card[data-v-ecd1d451]{background:color-mix(in srgb, var(--color-surface) 45%, transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-border-subtle)}.setup-dialog[data-v-ecd1d451]{box-shadow:var(--color-glass-shadow)}.modal-enter-active[data-v-ecd1d451],.modal-leave-active[data-v-ecd1d451]{transition:opacity .3s}.modal-enter-from[data-v-ecd1d451],.modal-leave-to[data-v-ecd1d451]{opacity:0}.modal-enter-active .glass-card[data-v-ecd1d451],.modal-leave-active .glass-card[data-v-ecd1d451]{transition:transform .3s}.modal-enter-from .glass-card[data-v-ecd1d451],.modal-leave-to .glass-card[data-v-ecd1d451]{transform:scale(.9)}.slide-enter-active[data-v-ecd1d451],.slide-leave-active[data-v-ecd1d451]{transition:all .3s}.slide-enter-from[data-v-ecd1d451],.slide-leave-to[data-v-ecd1d451]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-ecd1d451{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px)scale(1.05)rotate(-24.22deg)}}@keyframes float-slower-ecd1d451{0%,to{opacity:.75;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px)scale(1.08)rotate(-24.22deg)}}@keyframes float-slowest-ecd1d451{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px)scale(1.1)rotate(-24.22deg)}}.animate-pulse-slow[data-v-ecd1d451]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-ecd1d451}.animate-pulse-slower[data-v-ecd1d451]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-ecd1d451}.animate-pulse-slowest[data-v-ecd1d451]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-ecd1d451}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-xkq2menr.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=4,p=5;function m(e,t){return e-t}function h(e){return l[e]??l[d]}function g(e,t){if(e<t)return{bars:0,color:`text-accent-red`,bgColor:`bg-accent-red`,snr:e,quality:`None`};let n=Math.min(p,Math.floor((e-t)/f)+1);return{bars:n,snr:e,...{1:{color:`text-accent-red`,bgColor:`bg-accent-red`,quality:`Poor`},2:{color:`text-accent-orange`,bgColor:`bg-accent-orange`,quality:`Poor`},3:{color:`text-accent-amber`,bgColor:`bg-accent-amber`,quality:`Fair`},4:{color:`text-accent-green-light`,bgColor:`bg-accent-green-light`,quality:`Good`},5:{color:`text-accent-green`,bgColor:`bg-accent-green`,quality:`Excellent`}}[n]}}function _(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>h(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`};let n=m(e,t.value);return g(Math.max(-30,Math.min(20,n)),r.value)},getSignalQualityFromSNR:e=>e===null||!Number.isFinite(e)?{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`}:g(Math.max(-30,Math.min(20,e)),r.value),noiseFloor:t,spreadingFactor:n,minSNR:r}}var v={class:`flex items-end gap-0.5`},y=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,v,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{_ as n,y as t};
@@ -1 +0,0 @@
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-BwYDm56e.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=5;function p(e,t){return e-t}function m(e){return l[e]??l[d]}function h(e,t){let n=t+f;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,bgColor:`bg-accent-red`,snr:e,quality:n===0?`None`:`Poor`}}if(e<n){let n=(e-t)/f<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,bgColor:n===2?`bg-orange-600 dark:bg-orange-400`:`bg-yellow-600 dark:bg-yellow-400`,snr:e,quality:`Fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,bgColor:`bg-accent-green`,snr:e,quality:r===5?`Excellent`:`Good`}}function g(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>m(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,bgColor:`bg-gray-400 dark:bg-gray-500`,snr:-999,quality:`None`};let n=p(e,t.value);return h(Math.max(-30,Math.min(20,n)),r.value)},noiseFloor:t,spreadingFactor:n,minSNR:r}}var _={class:`flex items-end gap-0.5`},v=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,_,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{g as n,v as t};
@@ -1 +1 @@
import{T as e,_t as t,c as n,gt as r,h as i,l as a,m as o,o as s,p as c,r as l,s as u,u as d}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as f}from"./Spinner-CMJUE3iy.js";import{d as p}from"./index-Cijj_ZXo.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t};
import{T as e,_t as t,c as n,gt as r,h as i,l as a,m as o,o as s,p as c,r as l,s as u,u as d}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as f}from"./Spinner-CMJUE3iy.js";import{d as p}from"./index-BVAZTGr4.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.chart-updating[data-v-e3234fe9]{animation:.8s ease-in-out subtle-pulse-e3234fe9}@keyframes subtle-pulse-e3234fe9{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.process-row[data-v-e3234fe9]{transition:all .3s}.process-row[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-background) 15%, transparent);transform:translate(2px)}.dark .process-row[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-surface) 20%, transparent)}.process-row-enter-active[data-v-e3234fe9],.process-row-leave-active[data-v-e3234fe9]{transition:all .4s}.process-row-enter-from[data-v-e3234fe9]{opacity:0;transform:translateY(-10px)scale(.95)}.process-row-leave-to[data-v-e3234fe9]{opacity:0;transform:translateY(10px)scale(.95)}.process-row-move[data-v-e3234fe9]{transition:transform .4s}.cpu-value[data-v-e3234fe9],.memory-value[data-v-e3234fe9]{border-radius:4px;padding:2px 6px;transition:all .3s}.cpu-value[data-v-e3234fe9]:hover,.memory-value[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-secondary) 10%, transparent);transform:scale(1.05)}@keyframes value-update-e3234fe9{0%{background:color-mix(in srgb, var(--color-secondary) 30%, transparent)}to{background:0 0}}.value-updated[data-v-e3234fe9]{animation:.6s ease-out value-update-e3234fe9}
@@ -0,0 +1 @@
.chart-updating[data-v-66d09830]{animation:.8s ease-in-out subtle-pulse-66d09830}@keyframes subtle-pulse-66d09830{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.process-row[data-v-66d09830]{transition:all .3s}.process-row[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-background) 15%, transparent);transform:translate(2px)}.dark .process-row[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-surface) 20%, transparent)}.process-row-enter-active[data-v-66d09830],.process-row-leave-active[data-v-66d09830]{transition:all .4s}.process-row-enter-from[data-v-66d09830]{opacity:0;transform:translateY(-10px)scale(.95)}.process-row-leave-to[data-v-66d09830]{opacity:0;transform:translateY(10px)scale(.95)}.process-row-move[data-v-66d09830]{transition:transform .4s}.cpu-value[data-v-66d09830],.memory-value[data-v-66d09830]{border-radius:4px;padding:2px 6px;transition:all .3s}.cpu-value[data-v-66d09830]:hover,.memory-value[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-secondary) 10%, transparent);transform:scale(1.05)}@keyframes value-update-66d09830{0%{background:color-mix(in srgb, var(--color-secondary) 30%, transparent)}to{background:0 0}}.value-updated[data-v-66d09830]{animation:.6s ease-out value-update-66d09830}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More