test_version_in_local

main
AlexsandrSnytkin 2 weeks ago
commit 820fe674ea

@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY . /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
EXPOSE 8081
CMD ["python", "service.py", "--config", "docker/config.docker.json"]

@ -0,0 +1,182 @@
# Triangulation Service
Сервис решает 3D-трилатерацию по 3 ресиверам:
- центры сфер: координаты ресиверов;
- радиусы сфер: расстояния, оцененные из RSSI с учетом частоты;
- расчет идет по одинаковым частотам, которые есть у всех 3 ресиверов;
- формируется таблица `frequency_table` (по каждой частоте отдельное решение);
- выбирается итоговая частота `selected_frequency_hz` по минимальному `rmse_m`.
## Что реализовано
- Автоматический polling 3 входных серверов (`http_sources`).
- Валидация входных payload с подробными ошибками.
- API:
- `GET /health`
- `GET /result`
- `GET /frequencies`
- `POST /refresh`
- `GET /config`
- `POST /config`
- UI (`/ui`) с:
- входными данными ресиверов;
- таблицей пересечений по частотам;
- итоговой позицией;
- статусом отправки на конечный сервер.
- Опциональный push результата на внешний сервер (`runtime.output_server`).
## Структура проекта
- [service.py](/c:/Users/snytk/triangulation/service.py) - автосервис + API + UI статик.
- [triangulation.py](/c:/Users/snytk/triangulation/triangulation.py) - математика.
- [config.template.json](/c:/Users/snytk/triangulation/config.template.json) - шаблон конфига.
- [web/index.html](/c:/Users/snytk/triangulation/web/index.html), [web/styles.css](/c:/Users/snytk/triangulation/web/styles.css), [web/app.js](/c:/Users/snytk/triangulation/web/app.js) - UI.
- [docker-compose.yml](/c:/Users/snytk/triangulation/docker-compose.yml) - test/prod профили.
- [docker/config.docker.test.json](/c:/Users/snytk/triangulation/docker/config.docker.test.json) - тестовый конфиг.
- [docker/mock_receiver.py](/c:/Users/snytk/triangulation/docker/mock_receiver.py) - mock входные сервера (random RSSI).
- [docker/mock_output_sink.py](/c:/Users/snytk/triangulation/docker/mock_output_sink.py) - mock конечный сервер.
## Docker Compose: test/prod режимы
`docker-compose.yml` разделен на профили:
- `test`:
- `triangulation-test`
- `receiver-r0`, `receiver-r1`, `receiver-r2`
- `output-sink`
- `prod`:
- `triangulation-prod` (читает ваш `./config.json`)
Это позволяет легко отключить тестовый режим и перейти на реальные сервера.
## Быстрый старт: Test Mode
Поднимает все контейнеры для end-to-end проверки:
- 3 входных mock сервера с random данными;
- основной сервис;
- output-sink, принимающий отправленные результаты.
```bash
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`
Остановить:
```bash
docker compose --profile test down
```
## Быстрый старт: Prod Mode
1. Создайте `config.json` из шаблона:
```bash
cp config.template.json config.json
```
2. Заполните ваши реальные:
- `input.receivers[].source_url`
- `input.receivers[].center`
- `runtime.output_server`
3. Запустите:
```bash
docker compose --profile prod up --build
```
Остановить:
```bash
docker compose --profile prod down
```
## Как проверить, что данные приходят и отправляются
В UI (`/ui`) видно:
- блок `Ресиверы`: входящие samples;
- таблица `Таблица пересечений по частотам`: решения по каждой общей частоте;
- блок `Отправка на конечный сервер`: статус доставки (`ok/error`), HTTP-код, время, target.
Дополнительно:
- `GET /result` возвращает `output_delivery`.
- `GET /frequencies` тоже возвращает `output_delivery`.
- `GET http://localhost:8080/latest` показывает, что именно принял output-sink.
## Конфиг (основные поля)
Пример: [config.template.json](/c:/Users/snytk/triangulation/config.template.json)
Критичные поля:
- `input.mode`: только `"http_sources"` для автосервиса.
- `input.receivers`: ровно 3 ресивера.
- `input.aggregation`: `"median"` или `"mean"`.
- `runtime.poll_interval_s`: период опроса.
- `runtime.output_server.enabled`: push во внешний сервер.
## Формат входных payload
Поддержка:
- объект с `measurements`/`samples`/`data`;
- или сразу массив измерений.
Измерение:
- `frequency_hz` (или `freq_hz`/`frequency`/`freq`)
- `amplitude_dbm` (или `rssi_dbm`/`amplitude`/`rssi`)
Пример:
```json
{
"receiver_id": "r0",
"measurements": [
{ "frequency_hz": 433920000, "rssi_dbm": -61.5 },
{ "frequency_hz": 868100000, "rssi_dbm": -67.2 }
]
}
```
Если `receiver_id` передан, сервис сверяет его с ожидаемым receiver из конфига.
## Валидация и ошибки некорректного контекста
Проверяется:
- тип payload;
- наличие измерений;
- числовые и конечные значения;
- `frequency_hz > 0`;
- соответствие `receiver_id` при наличии;
- наличие общих частот у всех 3 ресиверов.
Ошибки содержат:
- `source_url=...`
- номер строки `row #...`
- проблемное поле.
## Тесты
Запуск:
```bash
pytest -q
```
Покрытие:
- математика триангуляции;
- влияние частоты на RSSI->distance;
- интеграция `AutoService.refresh_once()`;
- валидационные сценарии;
- ошибки контекста (нет общих частот, bad field, receiver mismatch, network error, output reject).
Файл интеграционных тестов:
- [test_service_integration.py](/c:/Users/snytk/triangulation/test_service_integration.py)
## Локальный запуск без Docker
```bash
python service.py --config config.json
```
UI:
- `http://127.0.0.1:8081/ui`

410
cli.py

