Refactoring UI

main
AlexsandrSnytkin 1 week ago
parent a568083cce
commit f327c8f0bb

@ -1,196 +1,166 @@
# Triangulation Service # Triangulation Service
Сервис решает 3D-трилатерацию по 3 ресиверам: Сервис автоматически собирает RSSI-измерения с нескольких входных серверов (ресиверов), группирует данные по одинаковым частотам и рассчитывает 3D-положение источника через пересечение сфер (трилатерация).
- центры сфер: координаты ресиверов;
- радиусы сфер: расстояния, оцененные из RSSI с учетом частоты; Каждый ресивер задается:
- расчет идет по одинаковым частотам, которые есть у всех 3 ресиверов; - координатами `center` (центр сферы),
- формируется таблица `frequency_table` (по каждой частоте отдельное решение); - измерениями `RSSI + частота` (для расчета радиуса через модель распространения),
- выбирается итоговая частота `selected_frequency_hz` по минимальному `rmse_m`. - URL источника входных данных.
## Что реализовано Сервис:
- ведет актуальную таблицу решений по каждой общей частоте,
- Автоматический polling 3 входных серверов (`http_sources`). - выбирает итоговое лучшее решение по минимальному `rmse_m`,
- Валидация входных payload с подробными ошибками. - отправляет компактный результат на один или несколько выходных серверов,
- API: - отдает API + веб-интерфейс для мониторинга и настройки.
- `GET /health`
- `GET /result` ## Ключевые возможности
- `GET /frequencies`
- `POST /refresh` - Автоматический polling входных серверов (`input.mode = "http_sources"`).
- `GET /config` - Поддержка `N >= 3` входных ресиверов.
- `POST /config` - Расчет по общим частотам, которые есть минимум у 3 ресиверов и разрешены в `input.receivers[].frequencies_mhz`.
- UI (`/ui`) с: - Поддержка общего фильтра входа `input.default_input_filter` и override per-receiver (`input_filter`).
- входными данными ресиверов; - Поддержка нескольких выходных серверов `runtime.output_servers[]` с настройкой по имени и IP.
- таблицей пересечений по частотам; - Горячее применение нового конфига через `POST /config` (без ручного рестарта процесса).
- итоговой позицией; - Защита write-endpoints токеном (`runtime.write_api_token`).
- статусом отправки на конечный сервер. - Русскоязычный UI (`/ui`) с вкладками: обзор, частоты, ресиверы, доставка, серверы, JSON-конфиг.
- Опциональный push результата на внешний сервер (`runtime.output_server`). - Интеграционные и юнит-тесты.
## Структура проекта ## Структура проекта
- [service.py](/c:/Users/snytk/triangulation/service.py) - автосервис + API + UI статик. - [service.py](./service.py) — основной автосервис, API, статика UI.
- [triangulation.py](/c:/Users/snytk/triangulation/triangulation.py) - математика. - [triangulation.py](./triangulation.py) — математика трилатерации и сетевой отправки.
- [config.template.json](/c:/Users/snytk/triangulation/config.template.json) - шаблон конфига. - [config.template.json](./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. - [web/index.html](./web/index.html), [web/app.js](./web/app.js), [web/styles.css](./web/styles.css) — веб-интерфейс.
- [docker-compose.yml](/c:/Users/snytk/triangulation/docker-compose.yml) - test/prod профили. - [docker-compose.yml](./docker-compose.yml) — профили test/prod.
- [docker/config.docker.test.json](/c:/Users/snytk/triangulation/docker/config.docker.test.json) - тестовый конфиг. - [docker/config.docker.test.json](./docker/config.docker.test.json) — тестовый docker-конфиг.
- [docker/mock_receiver.py](/c:/Users/snytk/triangulation/docker/mock_receiver.py) - mock входные сервера (random RSSI). - [docker/mock_receiver.py](./docker/mock_receiver.py) — генератор входных тестовых данных.
- [docker/mock_output_sink.py](/c:/Users/snytk/triangulation/docker/mock_output_sink.py) - mock конечный сервер. - [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`: Тестовый режим (все контейнеры: 3 mock-входа + сервис + mock-выход):
- `triangulation-test`
- `receiver-r0`, `receiver-r1`, `receiver-r2`
- `output-sink`
- `prod`:
- `triangulation-prod` (читает ваш `./config.json`)
Это позволяет легко отключить тестовый режим и перейти на реальные сервера.
## Быстрый старт: Test Mode
Поднимает все контейнеры для end-to-end проверки:
- 3 входных mock сервера с random данными;
- основной сервис;
- output-sink, принимающий отправленные результаты.
```bash ```bash
docker compose --profile test up --build docker compose --profile test up --build -d
``` ```
Открыть: Проверить:
- UI: `http://127.0.0.1:38081/ui` - UI: `http://127.0.0.1:38081/ui`
- Полный результат: `http://127.0.0.1:38081/result` - Health: `http://127.0.0.1:38081/health`
- Частоты: `http://127.0.0.1:38081/frequencies` - Result: `http://127.0.0.1:38081/result`
- Полученные output-sink данные (изнутри сети контейнеров): - Frequencies: `http://127.0.0.1:38081/frequencies`
- `docker compose --profile test exec output-sink wget -qO- http://127.0.0.1:8080/latest`
Остановить: Остановить:
```bash ```bash
docker compose --profile test down docker compose --profile test down
``` ```
## Быстрый старт: Prod Mode Прод-режим (ваш `config.json`):
1. Создайте `config.json` из шаблона: 1. Создайте `config.json` из шаблона:
```bash ```bash
cp config.template.json config.json cp config.template.json config.json
``` ```
2. Заполните ваши реальные: 2. Заполните реальные URL/координаты/выходные серверы.
- `input.receivers[].source_url`
- `input.receivers[].center`
- `runtime.output_server`
3. Запустите: 3. Запустите:
```bash ```bash
docker compose --profile prod up --build docker compose --profile prod up --build -d
``` ```
Доступ к API/UI в `prod`: Доступ:
- `http://127.0.0.1:38082/ui` - UI: `http://127.0.0.1:38082/ui`
- `http://127.0.0.1:38082/result` - API: `http://127.0.0.1:38082/*`
- `http://127.0.0.1:38082/frequencies`
Остановить: Остановить:
```bash ```bash
docker compose --profile prod down docker compose --profile prod down
``` ```
## Как проверить, что данные приходят и отправляются ### Вариант 2: Локальный запуск Python
В UI (`/ui`) видно: Ubuntu:
- блок `Ресиверы`: входящие samples;
- таблица `Таблица пересечений по частотам`: решения по каждой общей частоте;
- блок `Отправка на конечный сервер`: статус доставки (`ok/error`), HTTP-код, время, target.
Дополнительно: ```bash
- `GET /result` возвращает `output_delivery`. bash setup.sh
- `GET /frequencies` тоже возвращает `output_delivery`. source .venv/bin/activate
- `docker compose --profile test logs output-sink -f` показывает факт приема. python service.py --config config.json
- `GET /latest` на `output-sink` доступен изнутри docker-сети. ```
## Конфиг (основные поля) 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.
Измерение: Примечание: после `POST /config` сервис применяет конфиг автоматически (`applied: true`, `restart_required: false`).
- `frequency_hz` (или `freq_hz`/`frequency`/`freq`)
- `amplitude_dbm` (или `rssi_dbm`/`amplitude`/`rssi`)
Пример: ## API (кратко)
```json
{
"receiver_id": "r0",
"measurements": [
{ "frequency_hz": 433920000, "rssi_dbm": -61.5 },
{ "frequency_hz": 868100000, "rssi_dbm": -67.2 }
]
}
```
Если `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 ресиверов.
Ошибки содержат: Базовый шаблон: [config.template.json](./config.template.json)
- `source_url=...`
- номер строки `row #...`
- проблемное поле.
## Тесты Ключевые блоки:
- `model` — радиомодель (RSSI -> расстояние).
- `solver` — параметры решателя сфер.
- `input` — источники входных данных, фильтры, агрегация.
- `runtime` — HTTP сервис, polling, write token, выходные серверы.
Запуск: Важно по частотам:
```bash - для каждого входного сервера задайте `input.receivers[].frequencies_mhz`;
pytest -q - сервис использует в расчёте только частоты из конфигурации ресиверов.
```
Покрытие:
- математика триангуляции;
- влияние частоты на RSSI->distance;
- интеграция `AutoService.refresh_once()`;
- валидационные сценарии;
- ошибки контекста (нет общих частот, bad field, receiver mismatch, network error, output reject).
Файл интеграционных тестов: Полное описание параметров: [docs/CONFIG_REFERENCE.md](./docs/CONFIG_REFERENCE.md).
- [test_service_integration.py](/c:/Users/snytk/triangulation/test_service_integration.py)
## Локальный запуск без 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: Готовые шаблоны: [docs/JSON_EXAMPLES.md](./docs/JSON_EXAMPLES.md).
- `http://127.0.0.1:38081/ui`
## Защита write-endpoints токеном ## Безопасность write-endpoints
Для защиты изменений состояния можно задать токен в конфиге: Чтобы ограничить изменение состояния, задайте:
```json ```json
{ {
@ -200,34 +170,43 @@ UI:
} }
``` ```
После этого `POST /refresh` и `POST /config` требуют токен в одном из заголовков: Тогда `POST /refresh` и `POST /config` требуют один из заголовков:
- `X-API-Token: <token>` - `X-API-Token: <token>`
- `Authorization: Bearer <token>` - `Authorization: Bearer <token>`
Что важно: `GET` endpoints доступны без токена.
- `GET` endpoints остаются без токена.
- `GET /config` отдает `runtime.write_api_token` в редактированном виде (`""`) и флаг `write_api_token_set`.
- В UI во вкладке `Servers` есть поле `Write API token (session only)`:
- токен хранится только в памяти браузера;
- используется для `POST /refresh` и `POST /config`.
## Фильтры входных данных по каждому серверу ## Тесты
Для каждого ресивера в `input.receivers[]` можно задать `input_filter`: Запуск:
```json ```bash
{ pytest -q
"input_filter": {
"enabled": true,
"min_frequency_mhz": 430.0,
"max_frequency_mhz": 440.0,
"min_rssi_dbm": -80.0,
"max_rssi_dbm": -40.0
}
}
``` ```
Смысл: Сценарии покрывают:
- фильтр применяется отдельно к данным каждого ресивера до триангуляции; - корректность трилатерации и преобразования RSSI,
- участвуют только измерения, попавшие в диапазоны частоты и 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": { "model": {
"tx_power_dbm": 20.0, "tx_power_dbm": 20.0,
"tx_gain_dbi": 0.0, "tx_gain_dbi": 0.0,
@ -16,21 +16,28 @@
"listen_port": 8081, "listen_port": 8081,
"poll_interval_s": 1.0, "poll_interval_s": 1.0,
"write_api_token": "", "write_api_token": "",
"output_servers": [
{
"name": "output_main",
"ip": ""
}
],
"output_server": { "output_server": {
"enabled": false, "name": "output_main",
"ip": "192.168.1.100", "ip": ""
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.0,
"frequency_filter_enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 0.0
} }
}, },
"input": { "input": {
"mode": "http_sources", "mode": "http_sources",
"aggregation": "median", "aggregation": "median",
"source_timeout_s": 3.0, "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": [ "receivers": [
{ {
"receiver_id": "r0", "receiver_id": "r0",
@ -39,13 +46,9 @@
"y": 0.0, "y": 0.0,
"z": 0.0 "z": 0.0
}, },
"source_url": "http://10.0.0.11:9000/measurements", "frequencies_mhz": [433.92, 868.1],
"input_filter": { "access": {
"enabled": false, "url": "http://10.0.0.11:9000/measurements"
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
} }
}, },
{ {
@ -55,13 +58,9 @@
"y": 0.0, "y": 0.0,
"z": 0.0 "z": 0.0
}, },
"source_url": "http://10.0.0.12:9000/measurements", "frequencies_mhz": [433.92, 868.1],
"input_filter": { "access": {
"enabled": false, "url": "http://10.0.0.12:9000/measurements"
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
} }
}, },
{ {
@ -71,13 +70,9 @@
"y": 8.0, "y": 8.0,
"z": 0.0 "z": 0.0
}, },
"source_url": "http://10.0.0.13:9000/measurements", "frequencies_mhz": [433.92, 868.1],
"input_filter": { "access": {
"enabled": false, "url": "http://10.0.0.13:9000/measurements"
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 1000000000.0,
"min_rssi_dbm": -200.0,
"max_rssi_dbm": 50.0
} }
} }
] ]

