diff --git a/README.md b/README.md index 86da6c6..618eb77 100644 --- a/README.md +++ b/README.md @@ -1,196 +1,166 @@ # 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`). +Сервис автоматически собирает RSSI-измерения с нескольких входных серверов (ресиверов), группирует данные по одинаковым частотам и рассчитывает 3D-положение источника через пересечение сфер (трилатерация). + +Каждый ресивер задается: +- координатами `center` (центр сферы), +- измерениями `RSSI + частота` (для расчета радиуса через модель распространения), +- URL источника входных данных. + +Сервис: +- ведет актуальную таблицу решений по каждой общей частоте, +- выбирает итоговое лучшее решение по минимальному `rmse_m`, +- отправляет компактный результат на один или несколько выходных серверов, +- отдает API + веб-интерфейс для мониторинга и настройки. + +## Ключевые возможности + +- Автоматический polling входных серверов (`input.mode = "http_sources"`). +- Поддержка `N >= 3` входных ресиверов. +- Расчет по общим частотам, которые есть минимум у 3 ресиверов и разрешены в `input.receivers[].frequencies_mhz`. +- Поддержка общего фильтра входа `input.default_input_filter` и override per-receiver (`input_filter`). +- Поддержка нескольких выходных серверов `runtime.output_servers[]` с настройкой по имени и IP. +- Горячее применение нового конфига через `POST /config` (без ручного рестарта процесса). +- Защита write-endpoints токеном (`runtime.write_api_token`). +- Русскоязычный UI (`/ui`) с вкладками: обзор, частоты, ресиверы, доставка, серверы, JSON-конфиг. +- Интеграционные и юнит-тесты. ## Структура проекта -- [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 конечный сервер. +- [service.py](./service.py) — основной автосервис, API, статика UI. +- [triangulation.py](./triangulation.py) — математика трилатерации и сетевой отправки. +- [config.template.json](./config.template.json) — шаблон основного конфига. +- [web/index.html](./web/index.html), [web/app.js](./web/app.js), [web/styles.css](./web/styles.css) — веб-интерфейс. +- [docker-compose.yml](./docker-compose.yml) — профили test/prod. +- [docker/config.docker.test.json](./docker/config.docker.test.json) — тестовый docker-конфиг. +- [docker/mock_receiver.py](./docker/mock_receiver.py) — генератор входных тестовых данных. +- [docker/mock_output_sink.py](./docker/mock_output_sink.py) — тестовый приемник выходных payload. +- [test_service_integration.py](./test_service_integration.py), [test_triangulation.py](./test_triangulation.py) — тесты. +- [docs/API.md](./docs/API.md) — подробный API. +- [docs/CONFIG_REFERENCE.md](./docs/CONFIG_REFERENCE.md) — справочник параметров конфига. +- [docs/JSON_EXAMPLES.md](./docs/JSON_EXAMPLES.md) — шаблоны JSON. -## Docker Compose: test/prod режимы +## Быстрый старт -`docker-compose.yml` разделен на профили: +### Вариант 1: Docker (рекомендуется) -- `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, принимающий отправленные результаты. +Тестовый режим (все контейнеры: 3 mock-входа + сервис + mock-выход): ```bash -docker compose --profile test up --build +docker compose --profile test up --build -d ``` -Открыть: +Проверить: - 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` +- Health: `http://127.0.0.1:38081/health` +- Result: `http://127.0.0.1:38081/result` +- Frequencies: `http://127.0.0.1:38081/frequencies` Остановить: + ```bash docker compose --profile test down ``` -## Быстрый старт: Prod Mode +Прод-режим (ваш `config.json`): 1. Создайте `config.json` из шаблона: + ```bash cp config.template.json config.json ``` -2. Заполните ваши реальные: -- `input.receivers[].source_url` -- `input.receivers[].center` -- `runtime.output_server` - +2. Заполните реальные URL/координаты/выходные серверы. 3. Запустите: + ```bash -docker compose --profile prod up --build +docker compose --profile prod up --build -d ``` -Доступ к API/UI в `prod`: -- `http://127.0.0.1:38082/ui` -- `http://127.0.0.1:38082/result` -- `http://127.0.0.1:38082/frequencies` +Доступ: +- UI: `http://127.0.0.1:38082/ui` +- API: `http://127.0.0.1:38082/*` Остановить: + ```bash docker compose --profile prod down ``` -## Как проверить, что данные приходят и отправляются +### Вариант 2: Локальный запуск Python -В UI (`/ui`) видно: -- блок `Ресиверы`: входящие samples; -- таблица `Таблица пересечений по частотам`: решения по каждой общей частоте; -- блок `Отправка на конечный сервер`: статус доставки (`ok/error`), HTTP-код, время, target. +Ubuntu: -Дополнительно: -- `GET /result` возвращает `output_delivery`. -- `GET /frequencies` тоже возвращает `output_delivery`. -- `docker compose --profile test logs output-sink -f` показывает факт приема. -- `GET /latest` на `output-sink` доступен изнутри docker-сети. +```bash +bash setup.sh +source .venv/bin/activate +python service.py --config config.json +``` -## Конфиг (основные поля) +Windows PowerShell: -Пример: [config.template.json](/c:/Users/snytk/triangulation/config.template.json) +```powershell +./setup.ps1 +.\.venv\Scripts\Activate.ps1 +python service.py --config config.json +``` -Критичные поля: -- `input.mode`: только `"http_sources"` для автосервиса. -- `input.receivers`: ровно 3 ресивера. -- `input.aggregation`: `"median"` или `"mean"`. -- `runtime.poll_interval_s`: период опроса. -- `runtime.output_server.enabled`: push во внешний сервер. +## Веб-интерфейс -## Формат входных payload +Открыть: `http://:/ui` -Поддержка: -- объект с `measurements`/`samples`/`data`; -- или сразу массив измерений. +Что доступно: +- обзор итоговой позиции, +- таблица всех частотных решений, +- просмотр сырых данных ресиверов и статуса доставки, +- настройка входных/выходных серверов (добавление/удаление, имена, URL, IP), +- редактирование сырого JSON-конфига, +- сохранение конфига в рантайме через API. -Измерение: -- `frequency_hz` (или `freq_hz`/`frequency`/`freq`) -- `amplitude_dbm` (или `rssi_dbm`/`amplitude`/`rssi`) +Примечание: после `POST /config` сервис применяет конфиг автоматически (`applied: true`, `restart_required: false`). -Пример: -```json -{ - "receiver_id": "r0", - "measurements": [ - { "frequency_hz": 433920000, "rssi_dbm": -61.5 }, - { "frequency_hz": 868100000, "rssi_dbm": -67.2 } - ] -} -``` +## API (кратко) -Если `receiver_id` передан, сервис сверяет его с ожидаемым receiver из конфига. +- `GET /health` — состояние сервиса. +- `GET /result` — последнее итоговое решение + delivery status. +- `GET /frequencies` — таблица решений по частотам + delivery status. +- `POST /refresh` — принудительное обновление. +- `GET /config` — текущий конфиг (с редактированием секрета токена). +- `POST /config` — валидация + применение + попытка сохранения в файл. -## Валидация и ошибки некорректного контекста +Полные примеры запросов/ответов: [docs/API.md](./docs/API.md). -Проверяется: -- тип payload; -- наличие измерений; -- числовые и конечные значения; -- `frequency_hz > 0`; -- соответствие `receiver_id` при наличии; -- наличие общих частот у всех 3 ресиверов. +## Конфигурация -Ошибки содержат: -- `source_url=...` -- номер строки `row #...` -- проблемное поле. +Базовый шаблон: [config.template.json](./config.template.json) -## Тесты +Ключевые блоки: +- `model` — радиомодель (RSSI -> расстояние). +- `solver` — параметры решателя сфер. +- `input` — источники входных данных, фильтры, агрегация. +- `runtime` — HTTP сервис, polling, write token, выходные серверы. -Запуск: -```bash -pytest -q -``` - -Покрытие: -- математика триангуляции; -- влияние частоты на RSSI->distance; -- интеграция `AutoService.refresh_once()`; -- валидационные сценарии; -- ошибки контекста (нет общих частот, bad field, receiver mismatch, network error, output reject). +Важно по частотам: +- для каждого входного сервера задайте `input.receivers[].frequencies_mhz`; +- сервис использует в расчёте только частоты из конфигурации ресиверов. -Файл интеграционных тестов: -- [test_service_integration.py](/c:/Users/snytk/triangulation/test_service_integration.py) +Полное описание параметров: [docs/CONFIG_REFERENCE.md](./docs/CONFIG_REFERENCE.md). -## Локальный запуск без Docker +## Форматы JSON -```bash -python service.py --config config.json -``` +Поддерживаются: +- компактный формат входа (`samples` + `f_mhz`/`rssi`), +- legacy-алиасы полей (`measurements`, `data`, `frequency_hz`, `rssi_dbm` и др.), +- минимальный выходной payload на конечные серверы: + - `x` + - `y` + - `z` -UI: -- `http://127.0.0.1:38081/ui` +Готовые шаблоны: [docs/JSON_EXAMPLES.md](./docs/JSON_EXAMPLES.md). -## Защита write-endpoints токеном +## Безопасность write-endpoints -Для защиты изменений состояния можно задать токен в конфиге: +Чтобы ограничить изменение состояния, задайте: ```json { @@ -200,34 +170,43 @@ UI: } ``` -После этого `POST /refresh` и `POST /config` требуют токен в одном из заголовков: +Тогда `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`. +`GET` endpoints доступны без токена. -## Фильтры входных данных по каждому серверу +## Тесты -Для каждого ресивера в `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 - } -} +```bash +pytest -q ``` -Смысл: -- фильтр применяется отдельно к данным каждого ресивера до триангуляции; -- участвуют только измерения, попавшие в диапазоны частоты и RSSI; -- если после фильтрации у ресивера нет данных, цикл расчета возвращает ошибку. +Сценарии покрывают: +- корректность трилатерации и преобразования RSSI, +- валидацию payload и граничные случаи, +- ошибки некорректного контекста, +- output delivery, +- безопасность API, +- горячее применение конфига, +- multi-input/multi-output поведение. + +## Диагностика + +### Ошибка: `port is already allocated` + +Значит локальный порт уже занят (например, `8080` или `38081`). + +Решения: +- остановить конфликтующий сервис, +- изменить публикацию порта в `docker-compose.yml`. + +### Ошибка: `Config file not found` + +Проверьте путь в аргументе `--config` и наличие файла внутри контейнера/хоста. + +## Лицензия + +Если нужна отдельная лицензия, добавьте файл `LICENSE` и разделите условия использования. diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc index 61d27a2..cd031ed 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 f3b3ae1..2de923d 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/__pycache__/triangulation.cpython-311.pyc b/__pycache__/triangulation.cpython-311.pyc index da514b1..4383551 100644 Binary files a/__pycache__/triangulation.cpython-311.pyc and b/__pycache__/triangulation.cpython-311.pyc differ diff --git a/config.template.json b/config.template.json index cc9ec3f..4009153 100644 --- a/config.template.json +++ b/config.template.json @@ -1,4 +1,4 @@ -{ +{ "model": { "tx_power_dbm": 20.0, "tx_gain_dbi": 0.0, @@ -16,21 +16,28 @@ "listen_port": 8081, "poll_interval_s": 1.0, "write_api_token": "", + "output_servers": [ + { + "name": "output_main", + "ip": "" + } + ], "output_server": { - "enabled": false, - "ip": "192.168.1.100", - "port": 8080, - "path": "/triangulation", - "timeout_s": 3.0, - "frequency_filter_enabled": false, - "min_frequency_mhz": 0.0, - "max_frequency_mhz": 0.0 + "name": "output_main", + "ip": "" } }, "input": { "mode": "http_sources", "aggregation": "median", "source_timeout_s": 3.0, + "default_input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + }, "receivers": [ { "receiver_id": "r0", @@ -39,13 +46,9 @@ "y": 0.0, "z": 0.0 }, - "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 + "frequencies_mhz": [433.92, 868.1], + "access": { + "url": "http://10.0.0.11:9000/measurements" } }, { @@ -55,13 +58,9 @@ "y": 0.0, "z": 0.0 }, - "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 + "frequencies_mhz": [433.92, 868.1], + "access": { + "url": "http://10.0.0.12:9000/measurements" } }, { @@ -71,13 +70,9 @@ "y": 8.0, "z": 0.0 }, - "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 + "frequencies_mhz": [433.92, 868.1], + "access": { + "url": "http://10.0.0.13:9000/measurements" } } ] diff --git a/docker/config.docker.json b/docker/config.docker.json index 78202cd..5fec788 100644 --- a/docker/config.docker.json +++ b/docker/config.docker.json @@ -1,4 +1,4 @@ -{ +{ "model": { "tx_power_dbm": 20.0, "tx_gain_dbi": 0.0, @@ -16,15 +16,15 @@ "listen_port": 8081, "poll_interval_s": 1.0, "write_api_token": "", + "output_servers": [ + { + "name": "output_sink_main", + "ip": "output-sink" + } + ], "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 + "name": "output_sink_main", + "ip": "output-sink" } }, "input": { @@ -39,6 +39,7 @@ "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r0:9000/measurements", "input_filter": { "enabled": false, @@ -55,6 +56,7 @@ "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r1:9000/measurements", "input_filter": { "enabled": false, @@ -71,6 +73,7 @@ "y": 8.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r2:9000/measurements", "input_filter": { "enabled": false, diff --git a/docker/config.docker.test.json b/docker/config.docker.test.json index 78202cd..5fec788 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, @@ -16,15 +16,15 @@ "listen_port": 8081, "poll_interval_s": 1.0, "write_api_token": "", + "output_servers": [ + { + "name": "output_sink_main", + "ip": "output-sink" + } + ], "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 + "name": "output_sink_main", + "ip": "output-sink" } }, "input": { @@ -39,6 +39,7 @@ "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r0:9000/measurements", "input_filter": { "enabled": false, @@ -55,6 +56,7 @@ "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r1:9000/measurements", "input_filter": { "enabled": false, @@ -71,6 +73,7 @@ "y": 8.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r2:9000/measurements", "input_filter": { "enabled": false, diff --git a/docker/mock_output_sink.py b/docker/mock_output_sink.py index 3870211..32e9970 100644 --- a/docker/mock_output_sink.py +++ b/docker/mock_output_sink.py @@ -37,10 +37,12 @@ def main() -> int: 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") + x = payload.get("x") + y = payload.get("y") + z = payload.get("z") latest["count"] = int(latest["count"]) + 1 latest["last_payload"] = payload - print(f"received payload, selected_frequency_hz={selected}") + print(f"received payload, x={x}, y={y}, z={z}") raw = json.dumps({"status": "ok"}).encode("utf-8") self.send_response(200) diff --git a/docker/mock_receiver.py b/docker/mock_receiver.py index c6a4a90..27510e1 100644 --- a/docker/mock_receiver.py +++ b/docker/mock_receiver.py @@ -12,13 +12,13 @@ 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}, + {"f_mhz": 433.920, "rssi": base_rssi + noise_a}, + {"f_mhz": 868.100, "rssi": base_rssi - 4.0 + noise_b}, ] return { "receiver_id": receiver_id, "timestamp_unix": time.time(), - "measurements": rows, + "samples": rows, } diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..54b7e81 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,216 @@ +# API Reference + +Базовый URL: `http://:` + +Контент ответов: `application/json; charset=utf-8` + +## GET /health + +Проверка состояния сервиса. + +### 200 OK + +```json +{ + "status": "ok", + "updated_at_utc": "2026-03-02T12:34:56+00:00", + "error": "" +} +``` + +### 503 Service Unavailable + +```json +{ + "status": "warming_up", + "updated_at_utc": null, + "error": "no data yet" +} +``` + +## GET /result + +Последний итоговый результат расчета. + +### 200 OK + +```json +{ + "status": "ok", + "updated_at_utc": "2026-03-02T12:35:01+00:00", + "data": { + "timestamp_utc": "2026-03-02T12:35:01+00:00", + "selected_frequency_hz": 868100000.0, + "selected_frequency_mhz": 868.1, + "position": { "x": 1.2, "y": 2.3, "z": 0.4 }, + "rmse_m": 0.52, + "frequency_table": [] + }, + "output_delivery": { + "enabled": true, + "status": "ok", + "ok_count": 1, + "error_count": 0, + "skipped_count": 0, + "servers": [] + } +} +``` + +### 503 Service Unavailable + +```json +{ + "status": "warming_up", + "updated_at_utc": null, + "error": "no data yet" +} +``` + +## GET /frequencies + +Таблица решений по частотам + статус доставки. + +### 200 OK + +```json +{ + "status": "ok", + "updated_at_utc": "2026-03-02T12:35:01+00:00", + "selected_frequency_hz": 868100000.0, + "selected_frequency_mhz": 868.1, + "frequency_table": [ + { + "frequency_hz": 433920000.0, + "frequency_mhz": 433.92, + "position": { "x": 1.0, "y": 2.0, "z": 0.3 }, + "rmse_m": 0.65, + "exact": false + } + ], + "output_delivery": { + "enabled": true, + "status": "partial" + } +} +``` + +## GET /config + +Возвращает текущий конфиг. + +Особенность: +- поле `runtime.write_api_token` редактируется в ответе (`""`), +- добавляется `runtime.write_api_token_set` (bool). + +### 200 OK + +```json +{ + "status": "ok", + "config_path": "config.json", + "config": { + "runtime": { + "write_api_token": "", + "write_api_token_set": true + } + } +} +``` + +## POST /refresh + +Принудительный запуск одного цикла опроса и расчета. + +Тело запроса: `{}` (или любой JSON-объект). + +### Заголовки при включенном токене + +- `X-API-Token: ` или +- `Authorization: Bearer ` + +### 200 OK + +```json +{ + "status": "ok", + "updated_at_utc": "2026-03-02T12:35:20+00:00" +} +``` + +### 401 Unauthorized + +```json +{ + "status": "error", + "error": "unauthorized: missing or invalid API token" +} +``` + +### 500 Internal Server Error + +```json +{ + "status": "error", + "error": "Output server(s) rejected payload: sink-a" +} +``` + +## POST /config + +Валидация и горячее применение нового конфига. + +Ограничения: +- тело должно быть JSON-объектом, +- максимальный размер `1_000_000` байт. + +### Заголовки при включенном токене + +- `X-API-Token: ` или +- `Authorization: Bearer ` + +### 200 OK + +```json +{ + "status": "ok", + "saved": true, + "save_error": "", + "restart_required": false, + "applied": true, + "config_path": "config.json" +} +``` + +### 400 Bad Request + +```json +{ + "status": "error", + "error": "Config validation failed: input.receivers must contain at least 3 objects." +} +``` + +### 413 Payload Too Large + +```json +{ + "status": "error", + "error": "Config payload too large: 1500000 bytes, max is 1000000" +} +``` + +### 401 Unauthorized + +```json +{ + "status": "error", + "error": "unauthorized: missing or invalid API token" +} +``` + +## UI и статические файлы + +- `GET /` и `GET /ui` — веб-интерфейс. +- `GET /static/*` — JS/CSS. + diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md new file mode 100644 index 0000000..f26fb5c --- /dev/null +++ b/docs/CONFIG_REFERENCE.md @@ -0,0 +1,198 @@ +# Config Reference + +Ниже описана структура `config.json` для `service.py`. + +Полный пример: [../config.template.json](../config.template.json) + +## Корневые блоки + +- `model` — параметры радиомодели RSSI -> distance. +- `solver` — параметры решателя пересечения сфер. +- `runtime` — HTTP-порт, polling, защита, выходные серверы. +- `input` — входные ресиверы, источники, фильтры, агрегация. + +## model + +```json +"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 +} +``` + +- `tx_power_dbm` (float, required) +- `tx_gain_dbi` (float, optional) +- `rx_gain_dbi` (float, optional) +- `path_loss_exponent` (float, optional) +- `reference_distance_m` (float, optional) +- `min_distance_m` (float, optional) + +## solver + +```json +"solver": { + "tolerance": 0.001, + "z_preference": "positive" +} +``` + +- `tolerance` (float, optional) +- `z_preference` (`"positive"` | `"negative"`) + +## runtime + +```json +"runtime": { + "listen_host": "0.0.0.0", + "listen_port": 8081, + "poll_interval_s": 1.0, + "write_api_token": "", + "output_servers": [] +} +``` + +- `listen_host` (string, optional) +- `listen_port` (int, optional) +- `poll_interval_s` (float, optional) +- `write_api_token` (string, optional) + +### runtime.output_servers (рекомендуется) + +Список выходных серверов. Можно задать несколько целей доставки. + +```json +"output_servers": [ + { + "name": "sink-main", + "ip": "192.168.1.100" + } +] +``` + +Поля: +- `name` (string) +- `ip` (string) + +Автоматически: +- если `enabled` не задан, выход считается включенным при непустом `ip`; +- `port`, `path`, `timeout_s` берутся по умолчанию (`8080`, `/triangulation`, `3.0`). + +Примечание: +- legacy-поля (`enabled`, `port`, `path`, `timeout_s`, `frequency_filter_*`) по-прежнему поддерживаются для обратной совместимости. + +### runtime.output_server (legacy) + +Одиночная цель. Поддерживается для обратной совместимости. + +## input + +```json +"input": { + "mode": "http_sources", + "aggregation": "median", + "source_timeout_s": 3.0, + "default_input_filter": {}, + "receivers": [] +} +``` + +- `mode` — для автосервиса только `"http_sources"`. +- `aggregation` — `"median"` или `"mean"`. +- `source_timeout_s` — timeout входных HTTP-запросов. +- `default_input_filter` — общий фильтр, автоматически применяемый ко всем ресиверам. +- `receivers` — массив ресиверов, минимум 3. + +### input.default_input_filter + +```json +"default_input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 +} +``` + +Ограничения: +- `max_frequency_mhz >= min_frequency_mhz` +- `max_rssi_dbm >= min_rssi_dbm` + +### input.receivers[] + +```json +{ + "receiver_id": "r0", + "center": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://host:9000/measurements", "api_token": "" }, + "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` +- `center.x`, `center.y`, `center.z` +- URL источника: + - `access.url` (предпочтительно) или + - `source_url` (legacy) + +Дополнительно: +- `access.api_token` или `source_api_token` — токен входного сервера (добавляется как `Authorization: Bearer ...`). +- `input_filter` — override фильтра для конкретного ресивера. +- `frequencies_mhz` — список разрешённых частот для ресивера; в расчёт попадут только они. + +## Пример минимально рабочего конфига + +```json +{ + "model": { "tx_power_dbm": 20.0 }, + "solver": { "tolerance": 0.001, "z_preference": "positive" }, + "runtime": { + "listen_host": "0.0.0.0", + "listen_port": 8081, + "poll_interval_s": 1.0, + "output_servers": [ + { + "name": "sink", + "ip": "" + } + ] + }, + "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 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://127.0.0.1:9001/measurements" } + }, + { + "receiver_id": "r1", + "center": { "x": 10.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://127.0.0.1:9002/measurements" } + }, + { + "receiver_id": "r2", + "center": { "x": 0.0, "y": 8.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://127.0.0.1:9003/measurements" } + } + ] + } +} +``` diff --git a/docs/JSON_EXAMPLES.md b/docs/JSON_EXAMPLES.md new file mode 100644 index 0000000..2a7ec55 --- /dev/null +++ b/docs/JSON_EXAMPLES.md @@ -0,0 +1,106 @@ +# JSON Examples + +Ниже даны практические шаблоны JSON для интеграции. + +## 1) Входной payload от ресивера (рекомендуемый компактный формат) + +```json +{ + "receiver_id": "r0", + "timestamp_unix": 1767354000.125, + "samples": [ + { "f_mhz": 433.92, "rssi": -61.5 }, + { "f_mhz": 868.10, "rssi": -67.2 } + ] +} +``` + +## 2) Входной payload (совместимый legacy-формат) + +```json +{ + "receiver_id": "r0", + "measurements": [ + { "frequency_hz": 433920000, "rssi_dbm": -61.5 }, + { "frequency_hz": 868100000, "rssi_dbm": -67.2 } + ] +} +``` + +Также поддерживаются: +- корневой массив измерений без обертки, +- ключи массивов: `samples`, `measurements`, `data`, `m`, +- частота: `frequency_hz`, `freq_hz`, `f_hz`, `frequency_mhz`, `freq_mhz`, `f_mhz`, `frequency`, `freq`, `f`, +- RSSI: `amplitude_dbm`, `rssi_dbm`, `dbm`, `amplitude`, `rssi`. + +## 3) Выходной payload (отправляется на конечный сервер) + +Сервис отправляет минимальный JSON: + +```json +{ + "x": 1.2, + "y": 2.3, + "z": 0.4 +} +``` + +Только эти поля: +- `x` +- `y` +- `z` + +## 4) Шаблон блока `runtime.output_servers` + +```json +{ + "runtime": { + "output_servers": [ + { + "name": "sink-a", + "ip": "10.0.0.50" + }, + { + "name": "sink-b", + "ip": "10.0.0.51" + } + ] + } +} +``` + +## 5) Шаблон входных ресиверов с общим фильтром + +```json +{ + "input": { + "default_input_filter": { + "enabled": true, + "min_frequency_mhz": 430.0, + "max_frequency_mhz": 900.0, + "min_rssi_dbm": -90.0, + "max_rssi_dbm": -20.0 + }, + "receivers": [ + { + "receiver_id": "r0", + "center": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://10.0.0.11:9000/measurements" } + }, + { + "receiver_id": "r1", + "center": { "x": 10.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://10.0.0.12:9000/measurements" } + }, + { + "receiver_id": "r2", + "center": { "x": 0.0, "y": 8.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://10.0.0.13:9000/measurements" } + } + ] + } +} +``` diff --git a/service.py b/service.py index a3e1b17..319b0bc 100644 --- a/service.py +++ b/service.py @@ -1,8 +1,8 @@ from __future__ import annotations import argparse -import copy import hmac +import itertools import json import math import mimetypes @@ -27,6 +27,10 @@ MAX_CONFIG_BODY_BYTES = 1_000_000 # 1 MB guardrail for /config POST. HZ_IN_MHZ = 1_000_000.0 +def _utc_now_iso_seconds() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + def _load_json(path: str) -> Dict[str, object]: file_path = Path(path) if not file_path.exists(): @@ -114,20 +118,23 @@ def _parse_frequency_hz_from_measurement( keys=( "frequency_hz", "freq_hz", + "f_hz", "frequency_mhz", "freq_mhz", + "f_mhz", "frequency", "freq", + "f", ), field_name="frequency", source_label=source_label, row_index=row_index, ) - if key in ("frequency_hz", "freq_hz"): + if key in ("frequency_hz", "freq_hz", "f_hz"): return value - if key in ("frequency_mhz", "freq_mhz"): + if key in ("frequency_mhz", "freq_mhz", "f_mhz"): return value * HZ_IN_MHZ - # For generic fields "frequency"/"freq" default to MHz in this project. + # For generic fields default to MHz in this project. # Keep backward compatibility: very large values are treated as Hz. if value >= 10_000_000.0: return value @@ -135,11 +142,21 @@ def _parse_frequency_hz_from_measurement( def _parse_receiver_input_filter( - receiver_obj: Dict[str, object], receiver_id: str + receiver_obj: Dict[str, object], + receiver_id: str, + default_filter_obj: Optional[Dict[str, object]] = None, ) -> Dict[str, object]: - filter_obj = receiver_obj.get("input_filter", {}) - if filter_obj is None: - filter_obj = {} + raw_receiver_filter = receiver_obj.get("input_filter") + if raw_receiver_filter is None: + raw_receiver_filter = {} + if not isinstance(raw_receiver_filter, dict): + raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.") + + merged_filter: Dict[str, object] = {} + if isinstance(default_filter_obj, dict): + merged_filter.update(default_filter_obj) + merged_filter.update(raw_receiver_filter) + filter_obj = merged_filter if not isinstance(filter_obj, dict): raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.") @@ -191,6 +208,110 @@ def _apply_receiver_input_filter( return filtered +def _parse_receiver_configured_frequencies( + receiver_obj: Dict[str, object], + receiver_id: str, +) -> List[int]: + raw_frequencies = receiver_obj.get("frequencies_mhz") + if raw_frequencies is None: + return [] + if not isinstance(raw_frequencies, list): + raise ValueError( + f"receiver '{receiver_id}': frequencies_mhz must be an array of numbers." + ) + + parsed_hz: List[int] = [] + for index, value in enumerate(raw_frequencies, start=1): + try: + frequency_mhz = float(value) + except (TypeError, ValueError): + raise ValueError( + f"receiver '{receiver_id}': frequencies_mhz[{index}] must be numeric." + ) from None + if not math.isfinite(frequency_mhz) or frequency_mhz <= 0.0: + raise ValueError( + f"receiver '{receiver_id}': frequencies_mhz[{index}] must be > 0." + ) + parsed_hz.append(int(round(frequency_mhz * HZ_IN_MHZ))) + return sorted(set(parsed_hz)) + + +def _apply_receiver_configured_frequencies( + measurements: Sequence[Tuple[float, float]], + configured_frequencies_hz: Sequence[int], +) -> List[Tuple[float, float]]: + if not configured_frequencies_hz: + return list(measurements) + + allowed = set(int(value) for value in configured_frequencies_hz) + filtered: List[Tuple[float, float]] = [] + for frequency_hz, rssi_dbm in measurements: + rounded_hz = int(round(frequency_hz)) + if rounded_hz in allowed: + filtered.append((float(rounded_hz), rssi_dbm)) + return filtered + + +def _parse_output_server_config( + output_obj: Dict[str, object], + default_name: str, +) -> Dict[str, object]: + name = str(output_obj.get("name", default_name)).strip() or default_name + ip = str(output_obj.get("ip", "")).strip() + # Keep backward compatibility for explicit enabled flag, but allow simplified config: + # if enabled is omitted, non-empty IP means enabled output target. + if "enabled" in output_obj: + enabled = bool(output_obj.get("enabled")) + else: + enabled = bool(ip) + port = int(output_obj.get("port", 8080)) + path = str(output_obj.get("path", "/triangulation")) + timeout_s = float(output_obj.get("timeout_s", 3.0)) + 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 + + min_frequency_mhz = float(min_frequency_mhz_raw or 0.0) + max_frequency_mhz = float(max_frequency_mhz_raw or 0.0) + min_frequency_hz = min_frequency_mhz * HZ_IN_MHZ + max_frequency_hz = max_frequency_mhz * HZ_IN_MHZ + + if enabled and not ip: + raise ValueError(f"runtime output '{name}': ip must be non-empty when enabled=true.") + if frequency_filter_enabled: + if min_frequency_mhz <= 0.0: + raise ValueError( + f"runtime output '{name}': min_frequency_mhz must be > 0 when frequency filter is enabled." + ) + if max_frequency_mhz <= 0.0: + raise ValueError( + f"runtime output '{name}': max_frequency_mhz must be > 0 when frequency filter is enabled." + ) + if max_frequency_mhz < min_frequency_mhz: + raise ValueError( + f"runtime output '{name}': max_frequency_mhz must be >= min_frequency_mhz." + ) + + return { + "name": name, + "enabled": enabled, + "ip": ip, + "port": port, + "path": path, + "timeout_s": timeout_s, + "frequency_filter_enabled": frequency_filter_enabled, + "min_frequency_mhz": min_frequency_mhz, + "max_frequency_mhz": max_frequency_mhz, + "min_frequency_hz": min_frequency_hz, + "max_frequency_hz": max_frequency_hz, + } + + def parse_source_payload( payload: object, source_label: str, @@ -209,6 +330,8 @@ def parse_source_payload( raw_items = payload.get("samples") if raw_items is None: raw_items = payload.get("data") + if raw_items is None: + raw_items = payload.get("m") elif isinstance(payload, list): raw_items = payload else: @@ -228,7 +351,7 @@ def parse_source_payload( ) amplitude_dbm = _float_from_measurement( row, - keys=("amplitude_dbm", "rssi_dbm", "amplitude", "rssi"), + keys=("amplitude_dbm", "rssi_dbm", "dbm", "amplitude", "rssi"), field_name="amplitude_dbm", source_label=source_label, row_index=row_index, @@ -272,9 +395,13 @@ def _fetch_measurements( url: str, timeout_s: float, expected_receiver_id: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, ) -> List[Tuple[float, float]]: source_label = f"source_url={url}" - req = request.Request(url=url, method="GET", headers={"Accept": "application/json"}) + request_headers = {"Accept": "application/json"} + if headers: + request_headers.update(headers) + req = request.Request(url=url, method="GET", headers=request_headers) try: with request.urlopen(req, timeout=timeout_s) as response: payload = json.loads(response.read().decode("utf-8")) @@ -320,45 +447,35 @@ class AutoService: 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 = {} - 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)) - 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." + parsed_output_servers: List[Dict[str, object]] = [] + output_servers_obj = runtime_obj.get("output_servers") + if output_servers_obj is not None: + if not isinstance(output_servers_obj, list): + raise ValueError("runtime.output_servers must be list.") + for index, output_obj in enumerate(output_servers_obj, start=1): + if not isinstance(output_obj, dict): + raise ValueError("runtime.output_servers[] must be object.") + parsed_output_servers.append( + _parse_output_server_config( + output_obj=output_obj, + default_name=f"output_{index}", + ) ) - 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." + else: + 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.") + parsed_output_servers.append( + _parse_output_server_config( + output_obj=output_obj, + default_name="output_1", ) + ) + + self.output_servers = parsed_output_servers + self.output_enabled = any(bool(server.get("enabled")) for server in self.output_servers) self.source_timeout_s = float(input_obj.get("source_timeout_s", 3.0)) self.aggregation = str(input_obj.get("aggregation", "median")) @@ -369,22 +486,59 @@ class AutoService: if input_mode != "http_sources": raise ValueError("Automatic service requires input.mode = 'http_sources'.") + raw_default_filter = input_obj.get("default_input_filter") + default_filter_obj: Optional[Dict[str, object]] = None + if raw_default_filter is not None: + if not isinstance(raw_default_filter, dict): + raise ValueError("input.default_input_filter must be object.") + default_filter_obj = raw_default_filter + receivers = input_obj.get("receivers") - if not isinstance(receivers, list) or len(receivers) != 3: - raise ValueError("input.receivers must contain exactly 3 objects.") + if not isinstance(receivers, list) or len(receivers) < 3: + raise ValueError("input.receivers must contain at least 3 objects.") parsed_receivers: List[Dict[str, object]] = [] for receiver in receivers: if not isinstance(receiver, dict): raise ValueError("Each receiver must be object.") + access_obj = receiver.get("access", {}) + if access_obj is None: + access_obj = {} + if not isinstance(access_obj, dict): + raise ValueError("receiver.access must be object.") + + source_url = str( + receiver.get("source_url") + or access_obj.get("url") + or access_obj.get("source_url") + or "" + ).strip() + if not source_url: + raise ValueError( + f"receiver '{receiver.get('receiver_id', '')}': source_url/access.url must be non-empty." + ) + + source_headers: Dict[str, str] = {} + source_api_token = str( + receiver.get("source_api_token") or access_obj.get("api_token") or "" + ).strip() + if source_api_token: + source_headers["Authorization"] = f"Bearer {source_api_token}" + parsed_receivers.append( { "receiver_id": str(receiver["receiver_id"]), "center": _center_from_obj(receiver), - "source_url": str(receiver["source_url"]), + "source_url": source_url, + "source_headers": source_headers, + "configured_frequencies_hz": _parse_receiver_configured_frequencies( + receiver_obj=receiver, + receiver_id=str(receiver["receiver_id"]), + ), "input_filter": _parse_receiver_input_filter( receiver_obj=receiver, receiver_id=str(receiver["receiver_id"]), + default_filter_obj=default_filter_obj, ), } ) @@ -400,6 +554,27 @@ class AutoService: "http_status": None, "response_body": "", "sent_at_utc": None, + "servers": [ + { + "name": server["name"], + "enabled": bool(server["enabled"]), + "status": "disabled" if not bool(server["enabled"]) else "pending", + "http_status": None, + "response_body": "", + "sent_at_utc": None, + "target": { + "ip": server["ip"], + "port": server["port"], + "path": server["path"], + }, + "frequency_filter": { + "enabled": server["frequency_filter_enabled"], + "min_frequency_mhz": server["min_frequency_mhz"], + "max_frequency_mhz": server["max_frequency_mhz"], + }, + } + for server in self.output_servers + ], } self.stop_event = threading.Event() @@ -410,7 +585,8 @@ class AutoService: def stop(self) -> None: self.stop_event.set() - self.poll_thread.join(timeout=2.0) + if self.poll_thread.is_alive(): + self.poll_thread.join(timeout=2.0) def refresh_once(self) -> None: receiver_payloads: List[Dict[str, object]] = [] @@ -420,18 +596,26 @@ class AutoService: receiver_id = str(receiver["receiver_id"]) center = receiver["center"] source_url = str(receiver["source_url"]) + source_headers = receiver.get("source_headers") raw_measurements = _fetch_measurements( source_url, timeout_s=self.source_timeout_s, expected_receiver_id=receiver_id, + headers=source_headers if isinstance(source_headers, dict) else None, ) receiver_filter = receiver["input_filter"] measurements = _apply_receiver_input_filter( raw_measurements, receiver_filter=receiver_filter ) + configured_frequencies_hz = receiver.get("configured_frequencies_hz", []) + if isinstance(configured_frequencies_hz, list): + measurements = _apply_receiver_configured_frequencies( + measurements, + configured_frequencies_hz=configured_frequencies_hz, + ) if not measurements: raise RuntimeError( - f"receiver '{receiver_id}': no measurements left after input_filter." + f"receiver '{receiver_id}': no measurements left after configured filters." ) grouped = _group_by_frequency(measurements) grouped_by_receiver.append(grouped) @@ -460,6 +644,14 @@ class AutoService: "source_url": source_url, "aggregation": self.aggregation, "input_filter": receiver_filter, + "configured_frequencies_mhz": [ + float(int(value)) / HZ_IN_MHZ + for value in ( + configured_frequencies_hz + if isinstance(configured_frequencies_hz, list) + else [] + ) + ], "raw_samples_count": len(raw_measurements), "filtered_samples_count": len(measurements), "radius_m_all_freq": radius_m, @@ -467,74 +659,105 @@ class AutoService: } ) - # Only compare homogeneous measurements: same frequency across all receivers. - 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), - } + all_frequencies = sorted( + {frequency for grouped in grouped_by_receiver for frequency in grouped.keys()} + ) + for frequency_hz in all_frequencies: + available_indices = [ + idx for idx, grouped in enumerate(grouped_by_receiver) if frequency_hz in grouped + ] + if len(available_indices) < 3: + continue + + best_combo_row: Optional[Dict[str, object]] = None + best_combo_result = None + best_combo_indices: Optional[Tuple[int, int, int]] = None + best_combo_spheres: Optional[List[Sphere]] = None + + for combo in itertools.combinations(available_indices, 3): + spheres_for_frequency: List[Sphere] = [] + row_receivers: List[Dict[str, object]] = [] + + for receiver_index in combo: + receiver = self.receivers[receiver_index] + measurement_subset = grouped_by_receiver[receiver_index][frequency_hz] + radius_m = aggregate_radius( + measurement_subset, model=self.model, method=self.aggregation + ) + spheres_for_frequency.append( + Sphere(center=receiver["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] ) + candidate_row = { + "frequency_hz": frequency_hz, + "frequency_mhz": frequency_hz / HZ_IN_MHZ, + "position": { + "x": result.point[0], + "y": result.point[1], + "z": result.point[2], + }, + "exact": result.exact, + "rmse_m": result.rmse, + "receivers": row_receivers, + "used_receivers_count": 3, + "available_receivers_count": len(available_indices), + } + if ( + best_combo_row is None + or float(candidate_row["rmse_m"]) < float(best_combo_row["rmse_m"]) + ): + best_combo_row = candidate_row + best_combo_result = result + best_combo_indices = combo + best_combo_spheres = spheres_for_frequency + + if ( + best_combo_row is None + or best_combo_result is None + or best_combo_indices is None + or best_combo_spheres is None + ): + continue - 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( + row_receivers = best_combo_row["receivers"] + for local_index, receiver_index in enumerate(best_combo_indices): + residual = best_combo_result.residuals[local_index] + row_receivers[local_index]["residual_m"] = residual + receiver_payloads[receiver_index].setdefault("per_frequency", []).append( { "frequency_hz": frequency_hz, "frequency_mhz": frequency_hz / HZ_IN_MHZ, - "radius_m": spheres_for_frequency[index].radius, + "radius_m": best_combo_spheres[local_index].radius, "residual_m": residual, - "samples_count": len(grouped_by_receiver[index][frequency_hz]), + "samples_count": len(grouped_by_receiver[receiver_index][frequency_hz]), } ) - row = { - "frequency_hz": frequency_hz, - "frequency_mhz": frequency_hz / HZ_IN_MHZ, - "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 + frequency_rows.append(best_combo_row) + if best_row is None or float(best_combo_row["rmse_m"]) < float(best_row["rmse_m"]): + best_row = best_combo_row if best_row is None: + if len(self.receivers) == 3: + raise RuntimeError("No common frequencies across all 3 receivers.") raise RuntimeError("Cannot build frequency table for trilateration.") payload = { - "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "timestamp_utc": _utc_now_iso_seconds(), "selected_frequency_hz": best_row["frequency_hz"], "selected_frequency_mhz": float(best_row["frequency_hz"]) / HZ_IN_MHZ, "position": best_row["position"], @@ -556,109 +779,165 @@ class AutoService: self.updated_at_utc = payload["timestamp_utc"] # type: ignore[index] 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=output_payload, - port=self.output_port, - path=self.output_path, - timeout_s=self.output_timeout_s, + delivery = self._deliver_to_output_servers(payload) + with self.state_lock: + self.last_output_delivery = delivery + + if delivery["status"] in ("error", "partial"): + failed_servers = [ + row["name"] + for row in delivery.get("servers", []) + if isinstance(row, dict) and row.get("status") == "error" + ] + raise RuntimeError( + "Output server(s) rejected payload: " + + ", ".join(str(name) for name in failed_servers) ) - # Keep delivery diagnostics in snapshot so UI/API can show transport health. - 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, - }, - "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( - "Output server rejected payload: " - 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 + @staticmethod + def _row_frequency_mhz(row: Dict[str, object]) -> Optional[float]: + mhz = row.get("frequency_mhz") + if isinstance(mhz, (int, float)): + return float(mhz) + hz = row.get("frequency_hz") + if isinstance(hz, (int, float)): + return float(hz) / HZ_IN_MHZ + return None + + @staticmethod + def _position_from_row(row: Dict[str, object]) -> Optional[Dict[str, float]]: + position_obj = row.get("position") + if not isinstance(position_obj, dict): + return None + try: + return { + "x": float(position_obj["x"]), + "y": float(position_obj["y"]), + "z": float(position_obj["z"]), + } + except (TypeError, ValueError, KeyError): + return None - # 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") + def _build_output_payload( + self, + payload: Dict[str, object], + output_server: Dict[str, object], + ) -> Optional[Dict[str, object]]: + table_obj = payload.get("frequency_table") if not isinstance(table_obj, list): return None - filtered_rows = [] + rows: List[Dict[str, object]] = [] 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: + if self._position_from_row(row) is None: + continue + if bool(output_server.get("frequency_filter_enabled", False)): + if not ( + float(output_server.get("min_frequency_hz", 0.0)) + <= float(frequency_hz) + <= float(output_server.get("max_frequency_hz", 0.0)) + ): + continue + rows.append(row) + + if not 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 + best_row = min( + rows, + key=lambda row: float(row.get("rmse_m", float("inf"))), + ) + best_position = self._position_from_row(best_row) + if best_position is None: + return None + + # Minimal transport payload for final server integration: coordinates only. + return best_position + + def _deliver_to_output_servers(self, payload: Dict[str, object]) -> Dict[str, object]: + now = _utc_now_iso_seconds() + servers_delivery: List[Dict[str, object]] = [] + enabled_targets = [server for server in self.output_servers if bool(server.get("enabled"))] + + for server in self.output_servers: + server_delivery = { + "name": server["name"], + "enabled": bool(server["enabled"]), + "status": "disabled", + "http_status": None, + "response_body": "", + "sent_at_utc": now, + "target": { + "ip": server["ip"], + "port": server["port"], + "path": server["path"], + }, + "frequency_filter": { + "enabled": server["frequency_filter_enabled"], + "min_frequency_mhz": server["min_frequency_mhz"], + "max_frequency_mhz": server["max_frequency_mhz"], + }, + } + if not bool(server["enabled"]): + servers_delivery.append(server_delivery) + continue + + output_payload = self._build_output_payload(payload=payload, output_server=server) + if output_payload is None: + server_delivery["status"] = "skipped" + server_delivery["response_body"] = "No frequencies in configured output range" + servers_delivery.append(server_delivery) + continue + + status_code, response_body = send_payload_to_server( + server_ip=str(server["ip"]), + payload=output_payload, + port=int(server["port"]), + path=str(server["path"]), + timeout_s=float(server["timeout_s"]), + ) + server_delivery["http_status"] = status_code + server_delivery["response_body"] = response_body + server_delivery["status"] = "ok" if 200 <= status_code < 300 else "error" + servers_delivery.append(server_delivery) + + ok_count = sum(1 for row in servers_delivery if row["status"] == "ok") + error_count = sum(1 for row in servers_delivery if row["status"] == "error") + skipped_count = sum(1 for row in servers_delivery if row["status"] == "skipped") + + if not enabled_targets: + status = "disabled" + elif error_count > 0 and ok_count > 0: + status = "partial" + elif error_count > 0: + status = "error" + elif ok_count == 0 and skipped_count > 0: + status = "skipped" + else: + status = "ok" + + primary = next((row for row in servers_delivery if row["enabled"]), None) + if primary is None and servers_delivery: + primary = servers_delivery[0] + + return { + "enabled": bool(enabled_targets), + "status": status, + "http_status": None if primary is None else primary["http_status"], + "response_body": "" if primary is None else primary["response_body"], + "sent_at_utc": now, + "target": None if primary is None else primary["target"], + "frequency_filter": None if primary is None else primary["frequency_filter"], + "ok_count": ok_count, + "error_count": error_count, + "skipped_count": skipped_count, + "servers": servers_delivery, + } def _poll_loop(self) -> None: while not self.stop_event.is_set(): @@ -680,9 +959,17 @@ class AutoService: def _make_handler(service: AutoService): + service_holder = {"current": service} + service_swap_lock = threading.Lock() + class ServiceHandler(BaseHTTPRequestHandler): + @staticmethod + def _current_service() -> AutoService: + return service_holder["current"] + def _is_write_authorized(self) -> bool: - expected_token = service.write_api_token + service_obj = self._current_service() + expected_token = service_obj.write_api_token if not expected_token: return True @@ -733,6 +1020,12 @@ def _make_handler(service: AutoService): mime_type, _ = mimetypes.guess_type(str(file_path)) if mime_type is None: mime_type = "application/octet-stream" + # Force UTF-8 for text assets to avoid mojibake in browsers. + if mime_type.startswith("text/") or mime_type in ( + "application/javascript", + "application/x-javascript", + ): + mime_type = f"{mime_type}; charset=utf-8" self._write_bytes(200, file_path.read_bytes(), mime_type) def log_message(self, format: str, *args) -> None: @@ -740,7 +1033,8 @@ def _make_handler(service: AutoService): def do_GET(self) -> None: path = parse.urlparse(self.path).path - snapshot = service.snapshot() + service_obj = self._current_service() + snapshot = service_obj.snapshot() if path == "/" or path == "/ui": self._write_static("index.html") @@ -812,17 +1106,17 @@ def _make_handler(service: AutoService): return if path == "/config": - public_config = json.loads(json.dumps(service.config)) + public_config = json.loads(json.dumps(service_obj.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) + runtime_obj["write_api_token_set"] = bool(service_obj.write_api_token) self._write_json( 200, { "status": "ok", - "config_path": service.config_path, + "config_path": service_obj.config_path, "config": public_config, }, ) @@ -840,6 +1134,7 @@ def _make_handler(service: AutoService): return if path == "/config": + service_obj = self._current_service() try: content_length = int(self.headers.get("Content-Length", "0")) except ValueError: @@ -872,13 +1167,13 @@ def _make_handler(service: AutoService): # 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: + if isinstance(runtime_obj, dict) and service_obj.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 + runtime_obj["write_api_token"] = service_obj.write_api_token try: - AutoService(new_config) + new_service = AutoService(new_config, config_path=service_obj.config_path) except Exception as exc: self._write_json( 400, @@ -886,19 +1181,41 @@ def _make_handler(service: AutoService): ) 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", + save_error = "" + if service_obj.config_path: + try: + Path(service_obj.config_path).write_text( + json.dumps(new_config, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError as exc: + save_error = str(exc) + + try: + new_service.start() + except Exception as exc: + self._write_json( + 500, + { + "status": "error", + "error": f"Failed to start service with new config: {exc}", + }, ) + return + with service_swap_lock: + old_service = service_holder["current"] + service_holder["current"] = new_service + old_service.stop() + self._write_json( 200, { "status": "ok", - "saved": bool(service.config_path), - "restart_required": True, - "config_path": service.config_path, + "saved": bool(service_obj.config_path) and not bool(save_error), + "save_error": save_error, + "restart_required": False, + "applied": True, + "config_path": service_obj.config_path, }, ) return @@ -908,12 +1225,12 @@ def _make_handler(service: AutoService): return try: - service.refresh_once() + self._current_service().refresh_once() except Exception as exc: self._write_json(500, {"status": "error", "error": str(exc)}) return - snapshot = service.snapshot() + snapshot = self._current_service().snapshot() self._write_json( 200, { @@ -922,6 +1239,7 @@ def _make_handler(service: AutoService): }, ) + ServiceHandler.service_holder = service_holder # type: ignore[attr-defined] return ServiceHandler @@ -947,8 +1265,8 @@ def main() -> int: service = AutoService(config, config_path=args.config) service.start() - - server = ThreadingHTTPServer((host, port), _make_handler(service)) + handler = _make_handler(service) + server = ThreadingHTTPServer((host, port), handler) print(f"service_listen: http://{host}:{port}") try: server.serve_forever() @@ -956,7 +1274,8 @@ def main() -> int: pass finally: server.server_close() - service.stop() + current_service = handler.service_holder["current"] # type: ignore[attr-defined] + current_service.stop() return 0 diff --git a/test_service_integration.py b/test_service_integration.py index a750d67..c0a65b7 100644 --- a/test_service_integration.py +++ b/test_service_integration.py @@ -198,7 +198,7 @@ def test_refresh_once_raises_when_output_server_rejects_payload( ) svc = service.AutoService(config) - with pytest.raises(RuntimeError, match="Output server rejected payload: HTTP 500"): + with pytest.raises(RuntimeError, match="Output server\\(s\\) rejected payload"): svc.refresh_once() @@ -250,6 +250,24 @@ def test_parse_source_payload_treats_generic_frequency_as_mhz(): assert parsed[0][0] == pytest.approx(433_920_000.0) +def test_parse_source_payload_accepts_compact_short_keys(): + payload = { + "receiver_id": "r0", + "samples": [ + {"f_mhz": 868.1, "rssi": -60.0}, + {"f_hz": 433_920_000.0, "dbm": -62.5}, + ], + } + parsed = service.parse_source_payload( + payload=payload, + source_label="source_url=test", + expected_receiver_id="r0", + ) + assert parsed[0][0] == pytest.approx(868_100_000.0) + assert parsed[1][0] == pytest.approx(433_920_000.0) + assert parsed[1][1] == pytest.approx(-62.5) + + def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch): config = _base_config() svc = service.AutoService(config) @@ -263,6 +281,8 @@ def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch): http_server.shutdown() http_server.server_close() thread.join(timeout=1.0) + current_service = http_server.RequestHandlerClass.service_holder["current"] # type: ignore[attr-defined] + current_service.stop() def test_http_config_rejects_empty_body(monkeypatch: pytest.MonkeyPatch): @@ -320,6 +340,42 @@ def test_http_config_rejects_too_large_payload(monkeypatch: pytest.MonkeyPatch): thread.join(timeout=1.0) +def test_http_config_applies_without_manual_restart(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + monkeypatch.setattr( + service, + "_fetch_measurements", + lambda *_, **__: [(915e6, -60.0)], + ) + + svc = service.AutoService(config) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + new_config = _base_config() + new_config["runtime"]["poll_interval_s"] = 0.25 # type: ignore[index] + raw = json.dumps(new_config).encode("utf-8") + req = urllib_request.Request( + url=f"{base_url}/config", + method="POST", + data=raw, + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(req) as response: + payload = json.loads(response.read().decode("utf-8")) + assert payload["status"] == "ok" + assert payload["applied"] is True + assert payload["restart_required"] is False + + with urllib_request.urlopen(f"{base_url}/config") as response: + payload = json.loads(response.read().decode("utf-8")) + assert payload["config"]["runtime"]["poll_interval_s"] == 0.25 + 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] @@ -411,10 +467,7 @@ def test_output_payload_is_filtered_by_frequency_range(monkeypatch: pytest.Monke 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 + assert set(sent_payload.keys()) == {"x", "y", "z"} def test_output_delivery_skipped_when_range_has_no_frequencies(monkeypatch: pytest.MonkeyPatch): @@ -441,6 +494,55 @@ def test_output_delivery_skipped_when_range_has_no_frequencies(monkeypatch: pyte assert snapshot["output_delivery"]["status"] == "skipped" +def test_multiple_output_servers_with_names(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["runtime"]["output_servers"] = [ # type: ignore[index] + { + "name": "sink_a", + "enabled": True, + "ip": "127.0.0.1", + "port": 8080, + "path": "/triangulation", + "timeout_s": 1.0, + }, + { + "name": "sink_b", + "enabled": True, + "ip": "127.0.0.2", + "port": 8081, + "path": "/triangulation", + "timeout_s": 1.0, + }, + ] + 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) + + calls: List[Dict[str, object]] = [] + + def _fake_send_payload_to_server(**kwargs): + calls.append(kwargs) + if kwargs["server_ip"] == "127.0.0.2": + return 500, "fail" + return 200, "ok" + + monkeypatch.setattr(service, "send_payload_to_server", _fake_send_payload_to_server) + + svc = service.AutoService(config) + with pytest.raises(RuntimeError, match="sink_b"): + svc.refresh_once() + + snapshot = svc.snapshot() + assert snapshot["output_delivery"]["status"] in ("partial", "error") + servers = snapshot["output_delivery"]["servers"] + assert isinstance(servers, list) + assert {row["name"] for row in servers} == {"sink_a", "sink_b"} + assert len(calls) == 2 + + def test_config_validation_rejects_invalid_frequency_filter_range(): config = _base_config() config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index] @@ -509,7 +611,7 @@ def test_receiver_input_filter_empty_result_raises(monkeypatch: pytest.MonkeyPat _install_urlopen(monkeypatch, responses) svc = service.AutoService(config) - with pytest.raises(RuntimeError, match="no measurements left after input_filter"): + with pytest.raises(RuntimeError, match="no measurements left after configured filters"): svc.refresh_once() @@ -524,3 +626,112 @@ def test_receiver_input_filter_validation_rejects_invalid_rssi_range(): } with pytest.raises(ValueError, match="max_rssi_dbm must be >= min_rssi_dbm"): service.AutoService(config) + + +def test_refresh_once_supports_more_than_three_receivers_and_chooses_best_triplet( + monkeypatch: pytest.MonkeyPatch, +): + config = _base_config() + config["input"]["receivers"].append( # type: ignore[index] + { + "receiver_id": "r3", + "center": {"x": 50.0, "y": 50.0, "z": 0.0}, + "source_url": "http://r3.local/measurements", + } + ) + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -61.0}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -62.0}]}, + "http://r3.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -120.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 + row = payload["frequency_table"][0] + assert row["used_receivers_count"] == 3 + assert row["available_receivers_count"] == 4 + assert len(row["receivers"]) == 3 + + +def test_receiver_access_url_and_default_input_filter(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["input"]["default_input_filter"] = { # type: ignore[index] + "enabled": True, + "min_frequency_mhz": 900.0, + "max_frequency_mhz": 920.0, + "min_rssi_dbm": -90.0, + "max_rssi_dbm": -50.0, + } + for receiver in config["input"]["receivers"]: # type: ignore[index] + receiver["access"] = {"url": receiver["source_url"]} + del receiver["source_url"] + receiver.pop("input_filter", None) + + responses = { + "http://r0.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -60.0}, + {"frequency_hz": 433e6, "rssi_dbm": -60.0}, + ] + }, + "http://r1.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -61.0}, + {"frequency_hz": 433e6, "rssi_dbm": -61.0}, + ] + }, + "http://r2.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -62.0}, + {"frequency_hz": 433e6, "rssi_dbm": -62.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_hz"] == pytest.approx(915e6) + + +def test_receiver_configured_frequencies_limit_trilateration(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + for receiver in config["input"]["receivers"]: # type: ignore[index] + receiver["frequencies_mhz"] = [915.0] + + responses = { + "http://r0.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -60.0}, + {"frequency_hz": 433_920_000.0, "rssi_dbm": -61.0}, + ] + }, + "http://r1.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -61.0}, + {"frequency_hz": 433_920_000.0, "rssi_dbm": -62.0}, + ] + }, + "http://r2.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -62.0}, + {"frequency_hz": 433_920_000.0, "rssi_dbm": -63.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_hz"] == pytest.approx(915e6) diff --git a/triangulation.py b/triangulation.py index 7e95166..eaca9ad 100644 --- a/triangulation.py +++ b/triangulation.py @@ -12,6 +12,10 @@ Point3D = Tuple[float, float, float] SPEED_OF_LIGHT_M_S = 299_792_458.0 +def _utc_now_iso_seconds() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + @dataclass(frozen=True) class Sphere: center: Point3D @@ -330,7 +334,7 @@ def build_result_payload( ) return { - "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "timestamp_utc": _utc_now_iso_seconds(), "position": {"x": result.point[0], "y": result.point[1], "z": result.point[2]}, "exact": result.exact, "rmse_m": result.rmse, diff --git a/web/app.js b/web/app.js index c3c2899..dbd2a9d 100644 --- a/web/app.js +++ b/web/app.js @@ -7,19 +7,93 @@ const state = { activeSection: "overview", selectedReceiverIndex: 0, receiverDrafts: [], + sharedFilterDraft: { + enabled: false, + min_frequency_mhz: 0, + max_frequency_mhz: 0, + min_rssi_dbm: -200, + max_rssi_dbm: 50, + }, + selectedOutputIndex: 0, + outputDrafts: [], }; + const HZ_IN_MHZ = 1_000_000; function byId(id) { return document.getElementById(id); } +function setTextWithPulse(id, value) { + const el = byId(id); + if (!el) return; + const next = String(value); + const changed = el.textContent !== next; + el.textContent = next; + if (!changed) return; + el.classList.remove("value-updated"); + void el.offsetWidth; + el.classList.add("value-updated"); +} + 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); } +function localizeStatus(value) { + const status = String(value || "n/a"); + const mapping = { + ok: "ок", + error: "ошибка", + warming_up: "прогрев", + partial: "частично", + skipped: "пропущено", + disabled: "отключено", + not_found: "не найдено", + n_a: "н/д", + "n/a": "н/д", + true: "включено", + false: "отключено", + }; + return mapping[status] || status; +} + +function localizeErrorMessage(message) { + const text = String(message || "неизвестная ошибка"); + const known = { + "at least 3 input servers are required": "необходимо минимум 3 входных сервера", + "at least 1 output server is required": "необходим минимум 1 выходной сервер", + Unauthorized: "доступ запрещён (проверьте токен)", + "unauthorized: missing or invalid API token": "доступ запрещён: отсутствует или неверный API-токен", + warming_up: "прогрев", + not_found: "не найдено", + "no data yet": "данные пока не получены", + }; + if (known[text]) return known[text]; + if (text.startsWith("HTTP ")) return `ошибка HTTP: ${text.slice(5)}`; + if (text.startsWith("Config validation failed:")) { + return `ошибка валидации конфига: ${text.replace("Config validation failed:", "").trim()}`; + } + return text; +} + +function formatUpdatedTimestamp(value) { + if (!value) { + return { date: "дата: н/д", time: "время: н/д" }; + } + const text = String(value); + const match = text.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})?$/); + if (!match) { + return { date: `дата: ${text}`, time: "время: н/д" }; + } + const datePart = `${match[3]}.${match[2]}.${match[1]}`; + const zone = match[5] || ""; + const zoneLabel = zone === "Z" || zone === "+00:00" ? " UTC" : zone ? ` ${zone}` : ""; + return { date: `дата: ${datePart}`, time: `время: ${match[4]}${zoneLabel}` }; +} + function hzToMhz(value) { if (value === null || value === undefined) return null; const numeric = Number(value); @@ -27,6 +101,25 @@ function hzToMhz(value) { return numeric / HZ_IN_MHZ; } +function parseMhzList(raw) { + const text = String(raw || "").trim(); + if (!text) return []; + const values = text + .split(",") + .map((part) => Number(part.trim())) + .filter((value) => Number.isFinite(value) && value > 0); + return Array.from(new Set(values)); +} + +function formatMhzList(values) { + if (!Array.isArray(values) || values.length === 0) return ""; + return values + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => value.toFixed(3).replace(/\.?0+$/, "")) + .join(", "); +} + function authHeaders() { const token = state.writeToken || ""; if (!token) return {}; @@ -50,33 +143,62 @@ function setMenuOpen(isOpen) { byId("menu-list").classList.toggle("menu-list-open", isOpen); } -function normalizeReceiverDraft(receiver) { - const filter = receiver?.input_filter || {}; +function normalizeInputFilter(filter) { + const source = filter || {}; + return { + enabled: Boolean(source.enabled), + min_frequency_mhz: Number(source.min_frequency_mhz ?? hzToMhz(source.min_frequency_hz) ?? 0), + max_frequency_mhz: Number(source.max_frequency_mhz ?? hzToMhz(source.max_frequency_hz) ?? 0), + min_rssi_dbm: Number(source.min_rssi_dbm ?? -200), + max_rssi_dbm: Number(source.max_rssi_dbm ?? 50), + }; +} + +function createReceiverDraft(index) { 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, + receiver_id: `r${index}`, + source_url: "", + frequencies_mhz: [], + center: { x: 0, y: 0, z: 0 }, + }; +} + +function normalizeReceiverDraft(receiver, index) { + const center = receiver?.center || {}; + const access = receiver?.access || {}; + const frequencies = Array.isArray(receiver?.frequencies_mhz) + ? receiver.frequencies_mhz + : []; + return { + receiver_id: receiver?.receiver_id || `r${index}`, + source_url: String(receiver?.source_url || access.url || access.source_url || ""), + frequencies_mhz: frequencies + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0), + center: { + x: Number(center.x ?? 0), + y: Number(center.y ?? 0), + z: Number(center.z ?? 0), }, }; } +function updateReceiverCountBadge() { + byId("receiver-count").textContent = `входов: ${state.receiverDrafts.length}`; +} + function saveCurrentReceiverDraftFromInputs() { const idx = state.selectedReceiverIndex; if (!state.receiverDrafts[idx]) return; state.receiverDrafts[idx] = { ...state.receiverDrafts[idx], + receiver_id: byId("rx-id").value.trim() || `r${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), + frequencies_mhz: parseMhzList(byId("rx-frequencies").value), + center: { + x: Number(byId("rx-center-x").value), + y: Number(byId("rx-center-y").value), + z: Number(byId("rx-center-z").value), }, }; } @@ -84,12 +206,12 @@ function saveCurrentReceiverDraftFromInputs() { function renderSelectedReceiverDraft() { const draft = state.receiverDrafts[state.selectedReceiverIndex]; if (!draft) return; + byId("rx-id").value = draft.receiver_id; 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; + byId("rx-frequencies").value = formatMhzList(draft.frequencies_mhz); + byId("rx-center-x").value = draft.center.x; + byId("rx-center-y").value = draft.center.y; + byId("rx-center-z").value = draft.center.z; } function fillReceiverSelect() { @@ -98,13 +220,106 @@ function fillReceiverSelect() { state.receiverDrafts.forEach((draft, index) => { const option = document.createElement("option"); option.value = String(index); - option.textContent = draft.receiver_id || `receiver_${index + 1}`; + option.textContent = draft.receiver_id || `вход_${index + 1}`; select.appendChild(option); }); if (state.selectedReceiverIndex >= state.receiverDrafts.length) { - state.selectedReceiverIndex = 0; + state.selectedReceiverIndex = Math.max(0, state.receiverDrafts.length - 1); } select.value = String(state.selectedReceiverIndex); + updateReceiverCountBadge(); +} + +function addReceiverDraft() { + saveCurrentReceiverDraftFromInputs(); + const nextIndex = state.receiverDrafts.length; + state.receiverDrafts.push(createReceiverDraft(nextIndex)); + state.selectedReceiverIndex = nextIndex; + fillReceiverSelect(); + renderSelectedReceiverDraft(); +} + +function removeReceiverDraft() { + if (state.receiverDrafts.length <= 3) { + byId("servers-state").textContent = "серверы: необходимо минимум 3 входа"; + return; + } + state.receiverDrafts.splice(state.selectedReceiverIndex, 1); + state.selectedReceiverIndex = Math.max(0, state.selectedReceiverIndex - 1); + fillReceiverSelect(); + renderSelectedReceiverDraft(); +} + +function createOutputDraft(index) { + return { + name: `выход_${index + 1}`, + ip: "", + }; +} + +function normalizeOutputDraft(output, index) { + const source = output || {}; + return { + name: String(source.name || `выход_${index + 1}`), + ip: String(source.ip || ""), + }; +} + +function updateOutputCountBadge() { + byId("output-count").textContent = `выходов: ${state.outputDrafts.length}`; +} + +function saveCurrentOutputDraftFromInputs() { + const idx = state.selectedOutputIndex; + if (!state.outputDrafts[idx]) return; + state.outputDrafts[idx] = { + ...state.outputDrafts[idx], + name: byId("out-name").value.trim() || `выход_${idx + 1}`, + ip: byId("out-ip").value.trim(), + }; +} + +function renderSelectedOutputDraft() { + const draft = state.outputDrafts[state.selectedOutputIndex]; + if (!draft) return; + byId("out-name").value = draft.name; + byId("out-ip").value = draft.ip; +} + +function fillOutputSelect() { + const select = byId("output-select"); + select.innerHTML = ""; + state.outputDrafts.forEach((draft, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = draft.name || `выход_${index + 1}`; + select.appendChild(option); + }); + if (state.selectedOutputIndex >= state.outputDrafts.length) { + state.selectedOutputIndex = Math.max(0, state.outputDrafts.length - 1); + } + select.value = String(state.selectedOutputIndex); + updateOutputCountBadge(); +} + +function addOutputDraft() { + saveCurrentOutputDraftFromInputs(); + const nextIndex = state.outputDrafts.length; + state.outputDrafts.push(createOutputDraft(nextIndex)); + state.selectedOutputIndex = nextIndex; + fillOutputSelect(); + renderSelectedOutputDraft(); +} + +function removeOutputDraft() { + if (state.outputDrafts.length <= 1) { + byId("servers-state").textContent = "серверы: необходим минимум 1 выход"; + return; + } + state.outputDrafts.splice(state.selectedOutputIndex, 1); + state.selectedOutputIndex = Math.max(0, state.selectedOutputIndex - 1); + fillOutputSelect(); + renderSelectedOutputDraft(); } async function getJson(url) { @@ -132,16 +347,18 @@ async function postJson(url, payload) { 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"}`; + const updated = formatUpdatedTimestamp(state.result?.updated_at_utc); + setTextWithPulse("updated-date", updated.date); + setTextWithPulse("updated-time", updated.time); + setTextWithPulse("health-status", `состояние: ${localizeStatus(state.health?.status)}`); + setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`); if (!data) { - byId("selected-freq").textContent = "-"; - byId("pos-x").textContent = "-"; - byId("pos-y").textContent = "-"; - byId("pos-z").textContent = "-"; - byId("rmse").textContent = "-"; + setTextWithPulse("selected-freq", "-"); + setTextWithPulse("pos-x", "-"); + setTextWithPulse("pos-y", "-"); + setTextWithPulse("pos-z", "-"); + setTextWithPulse("rmse", "-"); byId("receivers-list").textContent = "Нет данных"; byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); byId("freq-table").querySelector("tbody").innerHTML = ""; @@ -149,12 +366,11 @@ function render() { } 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); - byId("rmse").textContent = fmt(data.rmse_m); + setTextWithPulse("selected-freq", selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} МГц`); + setTextWithPulse("pos-x", fmt(data.position?.x)); + setTextWithPulse("pos-y", fmt(data.position?.y)); + setTextWithPulse("pos-z", fmt(data.position?.z)); + setTextWithPulse("rmse", fmt(data.rmse_m)); const receivers = data.receivers || []; byId("receivers-list").textContent = JSON.stringify(receivers, null, 2); @@ -164,14 +380,14 @@ function render() { const tbody = byId("freq-table").querySelector("tbody"); tbody.innerHTML = rows .map( - (row) => ` - + (row, index) => ` + ${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)} ${fmt(row.position?.x)} ${fmt(row.position?.y)} ${fmt(row.position?.z)} ${fmt(row.rmse_m)} - ${row.exact ? "yes" : "no"} + ${row.exact ? "да" : "нет"} ` ) .join(""); @@ -200,11 +416,11 @@ async function loadConfig() { 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"; + byId("config-state").textContent = "конфиг: загружен"; + byId("servers-state").textContent = "серверы: загружены"; } catch (err) { - byId("config-state").textContent = `config: ${err.message}`; - byId("servers-state").textContent = `servers: ${err.message}`; + byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`; + byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`; } } @@ -214,33 +430,50 @@ async function saveConfig() { const parsed = JSON.parse(raw); const result = await postJson("/config", parsed); state.config = parsed; + const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : ""; byId("config-state").textContent = result.restart_required - ? "config: saved, restart required" - : "config: saved"; + ? `конфиг: сохранён, требуется перезапуск${saveSuffix}` + : `конфиг: сохранён${saveSuffix}`; } catch (err) { - byId("config-state").textContent = `config: ${err.message}`; + byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`; } } function fillServerForm() { const cfg = state.config; if (!cfg) return; + const receivers = cfg.input?.receivers || []; - state.receiverDrafts = receivers.map((receiver) => normalizeReceiverDraft(receiver)); + state.receiverDrafts = receivers.map((receiver, index) => normalizeReceiverDraft(receiver, index)); + if (state.receiverDrafts.length < 3) { + while (state.receiverDrafts.length < 3) { + state.receiverDrafts.push(createReceiverDraft(state.receiverDrafts.length)); + } + } fillReceiverSelect(); renderSelectedReceiverDraft(); - const out = cfg.runtime?.output_server || {}; + + const sharedFilterSource = + cfg.input?.default_input_filter || cfg.input?.receivers?.[0]?.input_filter || {}; + state.sharedFilterDraft = normalizeInputFilter(sharedFilterSource); + byId("shared-filter-enabled").value = String(Boolean(state.sharedFilterDraft.enabled)); + byId("shared-min-freq").value = state.sharedFilterDraft.min_frequency_mhz; + byId("shared-max-freq").value = state.sharedFilterDraft.max_frequency_mhz; + byId("shared-min-rssi").value = state.sharedFilterDraft.min_rssi_dbm; + byId("shared-max-rssi").value = state.sharedFilterDraft.max_rssi_dbm; + + const runtime = cfg.runtime || {}; + const outputServers = Array.isArray(runtime.output_servers) + ? runtime.output_servers + : [runtime.output_server || {}]; + state.outputDrafts = outputServers.map((output, index) => normalizeOutputDraft(output, index)); + if (state.outputDrafts.length < 1) { + state.outputDrafts.push(createOutputDraft(0)); + } + fillOutputSelect(); + renderSelectedOutputDraft(); + 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() { @@ -248,36 +481,62 @@ async function saveServers() { if (!state.config) { await loadConfig(); } + saveCurrentReceiverDraftFromInputs(); + saveCurrentOutputDraftFromInputs(); + + if (state.receiverDrafts.length < 3) { + throw new Error("at least 3 input servers are required"); + } + if (state.outputDrafts.length < 1) { + throw new Error("at least 1 output server is required"); + } + + const sharedFilter = { + enabled: byId("shared-filter-enabled").value === "true", + min_frequency_mhz: Number(byId("shared-min-freq").value), + max_frequency_mhz: Number(byId("shared-max-freq").value), + min_rssi_dbm: Number(byId("shared-min-rssi").value), + max_rssi_dbm: Number(byId("shared-max-rssi").value), + }; + 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 || {}; + cfg.input.default_input_filter = { ...sharedFilter }; + cfg.input.receivers = state.receiverDrafts.map((draft, index) => ({ + receiver_id: draft.receiver_id || `r${index}`, + center: { + x: Number(draft.center.x), + y: Number(draft.center.y), + z: Number(draft.center.z), + }, + access: { + url: draft.source_url, + }, + frequencies_mhz: Array.isArray(draft.frequencies_mhz) + ? draft.frequencies_mhz + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + : [], + })); - 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); + cfg.runtime = cfg.runtime || {}; + cfg.runtime.output_servers = state.outputDrafts.map((draft, index) => ({ + name: draft.name || `выход_${index + 1}`, + ip: draft.ip, + })); + cfg.runtime.output_server = { ...cfg.runtime.output_servers[0] }; const result = await postJson("/config", cfg); state.config = cfg; byId("config-editor").value = JSON.stringify(cfg, null, 2); + + const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : ""; byId("servers-state").textContent = result.restart_required - ? "servers: saved, restart required" - : "servers: saved"; + ? `серверы: сохранены, требуется перезапуск${saveSuffix}` + : `серверы: сохранены${saveSuffix}`; } catch (err) { - byId("servers-state").textContent = `servers: ${err.message}`; + byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`; } } @@ -287,31 +546,43 @@ function bindUi() { 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("add-receiver").addEventListener("click", addReceiverDraft); + byId("remove-receiver").addEventListener("click", removeReceiverDraft); byId("receiver-select").addEventListener("change", (event) => { saveCurrentReceiverDraftFromInputs(); state.selectedReceiverIndex = Number(event.target.value); renderSelectedReceiverDraft(); }); + + byId("add-output-server").addEventListener("click", addOutputDraft); + byId("remove-output-server").addEventListener("click", removeOutputDraft); + byId("output-select").addEventListener("change", (event) => { + saveCurrentOutputDraftFromInputs(); + state.selectedOutputIndex = Number(event.target.value); + renderSelectedOutputDraft(); + }); + + byId("write-token").addEventListener("input", (event) => { + state.writeToken = event.target.value; + }); + 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") - ) { + if (target.closest("#menu-toggle") || target.closest("#menu-list")) { return; } setMenuOpen(false); @@ -327,5 +598,5 @@ async function boot() { } boot().catch((err) => { - byId("health-status").textContent = `health: ${err.message}`; + setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`); }); diff --git a/web/index.html b/web/index.html index 21259d6..247a591 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - Triangulation Control Panel + Панель Триангуляции @@ -12,63 +12,64 @@
-