@ -0,0 +1,410 @@
from __future__ import annotations
import argparse
import json
import statistics
from pathlib import Path
from typing import Dict, List, Sequence, Tuple
from urllib import error, request
from triangulation import (
PropagationModel,
ReceiverSignal,
Sphere,
rssi_to_distance_m,
send_payload_to_server,
solve_and_prepare_payload,
solve_three_sphere_intersection,
)
Point3D = Tuple[float, float, float]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="3D trilateration from config file or direct CLI parameters."
)
parser.add_argument(
"--config",
type=str,
default="config.json",
help="Path to JSON config. Used when --receiver is not provided.",
)
parser.add_argument(
"--receiver",
action="append",
nargs=6,
metavar=("ID", "X", "Y", "Z", "AMPLITUDE_DBM", "FREQ_HZ"),
help="Direct receiver measurement. Provide exactly 3 times for CLI mode.",
)
parser.add_argument("--tx-power-dbm", type=float)
parser.add_argument("--tx-gain-dbi", type=float, default=0.0)
parser.add_argument("--rx-gain-dbi", type=float, default=0.0)
parser.add_argument("--path-loss-exponent", type=float, default=2.0)
parser.add_argument("--reference-distance-m", type=float, default=1.0)
parser.add_argument("--min-distance-m", type=float, default=1e-3)
parser.add_argument("--tolerance", type=float, default=1e-3)
parser.add_argument(
"--z-preference",
choices=("positive", "negative"),
default="positive",
)
parser.add_argument("--server-ip", type=str, default="")
parser.add_argument("--server-port", type=int, default=8080)
parser.add_argument("--server-path", type=str, default="/triangulation")
parser.add_argument("--timeout-s", type=float, default=3.0)
parser.add_argument("--no-print-json", action="store_true")
return parser.parse_args()
def _load_json(path: str) -> Dict[str, object]:
config_path = Path(path)
if not config_path.exists():
raise SystemExit(
f"Config file '{path}' not found. Create it from config.template.json or pass CLI receivers."
)
with config_path.open("r", encoding="utf-8") as file:
loaded = json.load(file)
if not isinstance(loaded, dict):
raise SystemExit("Config root must be a JSON object.")
return loaded
def _center_from_obj(obj: Dict[str, object]) -> Point3D:
center = obj.get("center")
if not isinstance(center, dict):
raise ValueError("Receiver must contain 'center' object.")
return (float(center["x"]), float(center["y"]), float(center["z"]))
def _model_from_obj(obj: Dict[str, object]) -> PropagationModel:
return PropagationModel(
tx_power_dbm=float(obj["tx_power_dbm"]),
tx_gain_dbi=float(obj.get("tx_gain_dbi", 0.0)),
rx_gain_dbi=float(obj.get("rx_gain_dbi", 0.0)),
path_loss_exponent=float(obj.get("path_loss_exponent", 2.0)),
reference_distance_m=float(obj.get("reference_distance_m", 1.0)),
min_distance_m=float(obj.get("min_distance_m", 1e-3)),
)
def _float_from_measurement(item: Dict[str, object], keys: Sequence[str], name: str) -> float:
for key in keys:
if key in item:
return float(item[key])
raise ValueError(f"Missing '{name}' in source measurement.")
def _parse_source_payload(payload: object) -> List[Tuple[float, float]]:
if isinstance(payload, dict):
raw_measurements = payload.get("measurements")
if raw_measurements is None:
raw_measurements = payload.get("samples")
if raw_measurements is None:
raw_measurements = payload.get("data")
elif isinstance(payload, list):
raw_measurements = payload
else:
raise ValueError("Source payload must be array or object with measurements.")
if not isinstance(raw_measurements, list) or not raw_measurements:
raise ValueError("Source payload has no measurements.")
parsed: List[Tuple[float, float]] = []
for item in raw_measurements:
if not isinstance(item, dict):
raise ValueError("Each measurement item must be an object.")
frequency_hz = _float_from_measurement(
item,
keys=("frequency_hz", "freq_hz", "frequency", "freq"),
name="frequency_hz",
)
amplitude_dbm = _float_from_measurement(
item,
keys=("amplitude_dbm", "rssi_dbm", "amplitude", "rssi"),
name="amplitude_dbm",
)
parsed.append((frequency_hz, amplitude_dbm))
return parsed
def _fetch_source_measurements(url: str, timeout_s: float) -> List[Tuple[float, float]]:
req = request.Request(url=url, method="GET", headers={"Accept": "application/json"})
try:
with request.urlopen(req, timeout=timeout_s) as response:
payload = json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} for '{url}': {body}")
except error.URLError as exc:
raise RuntimeError(f"Cannot reach '{url}': {exc.reason}")
except TimeoutError:
raise RuntimeError(f"Timeout while reading '{url}'")
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid JSON from '{url}': {exc}")
return _parse_source_payload(payload)
def _aggregate_radius(
measurements: Sequence[Tuple[float, float]],
model: PropagationModel,
method: str,
) -> float:
distances = [
rssi_to_distance_m(amplitude_dbm=amplitude, frequency_hz=frequency, model=model)
for frequency, amplitude in measurements
]
if method == "mean":
return sum(distances) / len(distances)
if method == "median":
return float(statistics.median(distances))
raise ValueError("aggregation must be 'median' or 'mean'")
def _run_direct_cli_mode(args: argparse.Namespace) -> int:
if not args.receiver or len(args.receiver) != 3:
raise SystemExit(
"CLI mode requires exactly 3 --receiver entries. Otherwise use --config."
)
if args.tx_power_dbm is None:
raise SystemExit("CLI mode requires --tx-power-dbm.")
signals: List[ReceiverSignal] = []
for receiver_id, x, y, z, amplitude_dbm, frequency_hz in args.receiver:
signals.append(
ReceiverSignal(
receiver_id=receiver_id,
center=(float(x), float(y), float(z)),
amplitude_dbm=float(amplitude_dbm),
frequency_hz=float(frequency_hz),
)
)
model = PropagationModel(
tx_power_dbm=args.tx_power_dbm,
tx_gain_dbi=args.tx_gain_dbi,
rx_gain_dbi=args.rx_gain_dbi,
path_loss_exponent=args.path_loss_exponent,
reference_distance_m=args.reference_distance_m,
min_distance_m=args.min_distance_m,
)
result, payload = solve_and_prepare_payload(
signals=signals,
model=model,
tolerance=args.tolerance,
z_preference=args.z_preference,
)
_print_result(result, payload, print_json=not args.no_print_json)
return _send_if_needed(
payload=payload,
server_ip=args.server_ip,
port=args.server_port,
path=args.server_path,
timeout_s=args.timeout_s,
)
def _run_config_mode(config: Dict[str, object]) -> int:
model_obj = config.get("model")
solver_obj = config.get("solver", {})
output_obj = config.get("output", {})
input_obj = config.get("input")
if not isinstance(model_obj, dict):
raise SystemExit("Config must contain object 'model'.")
if not isinstance(input_obj, dict):
raise SystemExit("Config must contain object 'input'.")
if not isinstance(solver_obj, dict):
raise SystemExit("'solver' must be object.")
if not isinstance(output_obj, dict):
raise SystemExit("'output' must be object.")
model = _model_from_obj(model_obj)
tolerance = float(solver_obj.get("tolerance", 1e-3))
z_preference = str(solver_obj.get("z_preference", "positive"))
if z_preference not in ("positive", "negative"):
raise SystemExit("solver.z_preference must be 'positive' or 'negative'.")
input_mode = str(input_obj.get("mode", "manual"))
if input_mode == "manual":
payload, result = _solve_from_manual_config(input_obj, model, tolerance, z_preference)
elif input_mode == "http_sources":
payload, result = _solve_from_sources_config(input_obj, model, tolerance, z_preference)
else:
raise SystemExit("input.mode must be 'manual' or 'http_sources'.")
print_json = bool(output_obj.get("print_json", True))
_print_result(result, payload, print_json=print_json)
server_ip = str(output_obj.get("server_ip", ""))
return _send_if_needed(
payload=payload,
server_ip=server_ip,
port=int(output_obj.get("server_port", 8080)),
path=str(output_obj.get("server_path", "/triangulation")),
timeout_s=float(output_obj.get("timeout_s", 3.0)),
)
def _solve_from_manual_config(
input_obj: Dict[str, object],
model: PropagationModel,
tolerance: float,
z_preference: str,
):
receivers = input_obj.get("receivers")
if not isinstance(receivers, list) or len(receivers) != 3:
raise SystemExit("input.receivers must contain exactly 3 receivers.")
signals: List[ReceiverSignal] = []
for receiver in receivers:
if not isinstance(receiver, dict):
raise SystemExit("Each receiver in input.receivers must be an object.")
signals.append(
ReceiverSignal(
receiver_id=str(receiver["receiver_id"]),
center=_center_from_obj(receiver),
amplitude_dbm=float(receiver["amplitude_dbm"]),
frequency_hz=float(receiver["frequency_hz"]),
)
)
result, payload = solve_and_prepare_payload(
signals=signals,
model=model,
tolerance=tolerance,
z_preference=z_preference, # type: ignore[arg-type]
)
return payload, result
def _solve_from_sources_config(
input_obj: Dict[str, object],
model: PropagationModel,
tolerance: float,
z_preference: str,
):
receivers = input_obj.get("receivers")
if not isinstance(receivers, list) or len(receivers) != 3:
raise SystemExit("input.receivers must contain exactly 3 receivers.")
timeout_s = float(input_obj.get("source_timeout_s", 3.0))
aggregation = str(input_obj.get("aggregation", "median"))
if aggregation not in ("median", "mean"):
raise SystemExit("input.aggregation must be 'median' or 'mean'.")
spheres: List[Sphere] = []
receiver_payloads: List[Dict[str, object]] = []
for receiver in receivers:
if not isinstance(receiver, dict):
raise SystemExit("Each receiver in input.receivers must be an object.")
receiver_id = str(receiver["receiver_id"])
center = _center_from_obj(receiver)
source_url = str(receiver["source_url"])
measurements = _fetch_source_measurements(source_url, timeout_s=timeout_s)
radius = _aggregate_radius(measurements, model=model, method=aggregation)
spheres.append(Sphere(center=center, radius=radius))
sample_payload = []
for frequency_hz, amplitude_dbm in measurements:
sample_payload.append(
{
"frequency_hz": frequency_hz,
"amplitude_dbm": amplitude_dbm,
"distance_m": rssi_to_distance_m(
amplitude_dbm=amplitude_dbm,
frequency_hz=frequency_hz,
model=model,
),
}
)
receiver_payloads.append(
{
"receiver_id": receiver_id,
"center": {"x": center[0], "y": center[1], "z": center[2]},
"source_url": source_url,
"aggregation": aggregation,
"radius_m": radius,
"samples": sample_payload,
}
)
result = solve_three_sphere_intersection(
spheres=spheres,
tolerance=tolerance,
z_preference=z_preference, # type: ignore[arg-type]
)
for idx, residual in enumerate(result.residuals):
receiver_payloads[idx]["residual_m"] = residual
payload = {
"position": {"x": result.point[0], "y": result.point[1], "z": result.point[2]},
"exact": result.exact,
"rmse_m": result.rmse,
"model": {
"tx_power_dbm": model.tx_power_dbm,
"tx_gain_dbi": model.tx_gain_dbi,
"rx_gain_dbi": model.rx_gain_dbi,
"path_loss_exponent": model.path_loss_exponent,
"reference_distance_m": model.reference_distance_m,
},
"receivers": receiver_payloads,
}
return payload, result
def _print_result(result, payload: Dict[str, object], print_json: bool) -> None:
x, y, z = result.point
print(f"point: ({x:.6f}, {y:.6f}, {z:.6f})")
print(
"residuals: "
f"({result.residuals[0]:.6e}, {result.residuals[1]:.6e}, {result.residuals[2]:.6e})"
)
print(f"rmse: {result.rmse:.6e}")
print(f"exact: {result.exact}")
if print_json:
print(json.dumps(payload, indent=2, ensure_ascii=False))
def _send_if_needed(
payload: Dict[str, object],
server_ip: str,
port: int,
path: str,
timeout_s: float,
) -> int:
if not server_ip:
return 0
status, body = send_payload_to_server(
server_ip=server_ip,
payload=payload,
port=port,
path=path,
timeout_s=timeout_s,
)
print(f"server_status: {status}")
if body:
print(f"server_response: {body}")
if status == 0 or status >= 400:
return 2
return 0
def main() -> int:
args = parse_args()
try:
if args.receiver:
return _run_direct_cli_mode(args)
config = _load_json(args.config)
return _run_config_mode(config)
except (RuntimeError, ValueError, KeyError) as exc:
raise SystemExit(str(exc))
if __name__ == "__main__":
raise SystemExit(main())

