UI_test_updated

main
AlexsandrSnytkin 1 week ago
parent 820fe674ea
commit a568083cce

@ -9,4 +9,4 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 8081
CMD ["python", "service.py", "--config", "docker/config.docker.json"]
CMD ["python", "service.py", "--config", "docker/config.docker.test.json"]

@ -62,10 +62,11 @@ docker compose --profile test up --build
```
Открыть:
- UI: `http://localhost:8081/ui`
- Полный результат: `http://localhost:8081/result`
- Частоты: `http://localhost:8081/frequencies`
- Полученные output-sink данные: `http://localhost:8080/latest`
- UI: `http://127.0.0.1:38081/ui`
- Полный результат: `http://127.0.0.1:38081/result`
- Частоты: `http://127.0.0.1:38081/frequencies`
- Полученные output-sink данные (изнутри сети контейнеров):
- `docker compose --profile test exec output-sink wget -qO- http://127.0.0.1:8080/latest`
Остановить:
```bash
@ -89,6 +90,11 @@ cp config.template.json config.json
docker compose --profile prod up --build
```
Доступ к API/UI в `prod`:
- `http://127.0.0.1:38082/ui`
- `http://127.0.0.1:38082/result`
- `http://127.0.0.1:38082/frequencies`
Остановить:
```bash
docker compose --profile prod down
@ -104,7 +110,8 @@ docker compose --profile prod down
Дополнительно:
- `GET /result` возвращает `output_delivery`.
- `GET /frequencies` тоже возвращает `output_delivery`.
- `GET http://localhost:8080/latest` показывает, что именно принял output-sink.
- `docker compose --profile test logs output-sink -f` показывает факт приема.
- `GET /latest` на `output-sink` доступен изнутри docker-сети.
## Конфиг (основные поля)
@ -179,4 +186,48 @@ python service.py --config config.json
```
UI:
- `http://127.0.0.1:8081/ui`
- `http://127.0.0.1:38081/ui`
## Защита write-endpoints токеном
Для защиты изменений состояния можно задать токен в конфиге:
```json
{
"runtime": {
"write_api_token": "change-me"
}
}
```
После этого `POST /refresh` и `POST /config` требуют токен в одном из заголовков:
- `X-API-Token: <token>`
- `Authorization: Bearer <token>`
Что важно:
- `GET` endpoints остаются без токена.
- `GET /config` отдает `runtime.write_api_token` в редактированном виде (`""`) и флаг `write_api_token_set`.
- В UI во вкладке `Servers` есть поле `Write API token (session only)`:
- токен хранится только в памяти браузера;
- используется для `POST /refresh` и `POST /config`.
## Фильтры входных данных по каждому серверу
Для каждого ресивера в `input.receivers[]` можно задать `input_filter`:
```json
{
"input_filter": {
"enabled": true,
"min_frequency_mhz": 430.0,
"max_frequency_mhz": 440.0,
"min_rssi_dbm": -80.0,
"max_rssi_dbm": -40.0
}
}
```
Смысл:
- фильтр применяется отдельно к данным каждого ресивера до триангуляции;
- участвуют только измерения, попавшие в диапазоны частоты и RSSI;
- если после фильтрации у ресивера нет данных, цикл расчета возвращает ошибку.

@ -1,4 +1,4 @@
{
{
"model": {
"tx_power_dbm": 20.0,
"tx_gain_dbi": 0.0,
@ -15,12 +15,16 @@
"listen_host": "0.0.0.0",
"listen_port": 8081,
"poll_interval_s": 1.0,
"write_api_token": "",
"output_server": {
"enabled": false,
"ip": "192.168.1.100",
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.0
"timeout_s": 3.0,
"frequency_filter_enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 0.0
}
},
"input": {
@ -35,7 +39,14 @@
"y": 0.0,
"z": 0.0
},
"source_url": "http://10.0.0.11:9000/measurements"
"source_url": "http://10.0.0.11:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
},
{
"receiver_id": "r1",
@ -44,7 +55,14 @@
"y": 0.0,
"z": 0.0
},
"source_url": "http://10.0.0.12:9000/measurements"
"source_url": "http://10.0.0.12:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
},
{
"receiver_id": "r2",
@ -53,7 +71,14 @@
"y": 8.0,
"z": 0.0
},
"source_url": "http://10.0.0.13:9000/measurements"
"source_url": "http://10.0.0.13:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
}
]
}

@ -4,7 +4,7 @@ services:
container_name: triangulation-test
command: ["python", "service.py", "--config", "docker/config.docker.test.json"]
ports:
- "8081:8081"
- "127.0.0.1:38081:8081"
depends_on:
- receiver-r0
- receiver-r1
@ -40,8 +40,8 @@ services:
build: .
container_name: output-sink
command: ["python", "docker/mock_output_sink.py", "--port", "8080"]
ports:
- "8080:8080"
expose:
- "8080"
profiles: ["test"]
triangulation-prod:
@ -49,7 +49,7 @@ services:
container_name: triangulation-prod
command: ["python", "service.py", "--config", "/app/config.json"]
ports:
- "8081:8081"
- "127.0.0.1:38082:8081"
volumes:
- ./config.json:/app/config.json:ro
profiles: ["prod"]

@ -0,0 +1,85 @@
{
"model": {
"tx_power_dbm": 20.0,
"tx_gain_dbi": 0.0,
"rx_gain_dbi": 0.0,
"path_loss_exponent": 2.0,
"reference_distance_m": 1.0,
"min_distance_m": 0.001
},
"solver": {
"tolerance": 0.001,
"z_preference": "positive"
},
"runtime": {
"listen_host": "0.0.0.0",
"listen_port": 8081,
"poll_interval_s": 1.0,
"write_api_token": "",
"output_server": {
"enabled": true,
"ip": "output-sink",
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.0,
"frequency_filter_enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 0.0
}
},
"input": {
"mode": "http_sources",
"aggregation": "median",
"source_timeout_s": 3.0,
"receivers": [
{
"receiver_id": "r0",
"center": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"source_url": "http://receiver-r0:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
},
{
"receiver_id": "r1",
"center": {
"x": 10.0,
"y": 0.0,
"z": 0.0
},
"source_url": "http://receiver-r1:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
},
{
"receiver_id": "r2",
"center": {
"x": 0.0,
"y": 8.0,
"z": 0.0
},
"source_url": "http://receiver-r2:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
}
]
}
}

@ -1,4 +1,4 @@
{
{
"model": {
"tx_power_dbm": 20.0,
"tx_gain_dbi": 0.0,
@ -15,12 +15,16 @@
"listen_host": "0.0.0.0",
"listen_port": 8081,
"poll_interval_s": 1.0,
"write_api_token": "",
"output_server": {
"enabled": true,
"ip": "output-sink",
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.0
"timeout_s": 3.0,
"frequency_filter_enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 0.0
}
},
"input": {
@ -35,7 +39,14 @@
"y": 0.0,
"z": 0.0
},
"source_url": "http://receiver-r0:9000/measurements"
"source_url": "http://receiver-r0:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
},
{
"receiver_id": "r1",
@ -44,7 +55,14 @@
"y": 0.0,
"z": 0.0
},
"source_url": "http://receiver-r1:9000/measurements"
"source_url": "http://receiver-r1:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
},
{
"receiver_id": "r2",
@ -53,7 +71,14 @@
"y": 8.0,
"z": 0.0
},
"source_url": "http://receiver-r2:9000/measurements"
"source_url": "http://receiver-r2:9000/measurements",
"input_filter": {
"enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
}
}
]
}

