Refactoring UI

main
AlexsandrSnytkin 1 week ago
parent a568083cce
commit f327c8f0bb

@ -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://<host>:<port>/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: <token>`
- `Authorization: Bearer <token>`
Что важно:
- `GET` endpoints остаются без токена.
- `GET /config` отдает `runtime.write_api_token` в редактированном виде (`""`) и флаг `write_api_token_set`.
- В UI во вкладке `Servers` есть поле `Write API token (session only)`:
- токен хранится только в памяти браузера;
- используется для `POST /refresh` и `POST /config`.
`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` и разделите условия использования.

@ -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"
}
}
]

@ -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,

@ -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,

@ -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)

@ -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,
}

@ -0,0 +1,216 @@
# API Reference
Базовый URL: `http://<listen_host>:<listen_port>`
Контент ответов: `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: <token>` или
- `Authorization: Bearer <token>`
### 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: <token>` или
- `Authorization: Bearer <token>`
### 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.

@ -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" }
}
]
}
}
```

@ -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" }
}
]
}
}
```

@ -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,46 +447,36 @@ 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()
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}",
)
)
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.")
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)
parsed_output_servers.append(
_parse_output_server_config(
output_obj=output_obj,
default_name="output_1",
)
min_frequency_mhz_raw = output_obj.get("min_frequency_mhz")
max_frequency_mhz_raw = output_obj.get("max_frequency_mhz")
if min_frequency_mhz_raw is None and "min_frequency_hz" in output_obj:
min_frequency_mhz_raw = float(output_obj["min_frequency_hz"]) / HZ_IN_MHZ
if max_frequency_mhz_raw is None and "max_frequency_hz" in output_obj:
max_frequency_mhz_raw = float(output_obj["max_frequency_hz"]) / HZ_IN_MHZ
self.output_min_frequency_mhz = float(min_frequency_mhz_raw or 0.0)
self.output_max_frequency_mhz = float(max_frequency_mhz_raw or 0.0)
self.output_min_frequency_hz = self.output_min_frequency_mhz * HZ_IN_MHZ
self.output_max_frequency_hz = self.output_max_frequency_mhz * HZ_IN_MHZ
if self.output_enabled and not self.output_ip:
raise ValueError("runtime.output_server.ip must be non-empty when enabled=true.")
if self.output_frequency_filter_enabled:
if self.output_min_frequency_mhz <= 0.0:
raise ValueError(
"runtime.output_server.min_frequency_mhz must be > 0 when frequency filter is enabled."
)
if self.output_max_frequency_mhz <= 0.0:
raise ValueError(
"runtime.output_server.max_frequency_mhz must be > 0 when frequency filter is enabled."
)
if self.output_max_frequency_mhz < self.output_min_frequency_mhz:
raise ValueError(
"runtime.output_server.max_frequency_mhz must be >= min_frequency_mhz."
)
self.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"))
if self.aggregation not in ("median", "mean"):
@ -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,6 +585,7 @@ class AutoService:
def stop(self) -> None:
self.stop_event.set()
if self.poll_thread.is_alive():
self.poll_thread.join(timeout=2.0)
def refresh_once(self) -> None:
@ -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,28 +659,36 @@ 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):
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 index, receiver in enumerate(self.receivers):
center = receiver["center"]
measurement_subset = grouped_by_receiver[index][frequency_hz]
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=center, radius=radius_m))
spheres_for_frequency.append(
Sphere(center=receiver["center"], radius=radius_m)
)
row_receivers.append(
{
"receiver_id": str(receiver["receiver_id"]),
@ -502,19 +702,7 @@ class AutoService:
tolerance=self.tolerance,
z_preference=self.z_preference, # type: ignore[arg-type]
)
for index, residual in enumerate(result.residuals):
row_receivers[index]["residual_m"] = residual
receiver_payloads[index].setdefault("per_frequency", []).append(
{
"frequency_hz": frequency_hz,
"frequency_mhz": frequency_hz / HZ_IN_MHZ,
"radius_m": spheres_for_frequency[index].radius,
"residual_m": residual,
"samples_count": len(grouped_by_receiver[index][frequency_hz]),
}
)
row = {
candidate_row = {
"frequency_hz": frequency_hz,
"frequency_mhz": frequency_hz / HZ_IN_MHZ,
"position": {
@ -525,16 +713,51 @@ class AutoService:
"exact": result.exact,
"rmse_m": result.rmse,
"receivers": row_receivers,
"used_receivers_count": 3,
"available_receivers_count": len(available_indices),
}
frequency_rows.append(row)
if best_row is None or float(row["rmse_m"]) < float(best_row["rmse_m"]):
best_row = row
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
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": best_combo_spheres[local_index].radius,
"residual_m": residual,
"samples_count": len(grouped_by_receiver[receiver_index][frequency_hz]),
}
)
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:
delivery = self._deliver_to_output_servers(payload)
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
self.last_output_delivery = delivery
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,
)
# 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:
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 rejected payload: "
f"HTTP {status_code}, body={response_body}"
"Output server(s) rejected payload: "
+ ", ".join(str(name) for name in failed_servers)
)
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):
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
per_frequency = receiver.get("per_frequency")
if not isinstance(per_frequency, list):
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
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
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(
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

@ -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)

