diff --git a/Dockerfile b/Dockerfile index a5f0ec1..746eb73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 8091190..86da6c6 100644 --- a/README.md +++ b/README.md @@ -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: ` +- `Authorization: Bearer ` + +Что важно: +- `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; +- если после фильтрации у ресивера нет данных, цикл расчета возвращает ошибку. diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc index 3ea6cfa..61d27a2 100644 Binary files a/__pycache__/service.cpython-311.pyc and b/__pycache__/service.cpython-311.pyc differ diff --git a/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc index a1b2d17..f3b3ae1 100644 Binary files a/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc and b/__pycache__/test_service_integration.cpython-311-pytest-8.2.2.pyc differ diff --git a/config.template.json b/config.template.json index fa59ef3..cc9ec3f 100644 --- a/config.template.json +++ b/config.template.json @@ -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 + } } ] } diff --git a/docker-compose.yml b/docker-compose.yml index 866c460..54bb61c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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"] diff --git a/docker/config.docker.json b/docker/config.docker.json new file mode 100644 index 0000000..78202cd --- /dev/null +++ b/docker/config.docker.json @@ -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 + } + } + ] + } +} diff --git a/docker/config.docker.test.json b/docker/config.docker.test.json index 2fbdfa4..78202cd 100644 --- a/docker/config.docker.test.json +++ b/docker/config.docker.test.json @@ -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 + } } ] } diff --git a/service.py b/service.py index 1cad317..a3e1b17 100644 --- a/service.py +++ b/service.py @@ -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: diff --git a/test_service_integration.py b/test_service_integration.py index e9e5298..a750d67 100644 --- a/test_service_integration.py +++ b/test_service_integration.py @@ -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) diff --git a/web/app.js b/web/app.js index 5ffcd66..c3c2899 100644 --- a/web/app.js +++ b/web/app.js @@ -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) => ` - ${fmt(row.frequency_hz, 1)} + ${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)} ${fmt(row.position?.x)} ${fmt(row.position?.y)} ${fmt(row.position?.z)} @@ -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); diff --git a/web/index.html b/web/index.html index 760d9bf..21259d6 100644 --- a/web/index.html +++ b/web/index.html @@ -9,78 +9,149 @@
-
-
+ +
+
+ -
-
-

Итоговая позиция

-
-
Selected Freq: -
-
X: -
-
Y: -
-
Z: -
-
RMSE: -
-
-
+
+
+
+

RF Positioning Dashboard

+

Мониторинг и контроль расчета пересечения 3 сфер.

+
+ +
+
-
-

Ресиверы

-
-
-
+
+

Итоговая позиция

+
+
Selected Freq: -
+
X: -
+
Y: -
+
Z: -
+
RMSE: -
+
+
+
-
-

Отправка на конечный сервер

-
-
+
+
+

Таблица пересечений по частотам

+
+ + + + + + + + + + + + +
Frequency (MHz)XYZRMSEExact
+
+
+
-
-

Таблица пересечений по частотам

-
- - - - - - - - - - - - -
Frequency (Hz)XYZRMSEExact
-
-
+
+
+

Ресиверы

+
+
+
-
-

Конфигурация

-

- Изменения сохраняются в конфиг-файл сервиса. После сохранения нужен - перезапуск для применения. -

-
- - - config: n/a -
- +
+
+

Отправка на конечный сервер

+
+
+
+ +
+
+

Настройка серверов

+

Изменения сохраняются в конфиг и требуют перезапуска сервиса.

+
+ + + + + + + + + + + + + + + + +
+
+ + + servers: n/a +
+
+
+ +
+
+

Конфигурация (Raw JSON)

+
+ + + config: n/a +
+ +
+
+ - + diff --git a/web/styles.css b/web/styles.css index 769cc92..1b27417 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; } }