@ -0,0 +1,60 @@
{
"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,
"output_server": {
"enabled": false,
"ip": "192.168.1.100",
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.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://10.0.0.11:9000/measurements"
},
{
"receiver_id": "r1",
"center": {
"x": 10.0,
"y": 0.0,
"z": 0.0
},
"source_url": "http://10.0.0.12:9000/measurements"
},
{
"receiver_id": "r2",
"center": {
"x": 0.0,
"y": 8.0,
"z": 0.0
},
"source_url": "http://10.0.0.13:9000/measurements"
}
]
}
}

@ -0,0 +1,55 @@
services:
triangulation-test:
build: .
container_name: triangulation-test
command: ["python", "service.py", "--config", "docker/config.docker.test.json"]
ports:
- "8081:8081"
depends_on:
- receiver-r0
- receiver-r1
- receiver-r2
- output-sink
profiles: ["test"]
receiver-r0:
build: .
container_name: receiver-r0
command: ["python", "docker/mock_receiver.py", "--receiver-id", "r0", "--port", "9000", "--base-rssi", "-61.0"]
expose:
- "9000"
profiles: ["test"]
receiver-r1:
build: .
container_name: receiver-r1
command: ["python", "docker/mock_receiver.py", "--receiver-id", "r1", "--port", "9000", "--base-rssi", "-64.0"]
expose:
- "9000"
profiles: ["test"]
receiver-r2:
build: .
container_name: receiver-r2
command: ["python", "docker/mock_receiver.py", "--receiver-id", "r2", "--port", "9000", "--base-rssi", "-63.0"]
expose:
- "9000"
profiles: ["test"]
output-sink:
build: .
container_name: output-sink
command: ["python", "docker/mock_output_sink.py", "--port", "8080"]
ports:
- "8080:8080"
profiles: ["test"]
triangulation-prod:
build: .
container_name: triangulation-prod
command: ["python", "service.py", "--config", "/app/config.json"]
ports:
- "8081:8081"
volumes:
- ./config.json:/app/config.json:ro
profiles: ["prod"]

@ -0,0 +1,60 @@
{
"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,
"output_server": {
"enabled": true,
"ip": "output-sink",
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.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"
},
{
"receiver_id": "r1",
"center": {
"x": 10.0,
"y": 0.0,
"z": 0.0
},
"source_url": "http://receiver-r1:9000/measurements"
},
{
"receiver_id": "r2",
"center": {
"x": 0.0,
"y": 8.0,
"z": 0.0
},
"source_url": "http://receiver-r2:9000/measurements"
}
]
}
}