@ -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,

@ -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 {
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,
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: `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) => `
<tr>
(row, index) => `
<tr class="row-enter" style="animation-delay:${index * 40}ms">
<td>${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)}</td>
<td>${fmt(row.position?.x)}</td>
<td>${fmt(row.position?.y)}</td>
<td>${fmt(row.position?.z)}</td>
<td>${fmt(row.rmse_m)}</td>
<td>${row.exact ? "yes" : "no"}</td>
<td>${row.exact ? "да" : "нет"}</td>
</tr>`
)
.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.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)
: [],
}));
cfg.runtime = cfg.runtime || {};
cfg.runtime.output_server = cfg.runtime.output_server || {};
for (let i = 0; i < cfg.input.receivers.length; i += 1) {
const draft = state.receiverDrafts[i] || normalizeReceiverDraft(cfg.input.receivers[i]);
cfg.input.receivers[i].source_url = draft.source_url;
cfg.input.receivers[i].input_filter = { ...draft.input_filter };
}
cfg.runtime.output_server.enabled = byId("out-enabled").value === "true";
cfg.runtime.output_server.frequency_filter_enabled =
byId("out-freq-filter-enabled").value === "true";
cfg.runtime.output_server.min_frequency_mhz = Number(byId("out-min-freq").value);
cfg.runtime.output_server.max_frequency_mhz = Number(byId("out-max-freq").value);
cfg.runtime.output_server.ip = byId("out-ip").value.trim();
cfg.runtime.output_server.port = Number(byId("out-port").value);
cfg.runtime.output_server.path = byId("out-path").value.trim() || "/triangulation";
cfg.runtime.output_server.timeout_s = Number(byId("out-timeout").value);
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)}`);
});

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Triangulation Control Panel</title>
<title>Панель Триангуляции</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
@ -12,63 +12,64 @@
<main class="app-shell">
<aside class="side-nav card">
<p class="kicker">Triangulation</p>
<h1 class="side-title">Control</h1>
<p class="kicker">Триангуляция</p>
<h1 class="side-title">Пульт</h1>
<div class="menu-wrap">
<button id="menu-toggle" class="btn btn-primary menu-toggle">Sections ▼</button>
<button id="menu-toggle" class="btn btn-primary menu-toggle">Разделы</button>
<div id="menu-list" class="menu-list">
<button class="menu-item menu-item-active" data-section="overview">Overview</button>
<button class="menu-item" data-section="frequencies">Frequencies</button>
<button class="menu-item" data-section="receivers">Receivers</button>
<button class="menu-item" data-section="delivery">Delivery</button>
<button class="menu-item" data-section="servers">Servers</button>
<button class="menu-item" data-section="json">Raw JSON</button>
<button class="menu-item menu-item-active" data-section="overview">Обзор</button>
<button class="menu-item" data-section="frequencies">Частоты</button>
<button class="menu-item" data-section="receivers">Ресиверы</button>
<button class="menu-item" data-section="delivery">Доставка</button>
<button class="menu-item" data-section="servers">Серверы</button>
<button class="menu-item" data-section="json">Конфигурация</button>
</div>
</div>
<div class="side-meta">
<span id="updated-at" class="badge">updated: n/a</span>
<span id="health-status" class="badge">health: n/a</span>
<span id="delivery-status" class="badge">delivery: n/a</span>
<span id="updated-date" class="badge badge-meta">дата: н/д</span>
<span id="updated-time" class="badge badge-meta">время: н/д</span>
<span id="health-status" class="badge">состояние: н/д</span>
<span id="delivery-status" class="badge">доставка: н/д</span>
</div>
</aside>
<section class="content-area">
<section id="section-overview" class="panel panel-active">
<header class="hero card">
<h2>RF Positioning Dashboard</h2>
<p class="muted">Мониторинг и контроль расчета пересечения 3 сфер.</p>
<h2>Панель Радиопозиционирования</h2>
<p class="muted">Мониторинг и управление расчётом 3D триангуляции.</p>
<div class="hero-actions">
<button id="refresh-now" class="btn btn-primary">Refresh</button>
<button id="refresh-now" class="btn btn-primary">Обновить</button>
</div>
</header>
<article class="card">
<h2>Итоговая позиция</h2>
<h2>Итоговая Позиция</h2>
<div class="result-box">
<div><span class="muted">Selected Freq:</span> <b id="selected-freq">-</b></div>
<div><span class="muted">Выбранная частота:</span> <b id="selected-freq">-</b></div>
<div><span class="muted">X:</span> <b id="pos-x">-</b></div>
<div><span class="muted">Y:</span> <b id="pos-y">-</b></div>
<div><span class="muted">Z:</span> <b id="pos-z">-</b></div>
<div><span class="muted">RMSE:</span> <b id="rmse">-</b></div>
<div><span class="muted">СКО (RMSE):</span> <b id="rmse">-</b></div>
</div>
</article>
</section>
<section id="section-frequencies" class="panel">
<article class="card">
<h2>Таблица пересечений по частотам</h2>
<h2>Таблица По Частотам</h2>
<div class="table-wrap">
<table id="freq-table">
<thead>
<tr>
<th>Frequency (MHz)</th>
<th>Частота (МГц)</th>
<th>X</th>
<th>Y</th>
<th>Z</th>
<th>RMSE</th>
<th>Exact</th>
<th>СКО (RMSE)</th>
<th>Точно</th>
</tr>
</thead>
<tbody></tbody>
@ -86,65 +87,78 @@
<section id="section-delivery" class="panel">
<article class="card">
<h2>Отправка на конечный сервер</h2>
<h2>Доставка На Выходы</h2>
<div id="delivery-details" class="mono small"></div>
</article>
</section>
<section id="section-servers" class="panel">
<article class="card">
<h2>Настройка серверов</h2>
<p class="muted">Изменения сохраняются в конфиг и требуют перезапуска сервиса.</p>
<h2>Настройка Серверов</h2>
<p class="muted">Доступы входа и выхода задаются отдельно. Общий фильтр входа автоматически применяется ко всем входным серверам.</p>
<h3 class="servers-title">Доступ К Входным Серверам</h3>
<div class="server-grid">
<label>Receiver
<label>Выбранный сервер
<select id="receiver-select"></select>
</label>
<label>Receiver URL<input id="rx-url" type="text" /></label>
<label>Filter enabled
<select id="rx-filter-enabled">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label>Min freq MHz<input id="rx-min-freq" type="number" step="0.001" min="0" /></label>
<label>Max freq MHz<input id="rx-max-freq" type="number" step="0.001" min="0" /></label>
<label>Min RSSI dBm<input id="rx-min-rssi" type="number" step="0.1" /></label>
<label>Max RSSI dBm<input id="rx-max-rssi" type="number" step="0.1" /></label>
<label>Write API token (session only)<input id="write-token" type="password" /></label>
<label>Output enabled
<select id="out-enabled">
<option value="true">true</option>
<option value="false">false</option>
<div class="server-actions-row">
<button id="add-receiver" class="btn" type="button">Добавить вход</button>
<button id="remove-receiver" class="btn" type="button">Удалить вход</button>
<span id="receiver-count" class="badge">входов: 0</span>
</div>
<label>Имя ресивера<input id="rx-id" type="text" /></label>
<label>Адрес сервера (URL)<input id="rx-url" type="text" /></label>
<label>Частоты ресивера, МГц (через запятую)<input id="rx-frequencies" type="text" placeholder="433.92, 868.1" /></label>
<label>Координата X<input id="rx-center-x" type="number" step="0.001" /></label>
<label>Координата Y<input id="rx-center-y" type="number" step="0.001" /></label>
<label>Координата Z<input id="rx-center-z" type="number" step="0.001" /></label>
</div>
<h3 class="servers-title">Общий Фильтр Входа (Авто Для Всех)</h3>
<div class="server-grid">
<label>Фильтр включен
<select id="shared-filter-enabled">
<option value="true">да</option>
<option value="false">нет</option>
</select>
</label>
<label>Freq filter enabled
<select id="out-freq-filter-enabled">
<option value="true">true</option>
<option value="false">false</option>
</select>
<label>Мин. частота, МГц<input id="shared-min-freq" type="number" step="0.001" min="0" /></label>
<label>Макс. частота, МГц<input id="shared-max-freq" type="number" step="0.001" min="0" /></label>
<label>Мин. RSSI, дБм<input id="shared-min-rssi" type="number" step="0.1" /></label>
<label>Макс. RSSI, дБм<input id="shared-max-rssi" type="number" step="0.1" /></label>
</div>
<h3 class="servers-title">Выходные Серверы</h3>
<div class="server-grid">
<label>Выбранный выход
<select id="output-select"></select>
</label>
<label>Min frequency MHz<input id="out-min-freq" type="number" step="0.001" min="0" /></label>
<label>Max frequency MHz<input id="out-max-freq" type="number" step="0.001" min="0" /></label>
<label>Output IP/host<input id="out-ip" type="text" /></label>
<label>Output port<input id="out-port" type="number" min="1" /></label>
<label>Output path<input id="out-path" type="text" /></label>
<label>Output timeout s<input id="out-timeout" type="number" step="0.1" min="0.1" /></label>
<div class="server-actions-row">
<button id="add-output-server" class="btn" type="button">Добавить выход</button>
<button id="remove-output-server" class="btn" type="button">Удалить выход</button>
<span id="output-count" class="badge">выходов: 0</span>
</div>
<label>Имя выхода<input id="out-name" type="text" /></label>
<label>Токен записи (API, только сессия)<input id="write-token" type="password" /></label>
<label>IP/хост выхода<input id="out-ip" type="text" /></label>
</div>
<div class="editor-actions">
<button id="load-servers" class="btn">Load</button>
<button id="save-servers" class="btn btn-primary">Save servers</button>
<span id="servers-state" class="badge">servers: n/a</span>
<button id="load-servers" class="btn">Загрузить</button>
<button id="save-servers" class="btn btn-primary">Сохранить серверы</button>
<span id="servers-state" class="badge">серверы: н/д</span>
</div>
</article>
</section>
<section id="section-json" class="panel">
<article class="card">
<h2>Конфигурация (Raw JSON)</h2>
<h2>Конфигурация</h2>
<div class="editor-actions">
<button id="load-config" class="btn">Load</button>
<button id="save-config" class="btn btn-primary">Save JSON</button>
<span id="config-state" class="badge">config: n/a</span>
<button id="load-config" class="btn">Загрузить</button>
<button id="save-config" class="btn btn-primary">Сохранить конфиг</button>
<span id="config-state" class="badge">конфиг: н/д</span>
</div>
<textarea id="config-editor" class="editor" spellcheck="false"></textarea>
</article>

@ -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);
}
}
@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);
}
}
@media (max-width: 980px) {
@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;
}
}

Loading…
Cancel
Save