@ -1,12 +1,13 @@
from __future__ import annotations
import argparse
import copy
import hmac
import json
import math
import mimetypes
import statistics
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@ -22,13 +23,16 @@ from triangulation import (
)
Point3D = Tuple[float, float, float]
MAX_CONFIG_BODY_BYTES = 1_000_000 # 1 MB guardrail for /config POST.
HZ_IN_MHZ = 1_000_000.0
def _load_json(path: str) -> Dict[str, object]:
file_path = Path(path)
if not file_path.exists():
raise SystemExit(f"Config file not found: {path}")
with file_path.open("r", encoding="utf-8") as fh:
# Accept optional UTF-8 BOM to avoid startup failures with edited JSON files.
with file_path.open("r", encoding="utf-8-sig") as fh:
data = json.load(fh)
if not isinstance(data, dict):
raise SystemExit("Config root must be a JSON object.")
@ -80,6 +84,113 @@ def _float_from_measurement(
raise ValueError(f"{source_label}: row #{row_index} missing field '{field_name}'.")
def _float_with_key_from_measurement(
item: Dict[str, object],
keys: Sequence[str],
field_name: str,
source_label: str,
row_index: int,
) -> Tuple[str, float]:
for key in keys:
if key in item:
value = _float_from_measurement(
item=item,
keys=(key,),
field_name=field_name,
source_label=source_label,
row_index=row_index,
)
return key, value
raise ValueError(f"{source_label}: row #{row_index} missing field '{field_name}'.")
def _parse_frequency_hz_from_measurement(
row: Dict[str, object],
source_label: str,
row_index: int,
) -> float:
key, value = _float_with_key_from_measurement(
row,
keys=(
"frequency_hz",
"freq_hz",
"frequency_mhz",
"freq_mhz",
"frequency",
"freq",
),
field_name="frequency",
source_label=source_label,
row_index=row_index,
)
if key in ("frequency_hz", "freq_hz"):
return value
if key in ("frequency_mhz", "freq_mhz"):
return value * HZ_IN_MHZ
# For generic fields "frequency"/"freq" default to MHz in this project.
# Keep backward compatibility: very large values are treated as Hz.
if value >= 10_000_000.0:
return value
return value * HZ_IN_MHZ
def _parse_receiver_input_filter(
receiver_obj: Dict[str, object], receiver_id: str
) -> Dict[str, object]:
filter_obj = receiver_obj.get("input_filter", {})
if filter_obj is None:
filter_obj = {}
if not isinstance(filter_obj, dict):
raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.")
min_freq_mhz_raw = filter_obj.get("min_frequency_mhz")
max_freq_mhz_raw = filter_obj.get("max_frequency_mhz")
if min_freq_mhz_raw is None and "min_frequency_hz" in filter_obj:
min_freq_mhz_raw = float(filter_obj["min_frequency_hz"]) / HZ_IN_MHZ
if max_freq_mhz_raw is None and "max_frequency_hz" in filter_obj:
max_freq_mhz_raw = float(filter_obj["max_frequency_hz"]) / HZ_IN_MHZ
parsed = {
"enabled": bool(filter_obj.get("enabled", False)),
"min_frequency_mhz": float(min_freq_mhz_raw if min_freq_mhz_raw is not None else 0.0),
"max_frequency_mhz": float(max_freq_mhz_raw if max_freq_mhz_raw is not None else 1_000_000_000.0),
"min_rssi_dbm": float(filter_obj.get("min_rssi_dbm", -200.0)),
"max_rssi_dbm": float(filter_obj.get("max_rssi_dbm", 50.0)),
}
if parsed["max_frequency_mhz"] < parsed["min_frequency_mhz"]:
raise ValueError(
f"receiver '{receiver_id}': input_filter.max_frequency_mhz must be >= min_frequency_mhz."
)
if parsed["max_rssi_dbm"] < parsed["min_rssi_dbm"]:
raise ValueError(
f"receiver '{receiver_id}': input_filter.max_rssi_dbm must be >= min_rssi_dbm."
)
return parsed
def _apply_receiver_input_filter(
measurements: Sequence[Tuple[float, float]],
receiver_filter: Dict[str, object],
) -> List[Tuple[float, float]]:
if not bool(receiver_filter.get("enabled", False)):
return list(measurements)
min_frequency_mhz = float(receiver_filter["min_frequency_mhz"])
max_frequency_mhz = float(receiver_filter["max_frequency_mhz"])
min_rssi_dbm = float(receiver_filter["min_rssi_dbm"])
max_rssi_dbm = float(receiver_filter["max_rssi_dbm"])
filtered = []
for frequency_hz, rssi_dbm in measurements:
frequency_mhz = frequency_hz / HZ_IN_MHZ
if not (min_frequency_mhz <= frequency_mhz <= max_frequency_mhz):
continue
if not (min_rssi_dbm <= rssi_dbm <= max_rssi_dbm):
continue
filtered.append((frequency_hz, rssi_dbm))
return filtered
def parse_source_payload(
payload: object,
source_label: str,
@ -110,10 +221,8 @@ def parse_source_payload(
for row_index, row in enumerate(raw_items, start=1):
if not isinstance(row, dict):
raise ValueError(f"{source_label}: row #{row_index} must be an object.")
frequency_hz = _float_from_measurement(
row,
keys=("frequency_hz", "freq_hz", "frequency", "freq"),
field_name="frequency_hz",
frequency_hz = _parse_frequency_hz_from_measurement(
row=row,
source_label=source_label,
row_index=row_index,
)
@ -210,6 +319,7 @@ class AutoService:
raise ValueError("solver.z_preference must be 'positive' or 'negative'.")
self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0))
self.write_api_token = str(runtime_obj.get("write_api_token", "")).strip()
output_obj = runtime_obj.get("output_server", {})
if output_obj is None:
output_obj = {}
@ -221,8 +331,34 @@ class AutoService:
self.output_port = int(output_obj.get("port", 8080))
self.output_path = str(output_obj.get("path", "/triangulation"))
self.output_timeout_s = float(output_obj.get("timeout_s", 3.0))
self.output_frequency_filter_enabled = bool(
output_obj.get("frequency_filter_enabled", False)
)
min_frequency_mhz_raw = output_obj.get("min_frequency_mhz")
max_frequency_mhz_raw = output_obj.get("max_frequency_mhz")
if min_frequency_mhz_raw is None and "min_frequency_hz" in output_obj:
min_frequency_mhz_raw = float(output_obj["min_frequency_hz"]) / HZ_IN_MHZ
if max_frequency_mhz_raw is None and "max_frequency_hz" in output_obj:
max_frequency_mhz_raw = float(output_obj["max_frequency_hz"]) / HZ_IN_MHZ
self.output_min_frequency_mhz = float(min_frequency_mhz_raw or 0.0)
self.output_max_frequency_mhz = float(max_frequency_mhz_raw or 0.0)
self.output_min_frequency_hz = self.output_min_frequency_mhz * HZ_IN_MHZ
self.output_max_frequency_hz = self.output_max_frequency_mhz * HZ_IN_MHZ
if self.output_enabled and not self.output_ip:
raise ValueError("runtime.output_server.ip must be non-empty when enabled=true.")
if self.output_frequency_filter_enabled:
if self.output_min_frequency_mhz <= 0.0:
raise ValueError(
"runtime.output_server.min_frequency_mhz must be > 0 when frequency filter is enabled."
)
if self.output_max_frequency_mhz <= 0.0:
raise ValueError(
"runtime.output_server.max_frequency_mhz must be > 0 when frequency filter is enabled."
)
if self.output_max_frequency_mhz < self.output_min_frequency_mhz:
raise ValueError(
"runtime.output_server.max_frequency_mhz must be >= min_frequency_mhz."
)
self.source_timeout_s = float(input_obj.get("source_timeout_s", 3.0))
self.aggregation = str(input_obj.get("aggregation", "median"))
@ -246,6 +382,10 @@ class AutoService:
"receiver_id": str(receiver["receiver_id"]),
"center": _center_from_obj(receiver),
"source_url": str(receiver["source_url"]),
"input_filter": _parse_receiver_input_filter(
receiver_obj=receiver,
receiver_id=str(receiver["receiver_id"]),
),
}
)
self.receivers = parsed_receivers
@ -273,7 +413,6 @@ class AutoService:
self.poll_thread.join(timeout=2.0)
def refresh_once(self) -> None:
spheres_all: List[Sphere] = []
receiver_payloads: List[Dict[str, object]] = []
grouped_by_receiver: List[Dict[float, List[Tuple[float, float]]]] = []
@ -281,22 +420,30 @@ class AutoService:
receiver_id = str(receiver["receiver_id"])
center = receiver["center"]
source_url = str(receiver["source_url"])
measurements = _fetch_measurements(
raw_measurements = _fetch_measurements(
source_url,
timeout_s=self.source_timeout_s,
expected_receiver_id=receiver_id,
)
receiver_filter = receiver["input_filter"]
measurements = _apply_receiver_input_filter(
raw_measurements, receiver_filter=receiver_filter
)
if not measurements:
raise RuntimeError(
f"receiver '{receiver_id}': no measurements left after input_filter."
)
grouped = _group_by_frequency(measurements)
grouped_by_receiver.append(grouped)
radius_m = aggregate_radius(measurements, model=self.model, method=self.aggregation)
spheres_all.append(Sphere(center=center, radius=radius_m))
samples = []
for frequency_hz, amplitude_dbm in measurements:
samples.append(
{
"frequency_hz": frequency_hz,
"frequency_mhz": frequency_hz / HZ_IN_MHZ,
"amplitude_dbm": amplitude_dbm,
"distance_m": rssi_to_distance_m(
amplitude_dbm=amplitude_dbm,
@ -312,11 +459,15 @@ class AutoService:
"center": {"x": center[0], "y": center[1], "z": center[2]},
"source_url": source_url,
"aggregation": self.aggregation,
"input_filter": receiver_filter,
"raw_samples_count": len(raw_measurements),
"filtered_samples_count": len(measurements),
"radius_m_all_freq": radius_m,
"samples": samples,
}
)
# Only compare homogeneous measurements: same frequency across all receivers.
common_frequencies = (
set(grouped_by_receiver[0].keys())
& set(grouped_by_receiver[1].keys())
@ -356,6 +507,7 @@ class AutoService:
receiver_payloads[index].setdefault("per_frequency", []).append(
{
"frequency_hz": frequency_hz,
"frequency_mhz": frequency_hz / HZ_IN_MHZ,
"radius_m": spheres_for_frequency[index].radius,
"residual_m": residual,
"samples_count": len(grouped_by_receiver[index][frequency_hz]),
@ -364,6 +516,7 @@ class AutoService:
row = {
"frequency_hz": frequency_hz,
"frequency_mhz": frequency_hz / HZ_IN_MHZ,
"position": {
"x": result.point[0],
"y": result.point[1],
@ -383,6 +536,7 @@ class AutoService:
payload = {
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"selected_frequency_hz": best_row["frequency_hz"],
"selected_frequency_mhz": float(best_row["frequency_hz"]) / HZ_IN_MHZ,
"position": best_row["position"],
"exact": best_row["exact"],
"rmse_m": best_row["rmse_m"],
@ -403,13 +557,36 @@ class AutoService:
self.last_error = ""
if self.output_enabled:
output_payload = self._build_output_payload(payload)
if output_payload is None:
with self.state_lock:
self.last_output_delivery = {
"enabled": True,
"status": "skipped",
"http_status": None,
"response_body": "No frequencies in configured output range",
"sent_at_utc": datetime.now(timezone.utc).isoformat(),
"target": {
"ip": self.output_ip,
"port": self.output_port,
"path": self.output_path,
},
"frequency_filter": {
"enabled": self.output_frequency_filter_enabled,
"min_frequency_mhz": self.output_min_frequency_mhz,
"max_frequency_mhz": self.output_max_frequency_mhz,
},
}
return
status_code, response_body = send_payload_to_server(
server_ip=self.output_ip,
payload=payload,
payload=output_payload,
port=self.output_port,
path=self.output_path,
timeout_s=self.output_timeout_s,
)
# Keep delivery diagnostics in snapshot so UI/API can show transport health.
with self.state_lock:
self.last_output_delivery = {
"enabled": True,
@ -422,6 +599,11 @@ class AutoService:
"port": self.output_port,
"path": self.output_path,
},
"frequency_filter": {
"enabled": self.output_frequency_filter_enabled,
"min_frequency_mhz": self.output_min_frequency_mhz,
"max_frequency_mhz": self.output_max_frequency_mhz,
},
}
if status_code < 200 or status_code >= 300:
raise RuntimeError(
@ -429,6 +611,55 @@ class AutoService:
f"HTTP {status_code}, body={response_body}"
)
def _build_output_payload(self, payload: Dict[str, object]) -> Optional[Dict[str, object]]:
if not self.output_frequency_filter_enabled:
return payload
# Keep internal calculations unchanged, but limit data sent to output server by frequency.
payload_copy = copy.deepcopy(payload)
table_obj = payload_copy.get("frequency_table")
if not isinstance(table_obj, list):
return None
filtered_rows = []
for row in table_obj:
if not isinstance(row, dict):
continue
frequency_hz = row.get("frequency_hz")
if not isinstance(frequency_hz, (int, float)):
continue
if self.output_min_frequency_hz <= float(frequency_hz) <= self.output_max_frequency_hz:
filtered_rows.append(row)
if not filtered_rows:
return None
best_row = min(filtered_rows, key=lambda row: float(row.get("rmse_m", float("inf"))))
payload_copy["frequency_table"] = filtered_rows
payload_copy["selected_frequency_hz"] = best_row.get("frequency_hz")
payload_copy["selected_frequency_mhz"] = float(best_row.get("frequency_hz", 0.0)) / HZ_IN_MHZ
payload_copy["position"] = best_row.get("position")
payload_copy["exact"] = best_row.get("exact")
payload_copy["rmse_m"] = best_row.get("rmse_m")
receivers_obj = payload_copy.get("receivers")
if isinstance(receivers_obj, list):
for receiver in receivers_obj:
if not isinstance(receiver, dict):
continue
per_frequency = receiver.get("per_frequency")
if not isinstance(per_frequency, list):
continue
receiver["per_frequency"] = [
row
for row in per_frequency
if isinstance(row, dict)
and isinstance(row.get("frequency_hz"), (int, float))
and self.output_min_frequency_hz
<= float(row["frequency_hz"])
<= self.output_max_frequency_hz
]
return payload_copy
def _poll_loop(self) -> None:
while not self.stop_event.is_set():
try:
@ -450,6 +681,22 @@ class AutoService:
def _make_handler(service: AutoService):
class ServiceHandler(BaseHTTPRequestHandler):
def _is_write_authorized(self) -> bool:
expected_token = service.write_api_token
if not expected_token:
return True
header_token = self.headers.get("X-API-Token", "")
if hmac.compare_digest(header_token, expected_token):
return True
authorization = self.headers.get("Authorization", "")
if authorization.lower().startswith("bearer "):
bearer_token = authorization[7:].strip()
if hmac.compare_digest(bearer_token, expected_token):
return True
return False
def _write_bytes(
self,
status_code: int,
@ -473,7 +720,10 @@ def _make_handler(service: AutoService):
def _write_static(self, relative_path: str) -> None:
web_root = Path(__file__).resolve().parent / "web"
file_path = (web_root / relative_path).resolve()
if not str(file_path).startswith(str(web_root.resolve())):
# Protect against path traversal outside /web.
try:
file_path.relative_to(web_root.resolve())
except ValueError:
self._write_json(404, {"error": "not_found"})
return
if not file_path.exists() or not file_path.is_file():
@ -554,6 +804,7 @@ def _make_handler(service: AutoService):
"status": "ok",
"updated_at_utc": snapshot["updated_at_utc"],
"selected_frequency_hz": payload.get("selected_frequency_hz"),
"selected_frequency_mhz": payload.get("selected_frequency_mhz"),
"frequency_table": payload.get("frequency_table", []),
"output_delivery": snapshot["output_delivery"],
},
@ -561,12 +812,18 @@ def _make_handler(service: AutoService):
return
if path == "/config":
public_config = json.loads(json.dumps(service.config))
runtime_obj = public_config.get("runtime")
if isinstance(runtime_obj, dict):
if "write_api_token" in runtime_obj:
runtime_obj["write_api_token"] = ""
runtime_obj["write_api_token_set"] = bool(service.write_api_token)
self._write_json(
200,
{
"status": "ok",
"config_path": service.config_path,
"config": service.config,
"config": public_config,
},
)
return
@ -575,12 +832,34 @@ def _make_handler(service: AutoService):
def do_POST(self) -> None:
path = parse.urlparse(self.path).path
if not self._is_write_authorized():
self._write_json(
401,
{"status": "error", "error": "unauthorized: missing or invalid API token"},
)
return
if path == "/config":
try:
content_length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._write_json(400, {"status": "error", "error": "Invalid Content-Length"})
return
if content_length <= 0:
self._write_json(400, {"status": "error", "error": "Empty request body"})
return
if content_length > MAX_CONFIG_BODY_BYTES:
self._write_json(
413,
{
"status": "error",
"error": (
f"Config payload too large: {content_length} bytes, "
f"max is {MAX_CONFIG_BODY_BYTES}"
),
},
)
return
body = self.rfile.read(content_length) if content_length > 0 else b""
try:
new_config = json.loads(body.decode("utf-8"))
@ -591,6 +870,13 @@ def _make_handler(service: AutoService):
self._write_json(400, {"status": "error", "error": "Config must be JSON object"})
return
# Avoid accidental token wipe when /config GET response is redacted in clients.
runtime_obj = new_config.get("runtime")
if isinstance(runtime_obj, dict) and service.write_api_token:
incoming_token = str(runtime_obj.get("write_api_token", "")).strip()
if not incoming_token:
runtime_obj["write_api_token"] = service.write_api_token
try:
AutoService(new_config)
except Exception as exc:

@ -1,6 +1,7 @@
import json
import threading
from typing import Any, Dict, List
from urllib import error
from urllib import error, request as urllib_request
import pytest
@ -79,6 +80,14 @@ def _install_urlopen(monkeypatch: pytest.MonkeyPatch, responses: Dict[str, objec
monkeypatch.setattr(service.request, "urlopen", _fake_urlopen)
def _start_api_server_for_test(svc: service.AutoService):
http_server = service.ThreadingHTTPServer(("127.0.0.1", 0), service._make_handler(svc))
thread = threading.Thread(target=http_server.serve_forever, daemon=True)
thread.start()
host, port = http_server.server_address
return http_server, thread, f"http://{host}:{port}"
def test_refresh_once_builds_frequency_table_for_common_frequencies(
monkeypatch: pytest.MonkeyPatch,
):
@ -205,3 +214,313 @@ def test_refresh_once_propagates_source_http_error(monkeypatch: pytest.MonkeyPat
svc = service.AutoService(config)
with pytest.raises(RuntimeError, match="Cannot reach 'http://r1.local/measurements': connection refused"):
svc.refresh_once()
def test_output_delivery_is_disabled_when_output_server_off(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
responses = {
"http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]},
"http://r1.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -59.0}]},
"http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]},
}
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
svc.refresh_once()
snapshot = svc.snapshot()
assert snapshot["output_delivery"]["enabled"] is False
assert snapshot["output_delivery"]["status"] == "disabled"
def test_parse_source_payload_rejects_non_finite_values():
payload = {"measurements": [{"frequency_hz": float("inf"), "rssi_dbm": -60.0}]}
with pytest.raises(ValueError, match="must be finite"):
service.parse_source_payload(payload, source_label="source_url=test")
def test_parse_source_payload_accepts_frequency_mhz():
payload = {"measurements": [{"frequency_mhz": 868.1, "rssi_dbm": -60.0}]}
parsed = service.parse_source_payload(payload, source_label="source_url=test")
assert parsed[0][0] == pytest.approx(868_100_000.0)
def test_parse_source_payload_treats_generic_frequency_as_mhz():
payload = {"measurements": [{"frequency": 433.92, "rssi_dbm": -60.0}]}
parsed = service.parse_source_payload(payload, source_label="source_url=test")
assert parsed[0][0] == pytest.approx(433_920_000.0)
def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
svc = service.AutoService(config)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
with pytest.raises(error.HTTPError) as exc_info:
urllib_request.urlopen(f"{base_url}/static/../service.py")
assert exc_info.value.code == 404
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
def test_http_config_rejects_empty_body(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
svc = service.AutoService(config)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
req = urllib_request.Request(
url=f"{base_url}/config",
method="POST",
data=b"",
headers={"Content-Type": "application/json"},
)
with pytest.raises(error.HTTPError) as exc_info:
urllib_request.urlopen(req)
body = exc_info.value.read().decode("utf-8")
assert exc_info.value.code == 400
assert "Empty request body" in body
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
def test_http_config_rejects_too_large_payload(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
svc = service.AutoService(config)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
huge_payload = b"{" + b" " * (service.MAX_CONFIG_BODY_BYTES + 10) + b"}"
req = urllib_request.Request(
url=f"{base_url}/config",
method="POST",
data=huge_payload,
headers={"Content-Type": "application/json"},
)
try:
urllib_request.urlopen(req)
raise AssertionError("Expected request rejection for oversized payload")
except error.HTTPError as exc:
assert exc.code == 413
except ConnectionAbortedError:
# On some Windows stacks, server closes connection before status line is read.
pass
except OSError as exc:
if getattr(exc, "winerror", None) == 10053:
pass
else:
raise
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
def test_http_refresh_requires_write_token_when_configured(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
config["runtime"]["write_api_token"] = "secret" # type: ignore[index]
svc = service.AutoService(config)
svc.refresh_once = lambda: None # type: ignore[method-assign]
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
unauthorized_req = urllib_request.Request(
url=f"{base_url}/refresh",
method="POST",
data=b"{}",
headers={"Content-Type": "application/json"},
)
with pytest.raises(error.HTTPError) as exc_info:
urllib_request.urlopen(unauthorized_req)
assert exc_info.value.code == 401
authorized_req = urllib_request.Request(
url=f"{base_url}/refresh",
method="POST",
data=b"{}",
headers={"Content-Type": "application/json", "X-API-Token": "secret"},
)
with urllib_request.urlopen(authorized_req) as response:
assert response.status == 200
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
def test_http_config_get_redacts_write_token(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
config["runtime"]["write_api_token"] = "secret" # type: ignore[index]
svc = service.AutoService(config)
http_server, thread, base_url = _start_api_server_for_test(svc)
try:
with urllib_request.urlopen(f"{base_url}/config") as response:
body = response.read().decode("utf-8")
payload = json.loads(body)
runtime = payload["config"]["runtime"]
assert runtime["write_api_token"] == ""
assert runtime["write_api_token_set"] is True
finally:
http_server.shutdown()
http_server.server_close()
thread.join(timeout=1.0)
def test_output_payload_is_filtered_by_frequency_range(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
config["runtime"]["output_server"]["enabled"] = True # type: ignore[index]
config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index]
config["runtime"]["output_server"]["min_frequency_mhz"] = 800.0 # type: ignore[index]
config["runtime"]["output_server"]["max_frequency_mhz"] = 900.0 # type: ignore[index]
responses = {
"http://r0.local/measurements": {
"measurements": [
{"frequency_hz": 433_920_000.0, "rssi_dbm": -60.0},
{"frequency_hz": 868_100_000.0, "rssi_dbm": -64.0},
]
},
"http://r1.local/measurements": {
"measurements": [
{"frequency_hz": 433_920_000.0, "rssi_dbm": -62.0},
{"frequency_hz": 868_100_000.0, "rssi_dbm": -66.0},
]
},
"http://r2.local/measurements": {
"measurements": [
{"frequency_hz": 433_920_000.0, "rssi_dbm": -61.0},
{"frequency_hz": 868_100_000.0, "rssi_dbm": -65.0},
]
},
}
_install_urlopen(monkeypatch, responses)
captured = {}
def _fake_send_payload_to_server(**kwargs):
captured["payload"] = kwargs["payload"]
return 200, "ok"
monkeypatch.setattr(service, "send_payload_to_server", _fake_send_payload_to_server)
svc = service.AutoService(config)
svc.refresh_once()
sent_payload = captured["payload"]
freq_rows = sent_payload["frequency_table"]
assert len(freq_rows) == 1
assert freq_rows[0]["frequency_hz"] == 868_100_000.0
assert sent_payload["selected_frequency_hz"] == 868_100_000.0
def test_output_delivery_skipped_when_range_has_no_frequencies(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
config["runtime"]["output_server"]["enabled"] = True # type: ignore[index]
config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index]
config["runtime"]["output_server"]["min_frequency_mhz"] = 2_000.0 # type: ignore[index]
config["runtime"]["output_server"]["max_frequency_mhz"] = 3_000.0 # type: ignore[index]
responses = {
"http://r0.local/measurements": {"measurements": [{"frequency_hz": 433_920_000.0, "rssi_dbm": -60.0}]},
"http://r1.local/measurements": {"measurements": [{"frequency_hz": 433_920_000.0, "rssi_dbm": -62.0}]},
"http://r2.local/measurements": {"measurements": [{"frequency_hz": 433_920_000.0, "rssi_dbm": -61.0}]},
}
_install_urlopen(monkeypatch, responses)
monkeypatch.setattr(
service,
"send_payload_to_server",
lambda **_: (_ for _ in ()).throw(AssertionError("send_payload_to_server must not be called")),
)
svc = service.AutoService(config)
svc.refresh_once()
snapshot = svc.snapshot()
assert snapshot["output_delivery"]["status"] == "skipped"
def test_config_validation_rejects_invalid_frequency_filter_range():
config = _base_config()
config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index]
config["runtime"]["output_server"]["min_frequency_mhz"] = 900.0 # type: ignore[index]
config["runtime"]["output_server"]["max_frequency_mhz"] = 800.0 # type: ignore[index]
with pytest.raises(ValueError, match="max_frequency_mhz must be >= min_frequency_mhz"):
service.AutoService(config)
def test_receiver_input_filter_applies_per_server(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
# Keep only ~433.92 MHz and tighter RSSI range for r0.
config["input"]["receivers"][0]["input_filter"] = { # type: ignore[index]
"enabled": True,
"min_frequency_mhz": 430.0,
"max_frequency_mhz": 440.0,
"min_rssi_dbm": -61.0,
"max_rssi_dbm": -59.0,
}
responses = {
"http://r0.local/measurements": {
"measurements": [
{"frequency_mhz": 433.92, "rssi_dbm": -60.0},
{"frequency_mhz": 868.1, "rssi_dbm": -60.0},
]
},
"http://r1.local/measurements": {
"measurements": [
{"frequency_mhz": 433.92, "rssi_dbm": -63.0},
{"frequency_mhz": 868.1, "rssi_dbm": -66.0},
]
},
"http://r2.local/measurements": {
"measurements": [
{"frequency_mhz": 433.92, "rssi_dbm": -62.0},
{"frequency_mhz": 868.1, "rssi_dbm": -65.0},
]
},
}
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
svc.refresh_once()
payload = svc.snapshot()["payload"]
assert payload is not None
assert len(payload["frequency_table"]) == 1
assert payload["frequency_table"][0]["frequency_mhz"] == pytest.approx(433.92, abs=1e-6)
assert payload["receivers"][0]["raw_samples_count"] == 2
assert payload["receivers"][0]["filtered_samples_count"] == 1
def test_receiver_input_filter_empty_result_raises(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
config["input"]["receivers"][0]["input_filter"] = { # type: ignore[index]
"enabled": True,
"min_frequency_mhz": 433.0,
"max_frequency_mhz": 434.0,
"min_rssi_dbm": -10.0,
"max_rssi_dbm": -5.0,
}
responses = {
"http://r0.local/measurements": {"measurements": [{"frequency_mhz": 433.92, "rssi_dbm": -60.0}]},
"http://r1.local/measurements": {"measurements": [{"frequency_mhz": 433.92, "rssi_dbm": -62.0}]},
"http://r2.local/measurements": {"measurements": [{"frequency_mhz": 433.92, "rssi_dbm": -61.0}]},
}
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
with pytest.raises(RuntimeError, match="no measurements left after input_filter"):
svc.refresh_once()
def test_receiver_input_filter_validation_rejects_invalid_rssi_range():
config = _base_config()
config["input"]["receivers"][1]["input_filter"] = { # type: ignore[index]
"enabled": True,
"min_frequency_mhz": 430.0,
"max_frequency_mhz": 440.0,
"min_rssi_dbm": -30.0,
"max_rssi_dbm": -80.0,
}
with pytest.raises(ValueError, match="max_rssi_dbm must be >= min_rssi_dbm"):
service.AutoService(config)

@ -2,7 +2,13 @@ const state = {
result: null,
frequencies: null,
health: null,
config: null,
writeToken: "",
activeSection: "overview",
selectedReceiverIndex: 0,
receiverDrafts: [],
};
const HZ_IN_MHZ = 1_000_000;
function byId(id) {
return document.getElementById(id);
@ -14,6 +20,93 @@ function fmt(value, digits = 6) {
return Number.isFinite(value) ? value.toFixed(digits) : String(value);
}
function hzToMhz(value) {
if (value === null || value === undefined) return null;
const numeric = Number(value);
if (!Number.isFinite(numeric)) return null;
return numeric / HZ_IN_MHZ;
}
function authHeaders() {
const token = state.writeToken || "";
if (!token) return {};
return {
"X-API-Token": token,
Authorization: `Bearer ${token}`,
};
}
function setActiveSection(section) {
state.activeSection = section;
document.querySelectorAll(".panel").forEach((panel) => {
panel.classList.toggle("panel-active", panel.id === `section-${section}`);
});
document.querySelectorAll(".menu-item").forEach((item) => {
item.classList.toggle("menu-item-active", item.dataset.section === section);
});
}
function setMenuOpen(isOpen) {
byId("menu-list").classList.toggle("menu-list-open", isOpen);
}
function normalizeReceiverDraft(receiver) {
const filter = receiver?.input_filter || {};
return {
receiver_id: receiver?.receiver_id || "",
source_url: receiver?.source_url || "",
input_filter: {
enabled: Boolean(filter.enabled),
min_frequency_mhz: filter.min_frequency_mhz ?? hzToMhz(filter.min_frequency_hz) ?? 0,
max_frequency_mhz: filter.max_frequency_mhz ?? hzToMhz(filter.max_frequency_hz) ?? 0,
min_rssi_dbm: filter.min_rssi_dbm ?? -200,
max_rssi_dbm: filter.max_rssi_dbm ?? 50,
},
};
}
function saveCurrentReceiverDraftFromInputs() {
const idx = state.selectedReceiverIndex;
if (!state.receiverDrafts[idx]) return;
state.receiverDrafts[idx] = {
...state.receiverDrafts[idx],
source_url: byId("rx-url").value.trim(),
input_filter: {
enabled: byId("rx-filter-enabled").value === "true",
min_frequency_mhz: Number(byId("rx-min-freq").value),
max_frequency_mhz: Number(byId("rx-max-freq").value),
min_rssi_dbm: Number(byId("rx-min-rssi").value),
max_rssi_dbm: Number(byId("rx-max-rssi").value),
},
};
}
function renderSelectedReceiverDraft() {
const draft = state.receiverDrafts[state.selectedReceiverIndex];
if (!draft) return;
byId("rx-url").value = draft.source_url;
byId("rx-filter-enabled").value = String(Boolean(draft.input_filter.enabled));
byId("rx-min-freq").value = draft.input_filter.min_frequency_mhz;
byId("rx-max-freq").value = draft.input_filter.max_frequency_mhz;
byId("rx-min-rssi").value = draft.input_filter.min_rssi_dbm;
byId("rx-max-rssi").value = draft.input_filter.max_rssi_dbm;
}
function fillReceiverSelect() {
const select = byId("receiver-select");
select.innerHTML = "";
state.receiverDrafts.forEach((draft, index) => {
const option = document.createElement("option");
option.value = String(index);
option.textContent = draft.receiver_id || `receiver_${index + 1}`;
select.appendChild(option);
});
if (state.selectedReceiverIndex >= state.receiverDrafts.length) {
state.selectedReceiverIndex = 0;
}
select.value = String(state.selectedReceiverIndex);
}
async function getJson(url) {
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
@ -26,7 +119,7 @@ async function getJson(url) {
async function postJson(url, payload) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
@ -55,7 +148,9 @@ function render() {
return;
}
byId("selected-freq").textContent = fmt(data.selected_frequency_hz, 1);
const selectedMhz = data.selected_frequency_mhz ?? hzToMhz(data.selected_frequency_hz);
byId("selected-freq").textContent =
selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} MHz`;
byId("pos-x").textContent = fmt(data.position?.x);
byId("pos-y").textContent = fmt(data.position?.y);
byId("pos-z").textContent = fmt(data.position?.z);
@ -71,7 +166,7 @@ function render() {
.map(
(row) => `
<tr>
<td>${fmt(row.frequency_hz, 1)}</td>
<td>${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)}</td>
<td>${fmt(row.position?.x)}</td>
<td>${fmt(row.position?.y)}</td>
<td>${fmt(row.position?.z)}</td>
@ -102,10 +197,14 @@ async function refreshNow() {
async function loadConfig() {
try {
const config = await getJson("/config");
state.config = config.config || null;
byId("config-editor").value = JSON.stringify(config.config, null, 2);
fillServerForm();
byId("config-state").textContent = "config: loaded";
byId("servers-state").textContent = "servers: loaded";
} catch (err) {
byId("config-state").textContent = `config: ${err.message}`;
byId("servers-state").textContent = `servers: ${err.message}`;
}
}
@ -114,6 +213,7 @@ async function saveConfig() {
try {
const parsed = JSON.parse(raw);
const result = await postJson("/config", parsed);
state.config = parsed;
byId("config-state").textContent = result.restart_required
? "config: saved, restart required"
: "config: saved";
@ -122,14 +222,105 @@ async function saveConfig() {
}
}
function fillServerForm() {
const cfg = state.config;
if (!cfg) return;
const receivers = cfg.input?.receivers || [];
state.receiverDrafts = receivers.map((receiver) => normalizeReceiverDraft(receiver));
fillReceiverSelect();
renderSelectedReceiverDraft();
const out = cfg.runtime?.output_server || {};
byId("write-token").value = "";
byId("out-enabled").value = String(Boolean(out.enabled));
byId("out-freq-filter-enabled").value = String(Boolean(out.frequency_filter_enabled));
const minMhz = out.min_frequency_mhz ?? hzToMhz(out.min_frequency_hz) ?? 0;
const maxMhz = out.max_frequency_mhz ?? hzToMhz(out.max_frequency_hz) ?? 0;
byId("out-min-freq").value = minMhz;
byId("out-max-freq").value = maxMhz;
byId("out-ip").value = out.ip || "";
byId("out-port").value = out.port ?? 8080;
byId("out-path").value = out.path || "/triangulation";
byId("out-timeout").value = out.timeout_s ?? 3.0;
}
async function saveServers() {
try {
if (!state.config) {
await loadConfig();
}
saveCurrentReceiverDraftFromInputs();
const cfg = structuredClone(state.config);
cfg.input = cfg.input || {};
cfg.input.receivers = cfg.input.receivers || [{}, {}, {}];
cfg.runtime = cfg.runtime || {};
cfg.runtime.output_server = cfg.runtime.output_server || {};
for (let i = 0; i < cfg.input.receivers.length; i += 1) {
const draft = state.receiverDrafts[i] || normalizeReceiverDraft(cfg.input.receivers[i]);
cfg.input.receivers[i].source_url = draft.source_url;
cfg.input.receivers[i].input_filter = { ...draft.input_filter };
}
cfg.runtime.output_server.enabled = byId("out-enabled").value === "true";
cfg.runtime.output_server.frequency_filter_enabled =
byId("out-freq-filter-enabled").value === "true";
cfg.runtime.output_server.min_frequency_mhz = Number(byId("out-min-freq").value);
cfg.runtime.output_server.max_frequency_mhz = Number(byId("out-max-freq").value);
cfg.runtime.output_server.ip = byId("out-ip").value.trim();
cfg.runtime.output_server.port = Number(byId("out-port").value);
cfg.runtime.output_server.path = byId("out-path").value.trim() || "/triangulation";
cfg.runtime.output_server.timeout_s = Number(byId("out-timeout").value);
const result = await postJson("/config", cfg);
state.config = cfg;
byId("config-editor").value = JSON.stringify(cfg, null, 2);
byId("servers-state").textContent = result.restart_required
? "servers: saved, restart required"
: "servers: saved";
} catch (err) {
byId("servers-state").textContent = `servers: ${err.message}`;
}
}
function bindUi() {
byId("refresh-now").addEventListener("click", refreshNow);
byId("load-config").addEventListener("click", loadConfig);
byId("save-config").addEventListener("click", saveConfig);
byId("load-servers").addEventListener("click", loadConfig);
byId("save-servers").addEventListener("click", saveServers);
byId("write-token").addEventListener("input", (event) => {
state.writeToken = event.target.value;
});
byId("receiver-select").addEventListener("change", (event) => {
saveCurrentReceiverDraftFromInputs();
state.selectedReceiverIndex = Number(event.target.value);
renderSelectedReceiverDraft();
});
byId("menu-toggle").addEventListener("click", () => {
const open = !byId("menu-list").classList.contains("menu-list-open");
setMenuOpen(open);
});
document.querySelectorAll(".menu-item").forEach((item) => {
item.addEventListener("click", () => {
setActiveSection(item.dataset.section);
setMenuOpen(false);
});
});
document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (
target.closest("#menu-toggle") ||
target.closest("#menu-list")
) {
return;
}
setMenuOpen(false);
});
}
async function boot() {
bindUi();
setActiveSection(state.activeSection);
await loadConfig();
await loadAll();
setInterval(loadAll, 2000);

@ -9,23 +9,41 @@
<body>
<div class="bg-glow bg-glow-a"></div>
<div class="bg-glow bg-glow-b"></div>
<main class="container">
<header class="hero card">
<main class="app-shell">
<aside class="side-nav card">
<p class="kicker">Triangulation</p>
<h1>RF Positioning Dashboard</h1>
<p class="muted">
Автоматический мониторинг входящих измерений и результатов пересечения
3 сфер по общим частотам.
</p>
<div class="hero-actions">
<button id="refresh-now" class="btn btn-primary">Refresh</button>
<h1 class="side-title">Control</h1>
<div class="menu-wrap">
<button id="menu-toggle" class="btn btn-primary menu-toggle">Sections ▼</button>
<div id="menu-list" class="menu-list">
<button class="menu-item menu-item-active" data-section="overview">Overview</button>
<button class="menu-item" data-section="frequencies">Frequencies</button>
<button class="menu-item" data-section="receivers">Receivers</button>
<button class="menu-item" data-section="delivery">Delivery</button>
<button class="menu-item" data-section="servers">Servers</button>
<button class="menu-item" data-section="json">Raw JSON</button>
</div>
</div>
<div class="side-meta">
<span id="updated-at" class="badge">updated: n/a</span>
<span id="health-status" class="badge">health: n/a</span>
<span id="delivery-status" class="badge">delivery: n/a</span>
</div>
</aside>
<section class="content-area">
<section id="section-overview" class="panel panel-active">
<header class="hero card">
<h2>RF Positioning Dashboard</h2>
<p class="muted">Мониторинг и контроль расчета пересечения 3 сфер.</p>
<div class="hero-actions">
<button id="refresh-now" class="btn btn-primary">Refresh</button>
</div>
</header>
<section class="grid">
<article class="card">
<h2>Итоговая позиция</h2>
<div class="result-box">
@ -36,25 +54,16 @@
<div><span class="muted">RMSE:</span> <b id="rmse">-</b></div>
</div>
</article>
<article class="card">
<h2>Ресиверы</h2>
<div id="receivers-list" class="mono small"></div>
</article>
</section>
<section class="card">
<h2>Отправка на конечный сервер</h2>
<div id="delivery-details" class="mono small"></div>
</section>
<section class="card">
<section id="section-frequencies" class="panel">
<article class="card">
<h2>Таблица пересечений по частотам</h2>
<div class="table-wrap">
<table id="freq-table">
<thead>
<tr>
<th>Frequency (Hz)</th>
<th>Frequency (MHz)</th>
<th>X</th>
<th>Y</th>
<th>Z</th>
@ -65,22 +74,84 @@
<tbody></tbody>
</table>
</div>
</article>
</section>
<section class="card">
<h2>Конфигурация</h2>
<p class="muted">
Изменения сохраняются в конфиг-файл сервиса. После сохранения нужен
перезапуск для применения.
</p>
<section id="section-receivers" class="panel">
<article class="card">
<h2>Ресиверы</h2>
<div id="receivers-list" class="mono small"></div>
</article>
</section>
<section id="section-delivery" class="panel">
<article class="card">
<h2>Отправка на конечный сервер</h2>
<div id="delivery-details" class="mono small"></div>
</article>
</section>
<section id="section-servers" class="panel">
<article class="card">
<h2>Настройка серверов</h2>
<p class="muted">Изменения сохраняются в конфиг и требуют перезапуска сервиса.</p>
<div class="server-grid">
<label>Receiver
<select id="receiver-select"></select>
</label>
<label>Receiver URL<input id="rx-url" type="text" /></label>
<label>Filter enabled
<select id="rx-filter-enabled">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label>Min freq MHz<input id="rx-min-freq" type="number" step="0.001" min="0" /></label>
<label>Max freq MHz<input id="rx-max-freq" type="number" step="0.001" min="0" /></label>
<label>Min RSSI dBm<input id="rx-min-rssi" type="number" step="0.1" /></label>
<label>Max RSSI dBm<input id="rx-max-rssi" type="number" step="0.1" /></label>
<label>Write API token (session only)<input id="write-token" type="password" /></label>
<label>Output enabled
<select id="out-enabled">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label>Freq filter enabled
<select id="out-freq-filter-enabled">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label>Min frequency MHz<input id="out-min-freq" type="number" step="0.001" min="0" /></label>
<label>Max frequency MHz<input id="out-max-freq" type="number" step="0.001" min="0" /></label>
<label>Output IP/host<input id="out-ip" type="text" /></label>
<label>Output port<input id="out-port" type="number" min="1" /></label>
<label>Output path<input id="out-path" type="text" /></label>
<label>Output timeout s<input id="out-timeout" type="number" step="0.1" min="0.1" /></label>
</div>
<div class="editor-actions">
<button id="load-servers" class="btn">Load</button>
<button id="save-servers" class="btn btn-primary">Save servers</button>
<span id="servers-state" class="badge">servers: n/a</span>
</div>
</article>
</section>
<section id="section-json" class="panel">
<article class="card">
<h2>Конфигурация (Raw JSON)</h2>
<div class="editor-actions">
<button id="load-config" class="btn">Load</button>
<button id="save-config" class="btn btn-primary">Save</button>
<button id="save-config" class="btn btn-primary">Save JSON</button>
<span id="config-state" class="badge">config: n/a</span>
</div>
<textarea id="config-editor" class="editor" spellcheck="false"></textarea>
</article>
</section>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

@ -1,11 +1,11 @@
:root {
--bg: #f4f6f8;
--card: #ffffff;
--text: #101418;
--muted: #5b6872;
--line: #dbe2e8;
--accent: #0e6e6b;
--accent-soft: #d9f2f1;
--bg: #f2f4f7;
--card: #ffffffd4;
--text: #10161d;
--muted: #5f6f7d;
--line: #d8e0e7;
--accent: #0f766e;
--accent-soft: #e6f7f4;
}
* {
@ -16,47 +16,69 @@ body {
margin: 0;
font-family: "Segoe UI", "Noto Sans", sans-serif;
color: var(--text);
background: radial-gradient(circle at 20% 20%, #eef7ff 0%, var(--bg) 45%),
linear-gradient(180deg, #f8fbff, #f4f6f8);
background: linear-gradient(160deg, #f9fafc, #eef4f7 45%, #f2f4f7);
min-height: 100vh;
overflow-x: hidden;
}
.container {
width: min(1100px, 94vw);
margin: 32px auto;
.app-shell {
width: min(1240px, 96vw);
margin: 24px auto;
display: grid;
gap: 18px;
grid-template-columns: 280px 1fr;
gap: 16px;
position: relative;
z-index: 2;
}
.card {
background: color-mix(in oklab, var(--card), transparent 8%);
background: var(--card);
border: 1px solid var(--line);
border-radius: 16px;
padding: 18px;
box-shadow: 0 12px 30px rgba(16, 20, 24, 0.06);
animation: rise 450ms ease both;
padding: 16px;
backdrop-filter: blur(8px);
box-shadow: 0 14px 35px rgba(16, 22, 29, 0.06);
animation: rise 420ms ease both;
}
.hero h1 {
margin: 6px 0;
font-size: clamp(1.2rem, 3vw, 1.9rem);
.side-nav {
position: sticky;
top: 16px;
height: fit-content;
display: grid;
gap: 12px;
}
.kicker {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.12em;
letter-spacing: 0.14em;
color: var(--accent);
font-weight: 700;
font-size: 0.78rem;
font-size: 0.74rem;
}
.side-title {
margin: 0;
font-size: 1.3rem;
}
.grid {
.content-area {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.panel {
display: none;
animation: fadeSlide 220ms ease;
}
.panel-active {
display: grid;
gap: 16px;
}
.hero h2 {
margin: 0 0 8px;
}
.hero-actions,
@ -74,11 +96,12 @@ body {
border-radius: 10px;
padding: 8px 12px;
cursor: pointer;
transition: transform 150ms ease, background-color 150ms ease;
transition: transform 140ms ease, background-color 140ms ease, box-shadow 140ms ease;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1);
}
.btn-primary {
@ -87,12 +110,60 @@ body {
color: #fff;
}
.badge {
.menu-wrap {
position: relative;
}
.menu-toggle {
width: 100%;
}
.menu-list {
display: none;
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
border: 1px solid var(--line);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 10px 28px rgba(16, 22, 29, 0.1);
padding: 6px;
z-index: 20;
}
.menu-list-open {
display: grid;
gap: 5px;
}
.menu-item {
border: 1px solid transparent;
background: #f7fafb;
color: var(--text);
border-radius: 8px;
padding: 8px 10px;
text-align: left;
cursor: pointer;
}
.menu-item-active {
background: var(--accent-soft);
border-color: color-mix(in oklab, var(--accent), #fff 70%);
}
.side-meta {
display: grid;
gap: 6px;
}
.badge {
border: 1px solid var(--line);
background: #f3f9fb;
border-radius: 999px;
padding: 4px 10px;
font-size: 0.82rem;
font-size: 0.8rem;
width: fit-content;
}
.result-box {
@ -134,12 +205,12 @@ tbody tr {
}
tbody tr:hover {
background: #f5fbfb;
background: #f4fbfa;
}
.editor {
width: 100%;
min-height: 280px;
min-height: 320px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
@ -149,35 +220,56 @@ tbody tr:hover {
margin-top: 10px;
}
.server-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 14px;
}
.server-grid label {
display: grid;
gap: 6px;
font-size: 0.88rem;
}
.server-grid input,
.server-grid select {
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 9px;
font-size: 0.9rem;
background: #fff;
}
.bg-glow {
position: fixed;
width: 360px;
height: 360px;
border-radius: 50%;
filter: blur(48px);
opacity: 0.42;
filter: blur(55px);
opacity: 0.35;
pointer-events: none;
z-index: 1;
animation: drift 12s ease-in-out infinite alternate;
animation: drift 10s ease-in-out infinite alternate;
}
.bg-glow-a {
background: #a2e9db;
top: -90px;
right: -70px;
background: #8de4d5;
top: -110px;
right: -80px;
}
.bg-glow-b {
background: #c4dcff;
bottom: -120px;
background: #a9c9ff;
bottom: -130px;
left: -90px;
animation-delay: 1.4s;
animation-delay: 1.2s;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(7px);
transform: translateY(8px);
}
to {
opacity: 1;
@ -185,17 +277,38 @@ tbody tr:hover {
}
}
@keyframes fadeSlide {
from {
opacity: 0;
transform: translateX(7px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes drift {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(30px, -15px) scale(1.12);
transform: translate(26px, -16px) scale(1.1);
}
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.side-nav {
position: static;
}
}
@media (max-width: 800px) {
.grid {
@media (max-width: 740px) {
.server-grid {
grid-template-columns: 1fr;
}
}

Loading…
Cancel
Save