@ -0,0 +1,59 @@
from __future__ import annotations
import argparse
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8080)
args = parser.parse_args()
latest = {"count": 0, "last_payload": None}
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args2) -> None:
return
def do_GET(self) -> None:
if self.path != "/latest":
self.send_response(404)
self.end_headers()
return
raw = json.dumps(latest).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_POST(self) -> None:
if self.path != "/triangulation":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(content_length) if content_length > 0 else b"{}"
payload = json.loads(body.decode("utf-8"))
selected = payload.get("selected_frequency_hz")
latest["count"] = int(latest["count"]) + 1
latest["last_payload"] = payload
print(f"received payload, selected_frequency_hz={selected}")
raw = json.dumps({"status": "ok"}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
print(f"mock_output_sink listening on :{args.port}")
server.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
import json
import random
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Dict, List
def _build_payload(receiver_id: str, base_rssi: float) -> Dict[str, object]:
noise_a = random.uniform(-1.2, 1.2)
noise_b = random.uniform(-1.2, 1.2)
rows: List[Dict[str, float]] = [
{"frequency_hz": 433_920_000.0, "rssi_dbm": base_rssi + noise_a},
{"frequency_hz": 868_100_000.0, "rssi_dbm": base_rssi - 4.0 + noise_b},
]
return {
"receiver_id": receiver_id,
"timestamp_unix": time.time(),
"measurements": rows,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--receiver-id", required=True)
parser.add_argument("--port", type=int, default=9000)
parser.add_argument("--base-rssi", type=float, default=-62.0)
args = parser.parse_args()
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args2) -> None:
return
def do_GET(self) -> None:
if self.path != "/measurements":
self.send_response(404)
self.end_headers()
return
payload = _build_payload(args.receiver_id, args.base_rssi)
raw = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
server = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
print(f"mock_receiver({args.receiver_id}) listening on :{args.port}")
server.serve_forever()
return 0
if __name__ == "__main__":
raise SystemExit(main())

@ -0,0 +1,678 @@
from __future__ import annotations
import argparse
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
from typing import Dict, List, Optional, Sequence, Tuple
from urllib import error, parse, request
from triangulation import (
PropagationModel,
Sphere,
rssi_to_distance_m,
send_payload_to_server,
solve_three_sphere_intersection,
)
Point3D = Tuple[float, float, float]
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:
data = json.load(fh)
if not isinstance(data, dict):
raise SystemExit("Config root must be a JSON object.")
return data
def _center_from_obj(obj: Dict[str, object]) -> Point3D:
center = obj.get("center")
if not isinstance(center, dict):
raise ValueError("Receiver center must be an object.")
return (float(center["x"]), float(center["y"]), float(center["z"]))
def _parse_model(config: Dict[str, object]) -> PropagationModel:
model_obj = config.get("model")
if not isinstance(model_obj, dict):
raise ValueError("Config must contain object 'model'.")
return PropagationModel(
tx_power_dbm=float(model_obj["tx_power_dbm"]),
tx_gain_dbi=float(model_obj.get("tx_gain_dbi", 0.0)),
rx_gain_dbi=float(model_obj.get("rx_gain_dbi", 0.0)),
path_loss_exponent=float(model_obj.get("path_loss_exponent", 2.0)),
reference_distance_m=float(model_obj.get("reference_distance_m", 1.0)),
min_distance_m=float(model_obj.get("min_distance_m", 1e-3)),
)
def _float_from_measurement(
item: Dict[str, object],
keys: Sequence[str],
field_name: str,
source_label: str,
row_index: int,
) -> float:
for key in keys:
if key in item:
value = item[key]
try:
parsed = float(value)
except (TypeError, ValueError):
raise ValueError(
f"{source_label}: row #{row_index} field '{key}' must be numeric, got {value!r}."
) from None
if not math.isfinite(parsed):
raise ValueError(
f"{source_label}: row #{row_index} field '{key}' must be finite, got {value!r}."
)
return parsed
raise ValueError(f"{source_label}: row #{row_index} missing field '{field_name}'.")
def parse_source_payload(
payload: object,
source_label: str,
expected_receiver_id: Optional[str] = None,
) -> List[Tuple[float, float]]:
if isinstance(payload, dict):
if expected_receiver_id is not None and "receiver_id" in payload:
payload_receiver_id = str(payload["receiver_id"])
if payload_receiver_id != expected_receiver_id:
raise ValueError(
f"{source_label}: payload receiver_id '{payload_receiver_id}' "
f"does not match expected '{expected_receiver_id}'."
)
raw_items = payload.get("measurements")
if raw_items is None:
raw_items = payload.get("samples")
if raw_items is None:
raw_items = payload.get("data")
elif isinstance(payload, list):
raw_items = payload
else:
raise ValueError(f"{source_label}: payload must be list or object.")
if not isinstance(raw_items, list) or not raw_items:
raise ValueError(f"{source_label}: payload contains no measurements.")
parsed_items: List[Tuple[float, float]] = []
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",
source_label=source_label,
row_index=row_index,
)
amplitude_dbm = _float_from_measurement(
row,
keys=("amplitude_dbm", "rssi_dbm", "amplitude", "rssi"),
field_name="amplitude_dbm",
source_label=source_label,
row_index=row_index,
)
if frequency_hz <= 0.0:
raise ValueError(
f"{source_label}: row #{row_index} field 'frequency_hz' must be > 0."
)
parsed_items.append((frequency_hz, amplitude_dbm))
return parsed_items
def aggregate_radius(
measurements: Sequence[Tuple[float, float]],
model: PropagationModel,
method: str,
) -> float:
distances = [
rssi_to_distance_m(amplitude_dbm=amplitude_dbm, frequency_hz=frequency_hz, model=model)
for frequency_hz, amplitude_dbm in measurements
]
if method == "median":
return float(statistics.median(distances))
if method == "mean":
return float(sum(distances) / len(distances))
raise ValueError("aggregation must be 'median' or 'mean'")
def _group_by_frequency(
measurements: Sequence[Tuple[float, float]],
) -> Dict[float, List[Tuple[float, float]]]:
grouped: Dict[float, List[Tuple[float, float]]] = {}
for frequency_hz, amplitude_dbm in measurements:
if frequency_hz not in grouped:
grouped[frequency_hz] = []
grouped[frequency_hz].append((frequency_hz, amplitude_dbm))
return grouped
def _fetch_measurements(
url: str,
timeout_s: float,
expected_receiver_id: Optional[str] = None,
) -> List[Tuple[float, float]]:
source_label = f"source_url={url}"
req = request.Request(url=url, method="GET", headers={"Accept": "application/json"})
try:
with request.urlopen(req, timeout=timeout_s) as response:
payload = json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"HTTP {exc.code} for '{url}': {body}")
except error.URLError as exc:
raise RuntimeError(f"Cannot reach '{url}': {exc.reason}")
except TimeoutError:
raise RuntimeError(f"Timeout while reading '{url}'")
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid JSON from '{url}': {exc}")
try:
return parse_source_payload(
payload=payload,
source_label=source_label,
expected_receiver_id=expected_receiver_id,
)
except ValueError as exc:
raise RuntimeError(str(exc)) from None
class AutoService:
def __init__(self, config: Dict[str, object], config_path: Optional[str] = None) -> None:
self.config = config
self.config_path = config_path
self.model = _parse_model(config)
solver_obj = config.get("solver", {})
runtime_obj = config.get("runtime", {})
input_obj = config.get("input")
if not isinstance(solver_obj, dict):
raise ValueError("solver must be object.")
if not isinstance(runtime_obj, dict):
raise ValueError("runtime must be object.")
if not isinstance(input_obj, dict):
raise ValueError("input must be object.")
self.tolerance = float(solver_obj.get("tolerance", 1e-3))
self.z_preference = str(solver_obj.get("z_preference", "positive"))
if self.z_preference not in ("positive", "negative"):
raise ValueError("solver.z_preference must be 'positive' or 'negative'.")
self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0))
output_obj = runtime_obj.get("output_server", {})
if output_obj is None:
output_obj = {}
if not isinstance(output_obj, dict):
raise ValueError("runtime.output_server must be object.")
self.output_enabled = bool(output_obj.get("enabled", False))
self.output_ip = str(output_obj.get("ip", ""))
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))
if self.output_enabled and not self.output_ip:
raise ValueError("runtime.output_server.ip must be non-empty when enabled=true.")
self.source_timeout_s = float(input_obj.get("source_timeout_s", 3.0))
self.aggregation = str(input_obj.get("aggregation", "median"))
if self.aggregation not in ("median", "mean"):
raise ValueError("input.aggregation must be 'median' or 'mean'.")
input_mode = str(input_obj.get("mode", "http_sources"))
if input_mode != "http_sources":
raise ValueError("Automatic service requires input.mode = 'http_sources'.")
receivers = input_obj.get("receivers")
if not isinstance(receivers, list) or len(receivers) != 3:
raise ValueError("input.receivers must contain exactly 3 objects.")
parsed_receivers: List[Dict[str, object]] = []
for receiver in receivers:
if not isinstance(receiver, dict):
raise ValueError("Each receiver must be object.")
parsed_receivers.append(
{
"receiver_id": str(receiver["receiver_id"]),
"center": _center_from_obj(receiver),
"source_url": str(receiver["source_url"]),
}
)
self.receivers = parsed_receivers
self.state_lock = threading.Lock()
self.latest_payload: Optional[Dict[str, object]] = None
self.last_error: str = "no data yet"
self.updated_at_utc: Optional[str] = None
self.last_output_delivery: Dict[str, object] = {
"enabled": self.output_enabled,
"status": "disabled" if not self.output_enabled else "pending",
"http_status": None,
"response_body": "",
"sent_at_utc": None,
}
self.stop_event = threading.Event()
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
def start(self) -> None:
self.poll_thread.start()
def stop(self) -> None:
self.stop_event.set()
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]]]] = []
for receiver in self.receivers:
receiver_id = str(receiver["receiver_id"])
center = receiver["center"]
source_url = str(receiver["source_url"])
measurements = _fetch_measurements(
source_url,
timeout_s=self.source_timeout_s,
expected_receiver_id=receiver_id,
)
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,
"amplitude_dbm": amplitude_dbm,
"distance_m": rssi_to_distance_m(
amplitude_dbm=amplitude_dbm,
frequency_hz=frequency_hz,
model=self.model,
),
}
)
receiver_payloads.append(
{
"receiver_id": receiver_id,
"center": {"x": center[0], "y": center[1], "z": center[2]},
"source_url": source_url,
"aggregation": self.aggregation,
"radius_m_all_freq": radius_m,
"samples": samples,
}
)
common_frequencies = (
set(grouped_by_receiver[0].keys())
& set(grouped_by_receiver[1].keys())
& set(grouped_by_receiver[2].keys())
)
if not common_frequencies:
raise RuntimeError("No common frequencies across all 3 receivers.")
frequency_rows: List[Dict[str, object]] = []
best_row: Optional[Dict[str, object]] = None
for frequency_hz in sorted(common_frequencies):
spheres_for_frequency: List[Sphere] = []
row_receivers: List[Dict[str, object]] = []
for index, receiver in enumerate(self.receivers):
center = receiver["center"]
measurement_subset = grouped_by_receiver[index][frequency_hz]
radius_m = aggregate_radius(
measurement_subset, model=self.model, method=self.aggregation
)
spheres_for_frequency.append(Sphere(center=center, radius=radius_m))
row_receivers.append(
{
"receiver_id": str(receiver["receiver_id"]),
"radius_m": radius_m,
"samples_count": len(measurement_subset),
}
)
result = solve_three_sphere_intersection(
spheres=spheres_for_frequency,
tolerance=self.tolerance,
z_preference=self.z_preference, # type: ignore[arg-type]
)
for index, residual in enumerate(result.residuals):
row_receivers[index]["residual_m"] = residual
receiver_payloads[index].setdefault("per_frequency", []).append(
{
"frequency_hz": frequency_hz,
"radius_m": spheres_for_frequency[index].radius,
"residual_m": residual,
"samples_count": len(grouped_by_receiver[index][frequency_hz]),
}
)
row = {
"frequency_hz": frequency_hz,
"position": {
"x": result.point[0],
"y": result.point[1],
"z": result.point[2],
},
"exact": result.exact,
"rmse_m": result.rmse,
"receivers": row_receivers,
}
frequency_rows.append(row)
if best_row is None or float(row["rmse_m"]) < float(best_row["rmse_m"]):
best_row = row
if best_row is None:
raise RuntimeError("Cannot build frequency table for trilateration.")
payload = {
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"selected_frequency_hz": best_row["frequency_hz"],
"position": best_row["position"],
"exact": best_row["exact"],
"rmse_m": best_row["rmse_m"],
"frequency_table": frequency_rows,
"model": {
"tx_power_dbm": self.model.tx_power_dbm,
"tx_gain_dbi": self.model.tx_gain_dbi,
"rx_gain_dbi": self.model.rx_gain_dbi,
"path_loss_exponent": self.model.path_loss_exponent,
"reference_distance_m": self.model.reference_distance_m,
},
"receivers": receiver_payloads,
}
with self.state_lock:
self.latest_payload = payload
self.updated_at_utc = payload["timestamp_utc"] # type: ignore[index]
self.last_error = ""
if self.output_enabled:
status_code, response_body = send_payload_to_server(
server_ip=self.output_ip,
payload=payload,
port=self.output_port,
path=self.output_path,
timeout_s=self.output_timeout_s,
)
with self.state_lock:
self.last_output_delivery = {
"enabled": True,
"status": "ok" if 200 <= status_code < 300 else "error",
"http_status": status_code,
"response_body": response_body,
"sent_at_utc": datetime.now(timezone.utc).isoformat(),
"target": {
"ip": self.output_ip,
"port": self.output_port,
"path": self.output_path,
},
}
if status_code < 200 or status_code >= 300:
raise RuntimeError(
"Output server rejected payload: "
f"HTTP {status_code}, body={response_body}"
)
def _poll_loop(self) -> None:
while not self.stop_event.is_set():
try:
self.refresh_once()
except Exception as exc:
with self.state_lock:
self.last_error = str(exc)
self.stop_event.wait(self.poll_interval_s)
def snapshot(self) -> Dict[str, object]:
with self.state_lock:
return {
"updated_at_utc": self.updated_at_utc,
"last_error": self.last_error,
"payload": self.latest_payload,
"output_delivery": self.last_output_delivery,
}
def _make_handler(service: AutoService):
class ServiceHandler(BaseHTTPRequestHandler):
def _write_bytes(
self,
status_code: int,
content: bytes,
content_type: str,
) -> None:
self.send_response(status_code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.end_headers()
self.wfile.write(content)
def _write_json(self, status_code: int, payload: Dict[str, object]) -> None:
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self._write_bytes(
status_code=status_code,
content=raw,
content_type="application/json; charset=utf-8",
)
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())):
self._write_json(404, {"error": "not_found"})
return
if not file_path.exists() or not file_path.is_file():
self._write_json(404, {"error": "not_found"})
return
mime_type, _ = mimetypes.guess_type(str(file_path))
if mime_type is None:
mime_type = "application/octet-stream"
self._write_bytes(200, file_path.read_bytes(), mime_type)
def log_message(self, format: str, *args) -> None:
return
def do_GET(self) -> None:
path = parse.urlparse(self.path).path
snapshot = service.snapshot()
if path == "/" or path == "/ui":
self._write_static("index.html")
return
if path.startswith("/static/"):
self._write_static(path.removeprefix("/static/"))
return
if path == "/health":
status = "ok" if snapshot["payload"] else "warming_up"
http_code = 200 if status == "ok" else 503
self._write_json(
http_code,
{
"status": status,
"updated_at_utc": snapshot["updated_at_utc"],
"error": snapshot["last_error"],
},
)
return
if path == "/result":
payload = snapshot["payload"]
if payload is None:
self._write_json(
503,
{
"status": "warming_up",
"updated_at_utc": snapshot["updated_at_utc"],
"error": snapshot["last_error"],
},
)
return
self._write_json(
200,
{
"status": "ok",
"updated_at_utc": snapshot["updated_at_utc"],
"data": payload,
"output_delivery": snapshot["output_delivery"],
},
)
return
if path == "/frequencies":
payload = snapshot["payload"]
if payload is None:
self._write_json(
503,
{
"status": "warming_up",
"updated_at_utc": snapshot["updated_at_utc"],
"error": snapshot["last_error"],
},
)
return
self._write_json(
200,
{
"status": "ok",
"updated_at_utc": snapshot["updated_at_utc"],
"selected_frequency_hz": payload.get("selected_frequency_hz"),
"frequency_table": payload.get("frequency_table", []),
"output_delivery": snapshot["output_delivery"],
},
)
return
if path == "/config":
self._write_json(
200,
{
"status": "ok",
"config_path": service.config_path,
"config": service.config,
},
)
return
self._write_json(404, {"error": "not_found"})
def do_POST(self) -> None:
path = parse.urlparse(self.path).path
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
body = self.rfile.read(content_length) if content_length > 0 else b""
try:
new_config = json.loads(body.decode("utf-8"))
except json.JSONDecodeError as exc:
self._write_json(400, {"status": "error", "error": f"Invalid JSON: {exc}"})
return
if not isinstance(new_config, dict):
self._write_json(400, {"status": "error", "error": "Config must be JSON object"})
return
try:
AutoService(new_config)
except Exception as exc:
self._write_json(
400,
{"status": "error", "error": f"Config validation failed: {exc}"},
)
return
service.config = new_config
if service.config_path:
Path(service.config_path).write_text(
json.dumps(new_config, ensure_ascii=False, indent=2),
encoding="utf-8",
)
self._write_json(
200,
{
"status": "ok",
"saved": bool(service.config_path),
"restart_required": True,
"config_path": service.config_path,
},
)
return
if path != "/refresh":
self._write_json(404, {"error": "not_found"})
return
try:
service.refresh_once()
except Exception as exc:
self._write_json(500, {"status": "error", "error": str(exc)})
return
snapshot = service.snapshot()
self._write_json(
200,
{
"status": "ok",
"updated_at_utc": snapshot["updated_at_utc"],
},
)
return ServiceHandler
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Automatic trilateration service: polls 3 receiver servers and exposes result API."
)
parser.add_argument("--config", type=str, default="config.json")
parser.add_argument("--host", type=str, default="")
parser.add_argument("--port", type=int, default=0)
return parser.parse_args()
def main() -> int:
args = parse_args()
config = _load_json(args.config)
runtime = config.get("runtime", {})
if not isinstance(runtime, dict):
raise SystemExit("runtime must be object.")
host = args.host or str(runtime.get("listen_host", "0.0.0.0"))
port = args.port or int(runtime.get("listen_port", 8081))
service = AutoService(config, config_path=args.config)
service.start()
server = ThreadingHTTPServer((host, port), _make_handler(service))
print(f"service_listen: http://{host}:{port}")
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
service.stop()
return 0
if __name__ == "__main__":
raise SystemExit(main())

