mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-10 19:03:22 +02:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 438a02242d | |||
| 4fec619e36 | |||
| 8ba818c921 | |||
| 5d75cb9ad4 | |||
| ad87ca3db2 | |||
| 4990322cef | |||
| 0485023143 | |||
| fac5b1bdce | |||
| 86a2ac3083 | |||
| 25db2d19fc | |||
| b2eb45b199 | |||
| 95500ece09 | |||
| fa046a1c5f | |||
| ff53b2520b | |||
| 11552c467f | |||
| 9f75c0460d | |||
| 1906f576bb | |||
| 92310235ae | |||
| e22514882f | |||
| 676e2cea30 | |||
| d8bc448f82 | |||
| 4a124782ae | |||
| d1a9230ab5 | |||
| 667169c7ff | |||
| 17f8bcca2f | |||
| 05d1e391d6 | |||
| 630f2cfa4e | |||
| 143c7633bf | |||
| c14d8b2226 | |||
| 4a055be8df | |||
| 9177d1aacc | |||
| 1a0823b6d7 | |||
| 90d63d61de | |||
| f15884565e | |||
| 7d30dd4247 | |||
| 954150b2d8 | |||
| 43a112ce7c | |||
| b2e45c2038 | |||
| 9c6ea0151e | |||
| 0404b3ab44 | |||
| 3a29061e24 | |||
| e6d4b68d01 | |||
| fd43d86ea8 | |||
| 700a38b8c1 | |||
| 141126a9a6 | |||
| e52637535d | |||
| 0e26ed3cb3 | |||
| 1a2bf9e4fd | |||
| 8b3894d2d5 | |||
| daec7f0ebc | |||
| a5c85dd9d9 | |||
| ff43d85b85 | |||
| 229d71459f | |||
| 047ad19fd3 | |||
| 05449987c1 | |||
| ae6ac1bd05 | |||
| ae68112377 | |||
| 34747d2610 | |||
| 0561803eeb | |||
| a88ac21e61 | |||
| f3d3129d4a | |||
| 4a7720e7ae | |||
| add7011c21 | |||
| b84479de0c | |||
| baa89e836c | |||
| 823308aa3f | |||
| a964062bf6 | |||
| 9be74efeb0 | |||
| 38efb05eeb | |||
| 172c8cc016 | |||
| 60357f5808 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {})},
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
+1040
-22
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
+2
-2
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
@@ -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 & 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 & 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
@@ -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
+1
-1
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
@@ -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
+6
-5
File diff suppressed because one or more lines are too long
+1
-1
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
Reference in New Issue
Block a user