RF Positioning Dashboard

-

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

+

Панель Радиопозиционирования

+

Мониторинг и управление расчётом 3D триангуляции.

- +
-

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

+

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

-
Selected Freq: -
+
Выбранная частота: -
X: -
Y: -
Z: -
-
RMSE: -
+
СКО (RMSE): -
-

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

+

Таблица По Частотам

- + - - + + @@ -86,65 +87,78 @@
-

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

+

Доставка На Выходы

-

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

-

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

+

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

+

Доступы входа и выхода задаются отдельно. Общий фильтр входа автоматически применяется ко всем входным серверам.

+ +

Доступ К Входным Серверам

-
+ +

Общий Фильтр Входа (Авто Для Всех)

+
+ -
+ +

Выходные Серверы

+
+ - - - - - - +
+ + + выходов: 0 +
+ + +
+
- - - servers: n/a + + + серверы: н/д
-

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

+

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

- - - config: n/a + + + конфиг: н/д
diff --git a/web/styles.css b/web/styles.css index 1b27417..25cc62e 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1,84 +1,141 @@ +@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap"); + :root { - --bg: #f2f4f7; - --card: #ffffffd4; - --text: #10161d; - --muted: #5f6f7d; - --line: #d8e0e7; - --accent: #0f766e; - --accent-soft: #e6f7f4; + --bg-main: #f5f7fb; + --bg-secondary: #edf2ff; + --bg-tertiary: #fff7ee; + --card: rgba(255, 255, 255, 0.84); + --card-strong: rgba(255, 255, 255, 0.94); + --text: #1e2a3a; + --muted: #5f6b80; + --line: rgba(37, 53, 88, 0.14); + --accent: #246bff; + --accent-strong: #104fcf; + --accent-soft: #eaf0ff; + --accent-warm: #ff8a3d; + --success: #14a37f; + --shadow: 0 18px 40px rgba(24, 38, 66, 0.1); + --anim-fast: 170ms; + --anim-mid: 340ms; + --anim-slow: 680ms; } * { box-sizing: border-box; } +html, +body { + min-height: 100%; +} + body { margin: 0; - font-family: "Segoe UI", "Noto Sans", sans-serif; + font-family: "Manrope", "Noto Sans", sans-serif; color: var(--text); - background: linear-gradient(160deg, #f9fafc, #eef4f7 45%, #f2f4f7); - min-height: 100vh; + background: + radial-gradient(1200px 800px at 14% -20%, #dce6ff 0%, transparent 60%), + radial-gradient(920px 560px at 120% 12%, #ffe5cd 0%, transparent 58%), + linear-gradient(160deg, var(--bg-main), var(--bg-secondary) 50%, var(--bg-tertiary)); overflow-x: hidden; } .app-shell { - width: min(1240px, 96vw); - margin: 24px auto; + width: min(1320px, 96vw); + margin: 20px auto; display: grid; - grid-template-columns: 280px 1fr; + grid-template-columns: 300px 1fr; + align-items: start; gap: 16px; position: relative; z-index: 2; } .card { - background: var(--card); + background: linear-gradient(165deg, var(--card-strong), var(--card)); border: 1px solid var(--line); - border-radius: 16px; + border-radius: 18px; padding: 16px; - backdrop-filter: blur(8px); - box-shadow: 0 14px 35px rgba(16, 22, 29, 0.06); - animation: rise 420ms ease both; + backdrop-filter: blur(10px); + box-shadow: var(--shadow); + position: relative; + overflow: hidden; + animation: rise 460ms ease both; + transition: + transform var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + border-color var(--anim-fast) ease; +} + +.card::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient( + 120deg, + transparent 0%, + rgba(255, 255, 255, 0.18) 28%, + transparent 50%, + transparent 100% + ); + transform: translateX(-140%); + animation: sheen 6.6s linear infinite; +} + +.card:hover { + transform: translateY(-2px); + border-color: color-mix(in oklab, var(--accent), #ffffff 76%); + box-shadow: 0 22px 48px rgba(20, 38, 72, 0.14); } .side-nav { position: sticky; - top: 16px; - height: fit-content; + top: 12px; + min-height: 0; + max-height: calc(100dvh - 24px); + overflow-y: auto; + overscroll-behavior: contain; display: grid; gap: 12px; + scrollbar-gutter: stable; } .kicker { margin: 0; text-transform: uppercase; - letter-spacing: 0.14em; - color: var(--accent); - font-weight: 700; - font-size: 0.74rem; + letter-spacing: 0.16em; + color: var(--accent-strong); + font-weight: 800; + font-size: 0.7rem; } .side-title { margin: 0; - font-size: 1.3rem; + font-size: 1.4rem; + letter-spacing: 0.02em; } .content-area { display: grid; + min-width: 0; } .panel { display: none; - animation: fadeSlide 220ms ease; + animation: fadeSlide var(--anim-mid) ease; } .panel-active { display: grid; gap: 16px; + min-width: 0; } .hero h2 { margin: 0 0 8px; + font-size: clamp(1.3rem, 1rem + 1vw, 1.8rem); } .hero-actions, @@ -93,25 +150,37 @@ body { border: 1px solid var(--line); background: #fff; color: var(--text); - border-radius: 10px; - padding: 8px 12px; + border-radius: 11px; + padding: 8px 13px; cursor: pointer; - transition: transform 140ms ease, background-color 140ms ease, box-shadow 140ms ease; + font-family: inherit; + font-weight: 600; + transition: + transform var(--anim-fast) ease, + background-color var(--anim-fast) ease, + box-shadow var(--anim-fast) ease, + border-color var(--anim-fast) ease; } .btn:hover { transform: translateY(-1px); - box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1); + border-color: color-mix(in oklab, var(--accent), #ffffff 68%); + box-shadow: 0 10px 22px rgba(36, 107, 255, 0.16); } .btn-primary { - background: var(--accent); - border-color: var(--accent); + background: linear-gradient(140deg, var(--accent), var(--accent-strong)); + border-color: var(--accent-strong); color: #fff; } +.btn-primary:hover { + box-shadow: 0 12px 26px rgba(19, 87, 222, 0.32); +} + .menu-wrap { - position: relative; + display: grid; + gap: 8px; } .menu-toggle { @@ -120,16 +189,19 @@ body { .menu-list { display: none; - position: absolute; - left: 0; - right: 0; - top: calc(100% + 8px); + position: static; border: 1px solid var(--line); border-radius: 12px; - background: #ffffff; - box-shadow: 0 10px 28px rgba(16, 22, 29, 0.1); + background: rgba(255, 255, 255, 0.94); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.7), + 0 8px 24px rgba(21, 37, 73, 0.08); padding: 6px; - z-index: 20; + max-height: min(48dvh, 440px); + overflow-y: auto; + overscroll-behavior: contain; + transform-origin: top center; + animation: menuIn var(--anim-mid) ease both; } .menu-list-open { @@ -139,31 +211,69 @@ body { .menu-item { border: 1px solid transparent; - background: #f7fafb; + background: #f9fbff; color: var(--text); - border-radius: 8px; + border-radius: 9px; padding: 8px 10px; text-align: left; cursor: pointer; + font-family: inherit; + transition: + transform var(--anim-fast) ease, + border-color var(--anim-fast) ease, + background-color var(--anim-fast) ease; +} + +.menu-item:hover { + transform: translateX(4px); + border-color: color-mix(in oklab, var(--accent), #ffffff 70%); } .menu-item-active { - background: var(--accent-soft); - border-color: color-mix(in oklab, var(--accent), #fff 70%); + background: linear-gradient(90deg, var(--accent-soft), #f4f7ff); + border-color: color-mix(in oklab, var(--accent), #ffffff 64%); } .side-meta { display: grid; - gap: 6px; + gap: 7px; } .badge { border: 1px solid var(--line); - background: #f3f9fb; + background: rgba(236, 244, 255, 0.72); border-radius: 999px; - padding: 4px 10px; + padding: 6px 12px 6px 30px; + line-height: 1.2; font-size: 0.8rem; width: fit-content; + position: relative; + overflow: hidden; + display: inline-flex; + align-items: center; +} + +.badge::after { + content: ""; + position: absolute; + left: 12px; + top: 50%; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + transform: translateY(-50%); + opacity: 0.78; + animation: badgePulse 1.9s ease-in-out infinite; +} + +.badge-meta { + background: rgba(241, 246, 255, 0.78); + font-variant-numeric: tabular-nums; +} + +.badge-meta::after { + background: var(--accent); } .result-box { @@ -180,11 +290,14 @@ body { } .mono { - font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; } .table-wrap { overflow-x: auto; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.56); } table { @@ -195,27 +308,45 @@ table { th, td { text-align: left; - padding: 8px; + padding: 9px 10px; border-bottom: 1px solid var(--line); font-size: 0.9rem; } +thead th { + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #4a5a74; + background: rgba(245, 249, 255, 0.9); +} + tbody tr { - transition: background-color 180ms ease; + transition: background-color var(--anim-fast) ease; } tbody tr:hover { - background: #f4fbfa; + background: rgba(230, 239, 255, 0.58); +} + +.row-enter { + opacity: 0; + transform: translateY(6px); + animation: rowEnter var(--anim-mid) ease forwards; +} + +.value-updated { + animation: valuePulse 640ms ease; } .editor { width: 100%; - min-height: 320px; + min-height: 340px; border: 1px solid var(--line); border-radius: 12px; - padding: 10px; - background: #fbfdff; - font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + padding: 11px; + background: rgba(250, 253, 255, 0.88); + font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 0.85rem; margin-top: 10px; } @@ -226,50 +357,75 @@ tbody tr:hover { gap: 10px 14px; } +.servers-title { + margin: 16px 0 10px; + font-size: 0.98rem; + letter-spacing: 0.02em; +} + +.server-actions-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + .server-grid label { display: grid; gap: 6px; font-size: 0.88rem; + color: #34425c; } .server-grid input, .server-grid select { border: 1px solid var(--line); - border-radius: 8px; - padding: 7px 9px; + border-radius: 9px; + padding: 8px 10px; font-size: 0.9rem; background: #fff; + color: var(--text); + font-family: inherit; + transition: border-color var(--anim-fast) ease, box-shadow var(--anim-fast) ease; +} + +.server-grid input:focus, +.server-grid select:focus, +.editor:focus { + outline: none; + border-color: color-mix(in oklab, var(--accent), #ffffff 50%); + box-shadow: 0 0 0 3px rgba(36, 107, 255, 0.14); } .bg-glow { position: fixed; - width: 360px; - height: 360px; + width: 420px; + height: 420px; border-radius: 50%; - filter: blur(55px); - opacity: 0.35; + filter: blur(70px); + opacity: 0.34; pointer-events: none; z-index: 1; - animation: drift 10s ease-in-out infinite alternate; + animation: drift 11s ease-in-out infinite alternate; } .bg-glow-a { - background: #8de4d5; - top: -110px; - right: -80px; + background: #7eacff; + top: -130px; + right: -90px; } .bg-glow-b { - background: #a9c9ff; - bottom: -130px; + background: #ffbb80; + bottom: -145px; left: -90px; - animation-delay: 1.2s; + animation-delay: 1.3s; } @keyframes rise { from { opacity: 0; - transform: translateY(8px); + transform: translateY(10px); } to { opacity: 1; @@ -280,7 +436,7 @@ tbody tr:hover { @keyframes fadeSlide { from { opacity: 0; - transform: translateX(7px); + transform: translateX(10px); } to { opacity: 1; @@ -293,22 +449,88 @@ tbody tr:hover { transform: translate(0, 0) scale(1); } to { - transform: translate(26px, -16px) scale(1.1); + transform: translate(26px, -18px) scale(1.12); } } -@media (max-width: 980px) { +@keyframes sheen { + 0% { + transform: translateX(-140%); + } + 100% { + transform: translateX(170%); + } +} + +@keyframes menuIn { + from { + opacity: 0; + transform: translateY(-8px) scale(0.986); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes rowEnter { + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes valuePulse { + 0% { + text-shadow: 0 0 0 rgba(36, 107, 255, 0); + } + 40% { + text-shadow: 0 0 18px rgba(36, 107, 255, 0.35); + } + 100% { + text-shadow: 0 0 0 rgba(36, 107, 255, 0); + } +} + +@keyframes badgePulse { + 0%, + 100% { + transform: translateY(-50%) scale(1); + opacity: 0.68; + } + 50% { + transform: translateY(-50%) scale(1.4); + opacity: 1; + } +} + +@media (max-width: 1040px) { .app-shell { grid-template-columns: 1fr; } .side-nav { position: static; + max-height: none; + overflow: visible; } } -@media (max-width: 740px) { +@media (max-width: 760px) { .server-grid { grid-template-columns: 1fr; } + + .card { + padding: 14px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } }
Frequency (MHz)Частота (МГц) X Y ZRMSEExactСКО (RMSE)Точно