@ -0,0 +1,24 @@
param(
[string]$VenvDir = ".venv"
)
$ErrorActionPreference = "Stop"
Write-Host "Creating virtual environment in '$VenvDir'..."
python -m venv $VenvDir
$pythonExe = Join-Path $VenvDir "Scripts\python.exe"
if (-not (Test-Path $pythonExe)) {
throw "Python executable not found in virtual environment: $pythonExe"
}
Write-Host "Upgrading pip..."
& $pythonExe -m pip install --upgrade pip
Write-Host "Installing required packages..."
& $pythonExe -m pip install pytest
Write-Host ""
Write-Host "Done."
Write-Host "Activate venv: .\$VenvDir\Scripts\Activate.ps1"
Write-Host "Run tests: pytest -q"

@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
VENV_DIR="${1:-.venv}"
if ! command -v python3 >/dev/null 2>&1; then
echo "[setup] python3 not found. Installing..."
sudo apt-get update
sudo apt-get install -y python3 python3-venv python3-pip
fi
if ! dpkg -s python3-venv >/dev/null 2>&1; then
echo "[setup] Installing python3-venv..."
sudo apt-get update
sudo apt-get install -y python3-venv python3-pip
fi
echo "[setup] Creating virtual environment: ${VENV_DIR}"
python3 -m venv "${VENV_DIR}"
PYTHON_BIN="${VENV_DIR}/bin/python"
echo "[setup] Upgrading pip"
"${PYTHON_BIN}" -m pip install --upgrade pip
echo "[setup] Installing required Python packages"
"${PYTHON_BIN}" -m pip install pytest
echo "[setup] Done"
echo "Activate: source ${VENV_DIR}/bin/activate"
echo "Run tests: pytest -q"