@ -1,4 +1,4 @@
{ {
"model": { "model": {
"tx_power_dbm": 20.0, "tx_power_dbm": 20.0,
"tx_gain_dbi": 0.0, "tx_gain_dbi": 0.0,
@ -16,15 +16,15 @@
"listen_port": 8081, "listen_port": 8081,
"poll_interval_s": 1.0, "poll_interval_s": 1.0,
"write_api_token": "", "write_api_token": "",
"output_servers": [
{
"name": "output_sink_main",
"ip": "output-sink"
}
],
"output_server": { "output_server": {
"enabled": true, "name": "output_sink_main",
"ip": "output-sink", "ip": "output-sink"
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.0,
"frequency_filter_enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 0.0
} }
}, },
"input": { "input": {
@ -39,6 +39,7 @@
"y": 0.0, "y": 0.0,
"z": 0.0 "z": 0.0
}, },
"frequencies_mhz": [433.92, 868.1],
"source_url": "http://receiver-r0:9000/measurements", "source_url": "http://receiver-r0:9000/measurements",
"input_filter": { "input_filter": {
"enabled": false, "enabled": false,
@ -55,6 +56,7 @@
"y": 0.0, "y": 0.0,
"z": 0.0 "z": 0.0
}, },
"frequencies_mhz": [433.92, 868.1],
"source_url": "http://receiver-r1:9000/measurements", "source_url": "http://receiver-r1:9000/measurements",
"input_filter": { "input_filter": {
"enabled": false, "enabled": false,
@ -71,6 +73,7 @@
"y": 8.0, "y": 8.0,
"z": 0.0 "z": 0.0
}, },
"frequencies_mhz": [433.92, 868.1],
"source_url": "http://receiver-r2:9000/measurements", "source_url": "http://receiver-r2:9000/measurements",
"input_filter": { "input_filter": {
"enabled": false, "enabled": false,

@ -1,4 +1,4 @@
{ {
"model": { "model": {
"tx_power_dbm": 20.0, "tx_power_dbm": 20.0,
"tx_gain_dbi": 0.0, "tx_gain_dbi": 0.0,
@ -16,15 +16,15 @@
"listen_port": 8081, "listen_port": 8081,
"poll_interval_s": 1.0, "poll_interval_s": 1.0,
"write_api_token": "", "write_api_token": "",
"output_servers": [
{
"name": "output_sink_main",
"ip": "output-sink"
}
],
"output_server": { "output_server": {
"enabled": true, "name": "output_sink_main",
"ip": "output-sink", "ip": "output-sink"
"port": 8080,
"path": "/triangulation",
"timeout_s": 3.0,
"frequency_filter_enabled": false,
"min_frequency_mhz": 0.0,
"max_frequency_mhz": 0.0
} }
}, },
"input": { "input": {
@ -39,6 +39,7 @@
"y": 0.0, "y": 0.0,
"z": 0.0 "z": 0.0
}, },
"frequencies_mhz": [433.92, 868.1],
"source_url": "http://receiver-r0:9000/measurements", "source_url": "http://receiver-r0:9000/measurements",
"input_filter": { "input_filter": {
"enabled": false, "enabled": false,
@ -55,6 +56,7 @@
"y": 0.0, "y": 0.0,
"z": 0.0 "z": 0.0
}, },
"frequencies_mhz": [433.92, 868.1],
"source_url": "http://receiver-r1:9000/measurements", "source_url": "http://receiver-r1:9000/measurements",
"input_filter": { "input_filter": {
"enabled": false, "enabled": false,
@ -71,6 +73,7 @@
"y": 8.0, "y": 8.0,
"z": 0.0 "z": 0.0
}, },
"frequencies_mhz": [433.92, 868.1],
"source_url": "http://receiver-r2:9000/measurements", "source_url": "http://receiver-r2:9000/measurements",
"input_filter": { "input_filter": {
"enabled": false, "enabled": false,

@ -37,10 +37,12 @@ def main() -> int:
content_length = int(self.headers.get("Content-Length", "0")) content_length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(content_length) if content_length > 0 else b"{}" body = self.rfile.read(content_length) if content_length > 0 else b"{}"
payload = json.loads(body.decode("utf-8")) 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["count"] = int(latest["count"]) + 1
latest["last_payload"] = payload 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") raw = json.dumps({"status": "ok"}).encode("utf-8")
self.send_response(200) 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_a = random.uniform(-1.2, 1.2)
noise_b = random.uniform(-1.2, 1.2) noise_b = random.uniform(-1.2, 1.2)
rows: List[Dict[str, float]] = [ rows: List[Dict[str, float]] = [
{"frequency_hz": 433_920_000.0, "rssi_dbm": base_rssi + noise_a}, {"f_mhz": 433.920, "rssi": base_rssi + noise_a},
{"frequency_hz": 868_100_000.0, "rssi_dbm": base_rssi - 4.0 + noise_b}, {"f_mhz": 868.100, "rssi": base_rssi - 4.0 + noise_b},
] ]
return { return {
"receiver_id": receiver_id, "receiver_id": receiver_id,
"timestamp_unix": time.time(), "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 from __future__ import annotations
import argparse import argparse
import copy
import hmac import hmac
import itertools
import json import json
import math import math
import mimetypes 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 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]: def _load_json(path: str) -> Dict[str, object]:
file_path = Path(path) file_path = Path(path)
if not file_path.exists(): if not file_path.exists():
@ -114,20 +118,23 @@ def _parse_frequency_hz_from_measurement(
keys=( keys=(
"frequency_hz", "frequency_hz",
"freq_hz", "freq_hz",
"f_hz",
"frequency_mhz", "frequency_mhz",
"freq_mhz", "freq_mhz",
"f_mhz",
"frequency", "frequency",
"freq", "freq",
"f",
), ),
field_name="frequency", field_name="frequency",
source_label=source_label, source_label=source_label,
row_index=row_index, row_index=row_index,
) )
if key in ("frequency_hz", "freq_hz"): if key in ("frequency_hz", "freq_hz", "f_hz"):
return value return value
if key in ("frequency_mhz", "freq_mhz"): if key in ("frequency_mhz", "freq_mhz", "f_mhz"):
return value * HZ_IN_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. # Keep backward compatibility: very large values are treated as Hz.
if value >= 10_000_000.0: if value >= 10_000_000.0:
return value return value
@ -135,11 +142,21 @@ def _parse_frequency_hz_from_measurement(
def _parse_receiver_input_filter( 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]: ) -> Dict[str, object]:
filter_obj = receiver_obj.get("input_filter", {}) raw_receiver_filter = receiver_obj.get("input_filter")
if filter_obj is None: if raw_receiver_filter is None:
filter_obj = {} 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): if not isinstance(filter_obj, dict):
raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.") raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.")
@ -191,6 +208,110 @@ def _apply_receiver_input_filter(
return filtered 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( def parse_source_payload(
payload: object, payload: object,
source_label: str, source_label: str,
@ -209,6 +330,8 @@ def parse_source_payload(
raw_items = payload.get("samples") raw_items = payload.get("samples")
if raw_items is None: if raw_items is None:
raw_items = payload.get("data") raw_items = payload.get("data")
if raw_items is None:
raw_items = payload.get("m")
elif isinstance(payload, list): elif isinstance(payload, list):
raw_items = payload raw_items = payload
else: else:
@ -228,7 +351,7 @@ def parse_source_payload(
) )
amplitude_dbm = _float_from_measurement( amplitude_dbm = _float_from_measurement(
row, row,
keys=("amplitude_dbm", "rssi_dbm", "amplitude", "rssi"), keys=("amplitude_dbm", "rssi_dbm", "dbm", "amplitude", "rssi"),
field_name="amplitude_dbm", field_name="amplitude_dbm",
source_label=source_label, source_label=source_label,
row_index=row_index, row_index=row_index,
@ -272,9 +395,13 @@ def _fetch_measurements(
url: str, url: str,
timeout_s: float, timeout_s: float,
expected_receiver_id: Optional[str] = None, expected_receiver_id: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> List[Tuple[float, float]]: ) -> List[Tuple[float, float]]:
source_label = f"source_url={url}" 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: try:
with request.urlopen(req, timeout=timeout_s) as response: with request.urlopen(req, timeout=timeout_s) as response:
payload = json.loads(response.read().decode("utf-8")) 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.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0))
self.write_api_token = str(runtime_obj.get("write_api_token", "")).strip() 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", {}) output_obj = runtime_obj.get("output_server", {})
if output_obj is None: if output_obj is None:
output_obj = {} output_obj = {}
if not isinstance(output_obj, dict): if not isinstance(output_obj, dict):
raise ValueError("runtime.output_server must be object.") raise ValueError("runtime.output_server must be object.")
parsed_output_servers.append(
self.output_enabled = bool(output_obj.get("enabled", False)) _parse_output_server_config(
self.output_ip = str(output_obj.get("ip", "")) output_obj=output_obj,
self.output_port = int(output_obj.get("port", 8080)) default_name="output_1",
self.output_path = str(output_obj.get("path", "/triangulation"))
self.output_timeout_s = float(output_obj.get("timeout_s", 3.0))
self.output_frequency_filter_enabled = bool(
output_obj.get("frequency_filter_enabled", False)
) )
min_frequency_mhz_raw = output_obj.get("min_frequency_mhz")
max_frequency_mhz_raw = output_obj.get("max_frequency_mhz")
if min_frequency_mhz_raw is None and "min_frequency_hz" in output_obj:
min_frequency_mhz_raw = float(output_obj["min_frequency_hz"]) / HZ_IN_MHZ
if max_frequency_mhz_raw is None and "max_frequency_hz" in output_obj:
max_frequency_mhz_raw = float(output_obj["max_frequency_hz"]) / HZ_IN_MHZ
self.output_min_frequency_mhz = float(min_frequency_mhz_raw or 0.0)
self.output_max_frequency_mhz = float(max_frequency_mhz_raw or 0.0)
self.output_min_frequency_hz = self.output_min_frequency_mhz * HZ_IN_MHZ
self.output_max_frequency_hz = self.output_max_frequency_mhz * HZ_IN_MHZ
if self.output_enabled and not self.output_ip:
raise ValueError("runtime.output_server.ip must be non-empty when enabled=true.")
if self.output_frequency_filter_enabled:
if self.output_min_frequency_mhz <= 0.0:
raise ValueError(
"runtime.output_server.min_frequency_mhz must be > 0 when frequency filter is enabled."
)
if self.output_max_frequency_mhz <= 0.0:
raise ValueError(
"runtime.output_server.max_frequency_mhz must be > 0 when frequency filter is enabled."
)
if self.output_max_frequency_mhz < self.output_min_frequency_mhz:
raise ValueError(
"runtime.output_server.max_frequency_mhz must be >= min_frequency_mhz."
) )
self.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.source_timeout_s = float(input_obj.get("source_timeout_s", 3.0))
self.aggregation = str(input_obj.get("aggregation", "median")) self.aggregation = str(input_obj.get("aggregation", "median"))
if self.aggregation not in ("median", "mean"): if self.aggregation not in ("median", "mean"):
@ -369,22 +486,59 @@ class AutoService:
if input_mode != "http_sources": if input_mode != "http_sources":
raise ValueError("Automatic service requires 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") receivers = input_obj.get("receivers")
if not isinstance(receivers, list) or len(receivers) != 3: if not isinstance(receivers, list) or len(receivers) < 3:
raise ValueError("input.receivers must contain exactly 3 objects.") raise ValueError("input.receivers must contain at least 3 objects.")
parsed_receivers: List[Dict[str, object]] = [] parsed_receivers: List[Dict[str, object]] = []
for receiver in receivers: for receiver in receivers:
if not isinstance(receiver, dict): if not isinstance(receiver, dict):
raise ValueError("Each receiver must be object.") 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( parsed_receivers.append(
{ {
"receiver_id": str(receiver["receiver_id"]), "receiver_id": str(receiver["receiver_id"]),
"center": _center_from_obj(receiver), "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( "input_filter": _parse_receiver_input_filter(
receiver_obj=receiver, receiver_obj=receiver,
receiver_id=str(receiver["receiver_id"]), receiver_id=str(receiver["receiver_id"]),
default_filter_obj=default_filter_obj,
), ),
} }
) )
@ -400,6 +554,27 @@ class AutoService:
"http_status": None, "http_status": None,
"response_body": "", "response_body": "",
"sent_at_utc": None, "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() self.stop_event = threading.Event()
@ -410,6 +585,7 @@ class AutoService:
def stop(self) -> None: def stop(self) -> None:
self.stop_event.set() self.stop_event.set()
if self.poll_thread.is_alive():
self.poll_thread.join(timeout=2.0) self.poll_thread.join(timeout=2.0)
def refresh_once(self) -> None: def refresh_once(self) -> None:
@ -420,18 +596,26 @@ class AutoService:
receiver_id = str(receiver["receiver_id"]) receiver_id = str(receiver["receiver_id"])
center = receiver["center"] center = receiver["center"]
source_url = str(receiver["source_url"]) source_url = str(receiver["source_url"])
source_headers = receiver.get("source_headers")
raw_measurements = _fetch_measurements( raw_measurements = _fetch_measurements(
source_url, source_url,
timeout_s=self.source_timeout_s, timeout_s=self.source_timeout_s,
expected_receiver_id=receiver_id, expected_receiver_id=receiver_id,
headers=source_headers if isinstance(source_headers, dict) else None,
) )
receiver_filter = receiver["input_filter"] receiver_filter = receiver["input_filter"]
measurements = _apply_receiver_input_filter( measurements = _apply_receiver_input_filter(
raw_measurements, receiver_filter=receiver_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: if not measurements:
raise RuntimeError( 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 = _group_by_frequency(measurements)
grouped_by_receiver.append(grouped) grouped_by_receiver.append(grouped)
@ -460,6 +644,14 @@ class AutoService:
"source_url": source_url, "source_url": source_url,
"aggregation": self.aggregation, "aggregation": self.aggregation,
"input_filter": receiver_filter, "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), "raw_samples_count": len(raw_measurements),
"filtered_samples_count": len(measurements), "filtered_samples_count": len(measurements),
"radius_m_all_freq": radius_m, "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]] = [] frequency_rows: List[Dict[str, object]] = []
best_row: Optional[Dict[str, object]] = None 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] = [] spheres_for_frequency: List[Sphere] = []
row_receivers: List[Dict[str, object]] = [] row_receivers: List[Dict[str, object]] = []
for index, receiver in enumerate(self.receivers): for receiver_index in combo:
center = receiver["center"] receiver = self.receivers[receiver_index]
measurement_subset = grouped_by_receiver[index][frequency_hz] measurement_subset = grouped_by_receiver[receiver_index][frequency_hz]
radius_m = aggregate_radius( radius_m = aggregate_radius(
measurement_subset, model=self.model, method=self.aggregation 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( row_receivers.append(
{ {
"receiver_id": str(receiver["receiver_id"]), "receiver_id": str(receiver["receiver_id"]),
@ -502,19 +702,7 @@ class AutoService:
tolerance=self.tolerance, tolerance=self.tolerance,
z_preference=self.z_preference, # type: ignore[arg-type] z_preference=self.z_preference, # type: ignore[arg-type]
) )
for index, residual in enumerate(result.residuals): candidate_row = {
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 = {
"frequency_hz": frequency_hz, "frequency_hz": frequency_hz,
"frequency_mhz": frequency_hz / HZ_IN_MHZ, "frequency_mhz": frequency_hz / HZ_IN_MHZ,
"position": { "position": {
@ -525,16 +713,51 @@ class AutoService:
"exact": result.exact, "exact": result.exact,
"rmse_m": result.rmse, "rmse_m": result.rmse,
"receivers": row_receivers, "receivers": row_receivers,
"used_receivers_count": 3,
"available_receivers_count": len(available_indices),
} }
frequency_rows.append(row) if (
if best_row is None or float(row["rmse_m"]) < float(best_row["rmse_m"]): best_combo_row is None
best_row = row 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 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.") raise RuntimeError("Cannot build frequency table for trilateration.")
payload = { payload = {
"timestamp_utc": datetime.now(timezone.utc).isoformat(), "timestamp_utc": _utc_now_iso_seconds(),
"selected_frequency_hz": best_row["frequency_hz"], "selected_frequency_hz": best_row["frequency_hz"],
"selected_frequency_mhz": float(best_row["frequency_hz"]) / HZ_IN_MHZ, "selected_frequency_mhz": float(best_row["frequency_hz"]) / HZ_IN_MHZ,
"position": best_row["position"], "position": best_row["position"],
@ -556,109 +779,165 @@ class AutoService:
self.updated_at_utc = payload["timestamp_utc"] # type: ignore[index] self.updated_at_utc = payload["timestamp_utc"] # type: ignore[index]
self.last_error = "" self.last_error = ""
if self.output_enabled: delivery = self._deliver_to_output_servers(payload)
output_payload = self._build_output_payload(payload)
if output_payload is None:
with self.state_lock: with self.state_lock:
self.last_output_delivery = { self.last_output_delivery = delivery
"enabled": True,
"status": "skipped",
"http_status": None,
"response_body": "No frequencies in configured output range",
"sent_at_utc": datetime.now(timezone.utc).isoformat(),
"target": {
"ip": self.output_ip,
"port": self.output_port,
"path": self.output_path,
},
"frequency_filter": {
"enabled": self.output_frequency_filter_enabled,
"min_frequency_mhz": self.output_min_frequency_mhz,
"max_frequency_mhz": self.output_max_frequency_mhz,
},
}
return
status_code, response_body = send_payload_to_server( if delivery["status"] in ("error", "partial"):
server_ip=self.output_ip, failed_servers = [
payload=output_payload, row["name"]
port=self.output_port, for row in delivery.get("servers", [])
path=self.output_path, if isinstance(row, dict) and row.get("status") == "error"
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:
raise RuntimeError( raise RuntimeError(
"Output server rejected payload: " "Output server(s) rejected payload: "
f"HTTP {status_code}, body={response_body}" + ", ".join(str(name) for name in failed_servers)
) )
def _build_output_payload(self, payload: Dict[str, object]) -> Optional[Dict[str, object]]: @staticmethod
if not self.output_frequency_filter_enabled: def _row_frequency_mhz(row: Dict[str, object]) -> Optional[float]:
return payload 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. def _build_output_payload(
payload_copy = copy.deepcopy(payload) self,
table_obj = payload_copy.get("frequency_table") 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): if not isinstance(table_obj, list):
return None return None
filtered_rows = [] rows: List[Dict[str, object]] = []
for row in table_obj: for row in table_obj:
if not isinstance(row, dict): if not isinstance(row, dict):
continue continue
frequency_hz = row.get("frequency_hz") frequency_hz = row.get("frequency_hz")
if not isinstance(frequency_hz, (int, float)): if not isinstance(frequency_hz, (int, float)):
continue continue
if self.output_min_frequency_hz <= float(frequency_hz) <= self.output_max_frequency_hz: if self._position_from_row(row) is None:
filtered_rows.append(row) continue
if not filtered_rows: 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 return None
best_row = min(filtered_rows, key=lambda row: float(row.get("rmse_m", float("inf")))) best_row = min(
payload_copy["frequency_table"] = filtered_rows rows,
payload_copy["selected_frequency_hz"] = best_row.get("frequency_hz") key=lambda row: float(row.get("rmse_m", float("inf"))),
payload_copy["selected_frequency_mhz"] = float(best_row.get("frequency_hz", 0.0)) / HZ_IN_MHZ )
payload_copy["position"] = best_row.get("position") best_position = self._position_from_row(best_row)
payload_copy["exact"] = best_row.get("exact") if best_position is None:
payload_copy["rmse_m"] = best_row.get("rmse_m") return None
receivers_obj = payload_copy.get("receivers") # Minimal transport payload for final server integration: coordinates only.
if isinstance(receivers_obj, list): return best_position
for receiver in receivers_obj:
if not isinstance(receiver, dict): 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 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 continue
receiver["per_frequency"] = [
row status_code, response_body = send_payload_to_server(
for row in per_frequency server_ip=str(server["ip"]),
if isinstance(row, dict) payload=output_payload,
and isinstance(row.get("frequency_hz"), (int, float)) port=int(server["port"]),
and self.output_min_frequency_hz path=str(server["path"]),
<= float(row["frequency_hz"]) timeout_s=float(server["timeout_s"]),
<= self.output_max_frequency_hz )
] server_delivery["http_status"] = status_code
return payload_copy 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: def _poll_loop(self) -> None:
while not self.stop_event.is_set(): while not self.stop_event.is_set():
@ -680,9 +959,17 @@ class AutoService:
def _make_handler(service: AutoService): def _make_handler(service: AutoService):
service_holder = {"current": service}
service_swap_lock = threading.Lock()
class ServiceHandler(BaseHTTPRequestHandler): class ServiceHandler(BaseHTTPRequestHandler):
@staticmethod
def _current_service() -> AutoService:
return service_holder["current"]
def _is_write_authorized(self) -> bool: 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: if not expected_token:
return True return True
@ -733,6 +1020,12 @@ def _make_handler(service: AutoService):
mime_type, _ = mimetypes.guess_type(str(file_path)) mime_type, _ = mimetypes.guess_type(str(file_path))
if mime_type is None: if mime_type is None:
mime_type = "application/octet-stream" 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) self._write_bytes(200, file_path.read_bytes(), mime_type)
def log_message(self, format: str, *args) -> None: def log_message(self, format: str, *args) -> None:
@ -740,7 +1033,8 @@ def _make_handler(service: AutoService):
def do_GET(self) -> None: def do_GET(self) -> None:
path = parse.urlparse(self.path).path path = parse.urlparse(self.path).path
snapshot = service.snapshot() service_obj = self._current_service()
snapshot = service_obj.snapshot()
if path == "/" or path == "/ui": if path == "/" or path == "/ui":
self._write_static("index.html") self._write_static("index.html")
@ -812,17 +1106,17 @@ def _make_handler(service: AutoService):
return return
if path == "/config": 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") runtime_obj = public_config.get("runtime")
if isinstance(runtime_obj, dict): if isinstance(runtime_obj, dict):
if "write_api_token" in runtime_obj: if "write_api_token" in runtime_obj:
runtime_obj["write_api_token"] = "" 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( self._write_json(
200, 200,
{ {
"status": "ok", "status": "ok",
"config_path": service.config_path, "config_path": service_obj.config_path,
"config": public_config, "config": public_config,
}, },
) )
@ -840,6 +1134,7 @@ def _make_handler(service: AutoService):
return return
if path == "/config": if path == "/config":
service_obj = self._current_service()
try: try:
content_length = int(self.headers.get("Content-Length", "0")) content_length = int(self.headers.get("Content-Length", "0"))
except ValueError: except ValueError:
@ -872,13 +1167,13 @@ def _make_handler(service: AutoService):
# Avoid accidental token wipe when /config GET response is redacted in clients. # Avoid accidental token wipe when /config GET response is redacted in clients.
runtime_obj = new_config.get("runtime") 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() incoming_token = str(runtime_obj.get("write_api_token", "")).strip()
if not incoming_token: if not incoming_token:
runtime_obj["write_api_token"] = service.write_api_token runtime_obj["write_api_token"] = service_obj.write_api_token
try: try:
AutoService(new_config) new_service = AutoService(new_config, config_path=service_obj.config_path)
except Exception as exc: except Exception as exc:
self._write_json( self._write_json(
400, 400,
@ -886,19 +1181,41 @@ def _make_handler(service: AutoService):
) )
return return
service.config = new_config save_error = ""
if service.config_path: if service_obj.config_path:
Path(service.config_path).write_text( try:
Path(service_obj.config_path).write_text(
json.dumps(new_config, ensure_ascii=False, indent=2), json.dumps(new_config, ensure_ascii=False, indent=2),
encoding="utf-8", 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( self._write_json(
200, 200,
{ {
"status": "ok", "status": "ok",
"saved": bool(service.config_path), "saved": bool(service_obj.config_path) and not bool(save_error),
"restart_required": True, "save_error": save_error,
"config_path": service.config_path, "restart_required": False,
"applied": True,
"config_path": service_obj.config_path,
}, },
) )
return return
@ -908,12 +1225,12 @@ def _make_handler(service: AutoService):
return return
try: try:
service.refresh_once() self._current_service().refresh_once()
except Exception as exc: except Exception as exc:
self._write_json(500, {"status": "error", "error": str(exc)}) self._write_json(500, {"status": "error", "error": str(exc)})
return return
snapshot = service.snapshot() snapshot = self._current_service().snapshot()
self._write_json( self._write_json(
200, 200,
{ {
@ -922,6 +1239,7 @@ def _make_handler(service: AutoService):
}, },
) )
ServiceHandler.service_holder = service_holder # type: ignore[attr-defined]
return ServiceHandler return ServiceHandler
@ -947,8 +1265,8 @@ def main() -> int:
service = AutoService(config, config_path=args.config) service = AutoService(config, config_path=args.config)
service.start() service.start()
handler = _make_handler(service)
server = ThreadingHTTPServer((host, port), _make_handler(service)) server = ThreadingHTTPServer((host, port), handler)
print(f"service_listen: http://{host}:{port}") print(f"service_listen: http://{host}:{port}")
try: try:
server.serve_forever() server.serve_forever()
@ -956,7 +1274,8 @@ def main() -> int:
pass pass
finally: finally:
server.server_close() server.server_close()
service.stop() current_service = handler.service_holder["current"] # type: ignore[attr-defined]
current_service.stop()
return 0 return 0

@ -198,7 +198,7 @@ def test_refresh_once_raises_when_output_server_rejects_payload(
) )
svc = service.AutoService(config) 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() 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) 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): def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch):
config = _base_config() config = _base_config()
svc = service.AutoService(config) svc = service.AutoService(config)
@ -263,6 +281,8 @@ def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch):
http_server.shutdown() http_server.shutdown()
http_server.server_close() http_server.server_close()
thread.join(timeout=1.0) 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): 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) 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): def test_http_refresh_requires_write_token_when_configured(monkeypatch: pytest.MonkeyPatch):
config = _base_config() config = _base_config()
config["runtime"]["write_api_token"] = "secret" # type: ignore[index] 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() svc.refresh_once()
sent_payload = captured["payload"] sent_payload = captured["payload"]
freq_rows = sent_payload["frequency_table"] assert set(sent_payload.keys()) == {"x", "y", "z"}
assert len(freq_rows) == 1
assert freq_rows[0]["frequency_hz"] == 868_100_000.0
assert sent_payload["selected_frequency_hz"] == 868_100_000.0
def test_output_delivery_skipped_when_range_has_no_frequencies(monkeypatch: pytest.MonkeyPatch): 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" 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(): def test_config_validation_rejects_invalid_frequency_filter_range():
config = _base_config() config = _base_config()
config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index] 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) _install_urlopen(monkeypatch, responses)
svc = service.AutoService(config) 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() 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"): with pytest.raises(ValueError, match="max_rssi_dbm must be >= min_rssi_dbm"):
service.AutoService(config) 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 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) @dataclass(frozen=True)
class Sphere: class Sphere:
center: Point3D center: Point3D
@ -330,7 +334,7 @@ def build_result_payload(
) )
return { 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]}, "position": {"x": result.point[0], "y": result.point[1], "z": result.point[2]},
"exact": result.exact, "exact": result.exact,
"rmse_m": result.rmse, "rmse_m": result.rmse,

@ -7,19 +7,93 @@ const state = {
activeSection: "overview", activeSection: "overview",
selectedReceiverIndex: 0, selectedReceiverIndex: 0,
receiverDrafts: [], 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; const HZ_IN_MHZ = 1_000_000;
function byId(id) { function byId(id) {
return document.getElementById(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) { function fmt(value, digits = 6) {
if (value === null || value === undefined) return "-"; if (value === null || value === undefined) return "-";
if (typeof value !== "number") return String(value); if (typeof value !== "number") return String(value);
return Number.isFinite(value) ? value.toFixed(digits) : 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) { function hzToMhz(value) {
if (value === null || value === undefined) return null; if (value === null || value === undefined) return null;
const numeric = Number(value); const numeric = Number(value);
@ -27,6 +101,25 @@ function hzToMhz(value) {
return numeric / HZ_IN_MHZ; 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() { function authHeaders() {
const token = state.writeToken || ""; const token = state.writeToken || "";
if (!token) return {}; if (!token) return {};
@ -50,33 +143,62 @@ function setMenuOpen(isOpen) {
byId("menu-list").classList.toggle("menu-list-open", isOpen); byId("menu-list").classList.toggle("menu-list-open", isOpen);
} }
function normalizeReceiverDraft(receiver) { function normalizeInputFilter(filter) {
const filter = receiver?.input_filter || {}; const source = filter || {};
return {
enabled: Boolean(source.enabled),
min_frequency_mhz: Number(source.min_frequency_mhz ?? hzToMhz(source.min_frequency_hz) ?? 0),
max_frequency_mhz: Number(source.max_frequency_mhz ?? hzToMhz(source.max_frequency_hz) ?? 0),
min_rssi_dbm: Number(source.min_rssi_dbm ?? -200),
max_rssi_dbm: Number(source.max_rssi_dbm ?? 50),
};
}
function createReceiverDraft(index) {
return {
receiver_id: `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 { return {
receiver_id: receiver?.receiver_id || "", receiver_id: receiver?.receiver_id || `r${index}`,
source_url: receiver?.source_url || "", source_url: String(receiver?.source_url || access.url || access.source_url || ""),
input_filter: { frequencies_mhz: frequencies
enabled: Boolean(filter.enabled), .map((value) => Number(value))
min_frequency_mhz: filter.min_frequency_mhz ?? hzToMhz(filter.min_frequency_hz) ?? 0, .filter((value) => Number.isFinite(value) && value > 0),
max_frequency_mhz: filter.max_frequency_mhz ?? hzToMhz(filter.max_frequency_hz) ?? 0, center: {
min_rssi_dbm: filter.min_rssi_dbm ?? -200, x: Number(center.x ?? 0),
max_rssi_dbm: filter.max_rssi_dbm ?? 50, y: Number(center.y ?? 0),
z: Number(center.z ?? 0),
}, },
}; };
} }
function updateReceiverCountBadge() {
byId("receiver-count").textContent = `входов: ${state.receiverDrafts.length}`;
}
function saveCurrentReceiverDraftFromInputs() { function saveCurrentReceiverDraftFromInputs() {
const idx = state.selectedReceiverIndex; const idx = state.selectedReceiverIndex;
if (!state.receiverDrafts[idx]) return; if (!state.receiverDrafts[idx]) return;
state.receiverDrafts[idx] = { state.receiverDrafts[idx] = {
...state.receiverDrafts[idx], ...state.receiverDrafts[idx],
receiver_id: byId("rx-id").value.trim() || `r${idx}`,
source_url: byId("rx-url").value.trim(), source_url: byId("rx-url").value.trim(),
input_filter: { frequencies_mhz: parseMhzList(byId("rx-frequencies").value),
enabled: byId("rx-filter-enabled").value === "true", center: {
min_frequency_mhz: Number(byId("rx-min-freq").value), x: Number(byId("rx-center-x").value),
max_frequency_mhz: Number(byId("rx-max-freq").value), y: Number(byId("rx-center-y").value),
min_rssi_dbm: Number(byId("rx-min-rssi").value), z: Number(byId("rx-center-z").value),
max_rssi_dbm: Number(byId("rx-max-rssi").value),
}, },
}; };
} }
@ -84,12 +206,12 @@ function saveCurrentReceiverDraftFromInputs() {
function renderSelectedReceiverDraft() { function renderSelectedReceiverDraft() {
const draft = state.receiverDrafts[state.selectedReceiverIndex]; const draft = state.receiverDrafts[state.selectedReceiverIndex];
if (!draft) return; if (!draft) return;
byId("rx-id").value = draft.receiver_id;
byId("rx-url").value = draft.source_url; byId("rx-url").value = draft.source_url;
byId("rx-filter-enabled").value = String(Boolean(draft.input_filter.enabled)); byId("rx-frequencies").value = formatMhzList(draft.frequencies_mhz);
byId("rx-min-freq").value = draft.input_filter.min_frequency_mhz; byId("rx-center-x").value = draft.center.x;
byId("rx-max-freq").value = draft.input_filter.max_frequency_mhz; byId("rx-center-y").value = draft.center.y;
byId("rx-min-rssi").value = draft.input_filter.min_rssi_dbm; byId("rx-center-z").value = draft.center.z;
byId("rx-max-rssi").value = draft.input_filter.max_rssi_dbm;
} }
function fillReceiverSelect() { function fillReceiverSelect() {
@ -98,13 +220,106 @@ function fillReceiverSelect() {
state.receiverDrafts.forEach((draft, index) => { state.receiverDrafts.forEach((draft, index) => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = String(index); option.value = String(index);
option.textContent = draft.receiver_id || `receiver_${index + 1}`; option.textContent = draft.receiver_id || `вход_${index + 1}`;
select.appendChild(option); select.appendChild(option);
}); });
if (state.selectedReceiverIndex >= state.receiverDrafts.length) { if (state.selectedReceiverIndex >= state.receiverDrafts.length) {
state.selectedReceiverIndex = 0; state.selectedReceiverIndex = Math.max(0, state.receiverDrafts.length - 1);
} }
select.value = String(state.selectedReceiverIndex); 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) { async function getJson(url) {
@ -132,16 +347,18 @@ async function postJson(url, payload) {
function render() { function render() {
const data = state.result?.data; const data = state.result?.data;
const delivery = state.result?.output_delivery || state.frequencies?.output_delivery; const delivery = state.result?.output_delivery || state.frequencies?.output_delivery;
byId("updated-at").textContent = `updated: ${state.result?.updated_at_utc || "n/a"}`; const updated = formatUpdatedTimestamp(state.result?.updated_at_utc);
byId("health-status").textContent = `health: ${state.health?.status || "n/a"}`; setTextWithPulse("updated-date", updated.date);
byId("delivery-status").textContent = `delivery: ${delivery?.status || "n/a"}`; setTextWithPulse("updated-time", updated.time);
setTextWithPulse("health-status", `состояние: ${localizeStatus(state.health?.status)}`);
setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`);
if (!data) { if (!data) {
byId("selected-freq").textContent = "-"; setTextWithPulse("selected-freq", "-");
byId("pos-x").textContent = "-"; setTextWithPulse("pos-x", "-");
byId("pos-y").textContent = "-"; setTextWithPulse("pos-y", "-");
byId("pos-z").textContent = "-"; setTextWithPulse("pos-z", "-");
byId("rmse").textContent = "-"; setTextWithPulse("rmse", "-");
byId("receivers-list").textContent = "Нет данных"; byId("receivers-list").textContent = "Нет данных";
byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2);
byId("freq-table").querySelector("tbody").innerHTML = ""; byId("freq-table").querySelector("tbody").innerHTML = "";
@ -149,12 +366,11 @@ function render() {
} }
const selectedMhz = data.selected_frequency_mhz ?? hzToMhz(data.selected_frequency_hz); const selectedMhz = data.selected_frequency_mhz ?? hzToMhz(data.selected_frequency_hz);
byId("selected-freq").textContent = setTextWithPulse("selected-freq", selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} МГц`);
selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} MHz`; setTextWithPulse("pos-x", fmt(data.position?.x));
byId("pos-x").textContent = fmt(data.position?.x); setTextWithPulse("pos-y", fmt(data.position?.y));
byId("pos-y").textContent = fmt(data.position?.y); setTextWithPulse("pos-z", fmt(data.position?.z));
byId("pos-z").textContent = fmt(data.position?.z); setTextWithPulse("rmse", fmt(data.rmse_m));
byId("rmse").textContent = fmt(data.rmse_m);
const receivers = data.receivers || []; const receivers = data.receivers || [];
byId("receivers-list").textContent = JSON.stringify(receivers, null, 2); byId("receivers-list").textContent = JSON.stringify(receivers, null, 2);
@ -164,14 +380,14 @@ function render() {
const tbody = byId("freq-table").querySelector("tbody"); const tbody = byId("freq-table").querySelector("tbody");
tbody.innerHTML = rows tbody.innerHTML = rows
.map( .map(
(row) => ` (row, index) => `
<tr> <tr class="row-enter" style="animation-delay:${index * 40}ms">
<td>${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)}</td> <td>${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)}</td>
<td>${fmt(row.position?.x)}</td> <td>${fmt(row.position?.x)}</td>
<td>${fmt(row.position?.y)}</td> <td>${fmt(row.position?.y)}</td>
<td>${fmt(row.position?.z)}</td> <td>${fmt(row.position?.z)}</td>
<td>${fmt(row.rmse_m)}</td> <td>${fmt(row.rmse_m)}</td>
<td>${row.exact ? "yes" : "no"}</td> <td>${row.exact ? "да" : "нет"}</td>
</tr>` </tr>`
) )
.join(""); .join("");
@ -200,11 +416,11 @@ async function loadConfig() {
state.config = config.config || null; state.config = config.config || null;
byId("config-editor").value = JSON.stringify(config.config, null, 2); byId("config-editor").value = JSON.stringify(config.config, null, 2);
fillServerForm(); fillServerForm();
byId("config-state").textContent = "config: loaded"; byId("config-state").textContent = "конфиг: загружен";
byId("servers-state").textContent = "servers: loaded"; byId("servers-state").textContent = "серверы: загружены";
} catch (err) { } catch (err) {
byId("config-state").textContent = `config: ${err.message}`; byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`;
byId("servers-state").textContent = `servers: ${err.message}`; byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`;
} }
} }
@ -214,33 +430,50 @@ async function saveConfig() {
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
const result = await postJson("/config", parsed); const result = await postJson("/config", parsed);
state.config = parsed; state.config = parsed;
const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : "";
byId("config-state").textContent = result.restart_required byId("config-state").textContent = result.restart_required
? "config: saved, restart required" ? `конфиг: сохранён, требуется перезапуск${saveSuffix}`
: "config: saved"; : `конфиг: сохранён${saveSuffix}`;
} catch (err) { } catch (err) {
byId("config-state").textContent = `config: ${err.message}`; byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`;
} }
} }
function fillServerForm() { function fillServerForm() {
const cfg = state.config; const cfg = state.config;
if (!cfg) return; if (!cfg) return;
const receivers = cfg.input?.receivers || []; 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(); fillReceiverSelect();
renderSelectedReceiverDraft(); 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("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() { async function saveServers() {
@ -248,36 +481,62 @@ async function saveServers() {
if (!state.config) { if (!state.config) {
await loadConfig(); await loadConfig();
} }
saveCurrentReceiverDraftFromInputs(); 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); const cfg = structuredClone(state.config);
cfg.input = cfg.input || {}; cfg.input = cfg.input || {};
cfg.input.receivers = cfg.input.receivers || [{}, {}, {}]; cfg.input.default_input_filter = { ...sharedFilter };
cfg.runtime = cfg.runtime || {}; cfg.input.receivers = state.receiverDrafts.map((draft, index) => ({
cfg.runtime.output_server = cfg.runtime.output_server || {}; receiver_id: draft.receiver_id || `r${index}`,
center: {
x: Number(draft.center.x),
y: Number(draft.center.y),
z: Number(draft.center.z),
},
access: {
url: draft.source_url,
},
frequencies_mhz: Array.isArray(draft.frequencies_mhz)
? draft.frequencies_mhz
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
: [],
}));
for (let i = 0; i < cfg.input.receivers.length; i += 1) { cfg.runtime = cfg.runtime || {};
const draft = state.receiverDrafts[i] || normalizeReceiverDraft(cfg.input.receivers[i]); cfg.runtime.output_servers = state.outputDrafts.map((draft, index) => ({
cfg.input.receivers[i].source_url = draft.source_url; name: draft.name || `выход_${index + 1}`,
cfg.input.receivers[i].input_filter = { ...draft.input_filter }; ip: draft.ip,
} }));
cfg.runtime.output_server.enabled = byId("out-enabled").value === "true"; cfg.runtime.output_server = { ...cfg.runtime.output_servers[0] };
cfg.runtime.output_server.frequency_filter_enabled =
byId("out-freq-filter-enabled").value === "true";
cfg.runtime.output_server.min_frequency_mhz = Number(byId("out-min-freq").value);
cfg.runtime.output_server.max_frequency_mhz = Number(byId("out-max-freq").value);
cfg.runtime.output_server.ip = byId("out-ip").value.trim();
cfg.runtime.output_server.port = Number(byId("out-port").value);
cfg.runtime.output_server.path = byId("out-path").value.trim() || "/triangulation";
cfg.runtime.output_server.timeout_s = Number(byId("out-timeout").value);
const result = await postJson("/config", cfg); const result = await postJson("/config", cfg);
state.config = cfg; state.config = cfg;
byId("config-editor").value = JSON.stringify(cfg, null, 2); byId("config-editor").value = JSON.stringify(cfg, null, 2);
const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : "";
byId("servers-state").textContent = result.restart_required byId("servers-state").textContent = result.restart_required
? "servers: saved, restart required" ? `серверы: сохранены, требуется перезапуск${saveSuffix}`
: "servers: saved"; : `серверы: сохранены${saveSuffix}`;
} catch (err) { } 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("save-config").addEventListener("click", saveConfig);
byId("load-servers").addEventListener("click", loadConfig); byId("load-servers").addEventListener("click", loadConfig);
byId("save-servers").addEventListener("click", saveServers); 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) => { byId("receiver-select").addEventListener("change", (event) => {
saveCurrentReceiverDraftFromInputs(); saveCurrentReceiverDraftFromInputs();
state.selectedReceiverIndex = Number(event.target.value); state.selectedReceiverIndex = Number(event.target.value);
renderSelectedReceiverDraft(); 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", () => { byId("menu-toggle").addEventListener("click", () => {
const open = !byId("menu-list").classList.contains("menu-list-open"); const open = !byId("menu-list").classList.contains("menu-list-open");
setMenuOpen(open); setMenuOpen(open);
}); });
document.querySelectorAll(".menu-item").forEach((item) => { document.querySelectorAll(".menu-item").forEach((item) => {
item.addEventListener("click", () => { item.addEventListener("click", () => {
setActiveSection(item.dataset.section); setActiveSection(item.dataset.section);
setMenuOpen(false); setMenuOpen(false);
}); });
}); });
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
const target = event.target; const target = event.target;
if (!(target instanceof Element)) return; if (!(target instanceof Element)) return;
if ( if (target.closest("#menu-toggle") || target.closest("#menu-list")) {
target.closest("#menu-toggle") ||
target.closest("#menu-list")
) {
return; return;
} }
setMenuOpen(false); setMenuOpen(false);
@ -327,5 +598,5 @@ async function boot() {
} }
boot().catch((err) => { boot().catch((err) => {
byId("health-status").textContent = `health: ${err.message}`; setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`);
}); });

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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" /> <link rel="stylesheet" href="/static/styles.css" />
</head> </head>
<body> <body>
@ -12,63 +12,64 @@
<main class="app-shell"> <main class="app-shell">
<aside class="side-nav card"> <aside class="side-nav card">
<p class="kicker">Triangulation</p> <p class="kicker">Триангуляция</p>
<h1 class="side-title">Control</h1> <h1 class="side-title">Пульт</h1>
<div class="menu-wrap"> <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"> <div id="menu-list" class="menu-list">
<button class="menu-item menu-item-active" data-section="overview">Overview</button> <button class="menu-item menu-item-active" data-section="overview">Обзор</button>
<button class="menu-item" data-section="frequencies">Frequencies</button> <button class="menu-item" data-section="frequencies">Частоты</button>
<button class="menu-item" data-section="receivers">Receivers</button> <button class="menu-item" data-section="receivers">Ресиверы</button>
<button class="menu-item" data-section="delivery">Delivery</button> <button class="menu-item" data-section="delivery">Доставка</button>
<button class="menu-item" data-section="servers">Servers</button> <button class="menu-item" data-section="servers">Серверы</button>
<button class="menu-item" data-section="json">Raw JSON</button> <button class="menu-item" data-section="json">Конфигурация</button>
</div> </div>
</div> </div>
<div class="side-meta"> <div class="side-meta">
<span id="updated-at" class="badge">updated: n/a</span> <span id="updated-date" class="badge badge-meta">дата: н/д</span>
<span id="health-status" class="badge">health: n/a</span> <span id="updated-time" class="badge badge-meta">время: н/д</span>
<span id="delivery-status" class="badge">delivery: n/a</span> <span id="health-status" class="badge">состояние: н/д</span>
<span id="delivery-status" class="badge">доставка: н/д</span>
</div> </div>
</aside> </aside>
<section class="content-area"> <section class="content-area">
<section id="section-overview" class="panel panel-active"> <section id="section-overview" class="panel panel-active">
<header class="hero card"> <header class="hero card">
<h2>RF Positioning Dashboard</h2> <h2>Панель Радиопозиционирования</h2>
<p class="muted">Мониторинг и контроль расчета пересечения 3 сфер.</p> <p class="muted">Мониторинг и управление расчётом 3D триангуляции.</p>
<div class="hero-actions"> <div class="hero-actions">
<button id="refresh-now" class="btn btn-primary">Refresh</button> <button id="refresh-now" class="btn btn-primary">Обновить</button>
</div> </div>
</header> </header>
<article class="card"> <article class="card">
<h2>Итоговая позиция</h2> <h2>Итоговая Позиция</h2>
<div class="result-box"> <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">X:</span> <b id="pos-x">-</b></div>
<div><span class="muted">Y:</span> <b id="pos-y">-</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">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> </div>
</article> </article>
</section> </section>
<section id="section-frequencies" class="panel"> <section id="section-frequencies" class="panel">
<article class="card"> <article class="card">
<h2>Таблица пересечений по частотам</h2> <h2>Таблица По Частотам</h2>
<div class="table-wrap"> <div class="table-wrap">
<table id="freq-table"> <table id="freq-table">
<thead> <thead>
<tr> <tr>
<th>Frequency (MHz)</th> <th>Частота (МГц)</th>
<th>X</th> <th>X</th>
<th>Y</th> <th>Y</th>
<th>Z</th> <th>Z</th>
<th>RMSE</th> <th>СКО (RMSE)</th>
<th>Exact</th> <th>Точно</th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
@ -86,65 +87,78 @@
<section id="section-delivery" class="panel"> <section id="section-delivery" class="panel">
<article class="card"> <article class="card">
<h2>Отправка на конечный сервер</h2> <h2>Доставка На Выходы</h2>
<div id="delivery-details" class="mono small"></div> <div id="delivery-details" class="mono small"></div>
</article> </article>
</section> </section>
<section id="section-servers" class="panel"> <section id="section-servers" class="panel">
<article class="card"> <article class="card">
<h2>Настройка серверов</h2> <h2>Настройка Серверов</h2>
<p class="muted">Изменения сохраняются в конфиг и требуют перезапуска сервиса.</p> <p class="muted">Доступы входа и выхода задаются отдельно. Общий фильтр входа автоматически применяется ко всем входным серверам.</p>
<h3 class="servers-title">Доступ К Входным Серверам</h3>
<div class="server-grid"> <div class="server-grid">
<label>Receiver <label>Выбранный сервер
<select id="receiver-select"></select> <select id="receiver-select"></select>
</label> </label>
<label>Receiver URL<input id="rx-url" type="text" /></label> <div class="server-actions-row">
<label>Filter enabled <button id="add-receiver" class="btn" type="button">Добавить вход</button>
<select id="rx-filter-enabled"> <button id="remove-receiver" class="btn" type="button">Удалить вход</button>
<option value="true">true</option> <span id="receiver-count" class="badge">входов: 0</span>
<option value="false">false</option> </div>
</select> <label>Имя ресивера<input id="rx-id" type="text" /></label>
</label> <label>Адрес сервера (URL)<input id="rx-url" type="text" /></label>
<label>Min freq MHz<input id="rx-min-freq" type="number" step="0.001" min="0" /></label> <label>Частоты ресивера, МГц (через запятую)<input id="rx-frequencies" type="text" placeholder="433.92, 868.1" /></label>
<label>Max freq MHz<input id="rx-max-freq" type="number" step="0.001" min="0" /></label> <label>Координата X<input id="rx-center-x" type="number" step="0.001" /></label>
<label>Min RSSI dBm<input id="rx-min-rssi" type="number" step="0.1" /></label> <label>Координата Y<input id="rx-center-y" type="number" step="0.001" /></label>
<label>Max RSSI dBm<input id="rx-max-rssi" type="number" step="0.1" /></label> <label>Координата Z<input id="rx-center-z" type="number" step="0.001" /></label>
<label>Write API token (session only)<input id="write-token" type="password" /></label> </div>
<label>Output enabled
<select id="out-enabled"> <h3 class="servers-title">Общий Фильтр Входа (Авто Для Всех)</h3>
<option value="true">true</option> <div class="server-grid">
<option value="false">false</option> <label>Фильтр включен
<select id="shared-filter-enabled">
<option value="true">да</option>
<option value="false">нет</option>
</select> </select>
</label> </label>
<label>Freq filter enabled <label>Мин. частота, МГц<input id="shared-min-freq" type="number" step="0.001" min="0" /></label>
<select id="out-freq-filter-enabled"> <label>Макс. частота, МГц<input id="shared-max-freq" type="number" step="0.001" min="0" /></label>
<option value="true">true</option> <label>Мин. RSSI, дБм<input id="shared-min-rssi" type="number" step="0.1" /></label>
<option value="false">false</option> <label>Макс. RSSI, дБм<input id="shared-max-rssi" type="number" step="0.1" /></label>
</select> </div>
<h3 class="servers-title">Выходные Серверы</h3>
<div class="server-grid">
<label>Выбранный выход
<select id="output-select"></select>
</label> </label>
<label>Min frequency MHz<input id="out-min-freq" type="number" step="0.001" min="0" /></label> <div class="server-actions-row">
<label>Max frequency MHz<input id="out-max-freq" type="number" step="0.001" min="0" /></label> <button id="add-output-server" class="btn" type="button">Добавить выход</button>
<label>Output IP/host<input id="out-ip" type="text" /></label> <button id="remove-output-server" class="btn" type="button">Удалить выход</button>
<label>Output port<input id="out-port" type="number" min="1" /></label> <span id="output-count" class="badge">выходов: 0</span>
<label>Output path<input id="out-path" type="text" /></label> </div>
<label>Output timeout s<input id="out-timeout" type="number" step="0.1" min="0.1" /></label> <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>
<div class="editor-actions"> <div class="editor-actions">
<button id="load-servers" class="btn">Load</button> <button id="load-servers" class="btn">Загрузить</button>
<button id="save-servers" class="btn btn-primary">Save servers</button> <button id="save-servers" class="btn btn-primary">Сохранить серверы</button>
<span id="servers-state" class="badge">servers: n/a</span> <span id="servers-state" class="badge">серверы: н/д</span>
</div> </div>
</article> </article>
</section> </section>
<section id="section-json" class="panel"> <section id="section-json" class="panel">
<article class="card"> <article class="card">
<h2>Конфигурация (Raw JSON)</h2> <h2>Конфигурация</h2>
<div class="editor-actions"> <div class="editor-actions">
<button id="load-config" class="btn">Load</button> <button id="load-config" class="btn">Загрузить</button>
<button id="save-config" class="btn btn-primary">Save JSON</button> <button id="save-config" class="btn btn-primary">Сохранить конфиг</button>
<span id="config-state" class="badge">config: n/a</span> <span id="config-state" class="badge">конфиг: н/д</span>
</div> </div>
<textarea id="config-editor" class="editor" spellcheck="false"></textarea> <textarea id="config-editor" class="editor" spellcheck="false"></textarea>
</article> </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 { :root {
--bg: #f2f4f7; --bg-main: #f5f7fb;
--card: #ffffffd4; --bg-secondary: #edf2ff;
--text: #10161d; --bg-tertiary: #fff7ee;
--muted: #5f6f7d; --card: rgba(255, 255, 255, 0.84);
--line: #d8e0e7; --card-strong: rgba(255, 255, 255, 0.94);
--accent: #0f766e; --text: #1e2a3a;
--accent-soft: #e6f7f4; --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; box-sizing: border-box;
} }
html,
body {
min-height: 100%;
}
body { body {
margin: 0; margin: 0;
font-family: "Segoe UI", "Noto Sans", sans-serif; font-family: "Manrope", "Noto Sans", sans-serif;
color: var(--text); color: var(--text);
background: linear-gradient(160deg, #f9fafc, #eef4f7 45%, #f2f4f7); background:
min-height: 100vh; 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; overflow-x: hidden;
} }
.app-shell { .app-shell {
width: min(1240px, 96vw); width: min(1320px, 96vw);
margin: 24px auto; margin: 20px auto;
display: grid; display: grid;
grid-template-columns: 280px 1fr; grid-template-columns: 300px 1fr;
align-items: start;
gap: 16px; gap: 16px;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
.card { .card {
background: var(--card); background: linear-gradient(165deg, var(--card-strong), var(--card));
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 16px; border-radius: 18px;
padding: 16px; padding: 16px;
backdrop-filter: blur(8px); backdrop-filter: blur(10px);
box-shadow: 0 14px 35px rgba(16, 22, 29, 0.06); box-shadow: var(--shadow);
animation: rise 420ms ease both; 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 { .side-nav {
position: sticky; position: sticky;
top: 16px; top: 12px;
height: fit-content; min-height: 0;
max-height: calc(100dvh - 24px);
overflow-y: auto;
overscroll-behavior: contain;
display: grid; display: grid;
gap: 12px; gap: 12px;
scrollbar-gutter: stable;
} }
.kicker { .kicker {
margin: 0; margin: 0;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.14em; letter-spacing: 0.16em;
color: var(--accent); color: var(--accent-strong);
font-weight: 700; font-weight: 800;
font-size: 0.74rem; font-size: 0.7rem;
} }
.side-title { .side-title {
margin: 0; margin: 0;
font-size: 1.3rem; font-size: 1.4rem;
letter-spacing: 0.02em;
} }
.content-area { .content-area {
display: grid; display: grid;
min-width: 0;
} }
.panel { .panel {
display: none; display: none;
animation: fadeSlide 220ms ease; animation: fadeSlide var(--anim-mid) ease;
} }
.panel-active { .panel-active {
display: grid; display: grid;
gap: 16px; gap: 16px;
min-width: 0;
} }
.hero h2 { .hero h2 {
margin: 0 0 8px; margin: 0 0 8px;
font-size: clamp(1.3rem, 1rem + 1vw, 1.8rem);
} }
.hero-actions, .hero-actions,
@ -93,25 +150,37 @@ body {
border: 1px solid var(--line); border: 1px solid var(--line);
background: #fff; background: #fff;
color: var(--text); color: var(--text);
border-radius: 10px; border-radius: 11px;
padding: 8px 12px; padding: 8px 13px;
cursor: pointer; 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 { .btn:hover {
transform: translateY(-1px); 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 { .btn-primary {
background: var(--accent); background: linear-gradient(140deg, var(--accent), var(--accent-strong));
border-color: var(--accent); border-color: var(--accent-strong);
color: #fff; color: #fff;
} }
.btn-primary:hover {
box-shadow: 0 12px 26px rgba(19, 87, 222, 0.32);
}
.menu-wrap { .menu-wrap {
position: relative; display: grid;
gap: 8px;
} }
.menu-toggle { .menu-toggle {
@ -120,16 +189,19 @@ body {
.menu-list { .menu-list {
display: none; display: none;
position: absolute; position: static;
left: 0;
right: 0;
top: calc(100% + 8px);
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
background: #ffffff; background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10px 28px rgba(16, 22, 29, 0.1); box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.7),
0 8px 24px rgba(21, 37, 73, 0.08);
padding: 6px; 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 { .menu-list-open {
@ -139,31 +211,69 @@ body {
.menu-item { .menu-item {
border: 1px solid transparent; border: 1px solid transparent;
background: #f7fafb; background: #f9fbff;
color: var(--text); color: var(--text);
border-radius: 8px; border-radius: 9px;
padding: 8px 10px; padding: 8px 10px;
text-align: left; text-align: left;
cursor: pointer; 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 { .menu-item-active {
background: var(--accent-soft); background: linear-gradient(90deg, var(--accent-soft), #f4f7ff);
border-color: color-mix(in oklab, var(--accent), #fff 70%); border-color: color-mix(in oklab, var(--accent), #ffffff 64%);
} }
.side-meta { .side-meta {
display: grid; display: grid;
gap: 6px; gap: 7px;
} }
.badge { .badge {
border: 1px solid var(--line); border: 1px solid var(--line);
background: #f3f9fb; background: rgba(236, 244, 255, 0.72);
border-radius: 999px; border-radius: 999px;
padding: 4px 10px; padding: 6px 12px 6px 30px;
line-height: 1.2;
font-size: 0.8rem; font-size: 0.8rem;
width: fit-content; 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 { .result-box {
@ -180,11 +290,14 @@ body {
} }
.mono { .mono {
font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace;
} }
.table-wrap { .table-wrap {
overflow-x: auto; overflow-x: auto;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.56);
} }
table { table {
@ -195,27 +308,45 @@ table {
th, th,
td { td {
text-align: left; text-align: left;
padding: 8px; padding: 9px 10px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
font-size: 0.9rem; 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 { tbody tr {
transition: background-color 180ms ease; transition: background-color var(--anim-fast) ease;
} }
tbody tr:hover { 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 { .editor {
width: 100%; width: 100%;
min-height: 320px; min-height: 340px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 12px; border-radius: 12px;
padding: 10px; padding: 11px;
background: #fbfdff; background: rgba(250, 253, 255, 0.88);
font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; font-family: "JetBrains Mono", "Cascadia Code", "Fira Code", ui-monospace, monospace;
font-size: 0.85rem; font-size: 0.85rem;
margin-top: 10px; margin-top: 10px;
} }
@ -226,50 +357,75 @@ tbody tr:hover {
gap: 10px 14px; 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 { .server-grid label {
display: grid; display: grid;
gap: 6px; gap: 6px;
font-size: 0.88rem; font-size: 0.88rem;
color: #34425c;
} }
.server-grid input, .server-grid input,
.server-grid select { .server-grid select {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 8px; border-radius: 9px;
padding: 7px 9px; padding: 8px 10px;
font-size: 0.9rem; font-size: 0.9rem;
background: #fff; 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 { .bg-glow {
position: fixed; position: fixed;
width: 360px; width: 420px;
height: 360px; height: 420px;
border-radius: 50%; border-radius: 50%;
filter: blur(55px); filter: blur(70px);
opacity: 0.35; opacity: 0.34;
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
animation: drift 10s ease-in-out infinite alternate; animation: drift 11s ease-in-out infinite alternate;
} }
.bg-glow-a { .bg-glow-a {
background: #8de4d5; background: #7eacff;
top: -110px; top: -130px;
right: -80px; right: -90px;
} }
.bg-glow-b { .bg-glow-b {
background: #a9c9ff; background: #ffbb80;
bottom: -130px; bottom: -145px;
left: -90px; left: -90px;
animation-delay: 1.2s; animation-delay: 1.3s;
} }
@keyframes rise { @keyframes rise {
from { from {
opacity: 0; opacity: 0;
transform: translateY(8px); transform: translateY(10px);
} }
to { to {
opacity: 1; opacity: 1;
@ -280,7 +436,7 @@ tbody tr:hover {
@keyframes fadeSlide { @keyframes fadeSlide {
from { from {
opacity: 0; opacity: 0;
transform: translateX(7px); transform: translateX(10px);
} }
to { to {
opacity: 1; opacity: 1;
@ -293,22 +449,88 @@ tbody tr:hover {
transform: translate(0, 0) scale(1); transform: translate(0, 0) scale(1);
} }
to { 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);
}
}
@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);
} }
} }
@media (max-width: 980px) { @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 { .app-shell {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.side-nav { .side-nav {
position: static; position: static;
max-height: none;
overflow: visible;
} }
} }
@media (max-width: 740px) { @media (max-width: 760px) {
.server-grid { .server-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.card {
padding: 14px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
}
} }

Loading…
Cancel
Save