@ -0,0 +1,207 @@
import json
from typing import Any, Dict, List
from urllib import error
import pytest
import service
class _FakeResponse:
def __init__(self, payload: object, status: int = 200):
self._raw = json.dumps(payload).encode("utf-8")
self.status = status
def read(self) -> bytes:
return self._raw
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb) -> None:
return None
def _base_config() -> Dict[str, object]:
return {
"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": {
"poll_interval_s": 1.0,
"output_server": {
"enabled": False,
"ip": "192.168.1.10",
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.0,
},
},
"input": {
"mode": "http_sources",
"aggregation": "median",
"source_timeout_s": 1.0,
"receivers": [
{
"receiver_id": "r0",
"center": {"x": 0.0, "y": 0.0, "z": 0.0},
"source_url": "http://r0.local/measurements",
},
{
"receiver_id": "r1",
"center": {"x": 10.0, "y": 0.0, "z": 0.0},
"source_url": "http://r1.local/measurements",
},
{
"receiver_id": "r2",
"center": {"x": 0.0, "y": 8.0, "z": 0.0},
"source_url": "http://r2.local/measurements",
},
],
},
}
def _install_urlopen(monkeypatch: pytest.MonkeyPatch, responses: Dict[str, object]) -> None:
def _fake_urlopen(req: Any, timeout: float = 0.0):
url = getattr(req, "full_url", str(req))
payload_or_exc = responses[url]
if isinstance(payload_or_exc, Exception):
raise payload_or_exc
return _FakeResponse(payload_or_exc)
monkeypatch.setattr(service.request, "urlopen", _fake_urlopen)
def test_refresh_once_builds_frequency_table_for_common_frequencies(
monkeypatch: pytest.MonkeyPatch,
):
config = _base_config()
freq_a = 433_920_000.0
freq_b = 868_100_000.0
responses = {
"http://r0.local/measurements": {
"receiver_id": "r0",
"measurements": [
{"frequency_hz": freq_a, "rssi_dbm": -61.0},
{"frequency_hz": freq_b, "rssi_dbm": -68.0},
],
},
"http://r1.local/measurements": {
"receiver_id": "r1",
"measurements": [
{"frequency_hz": freq_a, "rssi_dbm": -64.0},
{"frequency_hz": freq_b, "rssi_dbm": -70.0},
],
},
"http://r2.local/measurements": {
"receiver_id": "r2",
"measurements": [
{"frequency_hz": freq_a, "rssi_dbm": -63.0},
{"frequency_hz": freq_b, "rssi_dbm": -69.0},
],
},
}
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
svc.refresh_once()
snapshot = svc.snapshot()
payload = snapshot["payload"]
assert snapshot["last_error"] == ""
assert payload is not None
assert payload["selected_frequency_hz"] in (freq_a, freq_b)
table = payload["frequency_table"]
assert isinstance(table, list)
assert len(table) == 2
for row in table:
assert row["frequency_hz"] in (freq_a, freq_b)
assert "position" in row
assert len(row["receivers"]) == 3
def test_refresh_once_fails_when_no_common_frequencies(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
responses = {
"http://r0.local/measurements": {"measurements": [{"frequency_hz": 100.0, "rssi_dbm": -60.0}]},
"http://r1.local/measurements": {"measurements": [{"frequency_hz": 200.0, "rssi_dbm": -60.0}]},
"http://r2.local/measurements": {"measurements": [{"frequency_hz": 300.0, "rssi_dbm": -60.0}]},
}
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
with pytest.raises(RuntimeError, match="No common frequencies across all 3 receivers"):
svc.refresh_once()
def test_refresh_once_reports_row_validation_error_with_source_context(
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": "bad"}]},
"http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]},
}
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
with pytest.raises(RuntimeError, match=r"source_url=http://r1\.local/measurements: row #1 field 'rssi_dbm' must be numeric"):
svc.refresh_once()
def test_refresh_once_validates_receiver_id_mismatch(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
responses = {
"http://r0.local/measurements": {"receiver_id": "r0", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]},
"http://r1.local/measurements": {"receiver_id": "WRONG", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -59.0}]},
"http://r2.local/measurements": {"receiver_id": "r2", "measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]},
}
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
with pytest.raises(RuntimeError, match="does not match expected 'r1'"):
svc.refresh_once()
def test_refresh_once_raises_when_output_server_rejects_payload(
monkeypatch: pytest.MonkeyPatch,
):
config = _base_config()
config["runtime"]["output_server"]["enabled"] = True # type: ignore[index]
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)
monkeypatch.setattr(
service,
"send_payload_to_server",
lambda **_: (500, "internal error"),
)
svc = service.AutoService(config)
with pytest.raises(RuntimeError, match="Output server rejected payload: HTTP 500"):
svc.refresh_once()
def test_refresh_once_propagates_source_http_error(monkeypatch: pytest.MonkeyPatch):
config = _base_config()
responses = {
"http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]},
"http://r1.local/measurements": error.URLError("connection refused"),
"http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]},
}
_install_urlopen(monkeypatch, responses)
svc = service.AutoService(config)
with pytest.raises(RuntimeError, match="Cannot reach 'http://r1.local/measurements': connection refused"):
svc.refresh_once()

@ -0,0 +1,84 @@
import math
from triangulation import (
PropagationModel,
ReceiverSignal,
SPEED_OF_LIGHT_M_S,
build_result_payload,
rssi_to_distance_m,
solve_from_signal_amplitudes,
solve_three_sphere_intersection,
Sphere,
)
def _distance_to_rssi(distance_m: float, frequency_hz: float, model: PropagationModel) -> float:
fspl_ref_db = 20.0 * math.log10(
4.0 * math.pi * model.reference_distance_m * frequency_hz / SPEED_OF_LIGHT_M_S
)
rx_power_at_ref_dbm = (
model.tx_power_dbm + model.tx_gain_dbi + model.rx_gain_dbi - fspl_ref_db
)
return rx_power_at_ref_dbm - 10.0 * model.path_loss_exponent * math.log10(
distance_m / model.reference_distance_m
)
def test_exact_three_sphere_intersection():
true_point = (2.0, 3.0, 4.0)
spheres = [
Sphere(center=(0.0, 0.0, 0.0), radius=math.dist(true_point, (0.0, 0.0, 0.0))),
Sphere(center=(10.0, 0.0, 0.0), radius=math.dist(true_point, (10.0, 0.0, 0.0))),
Sphere(center=(0.0, 8.0, 0.0), radius=math.dist(true_point, (0.0, 8.0, 0.0))),
]
result = solve_three_sphere_intersection(spheres=spheres, z_preference="positive")
assert result.exact
assert math.isclose(result.point[0], true_point[0], abs_tol=1e-9, rel_tol=0.0)
assert math.isclose(result.point[1], true_point[1], abs_tol=1e-9, rel_tol=0.0)
assert math.isclose(result.point[2], true_point[2], abs_tol=1e-9, rel_tol=0.0)
def test_frequency_affects_distance_conversion():
model = PropagationModel(tx_power_dbm=20.0)
amplitude = -60.0
low_freq_distance = rssi_to_distance_m(amplitude, 433e6, model)
high_freq_distance = rssi_to_distance_m(amplitude, 2.4e9, model)
assert high_freq_distance < low_freq_distance
def test_pipeline_from_signal_to_payload():
model = PropagationModel(tx_power_dbm=18.0, path_loss_exponent=2.0)
true_point = (3.0, 2.0, 1.5)
receiver_centers = [
("r0", (0.0, 0.0, 0.0)),
("r1", (8.0, 0.0, 0.0)),
("r2", (0.0, 7.0, 0.0)),
]
freqs = [915e6, 920e6, 930e6]
signals = []
for (receiver_id, center), freq in zip(receiver_centers, freqs):
distance = math.dist(true_point, center)
amplitude = _distance_to_rssi(distance, freq, model)
signals.append(
ReceiverSignal(
receiver_id=receiver_id,
center=center,
amplitude_dbm=amplitude,
frequency_hz=freq,
)
)
result, spheres = solve_from_signal_amplitudes(
signals=signals, model=model, z_preference="positive"
)
payload = build_result_payload(signals, spheres, result, model)
assert result.exact
assert math.isclose(result.point[0], true_point[0], abs_tol=1e-7, rel_tol=0.0)
assert math.isclose(result.point[1], true_point[1], abs_tol=1e-7, rel_tol=0.0)
assert math.isclose(result.point[2], true_point[2], abs_tol=1e-7, rel_tol=0.0)
assert "position" in payload
assert len(payload["receivers"]) == 3

@ -0,0 +1,394 @@
from __future__ import annotations
from datetime import datetime, timezone
import math
from dataclasses import dataclass
from typing import Dict, Iterable, List, Literal, Optional, Sequence, Tuple
from urllib import error, request
import json
Point3D = Tuple[float, float, float]
SPEED_OF_LIGHT_M_S = 299_792_458.0
@dataclass(frozen=True)
class Sphere:
center: Point3D
radius: float
@dataclass(frozen=True)
class PropagationModel:
tx_power_dbm: float
tx_gain_dbi: float = 0.0
rx_gain_dbi: float = 0.0
path_loss_exponent: float = 2.0
reference_distance_m: float = 1.0
min_distance_m: float = 1e-3
@dataclass(frozen=True)
class ReceiverSignal:
receiver_id: str
center: Point3D
amplitude_dbm: float
frequency_hz: float
@dataclass(frozen=True)
class TrilaterationResult:
point: Point3D
residuals: Tuple[float, float, float]
rmse: float
exact: bool
candidate_points: Tuple[Point3D, ...]
def _validate(spheres: Sequence[Sphere]) -> None:
if len(spheres) != 3:
raise ValueError("Expected exactly 3 spheres.")
for idx, sphere in enumerate(spheres, start=1):
if sphere.radius < 0:
raise ValueError(f"Radius for sphere #{idx} must be non-negative.")
def _vec_sub(a: Point3D, b: Point3D) -> Point3D:
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _vec_add(a: Point3D, b: Point3D) -> Point3D:
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def _vec_scale(a: Point3D, scale: float) -> Point3D:
return (a[0] * scale, a[1] * scale, a[2] * scale)
def _vec_dot(a: Point3D, b: Point3D) -> float:
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def _vec_cross(a: Point3D, b: Point3D) -> Point3D:
return (
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
)
def _vec_norm(a: Point3D) -> float:
return math.sqrt(_vec_dot(a, a))
def _vec_unit(a: Point3D) -> Point3D:
n = _vec_norm(a)
if math.isclose(n, 0.0, abs_tol=1e-12):
raise ValueError("Degenerate receiver geometry: duplicated centers.")
return _vec_scale(a, 1.0 / n)
def _distance(p1: Point3D, p2: Point3D) -> float:
d = _vec_sub(p1, p2)
return _vec_norm(d)
def rssi_to_distance_m(
amplitude_dbm: float, frequency_hz: float, model: PropagationModel
) -> float:
if frequency_hz <= 0.0:
raise ValueError("Frequency must be positive.")
if model.reference_distance_m <= 0.0:
raise ValueError("reference_distance_m must be positive.")
if model.path_loss_exponent <= 0.0:
raise ValueError("path_loss_exponent must be positive.")
fspl_ref_db = 20.0 * math.log10(
4.0 * math.pi * model.reference_distance_m * frequency_hz / SPEED_OF_LIGHT_M_S
)
rx_power_at_ref_dbm = (
model.tx_power_dbm + model.tx_gain_dbi + model.rx_gain_dbi - fspl_ref_db
)
distance = model.reference_distance_m * 10.0 ** (
(rx_power_at_ref_dbm - amplitude_dbm) / (10.0 * model.path_loss_exponent)
)
return max(distance, model.min_distance_m)
def _spheres_from_signals(
signals: Sequence[ReceiverSignal], model: PropagationModel
) -> List[Sphere]:
if len(signals) != 3:
raise ValueError("Expected exactly 3 receiver signals.")
spheres: List[Sphere] = []
for signal in signals:
radius = rssi_to_distance_m(
amplitude_dbm=signal.amplitude_dbm,
frequency_hz=signal.frequency_hz,
model=model,
)
spheres.append(Sphere(center=signal.center, radius=radius))
return spheres
def _sphere_intersection_candidates(spheres: Sequence[Sphere]) -> Tuple[Point3D, ...]:
p1, p2, p3 = spheres[0].center, spheres[1].center, spheres[2].center
r1, r2, r3 = spheres[0].radius, spheres[1].radius, spheres[2].radius
ex = _vec_unit(_vec_sub(p2, p1))
p3p1 = _vec_sub(p3, p1)
i = _vec_dot(ex, p3p1)
temp = _vec_sub(p3p1, _vec_scale(ex, i))
ey = _vec_unit(temp)
ez = _vec_cross(ex, ey)
d = _distance(p1, p2)
j = _vec_dot(ey, p3p1)
if math.isclose(j, 0.0, abs_tol=1e-12):
raise ValueError("Degenerate receiver geometry: centers are collinear.")
x = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d)
y = (r1 * r1 - r3 * r3 + i * i + j * j) / (2.0 * j) - (i / j) * x
z_sq = r1 * r1 - x * x - y * y
base = _vec_add(p1, _vec_add(_vec_scale(ex, x), _vec_scale(ey, y)))
if z_sq < 0.0:
return (base,)
z = math.sqrt(z_sq)
return (
_vec_add(base, _vec_scale(ez, z)),
_vec_add(base, _vec_scale(ez, -z)),
)
def _solve_3x3(a: List[List[float]], b: List[float]) -> Optional[List[float]]:
m = [row[:] + [rhs] for row, rhs in zip(a, b)]
n = 3
for col in range(n):
pivot = max(range(col, n), key=lambda r: abs(m[r][col]))
if math.isclose(m[pivot][col], 0.0, abs_tol=1e-12):
return None
if pivot != col:
m[col], m[pivot] = m[pivot], m[col]
pivot_value = m[col][col]
for k in range(col, n + 1):
m[col][k] /= pivot_value
for row in range(n):
if row == col:
continue
factor = m[row][col]
for k in range(col, n + 1):
m[row][k] -= factor * m[col][k]
return [m[0][n], m[1][n], m[2][n]]
def _gauss_newton_point(
spheres: Sequence[Sphere], initial_point: Point3D, iterations: int = 40
) -> Point3D:
x, y, z = initial_point
damping = 1e-6
for _ in range(iterations):
residuals: List[float] = []
j_rows: List[Point3D] = []
for sphere in spheres:
cx, cy, cz = sphere.center
dx = x - cx
dy = y - cy
dz = z - cz
dist = math.sqrt(dx * dx + dy * dy + dz * dz)
if dist < 1e-9:
dist = 1e-9
residuals.append(dist - sphere.radius)
j_rows.append((dx / dist, dy / dist, dz / dist))
jt_j = [[0.0, 0.0, 0.0] for _ in range(3)]
jt_r = [0.0, 0.0, 0.0]
for (jx, jy, jz), r in zip(j_rows, residuals):
jt_j[0][0] += jx * jx
jt_j[0][1] += jx * jy
jt_j[0][2] += jx * jz
jt_j[1][0] += jy * jx
jt_j[1][1] += jy * jy
jt_j[1][2] += jy * jz
jt_j[2][0] += jz * jx
jt_j[2][1] += jz * jy
jt_j[2][2] += jz * jz
jt_r[0] += jx * r
jt_r[1] += jy * r
jt_r[2] += jz * r
for i in range(3):
jt_j[i][i] += damping
delta = _solve_3x3(jt_j, [-jt_r[0], -jt_r[1], -jt_r[2]])
if delta is None:
break
x += delta[0]
y += delta[1]
z += delta[2]
if max(abs(delta[0]), abs(delta[1]), abs(delta[2])) < 1e-8:
break
return (x, y, z)
def _residuals(point: Point3D, spheres: Sequence[Sphere]) -> Tuple[float, float, float]:
values = tuple(_distance(point, sphere.center) - sphere.radius for sphere in spheres)
return (values[0], values[1], values[2])
def _rmse(residuals: Iterable[float]) -> float:
residual_list = list(residuals)
return math.sqrt(sum(r * r for r in residual_list) / len(residual_list))
def solve_three_sphere_intersection(
spheres: Sequence[Sphere],
tolerance: float = 1e-3,
z_preference: Literal["positive", "negative"] = "positive",
) -> TrilaterationResult:
_validate(spheres)
try:
analytic_candidates = list(_sphere_intersection_candidates(spheres))
except ValueError:
analytic_candidates = []
all_candidates = analytic_candidates[:]
center_guess = (
(spheres[0].center[0] + spheres[1].center[0] + spheres[2].center[0]) / 3.0,
(spheres[0].center[1] + spheres[1].center[1] + spheres[2].center[1]) / 3.0,
(spheres[0].center[2] + spheres[1].center[2] + spheres[2].center[2]) / 3.0,
)
all_candidates.append(_gauss_newton_point(spheres, center_guess))
if analytic_candidates:
all_candidates.append(_gauss_newton_point(spheres, analytic_candidates[0]))
if len(analytic_candidates) == 2:
all_candidates.append(_gauss_newton_point(spheres, analytic_candidates[1]))
scored: List[Tuple[float, float, Point3D, Tuple[float, float, float]]] = []
for point in all_candidates:
current_residuals = _residuals(point, spheres)
score_rmse = _rmse(current_residuals)
z_bias = -point[2] if z_preference == "positive" else point[2]
scored.append((score_rmse, z_bias, point, current_residuals))
scored.sort(key=lambda x: (x[0], x[1]))
best_rmse, _, best_point, best_residuals = scored[0]
exact = all(abs(r) <= tolerance for r in best_residuals)
return TrilaterationResult(
point=best_point,
residuals=best_residuals,
rmse=best_rmse,
exact=exact,
candidate_points=tuple(p for _, _, p, _ in scored),
)
def solve_from_signal_amplitudes(
signals: Sequence[ReceiverSignal],
model: PropagationModel,
tolerance: float = 1e-3,
z_preference: Literal["positive", "negative"] = "positive",
) -> Tuple[TrilaterationResult, List[Sphere]]:
spheres = _spheres_from_signals(signals, model)
result = solve_three_sphere_intersection(
spheres=spheres, tolerance=tolerance, z_preference=z_preference
)
return result, spheres
def build_result_payload(
signals: Sequence[ReceiverSignal],
spheres: Sequence[Sphere],
result: TrilaterationResult,
model: PropagationModel,
) -> Dict[str, object]:
receivers = []
for signal, sphere, residual in zip(signals, spheres, result.residuals):
receivers.append(
{
"receiver_id": signal.receiver_id,
"center": {
"x": signal.center[0],
"y": signal.center[1],
"z": signal.center[2],
},
"frequency_hz": signal.frequency_hz,
"amplitude_dbm": signal.amplitude_dbm,
"radius_m": sphere.radius,
"residual_m": residual,
}
)
return {
"timestamp_utc": datetime.now(timezone.utc).isoformat(),
"position": {"x": result.point[0], "y": result.point[1], "z": result.point[2]},
"exact": result.exact,
"rmse_m": result.rmse,
"model": {
"tx_power_dbm": model.tx_power_dbm,
"tx_gain_dbi": model.tx_gain_dbi,
"rx_gain_dbi": model.rx_gain_dbi,
"path_loss_exponent": model.path_loss_exponent,
"reference_distance_m": model.reference_distance_m,
},
"receivers": receivers,
}
def send_payload_to_server(
server_ip: str,
payload: Dict[str, object],
port: int = 8080,
path: str = "/triangulation",
timeout_s: float = 3.0,
) -> Tuple[int, str]:
if not path.startswith("/"):
path = "/" + path
url = f"http://{server_ip}:{port}{path}"
data = json.dumps(payload).encode("utf-8")
req = request.Request(
url=url,
data=data,
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with request.urlopen(req, timeout=timeout_s) as response:
body = response.read().decode("utf-8", errors="replace")
return response.status, body
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
return exc.code, body
except error.URLError as exc:
return 0, str(exc.reason)
def solve_and_prepare_payload(
signals: Sequence[ReceiverSignal],
model: PropagationModel,
tolerance: float = 1e-3,
z_preference: Literal["positive", "negative"] = "positive",
) -> Tuple[TrilaterationResult, Dict[str, object]]:
result, spheres = solve_from_signal_amplitudes(
signals=signals,
model=model,
tolerance=tolerance,
z_preference=z_preference,
)
payload = build_result_payload(
signals=signals,
spheres=spheres,
result=result,
model=model,
)
return result, payload

@ -0,0 +1,140 @@
const state = {
result: null,
frequencies: null,
health: null,
};
function byId(id) {
return document.getElementById(id);
}
function fmt(value, digits = 6) {
if (value === null || value === undefined) return "-";
if (typeof value !== "number") return String(value);
return Number.isFinite(value) ? value.toFixed(digits) : String(value);
}
async function getJson(url) {
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.status || `HTTP ${res.status}`);
}
return data;
}
async function postJson(url, payload) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || data.status || `HTTP ${res.status}`);
}
return data;
}
function render() {
const data = state.result?.data;
const delivery = state.result?.output_delivery || state.frequencies?.output_delivery;
byId("updated-at").textContent = `updated: ${state.result?.updated_at_utc || "n/a"}`;
byId("health-status").textContent = `health: ${state.health?.status || "n/a"}`;
byId("delivery-status").textContent = `delivery: ${delivery?.status || "n/a"}`;
if (!data) {
byId("selected-freq").textContent = "-";
byId("pos-x").textContent = "-";
byId("pos-y").textContent = "-";
byId("pos-z").textContent = "-";
byId("rmse").textContent = "-";
byId("receivers-list").textContent = "Нет данных";
byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2);
byId("freq-table").querySelector("tbody").innerHTML = "";
return;
}
byId("selected-freq").textContent = fmt(data.selected_frequency_hz, 1);
byId("pos-x").textContent = fmt(data.position?.x);
byId("pos-y").textContent = fmt(data.position?.y);
byId("pos-z").textContent = fmt(data.position?.z);
byId("rmse").textContent = fmt(data.rmse_m);
const receivers = data.receivers || [];
byId("receivers-list").textContent = JSON.stringify(receivers, null, 2);
byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2);
const rows = data.frequency_table || [];
const tbody = byId("freq-table").querySelector("tbody");
tbody.innerHTML = rows
.map(
(row) => `
<tr>
<td>${fmt(row.frequency_hz, 1)}</td>
<td>${fmt(row.position?.x)}</td>
<td>${fmt(row.position?.y)}</td>
<td>${fmt(row.position?.z)}</td>
<td>${fmt(row.rmse_m)}</td>
<td>${row.exact ? "yes" : "no"}</td>
</tr>`
)
.join("");
}
async function loadAll() {
const [healthRes, resultRes, freqRes] = await Promise.allSettled([
getJson("/health"),
getJson("/result"),
getJson("/frequencies"),
]);
state.health = healthRes.status === "fulfilled" ? healthRes.value : { status: "error" };
state.result = resultRes.status === "fulfilled" ? resultRes.value : null;
state.frequencies = freqRes.status === "fulfilled" ? freqRes.value : null;
render();
}
async function refreshNow() {
await postJson("/refresh", {});
await loadAll();
}
async function loadConfig() {
try {
const config = await getJson("/config");
byId("config-editor").value = JSON.stringify(config.config, null, 2);
byId("config-state").textContent = "config: loaded";
} catch (err) {
byId("config-state").textContent = `config: ${err.message}`;
}
}
async function saveConfig() {
const raw = byId("config-editor").value.trim();
try {
const parsed = JSON.parse(raw);
const result = await postJson("/config", parsed);
byId("config-state").textContent = result.restart_required
? "config: saved, restart required"
: "config: saved";
} catch (err) {
byId("config-state").textContent = `config: ${err.message}`;
}
}
function bindUi() {
byId("refresh-now").addEventListener("click", refreshNow);
byId("load-config").addEventListener("click", loadConfig);
byId("save-config").addEventListener("click", saveConfig);
}
async function boot() {
bindUi();
await loadConfig();
await loadAll();
setInterval(loadAll, 2000);
}
boot().catch((err) => {
byId("health-status").textContent = `health: ${err.message}`;
});

@ -0,0 +1,86 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Triangulation Control Panel</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="bg-glow bg-glow-a"></div>
<div class="bg-glow bg-glow-b"></div>
<main class="container">
<header class="hero 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>
<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>
</header>
<section class="grid">
<article class="card">
<h2>Итоговая позиция</h2>
<div class="result-box">
<div><span class="muted">Selected Freq:</span> <b id="selected-freq">-</b></div>
<div><span class="muted">X:</span> <b id="pos-x">-</b></div>
<div><span class="muted">Y:</span> <b id="pos-y">-</b></div>
<div><span class="muted">Z:</span> <b id="pos-z">-</b></div>
<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">
<h2>Таблица пересечений по частотам</h2>
<div class="table-wrap">
<table id="freq-table">
<thead>
<tr>
<th>Frequency (Hz)</th>
<th>X</th>
<th>Y</th>
<th>Z</th>
<th>RMSE</th>
<th>Exact</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<section class="card">
<h2>Конфигурация</h2>
<p class="muted">
Изменения сохраняются в конфиг-файл сервиса. После сохранения нужен
перезапуск для применения.
</p>
<div class="editor-actions">
<button id="load-config" class="btn">Load</button>
<button id="save-config" class="btn btn-primary">Save</button>
<span id="config-state" class="badge">config: n/a</span>
</div>
<textarea id="config-editor" class="editor" spellcheck="false"></textarea>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

@ -0,0 +1,201 @@
:root {
--bg: #f4f6f8;
--card: #ffffff;
--text: #101418;
--muted: #5b6872;
--line: #dbe2e8;
--accent: #0e6e6b;
--accent-soft: #d9f2f1;
}
* {
box-sizing: border-box;
}
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);
min-height: 100vh;
}
.container {
width: min(1100px, 94vw);
margin: 32px auto;
display: grid;
gap: 18px;
position: relative;
z-index: 2;
}
.card {
background: color-mix(in oklab, var(--card), transparent 8%);
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;
}
.hero h1 {
margin: 6px 0;
font-size: clamp(1.2rem, 3vw, 1.9rem);
}
.kicker {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
font-weight: 700;
font-size: 0.78rem;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.hero-actions,
.editor-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 10px;
}
.btn {
border: 1px solid var(--line);
background: #fff;
color: var(--text);
border-radius: 10px;
padding: 8px 12px;
cursor: pointer;
transition: transform 150ms ease, background-color 150ms ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.badge {
border: 1px solid var(--line);
background: var(--accent-soft);
border-radius: 999px;
padding: 4px 10px;
font-size: 0.82rem;
}
.result-box {
display: grid;
gap: 7px;
}
.muted {
color: var(--muted);
}
.small {
font-size: 0.86rem;
}
.mono {
font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 8px;
border-bottom: 1px solid var(--line);
font-size: 0.9rem;
}
tbody tr {
transition: background-color 180ms ease;
}
tbody tr:hover {
background: #f5fbfb;
}
.editor {
width: 100%;
min-height: 280px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
background: #fbfdff;
font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace;
font-size: 0.85rem;
margin-top: 10px;
}
.bg-glow {
position: fixed;
width: 360px;
height: 360px;
border-radius: 50%;
filter: blur(48px);
opacity: 0.42;
pointer-events: none;
z-index: 1;
animation: drift 12s ease-in-out infinite alternate;
}
.bg-glow-a {
background: #a2e9db;
top: -90px;
right: -70px;
}
.bg-glow-b {
background: #c4dcff;
bottom: -120px;
left: -90px;
animation-delay: 1.4s;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(7px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes drift {
from {
transform: translate(0, 0) scale(1);
}
to {
transform: translate(30px, -15px) scale(1.12);
}
}
@media (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
}
}
Loading…
Cancel
Save