From f327c8f0bb48dc4e2caabc729cb693ea57563617 Mon Sep 17 00:00:00 2001 From: AlexsandrSnytkin Date: Mon, 2 Mar 2026 03:14:03 +0700 Subject: [PATCH] Refactoring UI --- README.md | 299 ++++--- __pycache__/service.cpython-311.pyc | Bin 46516 -> 61063 bytes ...e_integration.cpython-311-pytest-8.2.2.pyc | Bin 47233 -> 69961 bytes __pycache__/triangulation.cpython-311.pyc | Bin 22696 -> 22911 bytes config.template.json | 55 +- docker/config.docker.json | 21 +- docker/config.docker.test.json | 21 +- docker/mock_output_sink.py | 6 +- docker/mock_receiver.py | 6 +- docs/API.md | 216 +++++ docs/CONFIG_REFERENCE.md | 198 +++++ docs/JSON_EXAMPLES.md | 106 +++ service.py | 747 +++++++++++++----- test_service_integration.py | 223 +++++- triangulation.py | 6 +- web/app.js | 445 +++++++++-- web/index.html | 136 ++-- web/styles.css | 368 +++++++-- 18 files changed, 2198 insertions(+), 655 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/CONFIG_REFERENCE.md create mode 100644 docs/JSON_EXAMPLES.md diff --git a/README.md b/README.md index 86da6c6..618eb77 100644 --- a/README.md +++ b/README.md @@ -1,196 +1,166 @@ # Triangulation Service -Сервис решает 3D-трилатерацию по 3 ресиверам: -- центры сфер: координаты ресиверов; -- радиусы сфер: расстояния, оцененные из RSSI с учетом частоты; -- расчет идет по одинаковым частотам, которые есть у всех 3 ресиверов; -- формируется таблица `frequency_table` (по каждой частоте отдельное решение); -- выбирается итоговая частота `selected_frequency_hz` по минимальному `rmse_m`. - -## Что реализовано - -- Автоматический polling 3 входных серверов (`http_sources`). -- Валидация входных payload с подробными ошибками. -- API: - - `GET /health` - - `GET /result` - - `GET /frequencies` - - `POST /refresh` - - `GET /config` - - `POST /config` -- UI (`/ui`) с: - - входными данными ресиверов; - - таблицей пересечений по частотам; - - итоговой позицией; - - статусом отправки на конечный сервер. -- Опциональный push результата на внешний сервер (`runtime.output_server`). +Сервис автоматически собирает RSSI-измерения с нескольких входных серверов (ресиверов), группирует данные по одинаковым частотам и рассчитывает 3D-положение источника через пересечение сфер (трилатерация). + +Каждый ресивер задается: +- координатами `center` (центр сферы), +- измерениями `RSSI + частота` (для расчета радиуса через модель распространения), +- URL источника входных данных. + +Сервис: +- ведет актуальную таблицу решений по каждой общей частоте, +- выбирает итоговое лучшее решение по минимальному `rmse_m`, +- отправляет компактный результат на один или несколько выходных серверов, +- отдает API + веб-интерфейс для мониторинга и настройки. + +## Ключевые возможности + +- Автоматический polling входных серверов (`input.mode = "http_sources"`). +- Поддержка `N >= 3` входных ресиверов. +- Расчет по общим частотам, которые есть минимум у 3 ресиверов и разрешены в `input.receivers[].frequencies_mhz`. +- Поддержка общего фильтра входа `input.default_input_filter` и override per-receiver (`input_filter`). +- Поддержка нескольких выходных серверов `runtime.output_servers[]` с настройкой по имени и IP. +- Горячее применение нового конфига через `POST /config` (без ручного рестарта процесса). +- Защита write-endpoints токеном (`runtime.write_api_token`). +- Русскоязычный UI (`/ui`) с вкладками: обзор, частоты, ресиверы, доставка, серверы, JSON-конфиг. +- Интеграционные и юнит-тесты. ## Структура проекта -- [service.py](/c:/Users/snytk/triangulation/service.py) - автосервис + API + UI статик. -- [triangulation.py](/c:/Users/snytk/triangulation/triangulation.py) - математика. -- [config.template.json](/c:/Users/snytk/triangulation/config.template.json) - шаблон конфига. -- [web/index.html](/c:/Users/snytk/triangulation/web/index.html), [web/styles.css](/c:/Users/snytk/triangulation/web/styles.css), [web/app.js](/c:/Users/snytk/triangulation/web/app.js) - UI. -- [docker-compose.yml](/c:/Users/snytk/triangulation/docker-compose.yml) - test/prod профили. -- [docker/config.docker.test.json](/c:/Users/snytk/triangulation/docker/config.docker.test.json) - тестовый конфиг. -- [docker/mock_receiver.py](/c:/Users/snytk/triangulation/docker/mock_receiver.py) - mock входные сервера (random RSSI). -- [docker/mock_output_sink.py](/c:/Users/snytk/triangulation/docker/mock_output_sink.py) - mock конечный сервер. +- [service.py](./service.py) — основной автосервис, API, статика UI. +- [triangulation.py](./triangulation.py) — математика трилатерации и сетевой отправки. +- [config.template.json](./config.template.json) — шаблон основного конфига. +- [web/index.html](./web/index.html), [web/app.js](./web/app.js), [web/styles.css](./web/styles.css) — веб-интерфейс. +- [docker-compose.yml](./docker-compose.yml) — профили test/prod. +- [docker/config.docker.test.json](./docker/config.docker.test.json) — тестовый docker-конфиг. +- [docker/mock_receiver.py](./docker/mock_receiver.py) — генератор входных тестовых данных. +- [docker/mock_output_sink.py](./docker/mock_output_sink.py) — тестовый приемник выходных payload. +- [test_service_integration.py](./test_service_integration.py), [test_triangulation.py](./test_triangulation.py) — тесты. +- [docs/API.md](./docs/API.md) — подробный API. +- [docs/CONFIG_REFERENCE.md](./docs/CONFIG_REFERENCE.md) — справочник параметров конфига. +- [docs/JSON_EXAMPLES.md](./docs/JSON_EXAMPLES.md) — шаблоны JSON. -## Docker Compose: test/prod режимы +## Быстрый старт -`docker-compose.yml` разделен на профили: +### Вариант 1: Docker (рекомендуется) -- `test`: - - `triangulation-test` - - `receiver-r0`, `receiver-r1`, `receiver-r2` - - `output-sink` - -- `prod`: - - `triangulation-prod` (читает ваш `./config.json`) - -Это позволяет легко отключить тестовый режим и перейти на реальные сервера. - -## Быстрый старт: Test Mode - -Поднимает все контейнеры для end-to-end проверки: -- 3 входных mock сервера с random данными; -- основной сервис; -- output-sink, принимающий отправленные результаты. +Тестовый режим (все контейнеры: 3 mock-входа + сервис + mock-выход): ```bash -docker compose --profile test up --build +docker compose --profile test up --build -d ``` -Открыть: +Проверить: - UI: `http://127.0.0.1:38081/ui` -- Полный результат: `http://127.0.0.1:38081/result` -- Частоты: `http://127.0.0.1:38081/frequencies` -- Полученные output-sink данные (изнутри сети контейнеров): - - `docker compose --profile test exec output-sink wget -qO- http://127.0.0.1:8080/latest` +- Health: `http://127.0.0.1:38081/health` +- Result: `http://127.0.0.1:38081/result` +- Frequencies: `http://127.0.0.1:38081/frequencies` Остановить: + ```bash docker compose --profile test down ``` -## Быстрый старт: Prod Mode +Прод-режим (ваш `config.json`): 1. Создайте `config.json` из шаблона: + ```bash cp config.template.json config.json ``` -2. Заполните ваши реальные: -- `input.receivers[].source_url` -- `input.receivers[].center` -- `runtime.output_server` - +2. Заполните реальные URL/координаты/выходные серверы. 3. Запустите: + ```bash -docker compose --profile prod up --build +docker compose --profile prod up --build -d ``` -Доступ к API/UI в `prod`: -- `http://127.0.0.1:38082/ui` -- `http://127.0.0.1:38082/result` -- `http://127.0.0.1:38082/frequencies` +Доступ: +- UI: `http://127.0.0.1:38082/ui` +- API: `http://127.0.0.1:38082/*` Остановить: + ```bash docker compose --profile prod down ``` -## Как проверить, что данные приходят и отправляются +### Вариант 2: Локальный запуск Python -В UI (`/ui`) видно: -- блок `Ресиверы`: входящие samples; -- таблица `Таблица пересечений по частотам`: решения по каждой общей частоте; -- блок `Отправка на конечный сервер`: статус доставки (`ok/error`), HTTP-код, время, target. +Ubuntu: -Дополнительно: -- `GET /result` возвращает `output_delivery`. -- `GET /frequencies` тоже возвращает `output_delivery`. -- `docker compose --profile test logs output-sink -f` показывает факт приема. -- `GET /latest` на `output-sink` доступен изнутри docker-сети. +```bash +bash setup.sh +source .venv/bin/activate +python service.py --config config.json +``` -## Конфиг (основные поля) +Windows PowerShell: -Пример: [config.template.json](/c:/Users/snytk/triangulation/config.template.json) +```powershell +./setup.ps1 +.\.venv\Scripts\Activate.ps1 +python service.py --config config.json +``` -Критичные поля: -- `input.mode`: только `"http_sources"` для автосервиса. -- `input.receivers`: ровно 3 ресивера. -- `input.aggregation`: `"median"` или `"mean"`. -- `runtime.poll_interval_s`: период опроса. -- `runtime.output_server.enabled`: push во внешний сервер. +## Веб-интерфейс -## Формат входных payload +Открыть: `http://:/ui` -Поддержка: -- объект с `measurements`/`samples`/`data`; -- или сразу массив измерений. +Что доступно: +- обзор итоговой позиции, +- таблица всех частотных решений, +- просмотр сырых данных ресиверов и статуса доставки, +- настройка входных/выходных серверов (добавление/удаление, имена, URL, IP), +- редактирование сырого JSON-конфига, +- сохранение конфига в рантайме через API. -Измерение: -- `frequency_hz` (или `freq_hz`/`frequency`/`freq`) -- `amplitude_dbm` (или `rssi_dbm`/`amplitude`/`rssi`) +Примечание: после `POST /config` сервис применяет конфиг автоматически (`applied: true`, `restart_required: false`). -Пример: -```json -{ - "receiver_id": "r0", - "measurements": [ - { "frequency_hz": 433920000, "rssi_dbm": -61.5 }, - { "frequency_hz": 868100000, "rssi_dbm": -67.2 } - ] -} -``` +## API (кратко) -Если `receiver_id` передан, сервис сверяет его с ожидаемым receiver из конфига. +- `GET /health` — состояние сервиса. +- `GET /result` — последнее итоговое решение + delivery status. +- `GET /frequencies` — таблица решений по частотам + delivery status. +- `POST /refresh` — принудительное обновление. +- `GET /config` — текущий конфиг (с редактированием секрета токена). +- `POST /config` — валидация + применение + попытка сохранения в файл. -## Валидация и ошибки некорректного контекста +Полные примеры запросов/ответов: [docs/API.md](./docs/API.md). -Проверяется: -- тип payload; -- наличие измерений; -- числовые и конечные значения; -- `frequency_hz > 0`; -- соответствие `receiver_id` при наличии; -- наличие общих частот у всех 3 ресиверов. +## Конфигурация -Ошибки содержат: -- `source_url=...` -- номер строки `row #...` -- проблемное поле. +Базовый шаблон: [config.template.json](./config.template.json) -## Тесты +Ключевые блоки: +- `model` — радиомодель (RSSI -> расстояние). +- `solver` — параметры решателя сфер. +- `input` — источники входных данных, фильтры, агрегация. +- `runtime` — HTTP сервис, polling, write token, выходные серверы. -Запуск: -```bash -pytest -q -``` - -Покрытие: -- математика триангуляции; -- влияние частоты на RSSI->distance; -- интеграция `AutoService.refresh_once()`; -- валидационные сценарии; -- ошибки контекста (нет общих частот, bad field, receiver mismatch, network error, output reject). +Важно по частотам: +- для каждого входного сервера задайте `input.receivers[].frequencies_mhz`; +- сервис использует в расчёте только частоты из конфигурации ресиверов. -Файл интеграционных тестов: -- [test_service_integration.py](/c:/Users/snytk/triangulation/test_service_integration.py) +Полное описание параметров: [docs/CONFIG_REFERENCE.md](./docs/CONFIG_REFERENCE.md). -## Локальный запуск без Docker +## Форматы JSON -```bash -python service.py --config config.json -``` +Поддерживаются: +- компактный формат входа (`samples` + `f_mhz`/`rssi`), +- legacy-алиасы полей (`measurements`, `data`, `frequency_hz`, `rssi_dbm` и др.), +- минимальный выходной payload на конечные серверы: + - `x` + - `y` + - `z` -UI: -- `http://127.0.0.1:38081/ui` +Готовые шаблоны: [docs/JSON_EXAMPLES.md](./docs/JSON_EXAMPLES.md). -## Защита write-endpoints токеном +## Безопасность write-endpoints -Для защиты изменений состояния можно задать токен в конфиге: +Чтобы ограничить изменение состояния, задайте: ```json { @@ -200,34 +170,43 @@ UI: } ``` -После этого `POST /refresh` и `POST /config` требуют токен в одном из заголовков: +Тогда `POST /refresh` и `POST /config` требуют один из заголовков: - `X-API-Token: ` - `Authorization: Bearer ` -Что важно: -- `GET` endpoints остаются без токена. -- `GET /config` отдает `runtime.write_api_token` в редактированном виде (`""`) и флаг `write_api_token_set`. -- В UI во вкладке `Servers` есть поле `Write API token (session only)`: - - токен хранится только в памяти браузера; - - используется для `POST /refresh` и `POST /config`. +`GET` endpoints доступны без токена. -## Фильтры входных данных по каждому серверу +## Тесты -Для каждого ресивера в `input.receivers[]` можно задать `input_filter`: +Запуск: -```json -{ - "input_filter": { - "enabled": true, - "min_frequency_mhz": 430.0, - "max_frequency_mhz": 440.0, - "min_rssi_dbm": -80.0, - "max_rssi_dbm": -40.0 - } -} +```bash +pytest -q ``` -Смысл: -- фильтр применяется отдельно к данным каждого ресивера до триангуляции; -- участвуют только измерения, попавшие в диапазоны частоты и RSSI; -- если после фильтрации у ресивера нет данных, цикл расчета возвращает ошибку. +Сценарии покрывают: +- корректность трилатерации и преобразования RSSI, +- валидацию payload и граничные случаи, +- ошибки некорректного контекста, +- output delivery, +- безопасность API, +- горячее применение конфига, +- multi-input/multi-output поведение. + +## Диагностика + +### Ошибка: `port is already allocated` + +Значит локальный порт уже занят (например, `8080` или `38081`). + +Решения: +- остановить конфликтующий сервис, +- изменить публикацию порта в `docker-compose.yml`. + +### Ошибка: `Config file not found` + +Проверьте путь в аргументе `--config` и наличие файла внутри контейнера/хоста. + +## Лицензия + +Если нужна отдельная лицензия, добавьте файл `LICENSE` и разделите условия использования. diff --git a/__pycache__/service.cpython-311.pyc b/__pycache__/service.cpython-311.pyc index 61d27a23e7b364ff214414f95cd0d777df0e7e01..cd031ed3302db64599107678aadfd9c50b1289dd 100644 GIT binary patch literal 61063 zcmc${32+=&dL~%+fjUuD018I|g_{6z5*JU9;7y7IDUlQuJYOUU!@8vBQ~|v{74Y%yM=^;q8r$Fl2R) zHH?k*e*eolG7A7j`G`#<{(SZFy_fI&-~ayqfB*Zg!%?Kc^?%*{V4V9CjpqNLAKA+w zJG7srwHnPG4X5F>S2Yvb5v`Wox~sYo9ee6W^z3OEF|em`#K@ke5fgiwN6hSL8L_aZ zb;OFN{%X-g(MZvRZN$dXGhDS#I7S>3&Jid3ZM^E5aF4i|+jO;f!ZYHT@Q!#Vd?UVz zl97^$(ved3&V03O!aw4lC?6@Gs2HhWewM416ICNs6M>PyMD8?74dBzKD2@O9%z8}}mD4!?HpG}nn|2lo=U3C~XMWv&m;E*5r#+lsK= z+$-D;_-*3Oa69qrv1;)4EO(CEi?_Wx%>fO!@9P?F|95m6&G+yxUn6~7h&zbz{oJeE zVLS)8QSLcBH;1=yN3O7NyjQ0QZ^i%S?;z6mvQahPK}&uQ|MK-UEvGx7X*vH3`q`pQ z+eRlRr=p|L@u|tkaEmT&;YOq3==el9Z6O$&nhe9YboXc^eDLJS<1d82ay=Z09vq$I zu7>%v_v9r$Jj#tvUZj^N!u)Gt9*G)`k47)G7}JKm<73gZ;mCL-nzkIfMjsiynzo#v zL?*|=Y2(T3*RFCG0N2VuH7!pMXHgRuXhZkO?)_dou5qWBT;^ge6yDx4*Y9By8y=>O*B`l8qMXL zJDM3o6zMX>V3)cTl8P|d5E_iN`#NsMKoo}mgF z9}9O}yOs8ZkU$8@g^&hnj(w7^MH+uQG1?%G9LqhxDbvUM)nIvJ%a_Rhf+H zK7!Kd(N9dnv9di=lNZJ>1}}_X4F@r*gBPZ*PjXv>X)PbKUXNaA-`pM zjoMgUCJH}=;1ky)(ct-Ta5Q-M#IfN_i*>YEcnfltHid6uq(#z2CvHWe;fZ}W$1$|0 zu7xMlhRcyDKpJ%EqVdT1q&y(g1`e&?V&Lm2x|MQ;u^GLT)?K)SccanK2zB8gE zib8DMQfmArcu>5^e+EFmbNgmbezWMEqM1V}n{UZhxoE2tZ2`#^SkY*W+m@Yvp?u3i zF92@Qxm9v*6)amHTRm@8zgaz}PhLq>i`Gq&brS^-ESz62<3255T90v(HXok?dg|G`_F!2-%sZO6CW+_ji_@yR$Fw%8=<0E; z57 zX@asxzTZw}`)pc!Gp)Uq*2XY}=r4w&d^-xpcL1b|UL3u8J-knzZ!Vz2MO$=y554XO zh|pA;?T_w|{DIH~erh6whT(VMEu|6(1I%br4)iesd$nY*{?M4bFn{8MD`Iey6x{UVeGi%*dVji6?AO{u>|Lzn~8*-Dmp4vfF;gZ=XswZdQjOy-L(0QIE z4bwkE8fMP&PtJ#xEBYs=VdLyiOXI%dJD5$sM>8tBFe5mJ)5Zy+nPT-BEYA=;x>9s> zJUdi^%`8ZBN7^2}8M-!gBg}`m^Al+seqBT`{22e6Y@%r!pZ86B2$=(AMBd;w${(Peo8E%SP1XWBVIN#`iHzxjVY`gHN=`QtlVO#BeO#_uMum%u&( z2M8Pj!27M_&W&e&8~vnl!EdMcI(`t3w3U^L#;j^Y+c5!w=nJv8{1Jrg!++$j0Wg}~ zrFV+nE}9z<-Sv{Ye&!I3=J5}GqOD1?HPL9^rDdpmP;~B;oI3@}&ShKWa!JM9mgG69 zvR5qWlS=xQedTj4@3p_%KJQqFh!xwUify8AyX4!x{)@_Bvi!S&_X9%bo`)yI+Cx(9 zA+hqXRC#zgP?y~D-5u}m5V{XM@{09GrTU{{U|0$aFPBx#z5L$Uch3qNw>>y126jt< z-D24uscg@R!)!NwssZ>VffbF}Xv&aeur`@$gkHG&u^o z-yWH|&ZF6`j-Cg(ZRLp|oaDkc(|XKFtdhr!sITm0hl61tnTCsN_iE1mt7&807}va} z;iu!8xB{_Ho2)9TIc`#q>S^<2cfBS`NH}vTr>hIP1`Qto-K6+_T>smko8tyRUEBz$ zkDGp5#~JQf)G2eCujy`RWcn*=&ZZ6u-H>(1&FUDD%BOzExb}BIjhV-ETFnW~?XRyB z-xSv&pXPf7W&fJC#WMWFyfug#57x&lAmh*`!RA<5b8d#4ygm`;$H&@&7eTH)(RRd2 z^Y1QzX_RveA<^V7YK9Z5fZqNr2!x zieUUOf#U?8C-4G5T94k7HoitvXxhZa5*H!rkcc?CWJ0bi#3ojG-kb=JMy~VW37`i) zgrIxyA1MJTKciW3YTUjRjl)>MKti9gIcFOZ+Y;MSK0jv3Ly3JWI-{d%xx9Mr`nRj@ zR?V7bO`k9O0{3Eqw{=CMcU0xUa%nJCRxkL%xaSYa&j%6tDZKJ(#^DQps@IiyeyIU? zTvoYa)D>5)=#dbpxnEf{KL0yAlBJ?|gXG;nNqe%Xa)OVeROv~n(o+}-WgI>(pX!Q6 zSL|6aX?zulqx82@uCbT>szPHcnX?NeFXEnmS$;k$lAnNL(P^pZv|uDU0%vyN+6qvINf(YMy?H2f)Kjz3M{B>ISUqwV2nB zb%p|+1&GjaWxXrgzk-?mbz>Y85PIb5j&>eVUZJ>O1;}+YtFd}T1EA<=%f5}NlJZn( z`Rt3S%EnZnB~?+sVl$OEKh*&Ik^nlPQ{yX}GtO=m>;d^+DW*pCJY%EE_*ajCJ%-lG zT~sdCdT}@km%khsHF31peZ9J^&Hy=1>!%HIJ!b%dMa!Jfz-@%PC~i=q&KTMk?+l#j z3Tx?1*fqkmn1@@eY5N7ncbOc!6}l8lo9P!h3>OGG@-HXw)56}8%XlF~4{Po{A|Pg3 zdx5`#Vtr&-3y-wu`7mDc7YJMgfX5|rj}y2IK%N@GGd6_oLXDZW9vlfB8V(&jID)P= z2A@1u;`3dF3d*{QjEEXC^L;LJD9Y;2at)ED@E>^%U`F$+B8_KL%3@p56*-GmN;STk zR7q{hQ@K*(_F!fO_$7g_9-i5gC|Rk~SZso`TQJj|a`_VWl(|T7_6laY;qk3oqAMs^ zfsO;8ETgdq zq5w}Svn;h&ROzX89*DnTFD&*;X{-P8y{IVY=W(uh|%w^9eD8Tp=G zJ+ppIi!=?YG*wHEOBFXJb9qxb zID@+Ugl^XfS-{(wHpfjfwH(4@cn4JLD`Kk%73*e2d_hJ^$+|DS6gP32Q*_ zaN0gTdF^@@Z#*f=A{7vpb_`{WLM@Lz3Dp-*jNUBpa;rljkXs!H zKIZV`==rN*Za=>V^}+Ca_|N}*C~c>>jG2#hC-c^5#vj5PUn%fDv7l&d4ww^jn#EELAg`V)V2jU{Q?bp0BMQW(X})9jKq zoS&My+G6FuLOJFMM9ACAkKPDnTP1_u(+<%27sFikC)Q_KtdE+_E zg_zEw7MIC9eqOr{r67z9>0g6f7@3Zf==5x@;~M zJPjXmqIsia-bhipQYx zJ#&~=<(EHf5N*wpt@#VTSyp@J^|xOanzo2#Tct7-z?WLX%UmXyYlMdW2dzTGUfj!A zl2-_AgHr8Iv2vGGx$9?J#L6Rr#h-fSkiWtTz&}^?p7UL2^43D5=-({)H;djal6MQ1 z^}@gbVc>*N-$VDyXT-o+DR6d`|0OXnE(OMy{eij5?@hitIe+fKi(>U&sd}&I-zWL^ zDPNovt9MJ)yG8#V$-f5_tP@LrfSLUuUM)_+Su2_Ak}nEvJEg{5i{@Q|c^7yYDSLRX zQm}{R8*3O>5mrX7BCKJoMMSVT6O)oTkTfMBY#Kc}yb(bo4qx zBciUKhSt?0mqKCG35}A>c^az|q9b!IpIDzMCkFL|1|mxxQZbCe*0toWUv$?e56xc}-F=d~Pqg(*wtm7(br3Sm9uZx&lB-s*)P90dJSB7t zD4_wo%FpYctC_Es$}`wv=E(9YpU1A^nS-gi4h(T4L|Oo%r8;4sy_E7de0WRr zcO@JtcWv^l;68wRkwKw9>tH`qc3;X}m9z-%&A1mCoIfHzWj_jGalhqy(=}I>yeas* zL`%11=@!_XvUz8Yu`&L{%0?-egJ)Hvk{B6mRQ6-6eu+hgF0Px=UBt>&MV>!pf$*&4 zImUusC7S}9@P2`i0VCxjHd3%W0IL$jbM}fClIEt#Hn1ucq;@pNeAj3gtI`ivrD9BH zmP01SOEKRoNKeI}R1n&ibECD8S&DIL;w+glTe|L-tc719fpL;rXVn!g$$Lf1^6t1P z>d*fozJeMfJ?$69`|n08^Rc6WygOd7EK{cg98ol!c4djr@oYo87lXmB zjuw}K;Jb}X$jYZr|6-x#P!M#QN=GV22dgA(jtH<%6PSr(_|)5R(lRf2)3W{F@!`bjZ2@f}u7- z=;Gr_*U(1Y#~WLA`;6)GwOa(%tbVW#k+8&*leDd`^4&FY2YIgOT0IcL34e^6FTfpWx)nmbw`C?ALE<|FNJi3jM4fQ`4=(70~By6wwkBj^lT;KKSD}>i~q>C02s=!H?BqPIEyodXx#&cSiDyP zbnca$duI-$TH7+@i{1!S0FWVHmP`EiO6KBXd7Fe~UrD=E(w-y`Hlv@@p7 zs`9Rv$`|NoVBxi1Oh*C2w5}V{SrD=)ZUtZ0#oAWQ9oBHJJl{|qOG&b3mVZS^#l2=K z?q^P=NLAO?Yg(>Y{oQGsDn!v5A?$IRDt90>ryX(oDnDo30I7f;gujAN<~&S1>0OHx z%0Cykam5)@d97H!HDcMH8lz;b7;#&UN4q*_rEA21_znVD2w!Qb9Jj^oaYx+AmEE<0 z`1KE`4NN0LH-0UxVT)y`)q-9epM{Do)2fU)J5~C;X)7sh1M`I3*j77mLZYAdEwLFD9HQ64XxE8$?ym2W!8O)@( zBg$V7cf@uhY?ez&n%=jFaL+{r4J}8?<%={{!72@oN3scJ(kNs!f5|j9Q9jmBV=X3d zi;e$2)dJ%({~oy(2-FeyA%Wi~utvRs4NNO0nPq`x20wmH=3$&5Uz(#C#V?D!UHtEo z2T@=0=h&CnH`n-Fx@1*lGQMxgs>!NL|XxlE?whOi+5A8F1 zm#y~1$Xn;$JU6#DStmLgB}b!ZZIY}_OV+kUYny28kgOduyNSzKJbM@`4BX3}(%Bd> zY?I?+S(jASC3?CgPj|wcFn@wq{5Lnevms$#wmEaT;ADzkUXkFV`By?yzvvo}TmzzQ zvt-+hcWy9_?@YcunS5oTUM%gGO8Z6kfaD&)@}#CddHB1-?+*(-M;~1m8%{|Lr^K2U zrJ5HZn|)l>^xp8h!@|Zvv1+GOwNo&Zr`GTyxo(5dvh6`kXgP#?IoO!IEOhUYn)Zsp zeNu4WpIgP?^MWOidghQ|841-rLiJW?lCfoz%raW#mnz%veK5Ft8FxG9d~9|SVRlYC z%gaxuymn@OuTXwQzD4I*$$3_=oK2Z)Ry1~_Gi9qv>KASGf~}q{imoJk1@{&CUcDHy z2fkEbp{8rm)+N}wSkkXh(pYlQU6^!{rnn^Ktw=fDDL1%OzEnwds$qL7&_Q=q8}5}d zJL$H`AgkbJ&g}e1n?Uqovq9PiVjWLVygAPDIQPVn6PuInn@UhlycR|d_5v1?gG${B zHSxl~6W4upfHoUooztUSC&i(hw%!zq^%XQ~uHZ|+0)7>dL1J@g>X7h!J)Ij62_RWl zUr>1k>GZuE`2ZbnwlX$>K+M%P*52mz z`$?o^)z=+RBh+xtdj&){wCxoH1c(D|+;rdd9rTs&vHn7pHf@O(XKRnX%elE?7S5yA z7@D?1jO@)2KkMbdw>k%Le9)MU~Rs%Cf?O^$x*V<$Z#Uh11Gb19lQFIc1$gBQ4 za7m!MK{Frur9C9bFLhR6vxp@VrnlAXW@W z6$2|8FL6i!-f$+YvpvhM^11qZQPI^Pxf+n1^^_K~Y0@qPPT*d6S$+bFo|BU2q+ma} zT(&Lc_NK}kQ>8U4W`n=zQw_i`2_)=Lbt~^m*O{SOdy-79Dx9!{a9XQXQwu?1HUTB4ecp z>2i)Q^F?9@q^8fL)}~5LIdv59Jycwef_JpaR!_b;DbJo9TE*H$@%g_@YkxH79x5Scesia$UZIWD@1k0wB*)dzXNLnXl6z0TS`8UtK zbMC{j`Ig_A{9sZn=@ngllB-X!^eq?pgpwU%(V$c`C>RE1*=!Tj49)0SFgfj7+7#+t zx!}6UMDPVT?r5N^3BnfS&PLYVbTr!H+Sjx^lnY2fnJSo`c``Q%pVb$sp07bRw8gb2 zH2l9qcyvWJv&oH-HeUq0`dUVM&w6dPJ7%Qy-Vh5BI#2rF@|N;j{9hrVU#Hd>fdh?U z(hO_?!@0SKkoI28ECfYMJ%r^Gz(aF41X~mCR6|qZ0EG4S+LXmLYnLp6B}>Dir6GBB z;i%BKTeR$vEPDiYr)-{?VYyH0uk%+KS0HV^gl+H;G(Pd?78iM)Tv}$F9aB>4IobF* zJymgboAR`QHXx5{p~0^QgUgsi8CG9~=nGP2q>{Z9XifvRuMrY%yj4(*v;{{QKIaA# zN_)S6EsM?1W+iGXH90MBLg`NZTYyGF>l(XC~P-vYlQp(9WFkvs}kU4018*Mq~?C~OOJTF*X z!T5q^+WNZnhE|nd5<;*G`K_6@s#14kQnOxmWJ4j{RjY+ux>eaw8VyqOp>HT;`ka%c z=332&RO$p>&aFI|Z<>k)e`bK#qH z>TgCHv+sattm?j^{zWM1PxdLUT^$_@!ybc)8Lo)LjAZ{J7&BqZJ?5U6 z9*oDBa}Tqvf%VTQRyo+mbyDWdg84}s^fQJtg3V8~%`xwxNo=Yb=j5IDwBI<`jI5|h z{1z|&>y$U@>uK`~vXP9m8Kt6a`20rlHqsXQ2$mwiM`;rWD-J+QPI_adEXw#}nbwDIj**HicPm1pDa)nAQ)X0pLl?qWEryh< zIsTUj`6gleb8H>5V%PYZlU~8wgnQ;ls;ow+?GZ|QNr!>)!ZxHVWlNUoMN9SE4bjpp zS(*h)^Kwz~?asNe`Njt|LeU|y=#W%&XlBofjx-dO9lqP4xfAm(f}>Y-^h%CiBD!~L zA6S-4E8e-B^wYl0->LbaW~p{yv3B5rS*+b9)$S5YhosV>!~rZ=5Lk{PLl_r;53CCm z_$$LG-LjE^H3NVC`6o8_?VV6fFz&%O@-XG{ymj-2b=N0z0e~+AN0yk(Yaf4?iMV&@$t`>@r^;`$jHi(gDj0*^LvEK zUAPza{OGXk5}iYmb4aiZ$#Kgl=8imI(Y7s|5GuFHx9Hq1IkyXz?aMZoWCItHcG)T+ zi!r$16)FdDKgc|DJ8|0Am% z?msFq>@GL|QMsPnRa$Zfn7h`suT}p?T|=fl{rdl@--922Y^m5=r~l)+4!D2jF4@

Av+#SXHHJYDmiuSkZe_j;W->m<6vk~qwd@pxpCTZxIR3 zr-HDCb;A}c^T0FA(E-SfUMaRzrwMJH+#CyPXw_`4O2_cb=@f8&!9!H$$N0?YtfE>} ze8zROc(^YR#-pWjbeWPSSx+m7`vS2%(#w@DuE|qioDEA8b^Y4aR08UyINP_-PU~zL z;~bn*-D>Jo@ckU!rYg^R>A5)f)6z@k=(~L0z&5~-@8vx3O`6Kt#WcIu`&Oao-5fW6 z^&6_*V2)O2Q^Hp;d*+Re74%Mzy4j@bc9r)fiDJz^L<7FfN7iHIkluPFY*vS!cBsDUSRWf;<6?KV z?oopU%1YdZE9_+f#%)o5?Q2?KD`(uXS2L?U`}1j+>f5eo#tN;4sm5LF^v{i}#;`l? zW@Ff``nLO-zui{&ZB@ToFCXpdSl2ej-RtDIqabAIx*-eG)M8!%t(2GeKFdrpT^uit zG^?nP&UI_Et8nH52cUa34*?j`^Gvm}NuA$o>zB2sAml%;tlqenw{X32FU<{c57)OH zeKzh~XGT%t3m4awXKSkS675lp3YYp_yg2TO_9@@1>t+2^@As?6e01O+lSxp~W`^>#Ahbls}9Qr|q%OwVXYzwB_1VWIPH3crcBj!N^ZrCP@$jk653afevN( zT#GDHmn`~mHZx?Oy#7_nG{jXoVHxrq54d$an00nAI~Ob$;9ebZ5c43 zJQ&5=&q!8~+cMBpxMg4+#q0u+o*DyWHWoC~a`_`2(Mc1H+pGae0v6f(B#PK5u04iM=_jLdd|+E<0zS06Tg*pPhfyRr9U3oT;v zcBwfla%XQ5Aa~1h40nbGx(q-=)wOvK&+kh-3~>VT(z$j z#C1S!M_fkxrD*h8Nd8=3Xu%cz%Jp$@24%wp9TX|JBiO8rh7Rk? zD)cKiXvSLQ9If>(m$ht;uF$f{mPi+bJi|0e){Tk;$wY zZbv%Xh`I3!)s*bejhT0cM|o)2rX8sAOH=%KjPbqEpkK*;4eaKen^`7+@RV}F>|*j{ zuq*A%WG2JM<=>;%uTf5I(6b?nuEASjn0kirl$m-+n`xsitdOaO9W8XRFwuVY*KlM+ z+2ds2m(%1YTcz9`Z`M_!d)9E6XmprpuoHqaLM*0LT2H|*g%uS1vgGr@S?_{b#1-Xz z@XMz9rLfS!IqvE?=UofL?UylCxD>j?uu`t!D*=WX9_5+F?*dgIO_Gqyt33+zjPX8B zVQ&(+MG-9dw1Gt+-o745+vH&)|8Qja>mg#Qr){u}9Sw~}$;xxugk6v@i<)-l1g9C* zaL8b1=o2>xE+ErB`$^#Ejxn~a=CPGaaZkcWvI4Npu>wzSAx(7&h3IznI<0n*n{ zD0eZvuI=Ra{}L!8@DBuFF;K(*h+KMd-6q!^0>44vCj<=iHT~%1t=trxQM%_H6a^Oa zHM~FpN#TO+>|_Q#cn`(1lI?S5s2%3mr^c?x8WAw^NNy*Lfy1afn6}Jtdb;Q;P+cg@ zOyoLcGbUWN=lIyW%ybIdqGX2`76JFYhTcVqPF)LyncF0rhKEsSMqFH-n!1*@F{hlB z7GK&B317XCuQo(yWL3ual}uW(A66O719zuOGFogZG9Z%nsDD!|{vOo>Q4TOl%}n61 z8OTE#aoH(C-gm&Tom@}uO!8Tz9U(E5Y;8DQB;#L}HlahloB&QF&_sOr3M5slE;e@3 zmhAi;^Q|?2?o$eLAVy$SHYE&_#lKuqn`{zGTBMQ|co#h`sk}4t_DIq) zUzgh(S6X%F^4ph_<@3A6(oU(gGhs{lD()P9`!EDtzGlhSj7_`H8Eb{bmCV*xt#trO zTtc8v^z=)fe!<>P0XAY+ud7XRVOOu#x|s#&7d-=#XF#wIP=J;XPl~Qq$py2ET5CTG z&?|cSBu}4U@B0J>8e{Lh{_gAZS040=HM^vmU1Ir=R6c}kJD00!l11NjzVDoW{Xt9& z9+rZK#j59|s^_ST<*mhX_tcO(v{T#)b8rUEsyMJUMJn<>m8xH5AwCHk3Ci&r!*9RInfY zq{Kg0{$AkSK=QTu*E8F#OLj^nJLMgs)r+=f!Pd+oy(oH4OP9ABfEVzeLkRaQuANvA#j=p^~xl#0WNWKnvP^pH*^V;u^Ewp?z zd4KZ3R~9>V2_3swR*s3DCgtP2(sdi92j+W(=kJCLj>6dcSIZ;=v2DAYAZG$Tt*3Ggen2sYa)^XgazQ znE&fCnIqdz?k>sQ1!dBO!#^4R_rnjjiM_|9-eaU=dV&;8gS59l==>X}He^CGz02LFQ;qG*4bAhGg@CkSr`WJdYS@K=v55xdCK{BRN@4P)aY(yH zxYWiSATAma6ab>;#?lq9lZGz9r+PPyCV&+JFdzw6ou;CdaN{Qx^>;hOidKpJbfvcK zWWy*`(}uUMRC_o32}89~S!-%To3vq@P<5Y&cV z3*!t992vpyQCA-*ED&Tdx4QnOt{tZ8JB?IW_1$*Kn%bfz0KG?=Bxy>nT(UJR+F)ia zWBlCOnXSy;}}h!_=$0kqNPzFEzVgeYpgoLepy2yzQ{T-%gN zhw4=M;wV<+*(+WYx2;xsj2B_Qb&iRPZVPk6r0%#{5TG(0RAAc^A}U=HePHz!FA&p1 zb+IxZ3VG68&I!Na&ean(W}(#;3a$<&Y$}2ndOL}s-)xw6f!V+2H{WP@qahm1e}y#l zKhO*?|{n4n%{#0q*1vuAHUoRUV@P;&i~>(;5<-& z%at^4#o1)4odeh}@6PA7piOmg+a$(Vq3Bm7GB4Por+Qb=a%^Y1?{0xF6C?aJY8>qQ z>EdXPk&gaR0%pFh@at+e>9i;A!Kl}roqn?+(SZFOrC+xw#n^=wehX8Z_NsF1Rj1CC zWrUO3+nbO(Sb3Q)!G9_K%c2|e^>(>5>yCT50@VSAlMYP#QNnWkSH%73S9hl?(Jt9Y zns{Zt)tGj?;`bX3ig#729$lsESITx?-J|2>Yxk#UXD$`)4q6Mx+LzOE{&H@mqBQE2;VcsOTKD|m%=u3*^CZ;@5g;yApgzj z3e_koRksa#@as%Lrw0AFnev;LYqP1wgQ|AWDRZG5W9pJ}!Mg=Yap;A=4$=DB*x9F!jWJSO(6UcyF;&YJ ze__k&;=bwPSsijyJkzf#n*y__^8NKDbvoBkxtJKSix%zw$ zC@7EOjXK3u^Jg)qzq=3AJbAe95oYajp5Xa2sKw;2772xw&o~3b?&+^sbt9>s9@)gX>IoWb>wc za$WdJePL{M?};IT+J`pDKz7nCZzRu4pBTFpLaHAkhGK`oYxoJN#T(>H@+i2OSn3Ac zv2F;^3U+`)kar;(9KAsHW@Ke$vWl3oKojWzTYncN^(O@W6aYIT!{ok1;6DPSi?VwI zLlZoWN4BN>7vzGJW`I0sRm^VzP%WCx@L+PU7vadry$XcQs`X2Oss>LqO^6GnIFx`0h?q$*6BKcdskDbWAh|7l z8MB}1V&dCpi~_(u4Wt9<(yW2s0)GUh9jT!Stc|a-g!n&1@~Xz5B^YarU2xpdTmlc{ zY$ZEn6u(zr>Qy}^Py?2(y0`VWjkl|^)vo1-5fQadl70R!3D7JaA$kgu0X~Q)YzV^A zr@V!B<1^HMHt@Hp`UwHRVxWCQzUKxW4gAISKi&R65B}Mp?DJqe@pA4p9IMyixnkEL zt=vXp8>0rWk@yYl?R-u118ZE1{$$S-Ozu?j24Nfm#@&?5Mx!FTBXfooP8Je=scvk)-H znMkH5mXE$RI*tR=aKdR;T$Fjmzk)oH1JT(Ckqtf7QaCD5v!Fxe;aOfoDzS$A$%xpS4e|;Q>hvmKyj8f*^2F`@r_u=vCD?HX#%n$25^-km}|_ z4W9KavhcN7s-q9CzPN&EiPOr&*AKctLMRqm7${pXUFlg?Sw@*)9v(DY*_oqCjUQ%n zW?w|nM^S!%x{RapqG(RCCrr>IWe|4>e_;; z)*$rKL#iFUaO5_+f1#7Da`O!|GeRW(M)IQA^qGpi(3K@~>iUPeiZ?RuMU zxVI;1`1YZ@hvZ#vN+{1%oow9Z&APGehO)Fh-?pD_$vaxL12-G`p*|b~1wQ8{xL4WjzYr>g$w11XOC-VMTOHLWXc>!|~CaH|33d8X1`1V!m32S)l zVl#OfCy}9YGxT80zaFJ>jHYj&g-ze7z5xN^{3EbsQ~B6mbr)!9NK1lFf{=7pK+>6< zma6;2ihil0Kk;0u1U^zpb7KE;dDYxY$*WSJM=bA^%6lQ=3^cxX=G`+wEA0GklLFgF zRJbYSu1Hl>OBJ1|#;)bo-i32NJ1MpvmRb)B{*!agIj3;^;lwF`a6g%?hW8%{|ZpnlK@ z#Rs9H=f}`|*ebcU0?*<=$eMjAUoh2i0J6a#3C)8fG!J@a?T~5uAc|zN(7Kc_l&lnd zI3SYl0_i8thAC^EZxc%gq|$-Jft0;w$=M+|N%kzL&^W4>>Sl>eYhXj;($ej!x^X zR_Z;JC4f)$J`$w@K%tC8spM+Y^zMOPAQ314`8+*(f zL|3EaYMj?Dx>^KR3*^|3w+<$TQ^5v=Ne%6T)<9|ttXx%pQeG#OcM4rY54*+keNy?p z6-}pO=cfc_jj4VdztITglv&5)CK#r7LF7(gb~sg2fA{tINvUSX?7>va_6O&rmLreO zNG&6RuW7laGv#kxX!udy@`?l!1*H?fQGEtlB()XRoC5}Ow~3@wVM#30;2=~U9Q42Yr=SJ z>`pay|M-yDxZ`o4{_dIi+Pk6DDhDFgB549en(BtTXHo!HQnekaw)W-jUg!gf-3O%Z z11sf@TJ#QpS;tDXrUWOzw$kayXXiIdfnKq!Pb%yCL*s+VM=wc(FN&K_OPfzGmYo*L zPCstkgi7AfA@~|oEr3m_mO;S(j>;&8fvcvJw?Xi>rM!)Tw_S$+6-`{#I=@Y-8b};n z?%XVFc~0y+B6S|YprNq{Kx5H1cP>@gCRO$?{F+p`XV#M1wrAEccLkak^V>!DW;&Rq zLvnX7xwkL6w?7CyIxo7Pm)y@UxnEjzzoZ<#4U|H|9;xAQs;OhSrA=tx^{`rMKQ26f zR%$;dwuGdX5S>*jx4@@*3yom_s3c{9PvfYRCAP^9(cUfDy9GN=sVoy*H6QlPS3j^w zEkk1cZmE9v&rUtsESx+eJ$F{zcTU=OZn6HHP>+JN%v%2YW1CmNN!KpVZ0|dRqN`4F z)g?z4UCn~4dAYPssDD8$Jt38z5Zou0J=Fr#t~}36p63Po^B5}Co5Vm5Fgvs+(3u-| zEH`bOKe%vCYTGL|?US0wDr(d7DL>BJlKgEnC0v9Vq*Q73+)b%;L#n3zaj@ZoA}KgP z!yzWs^yQvStsh*Ln)W~|;(n2|;Q%V46lj5fQ&X`Khjn_P^8k=AuQX_!p4%ayW-F9$ z08(`=iMel~d zx8YQu`7bwX0sqQxd9h9ZS8awDyUc&pqXlGZq9?AL<)q9($DkE+mk}-y@!HQw-I5eH zlDSt(2Uox|!$Od_FPST@RdUVL$MvXXW#F(Aw=Ne?&3nV4y6b(v;IInV_*Z^kb%@bZ zzh9>MenF~AV?D(CJHgaxTA(Vzn3Q2;8|(P3c*twe6XaqnL0W%cWjcmOT8}k(T8E<` zD2G}8RN6y?W}bK@!`-oJCHc4deE1?>D!;L+2@{9PYPHzj=E0oaktf91RH_MLB=3=R@OMTkecM zEA%t{bDnQ*d1uSLTUgo(_6;eQHxXs)v?mq0o|Ct^l{r&RwK^qU0rL9K*@@KM2Ad4f z7_fSz>O4wrfx06Vgj-i#r68Q*a8Y%7obj%n*;4{Vp1qXzaFq%w7xb{S1fBAiS?9@F zOEL}fj8CKHWAo3^V$ZDTQ^fF>`_#+iBF=Ey@?7{9vkiomJL_V0dB@q*B8P`^H>MENg{gT*6KY%56}ibhS$^Qlqr`Q{J-K zUeKdf|0g9CbKB?jQf1~qk>xM=4Z_0*L?#1=lgz~wSKQnB;Z4CsM_kfLL|MQ-g3kbP z`^iL1F>?2NgG23_kK4<3Tl9Zq)&j~#oJ_RxjpDt^3JSFo>ybagLW-=QB;m38T7}l0 z{Xk~cBcZqeRVi?-|Gq#)%BVy_1$|k2+aEV3S7!!3I6D!=$?rJEK^rJV_QGjl>-0aE z|3f(ymL{~f4+E9bxLkD=p0_rmz5S|6IZBsY6^A@%i;wy%>b&wHmD1LFseHi!C8{(E zcGfCRN^DkzTkl)uryquL1$B+CFQ3Kuzr${YqIT(xd==Ig1l^y*-Zg7~m2#H>27@%K z)Vs{eDVU25}N~QUWAa45R^jG^oeEr)zOLb+~{De zM>X9sMtYV3mf>zG1!QuhQ-qxAkmQW!-o9l0cbngDp5O4}eGeLcc;rV%#JVA=Zb+=! zEmiHFfr&pk183+_q(G^+;UV*j^Aq-4RJSwqg4l_ev&ScKf-q^x=Fti^_b|x=ZI3Yk zkw#eAV5i zN940~fsEylF7J}7Y0=e`jK~{Z1Xm9dpd&bsF8LZ4eT_oXu7{1H@1W#6D7p?wu0vQX zI0B#e%IDgbDmE@wY{YrL5A|ZjUa4ZQ=-Vgx_R%u(j6B6)h0QEa6Z|_YnaM^q!B1$V zxfJMF4A9|w{^d(P0*)OPr@4$4majr-J|-5`TNwB_4)di( zCDEfY;ALE2D;Ik+HJlN~zf@=2|Lfcj`wPoW#52^s^1Kqw36pA`seL-i+2;=>#sc#x4nP!4Gb;-mP6I#OQMJCQm zt}pS$pD3pn)qw@78dOh!IH16l&!f6_)gcQ~RP2f^2uE~J=0bYD^6ZZS5!(F{^}z&8 z>I@@M&~r9%rL64MY%OqQ>LZ6aKgrU8_KcI*D>9nV}?* zM(Vz=E*}iOV$#Z9Totrhz9@!G6(jHrG3=PELAp#kCR=b;RrM9tlXxo(4`tnYt_E)_ z6mMbm4?e{#40;!8#~QaQ!n^SnH3efoBtgntTyU-2K-{VBpU${*2D;`vX{cA{TXfo~ z8aoBL&!i(v#KEK)r&yKdV=-SN^Ytoxb&Aln3jcf>bqZf-rQ>`oJy*|8$7)!|Li&p+ z=Gw|NpssUhEL$1$ER^)B8~+ClOc?^oH6R+|2+kKFm31~qZ&gj0>k816^h4I7`Y6q$ zn~**;?chAyYR<&x zzeo99AV9PUAE2BQk(S>_&R3IP4FKpPGQ`T8;D(OGm2sT1%`vHVtOYsyTX|T

2J%!c+a>98hr^}e$@N!rRLwSod2>5?X;2k6}%#>O93;3;EkIFVTwtVT< z%L1PDY*g04_Ja^)O{=vbYIv)8JHy;%?AD#UQK>lW7DMkY zDZ^W-u%ztw&)${qQZY9oZCExrAg6fo}wWEog=)+0d&c z-<x@lJicjcw(86AkMkp!D; za|79gfo)d4e@FTGfWU_YsPr&^!jDgk^0(GKfRCR{2RTv?MeC2vVs ztFfh3s-gjsl~iqgs;2I7MKI~Ri(}U6$VNxz0k&8rBufPBnqU)FQ&J5i%#dsDky`fV z90=A(!2rsOYrsbXNF4iB1H>S6rny_t3>Cetk{6qhb+m2^F1ec*-OckZ2*xBgM4=Fm z`5y;rmjVNefq{o+;q+M{FdzobNr7{at-1n&t1;EqH|t7TM0ZQ7=Nw#!+qF(gpS0{G zy@Ly6GDP0*n({Z{km0$~r0H(8Sh7JX*--eif-2_Mq+pL&(JNK-Lax*b$p`?1ovlvM z-jsN5E&v%*$Ckvgxuc@JHPyKtu4AITE!EYt@M}2g2=^j`4^PNX*$;5uFmHHt5y20N z_Qq6pcXASudnJ3XVDE(k3qQ;rbT{+@$~)0T@37<@hKReg9Eg_g<^e#l{GA9}wk zS%pLPRQ{=wD*1QGT=AP{6KCh9=fAR8(}h#~G9e$AlqdG%SjG%8Kz_;&i)DwUvcr$| z$`ZhY8HZ&gb_z9}iJhs^rsP-97ZV2{6!cf2zRKF9vNl+}NY%F{ch2?B^*%5^bV!?r z<=-#Ny@1~?Y4dR~0aDeCb5pb2EVnT5V6)V-H?^U29yS+~h6ksGeW#@zFF}9^qZz3{ zYpSdnX9JR{Zy8vNR!=y@oyQ>3es&dO@5}E3j=XCn0T3g&k&^0CFC(x}IgHZ{O!*+- zx$gjSS8O7s%?HY591fR8uJXK9DE{Z-e>uf};_OdrU)N6JJPmr@&}O;(_g*|fr*p}(VbQZe z^t4JI;2@KA076kmC(f8$a@Q`pYejdxOS605IR+^}#+$u^__nwc;#;L(K4#t}i*Hwjg13Bu^W-4=^H58G*!b9z zJ-BEGdX{SYM0>vkL94wV0#|R@T18{!Q-cWYP55nJmu8`8Xh8o5=AljckF_O3b;gfN z?c}aA3^iFlZqk#xMN95h=H6sPa24r7y^@V0S@TxeE7*n_1zQO3a-#)q#F8(#*)o@Y zaoW3vF)SVFPrVe?qUY3Wx}j`-Lw8gEvgU@Cq*hw|v5g|SVNm%wy+yXtNV_tVb$0fK zIFxm(d@!c%$n+TZpmQh~!D&lma`al{(o|G!ttadI6Y5ZJA;aiUvK-%JCA|Zy^Ko61 zRM$tkFH^9}AXRoSy{PR_Nh5g@0ev zulXn1Ub8&dOnKw{{NE!)-Y7r+IevbmV+Q(}8`U^QA?gW~68JZ$n$^Dl1NoZdB^?WE zf`=j&Nx2Y3*OZ0GzT;ZT>?jeak{TtIK5IOqhQ;w_6-~~ zraa$)7WBhu^VoHshq=tVOTSVVSp8vpkoMi*rsmB(HLdOPtB9Y(8TDKaOe^= zbz!tM&)PX5TeLhV`^z1l>Q$9(LW^FiPFly%ew;T{2L|H{8#oiE`PaHUdvk56t~zx{@7FFVgeGg1lry6P zS%y!>Y%jGB9Y555GH1q@_oK05=JT>u&|eTmydQ$aht$*b1XA!}vdOgJ(!}Uk+6fhR z?7zY3x8oO~Wl!f!-GBvTI#`j9MsAG5sNate1@i*YXk=|t?xrM^bY@TZmF)<_2rNty zV*eAfDvXnLl@>|Ub~#V-%ZLeS2H7R|abE~jW5zCVCR&(_ZGFamzDC?XrM~{3@D)tw z71c`>or@K)jMObvbVETsut5rJ#; z4ni>L@{(5M_JwNEwM%mCQbec<-aWEZ)wWpGHXnL$UaT6Ds)iCrQ?9BdSKXqkPCmRhm9HO=*oAdEoGKR^G%JWSYiE!Ffd*7S=to28n~v-V|gndEIs#uv_s-o27{ zFHw_N|J2O&ro8^yK6#+h49A~9$$&6%8Zy~uG@ic&Lyxq>g#8XhZ($o6ZM9+Z|5yfx z;Bq zgV5xv7m%&AjRIwlM>F2Q#@2A!$oe@bDYoii&H3llrhfsD-FO+4hlM>eCRqE%Ha+8T zkjD{=^S{zC_$qP&IldhWjU{{aqP=?V*F^h9$-YsrZ-gj%$zHW+ubMkIe@3)#mF!U7 z+nVzg0`1?A{J3Y~#t*muXuH_COX}Pu+J_|jkYFFm1rqJmlD(Sr;_dF+hcn5Y1|tN@ zuh?uM-*V^85sxZ$DQYA()g<6htB>R-6P(_wyQ+Tt z6X!K;7f_VHp-ylTX@k9p zwQ}p+>4iSQvqki5kvvyB?N_ooHfaK(%bBhVqH zEgAq|0xAJ(SL}rfWAiBtma!AUREi0N>RIFd7KkEs0Z*uk;VPY8Ed00tH6>nSxID@}O$iT%@E7Ag*W(q8taU>}-o}4xjk%p<>mK$i%DfyJn zWy7$#v1v2*Mv3k%l6#B5Zt(rE!q&mKX$~;{U~thLOnQ?y7HmQjHuCI{+&cvKj+DD} z)`V?MMnsU=biTlnw|>!EFM1m#ZzD@&{^h0i!NvALv3-}+4%!f5zbMI1$S7y$kM#?O ze(3y>Q>@%6RqlM_NaE1!S5hv&;0mUy`e&_=UH&Ck{i3Trc_3@O+2Gi^U?Xhj#_njvm zA0j1+vM7_17@DG9*4uj6vS?ejWm&Q<>*4sBc($s9iX%T{=P2jF%86POs}lq$GX$*= zBnvxagPJCY7HmtpVOY{Rsk5Z*9e15r0vfyl-HQFu$!*XX*@o@&{T+|Tpcr-d z>c_j^`+ofWzV9y&Nn2QNcL3AYb1(o7ho{fY-0RVmQN#i~FHAci(D;%K*N53@qa#zX z@hiv6Ag~Di@sfc%WH2iAn~cW5B=&uNEJ448&@mH3w{kyO`6#|X%DF%oA-F?DDKAfM zw39|lGi%AlTa?WS>Mb;LoL0f2{tPqh1 zZ~E?B}MgUSW95?0?{{Cc9CcsC3d@LefwN@EUkx5Z=Zu zkU;)){w_RbcOxa88LDZ*H05}R0PX(3SF|GfN38H?mQL_O9+^ES{3q4NIA5eVm#oN& zeMOQ=wR=pAGC=wNiP~YBR{u?sj-IfR29nxvyUT>QX2+^U@{qWB_SwAnpV^P4x*61rMNE z$oZ{jo_hv%U#UnGDjvl$_pa9U#_M`l9+c|#$aQ-p=U&;lmptgFibkJ`m-z{Es%>6w zmTP;(s^KNqlIz{Js5w#BynO7B24VwC`OnhTj-1Kc)Q$m|*bG8F))q1?@Snr^N-5bKw#+oP65MRjy{0`|bJ`naneMDwl2 z?nK8nv2R4~I6(H%7v+W>s9CkJH(AKS9so51I1xD~I-JonHzbZ)&-_h^)*i8UzuYOjr?&)}011Zz|j(XR%{hgY(Ys6j0q^{#~*YQMm|2wU3w~8>y?>;Ga zpG>rNzqRA_9b(^sn*phHOl}?122-lez-^O_rW#K z!6(C#T|{Ie&$hMxUGH9y2Og06AC&tajE;)_t&(e7_M2$xMtFflp!0LrMw*p&G}edK z0^6mMez^o5D^gc3@=DY+52ZZ_~Phm7%q&JV|IM)I$OZxCL5SIn& z&jrV1#h8lojVYVY60#8qiY>SB{7_x?e04Dd)i_VGzSJkB=HFPbUnQukbioL|h9k2a zBAN1qTR}uY6li(bH6HJS)Fo3N%}FbGGIYk}e6nNE>g&8_ zn(N2gsMd;|cdBW;mnNX*h1MF)W%+`cBDdxnFVk@{KMh$LX&7BYO4D-Exp6nm*Q+h& z#5=$Ho7|AEUG^qT1=eO3nu`zubz0jnzbV)g;Q$ae{LYn+uvh2A*;1f2SEy2t)amCw zmSOs#)|~D5mux!2w#>GHEV>A}#5y4SBlQ7MR-{GWeq*D)-D|~jy?D!{2A}%e!@G5V zxpy0?L?DEWz6g(?r#))BK#DV-Wz9#%D%A4pR(pEo!*gp9&)}>qaQhVx{Vn5 zcC9{)yb5M!&Dx!8gRyHib?q|!Jl)C#$GJF`K|pA===V*u`^9G5i(k`yUv9>qb&yuq zv!yy?Ap1fw-Jz9|Uvq2Do?mlo!5$;coSaYg_zLa|n?nBi-CFU&@B4C@E?-vof)WFa zr#zwB%<`c?LGn_K0Z;sKw!>;YP*S0K%y2_~l8I@J-ot4a^Jmve-?L|CZXoBk)^(SC z6mR}+t1;gmr>@U@dn?9&FC(e_{E;9aNTIpJJBB%1qsB^t>+ZZbNAu?K*%xq%DMVw- z)j3tAU?vpwUz!bvL38>q3$R;4c;nfSAG#2JrhEW}G)5mX8t%7Lg9MP|w#-KHKeF*) zs(@aq%e+2EHtfH2W&SC6#e58$^Lal7*_Kr(=o*Ima~hwS2zGdoY{cuC{gnFLJj^npuYRV{k&eepnjQYRnI}>PUqQd3jYI=w zR#nGRBzDpio_>5L1cea%OT~_Kf_NwO5JXML4}Ye8k#W! z)VD%>faU-rs)alBkdeBdP>PYvjIut8nlx<{#Suy6*xpl9`zFSR4~|R?P7FOTHTb}> z{YR4~futV7zfcK{(*9Ta`YQq&rOk+J@Ul}eIhocVBm3W?#+mHJrf>*Ivcrh=q-EkL zbAFPct++@vkjf&X|5am@G{H_vHHsp}MW!tv{08}xHq5q4sQ;QOAZk77n1w8u@lF)2 z89U`lDcJ}`VCp38PzX!kVf==_p@QYACQkT?=_@SWXRa;EVoHO|0*Zce;^?tR`#m!N zDEm2Vg<+SO>5JuM0aUnGE85}}h%472S9DNNK~4OTaJk{*PwYA)y3XRdO`!;3)9=f?tYMzvvC#8lX za>J46cy#;@f-^$!QeLBhG$|fCx^hw+nG##Rf=hBu%dTnBHH|=;(c$PYBs=9bDalSC zb|hZWC{{FP6+JDsz*pt(Nv<=p3j;aBib8GZXZ8K@ihi-8Kb@aZ{q5^KS3AhPdlrg2 z_^-gVmfcVxPk&vvn28SnaN7hAjh~X6M&_##0U#M4Ab8+rS3G!F3?7cozZm(>Gk@?* z>}kop4N@=>*Zs$%yfQPj4X;%%54|<|`slkBscnzkwnwVnE7$Ig9*Q2qq#&{!+z#Gt zUe1W8PKzUF=xRBOOL9%g%&TQ;f@qSW!Qs@1M<6p2CaO=*fHK(+i!C8sl50kG z&4{iURu^>KH#NUH_tM<*1Cp;#_VwkV(T{U0r{%4av^tJ+^xA9Q+VuJ+vH#G`L8*B{ zZk~`DCgq07)rJ%Ch7(f5DY@Yk-2BzR28ydZ&L#SX(nBD9hq@MV>q$&AL0q0wxUhKXO#z^L0C4mGj*a@q zX2_1pXr>$npFe6#RQQ-Jl%>35y~teIu?EAK)8w|$vsLzNUG?mVd-kkBf9y~!@}BAD zwCJ6bypytbGSScm=gn1nIrxOAf-_G5w3q^S;C$0tN8`m$O$9Vl72iGe+NmX*_Q!f; zL>B6i*+wsElj1Bxi5)Atns6yIVWpW+LFP={YD5^*6XKC$B4EP_xT;9?H}*s>!!9_yD}?XWM@Eh25xCAKVqz{)i%T~$c?+C+5x$CK&(9pQ|v?1$VqwRq`2>7 zqH#;2t&5^kGp^72J@BX^@v>S9(yBI7K$M)4QiJ_SQfkTBBs-f{oxO2qujJe&JGY7K zQf*W6%cxh}AN0Hzet-Lo?PB9$T#|E4c8-b8v6Qfy1lCbAd;6-`+6tSjRd-w5-6pv^ zWOv7^yD#qUlidBXyB`8|m1UK1R^0Lh;Eq!4+$}d!e9Y8Fjh9V&KLFUXGC}xdoMG>` zZXR?Qe(0(j++qA-kOREY)3m?F@Ig)4aJlh=7VmJ0@xu}m(l`4y9&{K!a@Y>;Hh$#s z9t;{k3Yw7qyOM!XyWwNIZM4Jqab?+Pv+?6*6Q#Rdhb@LrEVjejjh~cz5BC^9=`m4y zmt)Lr{IQ!KtFipp#{vHIzQ#%3Ao8}!-A2*TI2kmGK@-yBs#OA=@@jgK(pE)1i(wc{ z^X?~fl>d|f!wohflVUkx&dABc9YpZ!EGtN3BD)kX{CnfdxkUJLdh{0r-XZWV z0fz7}{D%}X6eNfV6@E=$|BFBwPyq~rj()*`G&7V^&v(aBy-xaHBi9F{Rq=1=?wv)0 z`j;rKTrc8^nJ2Yu0AmE(#^=4tb8}g775VSU1OZ!dBlxpA;K^g(;@ns~jH53HZsCfV z7o1FBKFt-w{c;YB6g>v#((8sV1{I{+Ni)R@Ei_ZD1c1Q6u{ct=`vbZ`IWlIF2tL1m z&mO^#M6y(eA_?Wl`#w#jfQNT5mx+y=Vs5drd2xKrQ~R<*@@$qpn?=5Ioj=Nx{l&e) zpH+3Q)vkY0Y#m7b?zM@*ap&sK(pI zBS*+=5SlEr5Bqh2F;oI5=qFRca7K`gR73>C-k1ydq1k>p438L!n34#~4(Ni~Lok(k_yR^=}>42PuV?XgQ5K@X=Cevo`lgM(h5UvW-X$q3=7q?xC#6t zVeel<8OZNSE8&7O;W`JSLx&k>tiy~m)?r>824-fmuk!9V?_RRKS+R0Suz!L_-%n?}X3%*cEU3mqb|?9jcS783mfazow`Qm@EEHwxZ0h-c;j&EW zMYfN+uamuRTApzwFrOCa6Q8m`Zy;aMx1r}^lB)9gbnj4S2A8*BouA{=@EWhv)udjh zfAl$FXyM{3zdS+zUb2F?W(DH|hctuBxnNx2A1MN#6s>AXG2ot)`CjTnN;;5*6qBas zD_PEmIB=_>pplj=sAcGXcCY4~;GPAv9N^c2=6}F2$ER(iEwK#gk(M*NrUkRcvCniw zMm+UchNP!(4RgUd>i6%cHFbO;J2t?QafOxi-{@V^PQkur=3rSCo{w0zbg;klUD}Rj zU%;yn{s)Ig#I3RwltMv2X&eMQJ3r%s3Wtc6PIINQ&2%HJC^IlE2aV4{{Bal<(YD7h zsujw}@GZu2`8Iu}lU1Of58|E(h zmarVc)Wa9CIV&ePS&u?v;x_&%Jz)wTeyW=_N(X+@k}~2;xd6#Br^}}?!C#t2*zG1% z^Gn)Bzl&P1joK@p-TC~^#i6xQ_Y(iIUGZ$ZIGk{}zIFb&^Gj8+LCMi1JDL_p$l39^ zSLP*qy=<=swPxyFb9lt6ZpqOjJ9FfffSO>$DnkT@z zMU{aY{7t%XoxrmMv_U_oJkM7XEB}J>Tr2-7m5&i%&r{`JryHzRI{vd?fK^d9`vE=D z2KJZq^{)tMHCZ!yx`!T>6ZjDU?WMA*<}-4#@_&y^Fw7l?`_|QT{y(J@20iGg;sWaiJ=k7K`SifwPu7o89C z9GJ*CFk-cEo1lQG11>j*BV7-A4SdP#z25T2t+Zy0&M)nl?;T)6(rf# zblHu`kcQpTU5BO#X-G=qBul$Z0%_=yHfbcko1)cQZ%De?-Ly?=+GJmveZSpz&Rj{B z4DIfFf4yt_+_^Jn&U~FYbIzF=e{Dwk(yt_WPv_=p7*U<<_HkWYF}y3em0T&jtGHEMCA_QE47ZwF z!__@3-&w=0D6g%^T+3Hf)Gy`~a_ zH2{KGnA^=)z$>Bm_XdVS0p7otA0G4%@9F0kL3AZ)R}V^l$4p&R&q~^tKn&l4pcO$I zg5?O>0r(Vr1BS(tFth+cK7xe+d=kEie4w@1XmMKqNDm(j_y>o%zyRL~=?z4e`w`nn zKF?jlE}UAP_Zpjz*%Iol_m2d+cz&4Y7mz%?#oUDyS0Y%AU=4z`WQo3%tss8AQ@$M{ zmQNkm$JxENMMeA#2sR?v1OTdrnP`Q5O8yV#+KNcq00fl)+~UJmkk<`m@|_T~o&3`9 zb!ft<@m!4}q20>|4vq$fdhhq|KblbD7Y1_Yq6gtqg`Z@z$sFN(fW$MhTbc&3P{G~s z;_ns1ehl9SU?2Q3QA2M#AYcU&>eV~_Yr6d#R`2x5`FkKH**zGVtGygPY30SCFQH>W zers~y6M&Qh@E1G;;286{hA}LNt8!)~$~?`io-w)NW>4JUn6>8`6gL?FpCLHD_E^Wb zdA5*IJyb*6D`T&aoXRYjHfO9st8=$psQMYi7bbbwNDl1dqd{hdup8eS|U z)(=ebu;iGej~w_@C)x04I8;y49F%PE*OAax-Go%Je z$plPg(Hw?>tTM=;yln>k8HaH`zAThWhEI8k-{vM?S83JKd zHXWxAvth-B{HI{LJd0C?K4a>ziZdL5&VovtLnsK6PnZP?A{`^KE?y-wb@Thkp2aOz zlrgla31zTnaAY75&PdaY~`(%k+%-$Co zqVhQ5il(%0|3PwgF>2?HA;TZ zkilhLFw9fSS{Kl@LIa%`0$%Iu4f2lzGOU!Ng9+(CU`UkhGLhks7=Ma*mfM`-+Sjvh zA0OBUUEt??xW3UK|1H4yj3IX|e-y}ny!_b>&#>K4HQFsAk%V#|KRh~;O;LibJ8TrME-U#+VLORM1B-Ek4!NBln$RF$lL70Bj5Tb&y8sOs$ z$A+12GT)MnNkf$RM7;iVILvSoPRhyp*bDNfAjh+5T4ORbla&X>Z!^swmWA01ifk37 zkr|U`@^wFUyNE5Hl#`zQ#pKk4g)D8-lXp7vBx4G)OI}+t^tP7?<)ttgreBo`moWUg zF=bepk+-J998raEppy@$2`l^4!WZUI4mK8MlM0%wcnTW%Y`UQjvK%v}{EG50n^hi& zckzMpTw}^C>bXg3%6@GMi(Kij+C?e_sW^s#ye5_j8OhC#(gu{3$)`WvGQcR9M_r$y zjx=4A=3(ZD^ls)b3mxxD(O{UcA}l3iomP)~E)M7wXVVLLK{V)fHt1b*aTYb7q5Eh~ zaCnsO4fug^+7i-LU0cb2b{3dHR(u<1@}~eKl=}lcT!0Ufx~?((ag1Jr;2{7$1G&;= zV?89%H59=~DoO*C3qG|-;>i>)&6SV>>w@GB1V@I4f&nTK64JntUR-3j`-8}&;>-sq zsuM>4-ax2#zke{$6CC9OgP>Lh`PTtY2SXTa1LlKeM#Gp&%O{OdV;TG&TR)@8KUsIC z_k8K4ifHFu(IwlXnjJCC4nebHs&ti662aAB(sGB2d8MVjReGgf3&AU`vO5^%8;lgf zZ%A1T%V=1o>&lnDVQp7*IHf;w8sOtcbp@Sj>6_|W2*25E?$S!HYUL2VYR>N}XRelO zRvM*O8=Nciq(9D+L%0`;OFvSEK{*Q=3hJAWGXZ8NaNte}m(LkE6)5{s&d8}jgO|Zu z18+IJbKtGuOk5tkmGF&5hl(?EdU&fjb_?UPBy!qEL&IAF{Gq;;OAwGo zG`^;2Uc4%-1ohArR#1I1t0tU_90cR3Hbt3Xs6wd>t00H_wmICGBQigSDyxOc5tj30 z*8=KqOO=v^tGX?2T89iX{d4)!rLKpHg7N}H(_#(~)0skP#5A5GRXE_Kd4adUS3Y@R zv$d``ySfj;y40WUaDEGXrK`bt4zOhNokoxMHhI!@NMVug%?4{m-7+bWjdz;Kk*~JM-&QubRN{2b$A(kf>?sSpg0HG?bn*6NV zsZ@v6T=fAd)|L!x0i$EHgM4F)&M>CQnnRjUEBT?*Fs#|a-~{s0WuD#N`F33pwF5%T5cgGIlEHrf`Qe} z!@$h8J1#8B771MASy^_OEOYW3VOVDU-W0JAE)&V8zm%{YmIx%5rJ^`wBGg;y!!^Md zYER-9rukA|(A#Oo$qWZ2Tr-$Ui#dkV!{3s#a>!Mg%9T}o6^$i|j{G|Tr~0-y=)<57 zl#ym*uI0kg?50Ehx)dhIfaGl@?b`?0JIMF87fF7~kaxFlv#bDwgzhlk2P$CCNS{A6 zd>}BCAV1vET+dS*Cn4z@NytWq`B2{kNKYOa{%BrZi0|te+BZ4?S||8haOvDf{K(c*g^DeIp{0hV5-Q9~KTYZv*0&JFjflxxh z^#lfohkW(oDu$Xr_37|b({8{|kuI)iGYJM_kn&geKKXP&s@ovv_kF|P0SQ^(SEii zv0z~4RChmmYtkt_kt6exthdbsdD00znOXK9avh~Bg2VlrTJw$WgSMqWbvS46fZ?f0PH}0#*(Il$@9z615L#-~M=^MiC zLq{8sBLzK0rXVU3X{`+dU{MYAc$GD%p}i}gI@5C>8!@{d_dn{N^k3W)GcOU$OU5!YIL$(E;=om+O% zcWGP9+aY*6Vnv-oQRlc!P&sDog=cpD^}T<2@A*#fGB_IrXJgFXB-oqAWhe7y97Si2 zM*jLMfBBX3doOY^*HXc?H0Ed(9IcqwMw7+VXw%A=Yn9+y6?3c>9IM4-cj;vR(?jQm zF5Yw58!KBWl&y@pR|)P_nChIVTNu|`9_jvichu2%aWtlF6SQqNrILEhXAA(usRk#A zrz-Mv-nqPJ-5oJ^hv4prs+@6m7ov(~Gt`aI&$4b@+ zC2QVU6D!#k)q3LpZOoa9nrLlDv^KK#axhl0PN-Oibt+9oXUE3NtAz5-tM|vsHz8KZ zE#EmhA*Tq(=U#R)^yF8>5FM#qF~e@busf>V9Y>_PSw^ci#C1kNSA9`Bty|b1)h(Q{ zxZ|b+&^uNG^p4dq%SiGK&^vll3hTc@Iv*11)=%r!M|JBdv3uqb`&`9%%E*1(pOdD5 zXTc`+x!#MuXNE2eU1q0i+oQGZw5r`P!$!fdF{<5oV^LF7WsBQ9vrMi!4*)q4C?}un z^%`f2ODFfA4+~|jvEnwNxNW9n1!>=BtecffOD#7U0G}b4RjZ%{41k+bO>W+241n1= zU?E@Kw_cy4Sge_40AQbbKGzfJ{(A5OEZTop$t27i5R<=$7e5Z*8J7PNUO|{RjA;Mx z=5bR-%Q&2ipgI}4H1^1E0MIDUxt*P0RRH`-{1u< zi$I9dPssX*`-XT0&dE9QFylkRBS-ZawnHt(d=t@{fWR>(uF{^=T~|4#RgRd-2|SO? ztJ1>Exvp|ftDKX)CWwitDg{+#H1*3jl2_Gs_XHTokAq)%jD%`I z=m>3C4XRx0F0ft4a>w$*S|r<%8_o@(9*}&{9th`!(rPV!g~9VMSIz}ZHf?YmZyK}` z{Zp0>8MubEb6MdlH|s0IRm|zmN@=gB{Uu>l2$Xj88EnNs>Y3jkpzUcn{n>fF#KO3q z+#E`)$53raW>qG2)a7&RP+s9Yq>LJ-k#sH?oOyT|b)UoA4R~X^ur8>}8tMG(A$$6^oc5)*`Be+&AFmEAN%bz|MZX4^X}Zj~d>UsX$Aan&wy}JebAKPsKbFn>1=*BA zx-Fff+Fz6khj7hEKK*WTe1jDndf=8V3g>f})9-dzy zSIl|AKrQ*d>;D}mr4u1D+>a5c03EF&pByTRh=!DCFxEl_YTTYeq|=x| zCT>^xG2#S(gocBysGcEk)(`TaIWYX!0DO!gaMz>5f=904krlxCo?Rc&F2sr?^7)>_ ze%Lo8uODfWe3c;=9%wK^>Bwnt2!!re0lj_X!=n|-w~*jZ7;@+Vw`VtoX>AO!gz#nr zMgXuiD<+Y`2b^lOFnK#flbHt=l+gq$V%iW>&M(2_76iByB#(TM#&Bm_8jI#m=;lo7 z5E|-Y?@;OmNbg<9z7_UA5A?y_FGm)ft%^XcV@Pxw0SYFFKxfOG^8^lK(1;m~{YM1v zBe;R!0{{vAT&d!?_~Z#SY*T>S1N{TX5iw1kj_}9VAg-?(suW2`h7a(0@OiX!BlR)3 z#hh+F&>!dx1vvLe&;59srp3K_>())~qsnSGZhEx&ny6tex-MSFGN>tk3_~v?IEP>i z!M6}lMtKiI90GKSgSCGjKoxe)O88DFg5bV@-NciaXh2{_kQ`7z0Dn%fj~ne72!j0| z2yubEJ);95J|8F~Lavj}b?_#J}ZBKRkwKV7hZ z{|LkXjNo4oe2n0i2!4g&GX$u2@c#4_pW zvPaN~U+jZU33xR;4_1}@HPkEEtfHn-V{=qxkFW2JsvI|Lg=fL`Yf$Iigu_R{2N!r} zTnpm*f_OoZP*4{yt)ErO+>i#~GXzm>;jD(-e6Y-Ba?di-T*Hi|IBvRo(h)V?E&h_w zL;n()@l{7_+G4(Cf^XSOZNtUtSnYD5c6r>_5Oun7Gbjm_g5KJr$d-+JNRn738%wt_L93r&jvJf)kEPaKKc zJ(CrJy=p=cFI{}eER-(4tQAT(MD@ipj;gq+>^yg&|FTJF-#jao6dGXy9mm2e2Eq~`ox0t~8mnc!W9?gAXcbUPc{CUVaN1bxZmu9&_XSjaP3D(EY& z>laSz7hc?W`OcVrwV+>pUB7W!zwt)JqPVZ|RdvkQ9xqvPsZ=OgA(X6&m(|iruefX$ zYF1s-3N_nezPkk9UF2t9b44tSrzY;Hd08FvEV*HKoh_W~IqQnw5`daJp<)O$Oo97s zVH`kJ+*vtm&M^X;0T}O?wK2Jl>srsW)-&miY0Cs{SyWpV*IA=F*LNDv8_(~!;EZ{j z1#k1KrI%ILY{H5=J7X zoGZq=<2q+lR~k3lPy1(?{CpTR05`nl@!~3QYsGzAXUglI48OekrS-A$cA>oedilEP z@^!KD4MO>b8^vW$*2YUJgpx%fgud_=KwkjRG8NTA`MP*RV?=1^h8I9cuEAsgR(%5$ z1z@66FxAXTQzg^lZ(`>fYBDm{C2(5SFoB*I@pwqmoBJr9A zp=PZJA=!t?0Id2Ns1JY%nV|PU=On8~>j<5b3ll0UZmtD$A+d8ZbBbvtjNWl(%e2lN z)kWNs>n<*wE@_IEG=W&1m9XX&Y`n01R*C_TRQOrnmic*8Dn+j(01(D7odYbe)hzPX z>>idWZ3ap~TzIGR3h!)^PB+Q!Y*9|Pu>k)^d3(t=tMomqY@0**UJ(n>_H#>n(+-<7 zZjR3Vcw%-6slZ;8o z2OrqU4W&L`n&t44hBSm#!M^t+dGfEqK^rysWL>$OJ!uQe3HyPS?EX`|Q<0TJk$KD| zq$dX_iu6ziHMEUB=&m3ycx{y#`g;x_q&*c$o!*9;{K5e*52sx; ztZ8+rn@qwfGl=vz>A+RTspg{W^PE(Ws-89Me zwue>V^w>J4hQ1%}7W-a}jqzyb<I&CNc)ENg8GgM8a*8ehQ>V*PPurAk=zjpL$?Rf zV{?poIdG}t^;JJub$RtWf#}w|-x_#(Al7=1(0Wg-X_wHn>lni~0v-_AiUb{47&;C3 zLJVc==%UhUfrMW8OFv2o8Szxfzd_(J7QDZ5FfMnGl3#?Ya_`MNI7W11736NORuWPH z7Zxb^x)YLqIXSL^a;W1bivuP7ic}dQ2Yzovv?asrDapx0&^s*E`9R6Xriz<`4H#O7 zfOgQYG4u%lFby8VFm{#bamP7F&jMo1Hvk-MB-N)qWYfK-$Vu$K??T)caMXxVp$tob z8#c#`6_&p5gksie!CF15gcR6?uzM#LKHYq-`TVk&w?*)_yt?IT>815C+Zw^P1~v_V zgod#@1>2$-YvGwKlSP8FK4xtYte{Xy?2BMy#J=da2rv{`bi?WptP6iDPbJax44`Jr zwpmqHsyL2$RM}3*dWT2uPpG&+U<5-M=Y7N2+!6%0=_P7&B%~48xTL0rFNgM(cxp?V zv}XL2wwhXLc+ekCq$iKr`uX9*!Hn~r4ZtRe-1I&&y%j)}eB_~E4pibk+&mN?diaY+ z-~3Thb-H-@UK|5F?FC~%x0M#6!s&<`3&y)aQ?M1D9-G`Av(^aK8aOC!a$h%*LqPC^DY&A93ALXg}zxP57$`$6Dnl$isw3$T*|Qd&bX;CRjNPcZWG*~xFS}~%chvMNw79S zr%SZG z5{kZ{ANRCckek$9M|uMz^t1%EKk4p2nw|;S;hw$_Jp%yJ@k@x|Uj>kPM}_|aB92-z zcfR~1!vh0w<7If{ab!0o6an03nELmp5^T=zkO(zvkCPkct);ZUxkzgIQd?Gm>K*$9RfMF_r>VXbufy ztWQHue77_rgJ`;a$ZIf!Jws;>Z6ObPRm?M@To)(R6(ouP-4?-DAaE@GGb`z1<(ol8 z`XjHqq^ulvOw(XB4JssZ*tyRJsxGM<_~#bbt*Dd3l*tBrSn}z!4dP+$E2Q*7v26iV z4OJw({Y2MD_F)w#2{Gi|eOL>}E3Nc6 zrHr%D8+YR(J=7JgakAM800@RRM&xfOIEc(USo zd+`ADC6;(XjxIj&8DqmNC__+D@Q||P*Dtxq<>w6!$safl4ay(+uR0vq18d`Oh{sKggnAIp>-$4+EiJ+N=Q4YE2&D5rK{nZOLetEaCJY% z64|TEBqvm$yjqa43Sh^c?G%99mpHh^}{T%DfAL?9nd}c znw)HHG9xqNxqf$b5!v*bu^LqOIfH-)g&j5kRCFU%nIsB2ImBtkWMPu`l+7p$!^JEv zXD$=$zW@65Qu6+5T5A?D*nO2BtAHIDC04^ z$#ItjZ%fGVXf`Fa^-6xeGHWMP87jo1rCv8@3oGFOGRV2GlC!k}k<8Q1Ku%A3)?0Iw zTmhz~`Y|6`1!-UmiVu|Ye207bEX!4)^e&BJT5#j?g*L_rD-^ z&I=5DI)k`$sigjKWgYli(c8QM5MgT0C6^;2#vawyrF`+=t`!XtYJ;Ht1G8y{YW9>Q zHB!=PH20UvI`Pmu|6?RbPyfA3JU?1)cZrB5zweXY#oP`MDEDwo`Jqzh`}TL{QpL5Du>ZrhnY?v6vfszm(+cP z^L;VM01(A9M%(FSlO<33V#aC#e;dX-hq+xOB?tg1_> z>WaBn3htHU^B%&W`C?<}oKvaSt4DRQts6r{#_Ze0JMIV%q7M(s9m0kQJnCPNn1($kJ zKCQ!|McocV3&{J^#gP{==s`e*))a<*fM8B&QNc-Vs$XEj6$ERMDiv;6K_2$Oa0Q@8 z-9v#RAyN2IX-Dl4lx=YJGcD{Ac_7O|&>tK8aF22yoJD?(QWy#xPD!~}kuHk5L|z}L zaCB**C+I~%N#!3(hotzUUK?T+mMYsp_61ML$lb!;si^Q9R=b(KxLs&mj~?C4U

phX;>IvoPJn%0F#xn3; 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 delta 6044 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 Y=e3MUV%aT$B<8Y739R3F0&xlY4{YcW9smFU diff --git a/__pycache__/triangulation.cpython-311.pyc b/__pycache__/triangulation.cpython-311.pyc index da514b1398f132c4918292153023d93e9ce75454..4383551d50a09cfb815d09ae2f8fd9ce143f572c 100644 GIT binary patch delta 4888 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 delta 4762 zcmaJ_du&tJ8Ta)oc0wQ_58^x=Cn1juA@MK?&pa59JQz?ODG<__i_cAP;@IxFb_h^4 zNM#z`pbSnowH@6+S_8CQRc0pbkBNzjNvqa&f>qjd|CmrKSf^=|CSav(X#2kJI<}Lg zT!r5`_j{dlzSp_tzdx5QUzKt%<>qFa;90fgLhz-txh|>qZ2kA@%qG>&9Pc`(B$MeK z_-8z3Q?zFnZVktA??PJ;!QctY(8u zhOvhE*)kXxvjeOd##*K@AB=TQ6FbNPY}E-{e?4PtEzla+c9pfmM0T2hJ;a9DMqn>V zz8zwl;cY1kvL29WL@G#)uu--f=*yB)A@(f1H8IOBlRPF~G@lWBtnY|FSXy)VLhwJq z0#R;TYOkPIvDJ1?DiU{Wr#F`%!G%zYP=-*0uo$5Zp#fnDLOBANaO2D~4Miehf6=h* z0oyL|Ox8BHZqZ^~&*5r74UVhaA7s1$6$%lG#ILfd-HUKK%V;Cg5GuugvbqhUjbt?7 z7?cXc2ae^i^@om*IklIgHbTAF>D*>$FBenJ%3M2FV{snVt=l4D^;~{&;*N8j)LIOp zvW2gOLAM0igl^ZOhg7a=7NB@M9*zgqo&Iqp6l6+F<=yb^6+3gj?L*4LMklMz1u9$fb&U*ngq)uCM_{Y_NUyC2)+kv`Tyftr~6%C1dIje;`x4ZLO@M5@R z8_ed=RT$$tfz^hB&mho7Y+67Gso>YC+ylOp5jnY!U9)Xv8~Y%gK47jFtLJ;A3NbSO zyGv5q{A;$rA~C)Plr{SJ_ytu;`RI*(8%j36u0t!B2|m$7jEgTM_n5U@Bx_XSwSrj zifAg|2yYw@nDm4$g=hE_A`9l@;b4rbAk`=A1-rm}f5A}OenSi6M3x~`=JlYc#6_wY3c)7Tyt_sCcg{ND_ zlnJUi*`Bn3{84ecc&+3X^B0xaVn9&D(nViN6^WXX6_V5`_PQD;jpaxf08UNiMrrQg z8<0-RqjuV`ZdamFHO!1|fg0e=IEsuSP<7a~;BZ(8aWtjb0H8Xiv%1KitQ!BqAs!j? z8=@LN24aUrQR!Ku5LYMOEp=FnuvCmxsm9p>1Vw!4Uei|#Q#m)iWr_(^3gsIGk`jlU zOrhohRHrjxRq*G~L?LZC87{H6vRMj>la(#tx$Bj+w$mVTGVw*_V+maMdDWn$2189n zCtGxLlUKL!$YFp(S~|#r)iz6)#H+PFDtz7vD&j^{vA9>;A?+7+bsn(ZRkw8VZD43c z0FREFeo^rLj`yFxw)>Yg*Y;g?UvnQfiuH=5TSnC*JmdHv&&e-TJy=JwO}88kMs*uk z!ow<$q6FqP9azpz_ZQB9s@tN$@kmV1*+L`stGKF5qx@B5+J}@tEW-JZQ2#uDmJL7w z1nBHEx?Hwji-g8izi-0Fr+`0|SXX}$ee~ys68loxY#Pcz0J2NKpiBPDn&LkcRn3jk zZLzJnc9Ke<6NLEB5a@KN_z$l+<^AlHBb(acI2Vykxk`t`F!Cb^m~Z?70`AH1!|O1U zON}63M(P!WlL#*(oC46Zm@-xp4TgvLdq`_Rntj9%q1AYo$o8(Wzk&3ZM6Y*ACAIki z|F9C*G=Eq<9E*hg(MT{HDW$wnduE#(rvMLG^BzBT4XIK>voEyk%$MT>n+<4H&l?tm5|17 zg4BI+wx?Mf5PMfvNRP$%%4XUPIzN9C1do10GOd9775s8jJ`x!O}UJ@WHe!n8aYe1Y7rjOElMav-!1$D;K+GKZsRHVCjfeZ0=-uR zI$I^5cmwb~ajVl)hAV4%03Jw<%m;F9-uhbmXTW-5N~~PJ!P1M_#8U<#qQ{Vc5^%-D zaZbHHC6Vm<5b<~@rrANjSO84l+*ob92ZRRkw~ZT&SlksWHZ?+SE1SM58F2!So){iI z`HbXxvZSMmQ58;8_j2-sSRCuBoJ8mA zcFcPJ82L_jPJioaa*@%pC`l;vvyG!!YHe0DV+ltNbG%e_OFS0Pok1-!1fM)g%*Zv$ zFv_*`cD#$xL>)o{fb1}WNMXKMr(8t3FSx4D zm%nxBr=eRe-={9$A6;#?U2V5q9iO^7L~+kB6w8Y}uEJZOqC26&=~F(+m%)2ts^_Mq z31{F3Rna10IfwrXUip^@j}X2>c#L3z1-T?%+uBr3-L_bRFAjfHITDH}%pZ&R4dc87 z5K`<@nCY+oaszHA@AVkv@C#R#Td7r%eed$+!I%$3RnHY zyTf5CgY8U+wjF+>`mIJ~uEW*Z5ZVDCdFXQ=^NqgwjbA#%N7@YjE)*(4n48~=EV4v= zl!Qa~m?q~Y&!8q38%mkLkJZ`69L9$qZGkeKW|S@-6i+XkYz*T_9fkFiKlw=DFucOP z$fId*tVU|m8?T@Oo#qVANbCE^m-r&G7>&QiOj{Q$I+obLH_SkTKK~ezD43lFb<6~55mXf|GAVt2lfo5^ad&C z(5O+2WQg2=_aL;O2grJefaN96Gl~Zb%BUa|Gs*}Y(w8hIVpB!YZphxhQT^*S4mRf6 zw+j+mZWa@}%cTMFgWbC(9ax|wNr8yq;@C|M2Q=c0&eDyu?Fc&&`VjUY>_ZqtP!UED zVhG0&P9sbq&^4I40qG8c@@)amePm+;;8=FE{%hc|W8GMU#Y1X4_W&3EOxksEvG3Q3 LiDxYq`WgK{Lx}J^ diff --git a/config.template.json b/config.template.json index cc9ec3f..4009153 100644 --- a/config.template.json +++ b/config.template.json @@ -1,4 +1,4 @@ -{ +{ "model": { "tx_power_dbm": 20.0, "tx_gain_dbi": 0.0, @@ -16,21 +16,28 @@ "listen_port": 8081, "poll_interval_s": 1.0, "write_api_token": "", + "output_servers": [ + { + "name": "output_main", + "ip": "" + } + ], "output_server": { - "enabled": false, - "ip": "192.168.1.100", - "port": 8080, - "path": "/triangulation", - "timeout_s": 3.0, - "frequency_filter_enabled": false, - "min_frequency_mhz": 0.0, - "max_frequency_mhz": 0.0 + "name": "output_main", + "ip": "" } }, "input": { "mode": "http_sources", "aggregation": "median", "source_timeout_s": 3.0, + "default_input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + }, "receivers": [ { "receiver_id": "r0", @@ -39,13 +46,9 @@ "y": 0.0, "z": 0.0 }, - "source_url": "http://10.0.0.11:9000/measurements", - "input_filter": { - "enabled": false, - "min_frequency_mhz": 0.0, - "max_frequency_mhz": 1000000000.0, - "min_rssi_dbm": -200.0, - "max_rssi_dbm": 50.0 + "frequencies_mhz": [433.92, 868.1], + "access": { + "url": "http://10.0.0.11:9000/measurements" } }, { @@ -55,13 +58,9 @@ "y": 0.0, "z": 0.0 }, - "source_url": "http://10.0.0.12:9000/measurements", - "input_filter": { - "enabled": false, - "min_frequency_mhz": 0.0, - "max_frequency_mhz": 1000000000.0, - "min_rssi_dbm": -200.0, - "max_rssi_dbm": 50.0 + "frequencies_mhz": [433.92, 868.1], + "access": { + "url": "http://10.0.0.12:9000/measurements" } }, { @@ -71,13 +70,9 @@ "y": 8.0, "z": 0.0 }, - "source_url": "http://10.0.0.13:9000/measurements", - "input_filter": { - "enabled": false, - "min_frequency_mhz": 0.0, - "max_frequency_mhz": 1000000000.0, - "min_rssi_dbm": -200.0, - "max_rssi_dbm": 50.0 + "frequencies_mhz": [433.92, 868.1], + "access": { + "url": "http://10.0.0.13:9000/measurements" } } ] diff --git a/docker/config.docker.json b/docker/config.docker.json index 78202cd..5fec788 100644 --- a/docker/config.docker.json +++ b/docker/config.docker.json @@ -1,4 +1,4 @@ -{ +{ "model": { "tx_power_dbm": 20.0, "tx_gain_dbi": 0.0, @@ -16,15 +16,15 @@ "listen_port": 8081, "poll_interval_s": 1.0, "write_api_token": "", + "output_servers": [ + { + "name": "output_sink_main", + "ip": "output-sink" + } + ], "output_server": { - "enabled": true, - "ip": "output-sink", - "port": 8080, - "path": "/triangulation", - "timeout_s": 3.0, - "frequency_filter_enabled": false, - "min_frequency_mhz": 0.0, - "max_frequency_mhz": 0.0 + "name": "output_sink_main", + "ip": "output-sink" } }, "input": { @@ -39,6 +39,7 @@ "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r0:9000/measurements", "input_filter": { "enabled": false, @@ -55,6 +56,7 @@ "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r1:9000/measurements", "input_filter": { "enabled": false, @@ -71,6 +73,7 @@ "y": 8.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r2:9000/measurements", "input_filter": { "enabled": false, diff --git a/docker/config.docker.test.json b/docker/config.docker.test.json index 78202cd..5fec788 100644 --- a/docker/config.docker.test.json +++ b/docker/config.docker.test.json @@ -1,4 +1,4 @@ -{ +{ "model": { "tx_power_dbm": 20.0, "tx_gain_dbi": 0.0, @@ -16,15 +16,15 @@ "listen_port": 8081, "poll_interval_s": 1.0, "write_api_token": "", + "output_servers": [ + { + "name": "output_sink_main", + "ip": "output-sink" + } + ], "output_server": { - "enabled": true, - "ip": "output-sink", - "port": 8080, - "path": "/triangulation", - "timeout_s": 3.0, - "frequency_filter_enabled": false, - "min_frequency_mhz": 0.0, - "max_frequency_mhz": 0.0 + "name": "output_sink_main", + "ip": "output-sink" } }, "input": { @@ -39,6 +39,7 @@ "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r0:9000/measurements", "input_filter": { "enabled": false, @@ -55,6 +56,7 @@ "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r1:9000/measurements", "input_filter": { "enabled": false, @@ -71,6 +73,7 @@ "y": 8.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], "source_url": "http://receiver-r2:9000/measurements", "input_filter": { "enabled": false, diff --git a/docker/mock_output_sink.py b/docker/mock_output_sink.py index 3870211..32e9970 100644 --- a/docker/mock_output_sink.py +++ b/docker/mock_output_sink.py @@ -37,10 +37,12 @@ def main() -> int: content_length = int(self.headers.get("Content-Length", "0")) body = self.rfile.read(content_length) if content_length > 0 else b"{}" payload = json.loads(body.decode("utf-8")) - selected = payload.get("selected_frequency_hz") + x = payload.get("x") + y = payload.get("y") + z = payload.get("z") latest["count"] = int(latest["count"]) + 1 latest["last_payload"] = payload - print(f"received payload, selected_frequency_hz={selected}") + print(f"received payload, x={x}, y={y}, z={z}") raw = json.dumps({"status": "ok"}).encode("utf-8") self.send_response(200) diff --git a/docker/mock_receiver.py b/docker/mock_receiver.py index c6a4a90..27510e1 100644 --- a/docker/mock_receiver.py +++ b/docker/mock_receiver.py @@ -12,13 +12,13 @@ def _build_payload(receiver_id: str, base_rssi: float) -> Dict[str, object]: noise_a = random.uniform(-1.2, 1.2) noise_b = random.uniform(-1.2, 1.2) rows: List[Dict[str, float]] = [ - {"frequency_hz": 433_920_000.0, "rssi_dbm": base_rssi + noise_a}, - {"frequency_hz": 868_100_000.0, "rssi_dbm": base_rssi - 4.0 + noise_b}, + {"f_mhz": 433.920, "rssi": base_rssi + noise_a}, + {"f_mhz": 868.100, "rssi": base_rssi - 4.0 + noise_b}, ] return { "receiver_id": receiver_id, "timestamp_unix": time.time(), - "measurements": rows, + "samples": rows, } diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..54b7e81 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,216 @@ +# API Reference + +Базовый URL: `http://:` + +Контент ответов: `application/json; charset=utf-8` + +## GET /health + +Проверка состояния сервиса. + +### 200 OK + +```json +{ + "status": "ok", + "updated_at_utc": "2026-03-02T12:34:56+00:00", + "error": "" +} +``` + +### 503 Service Unavailable + +```json +{ + "status": "warming_up", + "updated_at_utc": null, + "error": "no data yet" +} +``` + +## GET /result + +Последний итоговый результат расчета. + +### 200 OK + +```json +{ + "status": "ok", + "updated_at_utc": "2026-03-02T12:35:01+00:00", + "data": { + "timestamp_utc": "2026-03-02T12:35:01+00:00", + "selected_frequency_hz": 868100000.0, + "selected_frequency_mhz": 868.1, + "position": { "x": 1.2, "y": 2.3, "z": 0.4 }, + "rmse_m": 0.52, + "frequency_table": [] + }, + "output_delivery": { + "enabled": true, + "status": "ok", + "ok_count": 1, + "error_count": 0, + "skipped_count": 0, + "servers": [] + } +} +``` + +### 503 Service Unavailable + +```json +{ + "status": "warming_up", + "updated_at_utc": null, + "error": "no data yet" +} +``` + +## GET /frequencies + +Таблица решений по частотам + статус доставки. + +### 200 OK + +```json +{ + "status": "ok", + "updated_at_utc": "2026-03-02T12:35:01+00:00", + "selected_frequency_hz": 868100000.0, + "selected_frequency_mhz": 868.1, + "frequency_table": [ + { + "frequency_hz": 433920000.0, + "frequency_mhz": 433.92, + "position": { "x": 1.0, "y": 2.0, "z": 0.3 }, + "rmse_m": 0.65, + "exact": false + } + ], + "output_delivery": { + "enabled": true, + "status": "partial" + } +} +``` + +## GET /config + +Возвращает текущий конфиг. + +Особенность: +- поле `runtime.write_api_token` редактируется в ответе (`""`), +- добавляется `runtime.write_api_token_set` (bool). + +### 200 OK + +```json +{ + "status": "ok", + "config_path": "config.json", + "config": { + "runtime": { + "write_api_token": "", + "write_api_token_set": true + } + } +} +``` + +## POST /refresh + +Принудительный запуск одного цикла опроса и расчета. + +Тело запроса: `{}` (или любой JSON-объект). + +### Заголовки при включенном токене + +- `X-API-Token: ` или +- `Authorization: Bearer ` + +### 200 OK + +```json +{ + "status": "ok", + "updated_at_utc": "2026-03-02T12:35:20+00:00" +} +``` + +### 401 Unauthorized + +```json +{ + "status": "error", + "error": "unauthorized: missing or invalid API token" +} +``` + +### 500 Internal Server Error + +```json +{ + "status": "error", + "error": "Output server(s) rejected payload: sink-a" +} +``` + +## POST /config + +Валидация и горячее применение нового конфига. + +Ограничения: +- тело должно быть JSON-объектом, +- максимальный размер `1_000_000` байт. + +### Заголовки при включенном токене + +- `X-API-Token: ` или +- `Authorization: Bearer ` + +### 200 OK + +```json +{ + "status": "ok", + "saved": true, + "save_error": "", + "restart_required": false, + "applied": true, + "config_path": "config.json" +} +``` + +### 400 Bad Request + +```json +{ + "status": "error", + "error": "Config validation failed: input.receivers must contain at least 3 objects." +} +``` + +### 413 Payload Too Large + +```json +{ + "status": "error", + "error": "Config payload too large: 1500000 bytes, max is 1000000" +} +``` + +### 401 Unauthorized + +```json +{ + "status": "error", + "error": "unauthorized: missing or invalid API token" +} +``` + +## UI и статические файлы + +- `GET /` и `GET /ui` — веб-интерфейс. +- `GET /static/*` — JS/CSS. + diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md new file mode 100644 index 0000000..f26fb5c --- /dev/null +++ b/docs/CONFIG_REFERENCE.md @@ -0,0 +1,198 @@ +# Config Reference + +Ниже описана структура `config.json` для `service.py`. + +Полный пример: [../config.template.json](../config.template.json) + +## Корневые блоки + +- `model` — параметры радиомодели RSSI -> distance. +- `solver` — параметры решателя пересечения сфер. +- `runtime` — HTTP-порт, polling, защита, выходные серверы. +- `input` — входные ресиверы, источники, фильтры, агрегация. + +## model + +```json +"model": { + "tx_power_dbm": 20.0, + "tx_gain_dbi": 0.0, + "rx_gain_dbi": 0.0, + "path_loss_exponent": 2.0, + "reference_distance_m": 1.0, + "min_distance_m": 0.001 +} +``` + +- `tx_power_dbm` (float, required) +- `tx_gain_dbi` (float, optional) +- `rx_gain_dbi` (float, optional) +- `path_loss_exponent` (float, optional) +- `reference_distance_m` (float, optional) +- `min_distance_m` (float, optional) + +## solver + +```json +"solver": { + "tolerance": 0.001, + "z_preference": "positive" +} +``` + +- `tolerance` (float, optional) +- `z_preference` (`"positive"` | `"negative"`) + +## runtime + +```json +"runtime": { + "listen_host": "0.0.0.0", + "listen_port": 8081, + "poll_interval_s": 1.0, + "write_api_token": "", + "output_servers": [] +} +``` + +- `listen_host` (string, optional) +- `listen_port` (int, optional) +- `poll_interval_s` (float, optional) +- `write_api_token` (string, optional) + +### runtime.output_servers (рекомендуется) + +Список выходных серверов. Можно задать несколько целей доставки. + +```json +"output_servers": [ + { + "name": "sink-main", + "ip": "192.168.1.100" + } +] +``` + +Поля: +- `name` (string) +- `ip` (string) + +Автоматически: +- если `enabled` не задан, выход считается включенным при непустом `ip`; +- `port`, `path`, `timeout_s` берутся по умолчанию (`8080`, `/triangulation`, `3.0`). + +Примечание: +- legacy-поля (`enabled`, `port`, `path`, `timeout_s`, `frequency_filter_*`) по-прежнему поддерживаются для обратной совместимости. + +### runtime.output_server (legacy) + +Одиночная цель. Поддерживается для обратной совместимости. + +## input + +```json +"input": { + "mode": "http_sources", + "aggregation": "median", + "source_timeout_s": 3.0, + "default_input_filter": {}, + "receivers": [] +} +``` + +- `mode` — для автосервиса только `"http_sources"`. +- `aggregation` — `"median"` или `"mean"`. +- `source_timeout_s` — timeout входных HTTP-запросов. +- `default_input_filter` — общий фильтр, автоматически применяемый ко всем ресиверам. +- `receivers` — массив ресиверов, минимум 3. + +### input.default_input_filter + +```json +"default_input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 +} +``` + +Ограничения: +- `max_frequency_mhz >= min_frequency_mhz` +- `max_rssi_dbm >= min_rssi_dbm` + +### input.receivers[] + +```json +{ + "receiver_id": "r0", + "center": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://host:9000/measurements", "api_token": "" }, + "input_filter": { + "enabled": false, + "min_frequency_mhz": 0.0, + "max_frequency_mhz": 1000000000.0, + "min_rssi_dbm": -200.0, + "max_rssi_dbm": 50.0 + } +} +``` + +Обязательные поля: +- `receiver_id` +- `center.x`, `center.y`, `center.z` +- URL источника: + - `access.url` (предпочтительно) или + - `source_url` (legacy) + +Дополнительно: +- `access.api_token` или `source_api_token` — токен входного сервера (добавляется как `Authorization: Bearer ...`). +- `input_filter` — override фильтра для конкретного ресивера. +- `frequencies_mhz` — список разрешённых частот для ресивера; в расчёт попадут только они. + +## Пример минимально рабочего конфига + +```json +{ + "model": { "tx_power_dbm": 20.0 }, + "solver": { "tolerance": 0.001, "z_preference": "positive" }, + "runtime": { + "listen_host": "0.0.0.0", + "listen_port": 8081, + "poll_interval_s": 1.0, + "output_servers": [ + { + "name": "sink", + "ip": "" + } + ] + }, + "input": { + "mode": "http_sources", + "aggregation": "median", + "source_timeout_s": 3.0, + "receivers": [ + { + "receiver_id": "r0", + "center": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://127.0.0.1:9001/measurements" } + }, + { + "receiver_id": "r1", + "center": { "x": 10.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://127.0.0.1:9002/measurements" } + }, + { + "receiver_id": "r2", + "center": { "x": 0.0, "y": 8.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://127.0.0.1:9003/measurements" } + } + ] + } +} +``` diff --git a/docs/JSON_EXAMPLES.md b/docs/JSON_EXAMPLES.md new file mode 100644 index 0000000..2a7ec55 --- /dev/null +++ b/docs/JSON_EXAMPLES.md @@ -0,0 +1,106 @@ +# JSON Examples + +Ниже даны практические шаблоны JSON для интеграции. + +## 1) Входной payload от ресивера (рекомендуемый компактный формат) + +```json +{ + "receiver_id": "r0", + "timestamp_unix": 1767354000.125, + "samples": [ + { "f_mhz": 433.92, "rssi": -61.5 }, + { "f_mhz": 868.10, "rssi": -67.2 } + ] +} +``` + +## 2) Входной payload (совместимый legacy-формат) + +```json +{ + "receiver_id": "r0", + "measurements": [ + { "frequency_hz": 433920000, "rssi_dbm": -61.5 }, + { "frequency_hz": 868100000, "rssi_dbm": -67.2 } + ] +} +``` + +Также поддерживаются: +- корневой массив измерений без обертки, +- ключи массивов: `samples`, `measurements`, `data`, `m`, +- частота: `frequency_hz`, `freq_hz`, `f_hz`, `frequency_mhz`, `freq_mhz`, `f_mhz`, `frequency`, `freq`, `f`, +- RSSI: `amplitude_dbm`, `rssi_dbm`, `dbm`, `amplitude`, `rssi`. + +## 3) Выходной payload (отправляется на конечный сервер) + +Сервис отправляет минимальный JSON: + +```json +{ + "x": 1.2, + "y": 2.3, + "z": 0.4 +} +``` + +Только эти поля: +- `x` +- `y` +- `z` + +## 4) Шаблон блока `runtime.output_servers` + +```json +{ + "runtime": { + "output_servers": [ + { + "name": "sink-a", + "ip": "10.0.0.50" + }, + { + "name": "sink-b", + "ip": "10.0.0.51" + } + ] + } +} +``` + +## 5) Шаблон входных ресиверов с общим фильтром + +```json +{ + "input": { + "default_input_filter": { + "enabled": true, + "min_frequency_mhz": 430.0, + "max_frequency_mhz": 900.0, + "min_rssi_dbm": -90.0, + "max_rssi_dbm": -20.0 + }, + "receivers": [ + { + "receiver_id": "r0", + "center": { "x": 0.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://10.0.0.11:9000/measurements" } + }, + { + "receiver_id": "r1", + "center": { "x": 10.0, "y": 0.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://10.0.0.12:9000/measurements" } + }, + { + "receiver_id": "r2", + "center": { "x": 0.0, "y": 8.0, "z": 0.0 }, + "frequencies_mhz": [433.92, 868.1], + "access": { "url": "http://10.0.0.13:9000/measurements" } + } + ] + } +} +``` diff --git a/service.py b/service.py index a3e1b17..319b0bc 100644 --- a/service.py +++ b/service.py @@ -1,8 +1,8 @@ from __future__ import annotations import argparse -import copy import hmac +import itertools import json import math import mimetypes @@ -27,6 +27,10 @@ MAX_CONFIG_BODY_BYTES = 1_000_000 # 1 MB guardrail for /config POST. HZ_IN_MHZ = 1_000_000.0 +def _utc_now_iso_seconds() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + def _load_json(path: str) -> Dict[str, object]: file_path = Path(path) if not file_path.exists(): @@ -114,20 +118,23 @@ def _parse_frequency_hz_from_measurement( keys=( "frequency_hz", "freq_hz", + "f_hz", "frequency_mhz", "freq_mhz", + "f_mhz", "frequency", "freq", + "f", ), field_name="frequency", source_label=source_label, row_index=row_index, ) - if key in ("frequency_hz", "freq_hz"): + if key in ("frequency_hz", "freq_hz", "f_hz"): return value - if key in ("frequency_mhz", "freq_mhz"): + if key in ("frequency_mhz", "freq_mhz", "f_mhz"): return value * HZ_IN_MHZ - # For generic fields "frequency"/"freq" default to MHz in this project. + # For generic fields default to MHz in this project. # Keep backward compatibility: very large values are treated as Hz. if value >= 10_000_000.0: return value @@ -135,11 +142,21 @@ def _parse_frequency_hz_from_measurement( def _parse_receiver_input_filter( - receiver_obj: Dict[str, object], receiver_id: str + receiver_obj: Dict[str, object], + receiver_id: str, + default_filter_obj: Optional[Dict[str, object]] = None, ) -> Dict[str, object]: - filter_obj = receiver_obj.get("input_filter", {}) - if filter_obj is None: - filter_obj = {} + raw_receiver_filter = receiver_obj.get("input_filter") + if raw_receiver_filter is None: + raw_receiver_filter = {} + if not isinstance(raw_receiver_filter, dict): + raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.") + + merged_filter: Dict[str, object] = {} + if isinstance(default_filter_obj, dict): + merged_filter.update(default_filter_obj) + merged_filter.update(raw_receiver_filter) + filter_obj = merged_filter if not isinstance(filter_obj, dict): raise ValueError(f"receiver '{receiver_id}': input_filter must be an object.") @@ -191,6 +208,110 @@ def _apply_receiver_input_filter( return filtered +def _parse_receiver_configured_frequencies( + receiver_obj: Dict[str, object], + receiver_id: str, +) -> List[int]: + raw_frequencies = receiver_obj.get("frequencies_mhz") + if raw_frequencies is None: + return [] + if not isinstance(raw_frequencies, list): + raise ValueError( + f"receiver '{receiver_id}': frequencies_mhz must be an array of numbers." + ) + + parsed_hz: List[int] = [] + for index, value in enumerate(raw_frequencies, start=1): + try: + frequency_mhz = float(value) + except (TypeError, ValueError): + raise ValueError( + f"receiver '{receiver_id}': frequencies_mhz[{index}] must be numeric." + ) from None + if not math.isfinite(frequency_mhz) or frequency_mhz <= 0.0: + raise ValueError( + f"receiver '{receiver_id}': frequencies_mhz[{index}] must be > 0." + ) + parsed_hz.append(int(round(frequency_mhz * HZ_IN_MHZ))) + return sorted(set(parsed_hz)) + + +def _apply_receiver_configured_frequencies( + measurements: Sequence[Tuple[float, float]], + configured_frequencies_hz: Sequence[int], +) -> List[Tuple[float, float]]: + if not configured_frequencies_hz: + return list(measurements) + + allowed = set(int(value) for value in configured_frequencies_hz) + filtered: List[Tuple[float, float]] = [] + for frequency_hz, rssi_dbm in measurements: + rounded_hz = int(round(frequency_hz)) + if rounded_hz in allowed: + filtered.append((float(rounded_hz), rssi_dbm)) + return filtered + + +def _parse_output_server_config( + output_obj: Dict[str, object], + default_name: str, +) -> Dict[str, object]: + name = str(output_obj.get("name", default_name)).strip() or default_name + ip = str(output_obj.get("ip", "")).strip() + # Keep backward compatibility for explicit enabled flag, but allow simplified config: + # if enabled is omitted, non-empty IP means enabled output target. + if "enabled" in output_obj: + enabled = bool(output_obj.get("enabled")) + else: + enabled = bool(ip) + port = int(output_obj.get("port", 8080)) + path = str(output_obj.get("path", "/triangulation")) + timeout_s = float(output_obj.get("timeout_s", 3.0)) + frequency_filter_enabled = bool(output_obj.get("frequency_filter_enabled", False)) + + min_frequency_mhz_raw = output_obj.get("min_frequency_mhz") + max_frequency_mhz_raw = output_obj.get("max_frequency_mhz") + if min_frequency_mhz_raw is None and "min_frequency_hz" in output_obj: + min_frequency_mhz_raw = float(output_obj["min_frequency_hz"]) / HZ_IN_MHZ + if max_frequency_mhz_raw is None and "max_frequency_hz" in output_obj: + max_frequency_mhz_raw = float(output_obj["max_frequency_hz"]) / HZ_IN_MHZ + + min_frequency_mhz = float(min_frequency_mhz_raw or 0.0) + max_frequency_mhz = float(max_frequency_mhz_raw or 0.0) + min_frequency_hz = min_frequency_mhz * HZ_IN_MHZ + max_frequency_hz = max_frequency_mhz * HZ_IN_MHZ + + if enabled and not ip: + raise ValueError(f"runtime output '{name}': ip must be non-empty when enabled=true.") + if frequency_filter_enabled: + if min_frequency_mhz <= 0.0: + raise ValueError( + f"runtime output '{name}': min_frequency_mhz must be > 0 when frequency filter is enabled." + ) + if max_frequency_mhz <= 0.0: + raise ValueError( + f"runtime output '{name}': max_frequency_mhz must be > 0 when frequency filter is enabled." + ) + if max_frequency_mhz < min_frequency_mhz: + raise ValueError( + f"runtime output '{name}': max_frequency_mhz must be >= min_frequency_mhz." + ) + + return { + "name": name, + "enabled": enabled, + "ip": ip, + "port": port, + "path": path, + "timeout_s": timeout_s, + "frequency_filter_enabled": frequency_filter_enabled, + "min_frequency_mhz": min_frequency_mhz, + "max_frequency_mhz": max_frequency_mhz, + "min_frequency_hz": min_frequency_hz, + "max_frequency_hz": max_frequency_hz, + } + + def parse_source_payload( payload: object, source_label: str, @@ -209,6 +330,8 @@ def parse_source_payload( raw_items = payload.get("samples") if raw_items is None: raw_items = payload.get("data") + if raw_items is None: + raw_items = payload.get("m") elif isinstance(payload, list): raw_items = payload else: @@ -228,7 +351,7 @@ def parse_source_payload( ) amplitude_dbm = _float_from_measurement( row, - keys=("amplitude_dbm", "rssi_dbm", "amplitude", "rssi"), + keys=("amplitude_dbm", "rssi_dbm", "dbm", "amplitude", "rssi"), field_name="amplitude_dbm", source_label=source_label, row_index=row_index, @@ -272,9 +395,13 @@ def _fetch_measurements( url: str, timeout_s: float, expected_receiver_id: Optional[str] = None, + headers: Optional[Dict[str, str]] = None, ) -> List[Tuple[float, float]]: source_label = f"source_url={url}" - req = request.Request(url=url, method="GET", headers={"Accept": "application/json"}) + request_headers = {"Accept": "application/json"} + if headers: + request_headers.update(headers) + req = request.Request(url=url, method="GET", headers=request_headers) try: with request.urlopen(req, timeout=timeout_s) as response: payload = json.loads(response.read().decode("utf-8")) @@ -320,45 +447,35 @@ class AutoService: self.poll_interval_s = float(runtime_obj.get("poll_interval_s", 1.0)) self.write_api_token = str(runtime_obj.get("write_api_token", "")).strip() - output_obj = runtime_obj.get("output_server", {}) - if output_obj is None: - output_obj = {} - if not isinstance(output_obj, dict): - raise ValueError("runtime.output_server must be object.") - - self.output_enabled = bool(output_obj.get("enabled", False)) - self.output_ip = str(output_obj.get("ip", "")) - self.output_port = int(output_obj.get("port", 8080)) - self.output_path = str(output_obj.get("path", "/triangulation")) - self.output_timeout_s = float(output_obj.get("timeout_s", 3.0)) - self.output_frequency_filter_enabled = bool( - output_obj.get("frequency_filter_enabled", False) - ) - min_frequency_mhz_raw = output_obj.get("min_frequency_mhz") - max_frequency_mhz_raw = output_obj.get("max_frequency_mhz") - if min_frequency_mhz_raw is None and "min_frequency_hz" in output_obj: - min_frequency_mhz_raw = float(output_obj["min_frequency_hz"]) / HZ_IN_MHZ - if max_frequency_mhz_raw is None and "max_frequency_hz" in output_obj: - max_frequency_mhz_raw = float(output_obj["max_frequency_hz"]) / HZ_IN_MHZ - self.output_min_frequency_mhz = float(min_frequency_mhz_raw or 0.0) - self.output_max_frequency_mhz = float(max_frequency_mhz_raw or 0.0) - self.output_min_frequency_hz = self.output_min_frequency_mhz * HZ_IN_MHZ - self.output_max_frequency_hz = self.output_max_frequency_mhz * HZ_IN_MHZ - if self.output_enabled and not self.output_ip: - raise ValueError("runtime.output_server.ip must be non-empty when enabled=true.") - if self.output_frequency_filter_enabled: - if self.output_min_frequency_mhz <= 0.0: - raise ValueError( - "runtime.output_server.min_frequency_mhz must be > 0 when frequency filter is enabled." + parsed_output_servers: List[Dict[str, object]] = [] + output_servers_obj = runtime_obj.get("output_servers") + if output_servers_obj is not None: + if not isinstance(output_servers_obj, list): + raise ValueError("runtime.output_servers must be list.") + for index, output_obj in enumerate(output_servers_obj, start=1): + if not isinstance(output_obj, dict): + raise ValueError("runtime.output_servers[] must be object.") + parsed_output_servers.append( + _parse_output_server_config( + output_obj=output_obj, + default_name=f"output_{index}", + ) ) - if self.output_max_frequency_mhz <= 0.0: - raise ValueError( - "runtime.output_server.max_frequency_mhz must be > 0 when frequency filter is enabled." - ) - if self.output_max_frequency_mhz < self.output_min_frequency_mhz: - raise ValueError( - "runtime.output_server.max_frequency_mhz must be >= min_frequency_mhz." + else: + output_obj = runtime_obj.get("output_server", {}) + if output_obj is None: + output_obj = {} + if not isinstance(output_obj, dict): + raise ValueError("runtime.output_server must be object.") + parsed_output_servers.append( + _parse_output_server_config( + output_obj=output_obj, + default_name="output_1", ) + ) + + self.output_servers = parsed_output_servers + self.output_enabled = any(bool(server.get("enabled")) for server in self.output_servers) self.source_timeout_s = float(input_obj.get("source_timeout_s", 3.0)) self.aggregation = str(input_obj.get("aggregation", "median")) @@ -369,22 +486,59 @@ class AutoService: if input_mode != "http_sources": raise ValueError("Automatic service requires input.mode = 'http_sources'.") + raw_default_filter = input_obj.get("default_input_filter") + default_filter_obj: Optional[Dict[str, object]] = None + if raw_default_filter is not None: + if not isinstance(raw_default_filter, dict): + raise ValueError("input.default_input_filter must be object.") + default_filter_obj = raw_default_filter + receivers = input_obj.get("receivers") - if not isinstance(receivers, list) or len(receivers) != 3: - raise ValueError("input.receivers must contain exactly 3 objects.") + if not isinstance(receivers, list) or len(receivers) < 3: + raise ValueError("input.receivers must contain at least 3 objects.") parsed_receivers: List[Dict[str, object]] = [] for receiver in receivers: if not isinstance(receiver, dict): raise ValueError("Each receiver must be object.") + access_obj = receiver.get("access", {}) + if access_obj is None: + access_obj = {} + if not isinstance(access_obj, dict): + raise ValueError("receiver.access must be object.") + + source_url = str( + receiver.get("source_url") + or access_obj.get("url") + or access_obj.get("source_url") + or "" + ).strip() + if not source_url: + raise ValueError( + f"receiver '{receiver.get('receiver_id', '')}': source_url/access.url must be non-empty." + ) + + source_headers: Dict[str, str] = {} + source_api_token = str( + receiver.get("source_api_token") or access_obj.get("api_token") or "" + ).strip() + if source_api_token: + source_headers["Authorization"] = f"Bearer {source_api_token}" + parsed_receivers.append( { "receiver_id": str(receiver["receiver_id"]), "center": _center_from_obj(receiver), - "source_url": str(receiver["source_url"]), + "source_url": source_url, + "source_headers": source_headers, + "configured_frequencies_hz": _parse_receiver_configured_frequencies( + receiver_obj=receiver, + receiver_id=str(receiver["receiver_id"]), + ), "input_filter": _parse_receiver_input_filter( receiver_obj=receiver, receiver_id=str(receiver["receiver_id"]), + default_filter_obj=default_filter_obj, ), } ) @@ -400,6 +554,27 @@ class AutoService: "http_status": None, "response_body": "", "sent_at_utc": None, + "servers": [ + { + "name": server["name"], + "enabled": bool(server["enabled"]), + "status": "disabled" if not bool(server["enabled"]) else "pending", + "http_status": None, + "response_body": "", + "sent_at_utc": None, + "target": { + "ip": server["ip"], + "port": server["port"], + "path": server["path"], + }, + "frequency_filter": { + "enabled": server["frequency_filter_enabled"], + "min_frequency_mhz": server["min_frequency_mhz"], + "max_frequency_mhz": server["max_frequency_mhz"], + }, + } + for server in self.output_servers + ], } self.stop_event = threading.Event() @@ -410,7 +585,8 @@ class AutoService: def stop(self) -> None: self.stop_event.set() - self.poll_thread.join(timeout=2.0) + if self.poll_thread.is_alive(): + self.poll_thread.join(timeout=2.0) def refresh_once(self) -> None: receiver_payloads: List[Dict[str, object]] = [] @@ -420,18 +596,26 @@ class AutoService: receiver_id = str(receiver["receiver_id"]) center = receiver["center"] source_url = str(receiver["source_url"]) + source_headers = receiver.get("source_headers") raw_measurements = _fetch_measurements( source_url, timeout_s=self.source_timeout_s, expected_receiver_id=receiver_id, + headers=source_headers if isinstance(source_headers, dict) else None, ) receiver_filter = receiver["input_filter"] measurements = _apply_receiver_input_filter( raw_measurements, receiver_filter=receiver_filter ) + configured_frequencies_hz = receiver.get("configured_frequencies_hz", []) + if isinstance(configured_frequencies_hz, list): + measurements = _apply_receiver_configured_frequencies( + measurements, + configured_frequencies_hz=configured_frequencies_hz, + ) if not measurements: raise RuntimeError( - f"receiver '{receiver_id}': no measurements left after input_filter." + f"receiver '{receiver_id}': no measurements left after configured filters." ) grouped = _group_by_frequency(measurements) grouped_by_receiver.append(grouped) @@ -460,6 +644,14 @@ class AutoService: "source_url": source_url, "aggregation": self.aggregation, "input_filter": receiver_filter, + "configured_frequencies_mhz": [ + float(int(value)) / HZ_IN_MHZ + for value in ( + configured_frequencies_hz + if isinstance(configured_frequencies_hz, list) + else [] + ) + ], "raw_samples_count": len(raw_measurements), "filtered_samples_count": len(measurements), "radius_m_all_freq": radius_m, @@ -467,74 +659,105 @@ class AutoService: } ) - # Only compare homogeneous measurements: same frequency across all receivers. - common_frequencies = ( - set(grouped_by_receiver[0].keys()) - & set(grouped_by_receiver[1].keys()) - & set(grouped_by_receiver[2].keys()) - ) - if not common_frequencies: - raise RuntimeError("No common frequencies across all 3 receivers.") - frequency_rows: List[Dict[str, object]] = [] best_row: Optional[Dict[str, object]] = None - for frequency_hz in sorted(common_frequencies): - spheres_for_frequency: List[Sphere] = [] - row_receivers: List[Dict[str, object]] = [] - - for index, receiver in enumerate(self.receivers): - center = receiver["center"] - measurement_subset = grouped_by_receiver[index][frequency_hz] - radius_m = aggregate_radius( - measurement_subset, model=self.model, method=self.aggregation - ) - spheres_for_frequency.append(Sphere(center=center, radius=radius_m)) - row_receivers.append( - { - "receiver_id": str(receiver["receiver_id"]), - "radius_m": radius_m, - "samples_count": len(measurement_subset), - } + all_frequencies = sorted( + {frequency for grouped in grouped_by_receiver for frequency in grouped.keys()} + ) + for frequency_hz in all_frequencies: + available_indices = [ + idx for idx, grouped in enumerate(grouped_by_receiver) if frequency_hz in grouped + ] + if len(available_indices) < 3: + continue + + best_combo_row: Optional[Dict[str, object]] = None + best_combo_result = None + best_combo_indices: Optional[Tuple[int, int, int]] = None + best_combo_spheres: Optional[List[Sphere]] = None + + for combo in itertools.combinations(available_indices, 3): + spheres_for_frequency: List[Sphere] = [] + row_receivers: List[Dict[str, object]] = [] + + for receiver_index in combo: + receiver = self.receivers[receiver_index] + measurement_subset = grouped_by_receiver[receiver_index][frequency_hz] + radius_m = aggregate_radius( + measurement_subset, model=self.model, method=self.aggregation + ) + spheres_for_frequency.append( + Sphere(center=receiver["center"], radius=radius_m) + ) + row_receivers.append( + { + "receiver_id": str(receiver["receiver_id"]), + "radius_m": radius_m, + "samples_count": len(measurement_subset), + } + ) + + result = solve_three_sphere_intersection( + spheres=spheres_for_frequency, + tolerance=self.tolerance, + z_preference=self.z_preference, # type: ignore[arg-type] ) + candidate_row = { + "frequency_hz": frequency_hz, + "frequency_mhz": frequency_hz / HZ_IN_MHZ, + "position": { + "x": result.point[0], + "y": result.point[1], + "z": result.point[2], + }, + "exact": result.exact, + "rmse_m": result.rmse, + "receivers": row_receivers, + "used_receivers_count": 3, + "available_receivers_count": len(available_indices), + } + if ( + best_combo_row is None + or float(candidate_row["rmse_m"]) < float(best_combo_row["rmse_m"]) + ): + best_combo_row = candidate_row + best_combo_result = result + best_combo_indices = combo + best_combo_spheres = spheres_for_frequency + + if ( + best_combo_row is None + or best_combo_result is None + or best_combo_indices is None + or best_combo_spheres is None + ): + continue - result = solve_three_sphere_intersection( - spheres=spheres_for_frequency, - tolerance=self.tolerance, - z_preference=self.z_preference, # type: ignore[arg-type] - ) - for index, residual in enumerate(result.residuals): - row_receivers[index]["residual_m"] = residual - receiver_payloads[index].setdefault("per_frequency", []).append( + row_receivers = best_combo_row["receivers"] + for local_index, receiver_index in enumerate(best_combo_indices): + residual = best_combo_result.residuals[local_index] + row_receivers[local_index]["residual_m"] = residual + receiver_payloads[receiver_index].setdefault("per_frequency", []).append( { "frequency_hz": frequency_hz, "frequency_mhz": frequency_hz / HZ_IN_MHZ, - "radius_m": spheres_for_frequency[index].radius, + "radius_m": best_combo_spheres[local_index].radius, "residual_m": residual, - "samples_count": len(grouped_by_receiver[index][frequency_hz]), + "samples_count": len(grouped_by_receiver[receiver_index][frequency_hz]), } ) - row = { - "frequency_hz": frequency_hz, - "frequency_mhz": frequency_hz / HZ_IN_MHZ, - "position": { - "x": result.point[0], - "y": result.point[1], - "z": result.point[2], - }, - "exact": result.exact, - "rmse_m": result.rmse, - "receivers": row_receivers, - } - frequency_rows.append(row) - if best_row is None or float(row["rmse_m"]) < float(best_row["rmse_m"]): - best_row = row + frequency_rows.append(best_combo_row) + if best_row is None or float(best_combo_row["rmse_m"]) < float(best_row["rmse_m"]): + best_row = best_combo_row if best_row is None: + if len(self.receivers) == 3: + raise RuntimeError("No common frequencies across all 3 receivers.") raise RuntimeError("Cannot build frequency table for trilateration.") payload = { - "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "timestamp_utc": _utc_now_iso_seconds(), "selected_frequency_hz": best_row["frequency_hz"], "selected_frequency_mhz": float(best_row["frequency_hz"]) / HZ_IN_MHZ, "position": best_row["position"], @@ -556,109 +779,165 @@ class AutoService: self.updated_at_utc = payload["timestamp_utc"] # type: ignore[index] self.last_error = "" - if self.output_enabled: - output_payload = self._build_output_payload(payload) - if output_payload is None: - with self.state_lock: - self.last_output_delivery = { - "enabled": True, - "status": "skipped", - "http_status": None, - "response_body": "No frequencies in configured output range", - "sent_at_utc": datetime.now(timezone.utc).isoformat(), - "target": { - "ip": self.output_ip, - "port": self.output_port, - "path": self.output_path, - }, - "frequency_filter": { - "enabled": self.output_frequency_filter_enabled, - "min_frequency_mhz": self.output_min_frequency_mhz, - "max_frequency_mhz": self.output_max_frequency_mhz, - }, - } - return - - status_code, response_body = send_payload_to_server( - server_ip=self.output_ip, - payload=output_payload, - port=self.output_port, - path=self.output_path, - timeout_s=self.output_timeout_s, + delivery = self._deliver_to_output_servers(payload) + with self.state_lock: + self.last_output_delivery = delivery + + if delivery["status"] in ("error", "partial"): + failed_servers = [ + row["name"] + for row in delivery.get("servers", []) + if isinstance(row, dict) and row.get("status") == "error" + ] + raise RuntimeError( + "Output server(s) rejected payload: " + + ", ".join(str(name) for name in failed_servers) ) - # Keep delivery diagnostics in snapshot so UI/API can show transport health. - with self.state_lock: - self.last_output_delivery = { - "enabled": True, - "status": "ok" if 200 <= status_code < 300 else "error", - "http_status": status_code, - "response_body": response_body, - "sent_at_utc": datetime.now(timezone.utc).isoformat(), - "target": { - "ip": self.output_ip, - "port": self.output_port, - "path": self.output_path, - }, - "frequency_filter": { - "enabled": self.output_frequency_filter_enabled, - "min_frequency_mhz": self.output_min_frequency_mhz, - "max_frequency_mhz": self.output_max_frequency_mhz, - }, - } - if status_code < 200 or status_code >= 300: - raise RuntimeError( - "Output server rejected payload: " - f"HTTP {status_code}, body={response_body}" - ) - def _build_output_payload(self, payload: Dict[str, object]) -> Optional[Dict[str, object]]: - if not self.output_frequency_filter_enabled: - return payload + @staticmethod + def _row_frequency_mhz(row: Dict[str, object]) -> Optional[float]: + mhz = row.get("frequency_mhz") + if isinstance(mhz, (int, float)): + return float(mhz) + hz = row.get("frequency_hz") + if isinstance(hz, (int, float)): + return float(hz) / HZ_IN_MHZ + return None + + @staticmethod + def _position_from_row(row: Dict[str, object]) -> Optional[Dict[str, float]]: + position_obj = row.get("position") + if not isinstance(position_obj, dict): + return None + try: + return { + "x": float(position_obj["x"]), + "y": float(position_obj["y"]), + "z": float(position_obj["z"]), + } + except (TypeError, ValueError, KeyError): + return None - # Keep internal calculations unchanged, but limit data sent to output server by frequency. - payload_copy = copy.deepcopy(payload) - table_obj = payload_copy.get("frequency_table") + def _build_output_payload( + self, + payload: Dict[str, object], + output_server: Dict[str, object], + ) -> Optional[Dict[str, object]]: + table_obj = payload.get("frequency_table") if not isinstance(table_obj, list): return None - filtered_rows = [] + rows: List[Dict[str, object]] = [] for row in table_obj: if not isinstance(row, dict): continue frequency_hz = row.get("frequency_hz") if not isinstance(frequency_hz, (int, float)): continue - if self.output_min_frequency_hz <= float(frequency_hz) <= self.output_max_frequency_hz: - filtered_rows.append(row) - if not filtered_rows: + if self._position_from_row(row) is None: + continue + if bool(output_server.get("frequency_filter_enabled", False)): + if not ( + float(output_server.get("min_frequency_hz", 0.0)) + <= float(frequency_hz) + <= float(output_server.get("max_frequency_hz", 0.0)) + ): + continue + rows.append(row) + + if not rows: return None - best_row = min(filtered_rows, key=lambda row: float(row.get("rmse_m", float("inf")))) - payload_copy["frequency_table"] = filtered_rows - payload_copy["selected_frequency_hz"] = best_row.get("frequency_hz") - payload_copy["selected_frequency_mhz"] = float(best_row.get("frequency_hz", 0.0)) / HZ_IN_MHZ - payload_copy["position"] = best_row.get("position") - payload_copy["exact"] = best_row.get("exact") - payload_copy["rmse_m"] = best_row.get("rmse_m") - - receivers_obj = payload_copy.get("receivers") - if isinstance(receivers_obj, list): - for receiver in receivers_obj: - if not isinstance(receiver, dict): - continue - per_frequency = receiver.get("per_frequency") - if not isinstance(per_frequency, list): - continue - receiver["per_frequency"] = [ - row - for row in per_frequency - if isinstance(row, dict) - and isinstance(row.get("frequency_hz"), (int, float)) - and self.output_min_frequency_hz - <= float(row["frequency_hz"]) - <= self.output_max_frequency_hz - ] - return payload_copy + best_row = min( + rows, + key=lambda row: float(row.get("rmse_m", float("inf"))), + ) + best_position = self._position_from_row(best_row) + if best_position is None: + return None + + # Minimal transport payload for final server integration: coordinates only. + return best_position + + def _deliver_to_output_servers(self, payload: Dict[str, object]) -> Dict[str, object]: + now = _utc_now_iso_seconds() + servers_delivery: List[Dict[str, object]] = [] + enabled_targets = [server for server in self.output_servers if bool(server.get("enabled"))] + + for server in self.output_servers: + server_delivery = { + "name": server["name"], + "enabled": bool(server["enabled"]), + "status": "disabled", + "http_status": None, + "response_body": "", + "sent_at_utc": now, + "target": { + "ip": server["ip"], + "port": server["port"], + "path": server["path"], + }, + "frequency_filter": { + "enabled": server["frequency_filter_enabled"], + "min_frequency_mhz": server["min_frequency_mhz"], + "max_frequency_mhz": server["max_frequency_mhz"], + }, + } + if not bool(server["enabled"]): + servers_delivery.append(server_delivery) + continue + + output_payload = self._build_output_payload(payload=payload, output_server=server) + if output_payload is None: + server_delivery["status"] = "skipped" + server_delivery["response_body"] = "No frequencies in configured output range" + servers_delivery.append(server_delivery) + continue + + status_code, response_body = send_payload_to_server( + server_ip=str(server["ip"]), + payload=output_payload, + port=int(server["port"]), + path=str(server["path"]), + timeout_s=float(server["timeout_s"]), + ) + server_delivery["http_status"] = status_code + server_delivery["response_body"] = response_body + server_delivery["status"] = "ok" if 200 <= status_code < 300 else "error" + servers_delivery.append(server_delivery) + + ok_count = sum(1 for row in servers_delivery if row["status"] == "ok") + error_count = sum(1 for row in servers_delivery if row["status"] == "error") + skipped_count = sum(1 for row in servers_delivery if row["status"] == "skipped") + + if not enabled_targets: + status = "disabled" + elif error_count > 0 and ok_count > 0: + status = "partial" + elif error_count > 0: + status = "error" + elif ok_count == 0 and skipped_count > 0: + status = "skipped" + else: + status = "ok" + + primary = next((row for row in servers_delivery if row["enabled"]), None) + if primary is None and servers_delivery: + primary = servers_delivery[0] + + return { + "enabled": bool(enabled_targets), + "status": status, + "http_status": None if primary is None else primary["http_status"], + "response_body": "" if primary is None else primary["response_body"], + "sent_at_utc": now, + "target": None if primary is None else primary["target"], + "frequency_filter": None if primary is None else primary["frequency_filter"], + "ok_count": ok_count, + "error_count": error_count, + "skipped_count": skipped_count, + "servers": servers_delivery, + } def _poll_loop(self) -> None: while not self.stop_event.is_set(): @@ -680,9 +959,17 @@ class AutoService: def _make_handler(service: AutoService): + service_holder = {"current": service} + service_swap_lock = threading.Lock() + class ServiceHandler(BaseHTTPRequestHandler): + @staticmethod + def _current_service() -> AutoService: + return service_holder["current"] + def _is_write_authorized(self) -> bool: - expected_token = service.write_api_token + service_obj = self._current_service() + expected_token = service_obj.write_api_token if not expected_token: return True @@ -733,6 +1020,12 @@ def _make_handler(service: AutoService): mime_type, _ = mimetypes.guess_type(str(file_path)) if mime_type is None: mime_type = "application/octet-stream" + # Force UTF-8 for text assets to avoid mojibake in browsers. + if mime_type.startswith("text/") or mime_type in ( + "application/javascript", + "application/x-javascript", + ): + mime_type = f"{mime_type}; charset=utf-8" self._write_bytes(200, file_path.read_bytes(), mime_type) def log_message(self, format: str, *args) -> None: @@ -740,7 +1033,8 @@ def _make_handler(service: AutoService): def do_GET(self) -> None: path = parse.urlparse(self.path).path - snapshot = service.snapshot() + service_obj = self._current_service() + snapshot = service_obj.snapshot() if path == "/" or path == "/ui": self._write_static("index.html") @@ -812,17 +1106,17 @@ def _make_handler(service: AutoService): return if path == "/config": - public_config = json.loads(json.dumps(service.config)) + public_config = json.loads(json.dumps(service_obj.config)) runtime_obj = public_config.get("runtime") if isinstance(runtime_obj, dict): if "write_api_token" in runtime_obj: runtime_obj["write_api_token"] = "" - runtime_obj["write_api_token_set"] = bool(service.write_api_token) + runtime_obj["write_api_token_set"] = bool(service_obj.write_api_token) self._write_json( 200, { "status": "ok", - "config_path": service.config_path, + "config_path": service_obj.config_path, "config": public_config, }, ) @@ -840,6 +1134,7 @@ def _make_handler(service: AutoService): return if path == "/config": + service_obj = self._current_service() try: content_length = int(self.headers.get("Content-Length", "0")) except ValueError: @@ -872,13 +1167,13 @@ def _make_handler(service: AutoService): # Avoid accidental token wipe when /config GET response is redacted in clients. runtime_obj = new_config.get("runtime") - if isinstance(runtime_obj, dict) and service.write_api_token: + if isinstance(runtime_obj, dict) and service_obj.write_api_token: incoming_token = str(runtime_obj.get("write_api_token", "")).strip() if not incoming_token: - runtime_obj["write_api_token"] = service.write_api_token + runtime_obj["write_api_token"] = service_obj.write_api_token try: - AutoService(new_config) + new_service = AutoService(new_config, config_path=service_obj.config_path) except Exception as exc: self._write_json( 400, @@ -886,19 +1181,41 @@ def _make_handler(service: AutoService): ) return - service.config = new_config - if service.config_path: - Path(service.config_path).write_text( - json.dumps(new_config, ensure_ascii=False, indent=2), - encoding="utf-8", + save_error = "" + if service_obj.config_path: + try: + Path(service_obj.config_path).write_text( + json.dumps(new_config, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except OSError as exc: + save_error = str(exc) + + try: + new_service.start() + except Exception as exc: + self._write_json( + 500, + { + "status": "error", + "error": f"Failed to start service with new config: {exc}", + }, ) + return + with service_swap_lock: + old_service = service_holder["current"] + service_holder["current"] = new_service + old_service.stop() + self._write_json( 200, { "status": "ok", - "saved": bool(service.config_path), - "restart_required": True, - "config_path": service.config_path, + "saved": bool(service_obj.config_path) and not bool(save_error), + "save_error": save_error, + "restart_required": False, + "applied": True, + "config_path": service_obj.config_path, }, ) return @@ -908,12 +1225,12 @@ def _make_handler(service: AutoService): return try: - service.refresh_once() + self._current_service().refresh_once() except Exception as exc: self._write_json(500, {"status": "error", "error": str(exc)}) return - snapshot = service.snapshot() + snapshot = self._current_service().snapshot() self._write_json( 200, { @@ -922,6 +1239,7 @@ def _make_handler(service: AutoService): }, ) + ServiceHandler.service_holder = service_holder # type: ignore[attr-defined] return ServiceHandler @@ -947,8 +1265,8 @@ def main() -> int: service = AutoService(config, config_path=args.config) service.start() - - server = ThreadingHTTPServer((host, port), _make_handler(service)) + handler = _make_handler(service) + server = ThreadingHTTPServer((host, port), handler) print(f"service_listen: http://{host}:{port}") try: server.serve_forever() @@ -956,7 +1274,8 @@ def main() -> int: pass finally: server.server_close() - service.stop() + current_service = handler.service_holder["current"] # type: ignore[attr-defined] + current_service.stop() return 0 diff --git a/test_service_integration.py b/test_service_integration.py index a750d67..c0a65b7 100644 --- a/test_service_integration.py +++ b/test_service_integration.py @@ -198,7 +198,7 @@ def test_refresh_once_raises_when_output_server_rejects_payload( ) svc = service.AutoService(config) - with pytest.raises(RuntimeError, match="Output server rejected payload: HTTP 500"): + with pytest.raises(RuntimeError, match="Output server\\(s\\) rejected payload"): svc.refresh_once() @@ -250,6 +250,24 @@ def test_parse_source_payload_treats_generic_frequency_as_mhz(): assert parsed[0][0] == pytest.approx(433_920_000.0) +def test_parse_source_payload_accepts_compact_short_keys(): + payload = { + "receiver_id": "r0", + "samples": [ + {"f_mhz": 868.1, "rssi": -60.0}, + {"f_hz": 433_920_000.0, "dbm": -62.5}, + ], + } + parsed = service.parse_source_payload( + payload=payload, + source_label="source_url=test", + expected_receiver_id="r0", + ) + assert parsed[0][0] == pytest.approx(868_100_000.0) + assert parsed[1][0] == pytest.approx(433_920_000.0) + assert parsed[1][1] == pytest.approx(-62.5) + + def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch): config = _base_config() svc = service.AutoService(config) @@ -263,6 +281,8 @@ def test_http_blocks_static_path_traversal(monkeypatch: pytest.MonkeyPatch): http_server.shutdown() http_server.server_close() thread.join(timeout=1.0) + current_service = http_server.RequestHandlerClass.service_holder["current"] # type: ignore[attr-defined] + current_service.stop() def test_http_config_rejects_empty_body(monkeypatch: pytest.MonkeyPatch): @@ -320,6 +340,42 @@ def test_http_config_rejects_too_large_payload(monkeypatch: pytest.MonkeyPatch): thread.join(timeout=1.0) +def test_http_config_applies_without_manual_restart(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + monkeypatch.setattr( + service, + "_fetch_measurements", + lambda *_, **__: [(915e6, -60.0)], + ) + + svc = service.AutoService(config) + http_server, thread, base_url = _start_api_server_for_test(svc) + + try: + new_config = _base_config() + new_config["runtime"]["poll_interval_s"] = 0.25 # type: ignore[index] + raw = json.dumps(new_config).encode("utf-8") + req = urllib_request.Request( + url=f"{base_url}/config", + method="POST", + data=raw, + headers={"Content-Type": "application/json"}, + ) + with urllib_request.urlopen(req) as response: + payload = json.loads(response.read().decode("utf-8")) + assert payload["status"] == "ok" + assert payload["applied"] is True + assert payload["restart_required"] is False + + with urllib_request.urlopen(f"{base_url}/config") as response: + payload = json.loads(response.read().decode("utf-8")) + assert payload["config"]["runtime"]["poll_interval_s"] == 0.25 + finally: + http_server.shutdown() + http_server.server_close() + thread.join(timeout=1.0) + + def test_http_refresh_requires_write_token_when_configured(monkeypatch: pytest.MonkeyPatch): config = _base_config() config["runtime"]["write_api_token"] = "secret" # type: ignore[index] @@ -411,10 +467,7 @@ def test_output_payload_is_filtered_by_frequency_range(monkeypatch: pytest.Monke svc.refresh_once() sent_payload = captured["payload"] - freq_rows = sent_payload["frequency_table"] - assert len(freq_rows) == 1 - assert freq_rows[0]["frequency_hz"] == 868_100_000.0 - assert sent_payload["selected_frequency_hz"] == 868_100_000.0 + assert set(sent_payload.keys()) == {"x", "y", "z"} def test_output_delivery_skipped_when_range_has_no_frequencies(monkeypatch: pytest.MonkeyPatch): @@ -441,6 +494,55 @@ def test_output_delivery_skipped_when_range_has_no_frequencies(monkeypatch: pyte assert snapshot["output_delivery"]["status"] == "skipped" +def test_multiple_output_servers_with_names(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["runtime"]["output_servers"] = [ # type: ignore[index] + { + "name": "sink_a", + "enabled": True, + "ip": "127.0.0.1", + "port": 8080, + "path": "/triangulation", + "timeout_s": 1.0, + }, + { + "name": "sink_b", + "enabled": True, + "ip": "127.0.0.2", + "port": 8081, + "path": "/triangulation", + "timeout_s": 1.0, + }, + ] + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -59.0}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -58.0}]}, + } + _install_urlopen(monkeypatch, responses) + + calls: List[Dict[str, object]] = [] + + def _fake_send_payload_to_server(**kwargs): + calls.append(kwargs) + if kwargs["server_ip"] == "127.0.0.2": + return 500, "fail" + return 200, "ok" + + monkeypatch.setattr(service, "send_payload_to_server", _fake_send_payload_to_server) + + svc = service.AutoService(config) + with pytest.raises(RuntimeError, match="sink_b"): + svc.refresh_once() + + snapshot = svc.snapshot() + assert snapshot["output_delivery"]["status"] in ("partial", "error") + servers = snapshot["output_delivery"]["servers"] + assert isinstance(servers, list) + assert {row["name"] for row in servers} == {"sink_a", "sink_b"} + assert len(calls) == 2 + + def test_config_validation_rejects_invalid_frequency_filter_range(): config = _base_config() config["runtime"]["output_server"]["frequency_filter_enabled"] = True # type: ignore[index] @@ -509,7 +611,7 @@ def test_receiver_input_filter_empty_result_raises(monkeypatch: pytest.MonkeyPat _install_urlopen(monkeypatch, responses) svc = service.AutoService(config) - with pytest.raises(RuntimeError, match="no measurements left after input_filter"): + with pytest.raises(RuntimeError, match="no measurements left after configured filters"): svc.refresh_once() @@ -524,3 +626,112 @@ def test_receiver_input_filter_validation_rejects_invalid_rssi_range(): } with pytest.raises(ValueError, match="max_rssi_dbm must be >= min_rssi_dbm"): service.AutoService(config) + + +def test_refresh_once_supports_more_than_three_receivers_and_chooses_best_triplet( + monkeypatch: pytest.MonkeyPatch, +): + config = _base_config() + config["input"]["receivers"].append( # type: ignore[index] + { + "receiver_id": "r3", + "center": {"x": 50.0, "y": 50.0, "z": 0.0}, + "source_url": "http://r3.local/measurements", + } + ) + responses = { + "http://r0.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -60.0}]}, + "http://r1.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -61.0}]}, + "http://r2.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -62.0}]}, + "http://r3.local/measurements": {"measurements": [{"frequency_hz": 915e6, "rssi_dbm": -120.0}]}, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + svc.refresh_once() + payload = svc.snapshot()["payload"] + assert payload is not None + assert len(payload["frequency_table"]) == 1 + row = payload["frequency_table"][0] + assert row["used_receivers_count"] == 3 + assert row["available_receivers_count"] == 4 + assert len(row["receivers"]) == 3 + + +def test_receiver_access_url_and_default_input_filter(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + config["input"]["default_input_filter"] = { # type: ignore[index] + "enabled": True, + "min_frequency_mhz": 900.0, + "max_frequency_mhz": 920.0, + "min_rssi_dbm": -90.0, + "max_rssi_dbm": -50.0, + } + for receiver in config["input"]["receivers"]: # type: ignore[index] + receiver["access"] = {"url": receiver["source_url"]} + del receiver["source_url"] + receiver.pop("input_filter", None) + + responses = { + "http://r0.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -60.0}, + {"frequency_hz": 433e6, "rssi_dbm": -60.0}, + ] + }, + "http://r1.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -61.0}, + {"frequency_hz": 433e6, "rssi_dbm": -61.0}, + ] + }, + "http://r2.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -62.0}, + {"frequency_hz": 433e6, "rssi_dbm": -62.0}, + ] + }, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + svc.refresh_once() + payload = svc.snapshot()["payload"] + assert payload is not None + assert len(payload["frequency_table"]) == 1 + assert payload["frequency_table"][0]["frequency_hz"] == pytest.approx(915e6) + + +def test_receiver_configured_frequencies_limit_trilateration(monkeypatch: pytest.MonkeyPatch): + config = _base_config() + for receiver in config["input"]["receivers"]: # type: ignore[index] + receiver["frequencies_mhz"] = [915.0] + + responses = { + "http://r0.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -60.0}, + {"frequency_hz": 433_920_000.0, "rssi_dbm": -61.0}, + ] + }, + "http://r1.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -61.0}, + {"frequency_hz": 433_920_000.0, "rssi_dbm": -62.0}, + ] + }, + "http://r2.local/measurements": { + "measurements": [ + {"frequency_hz": 915e6, "rssi_dbm": -62.0}, + {"frequency_hz": 433_920_000.0, "rssi_dbm": -63.0}, + ] + }, + } + _install_urlopen(monkeypatch, responses) + + svc = service.AutoService(config) + svc.refresh_once() + payload = svc.snapshot()["payload"] + assert payload is not None + assert len(payload["frequency_table"]) == 1 + assert payload["frequency_table"][0]["frequency_hz"] == pytest.approx(915e6) diff --git a/triangulation.py b/triangulation.py index 7e95166..eaca9ad 100644 --- a/triangulation.py +++ b/triangulation.py @@ -12,6 +12,10 @@ Point3D = Tuple[float, float, float] SPEED_OF_LIGHT_M_S = 299_792_458.0 +def _utc_now_iso_seconds() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + @dataclass(frozen=True) class Sphere: center: Point3D @@ -330,7 +334,7 @@ def build_result_payload( ) return { - "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "timestamp_utc": _utc_now_iso_seconds(), "position": {"x": result.point[0], "y": result.point[1], "z": result.point[2]}, "exact": result.exact, "rmse_m": result.rmse, diff --git a/web/app.js b/web/app.js index c3c2899..dbd2a9d 100644 --- a/web/app.js +++ b/web/app.js @@ -7,19 +7,93 @@ const state = { activeSection: "overview", selectedReceiverIndex: 0, receiverDrafts: [], + sharedFilterDraft: { + enabled: false, + min_frequency_mhz: 0, + max_frequency_mhz: 0, + min_rssi_dbm: -200, + max_rssi_dbm: 50, + }, + selectedOutputIndex: 0, + outputDrafts: [], }; + const HZ_IN_MHZ = 1_000_000; function byId(id) { return document.getElementById(id); } +function setTextWithPulse(id, value) { + const el = byId(id); + if (!el) return; + const next = String(value); + const changed = el.textContent !== next; + el.textContent = next; + if (!changed) return; + el.classList.remove("value-updated"); + void el.offsetWidth; + el.classList.add("value-updated"); +} + function fmt(value, digits = 6) { if (value === null || value === undefined) return "-"; if (typeof value !== "number") return String(value); return Number.isFinite(value) ? value.toFixed(digits) : String(value); } +function localizeStatus(value) { + const status = String(value || "n/a"); + const mapping = { + ok: "ок", + error: "ошибка", + warming_up: "прогрев", + partial: "частично", + skipped: "пропущено", + disabled: "отключено", + not_found: "не найдено", + n_a: "н/д", + "n/a": "н/д", + true: "включено", + false: "отключено", + }; + return mapping[status] || status; +} + +function localizeErrorMessage(message) { + const text = String(message || "неизвестная ошибка"); + const known = { + "at least 3 input servers are required": "необходимо минимум 3 входных сервера", + "at least 1 output server is required": "необходим минимум 1 выходной сервер", + Unauthorized: "доступ запрещён (проверьте токен)", + "unauthorized: missing or invalid API token": "доступ запрещён: отсутствует или неверный API-токен", + warming_up: "прогрев", + not_found: "не найдено", + "no data yet": "данные пока не получены", + }; + if (known[text]) return known[text]; + if (text.startsWith("HTTP ")) return `ошибка HTTP: ${text.slice(5)}`; + if (text.startsWith("Config validation failed:")) { + return `ошибка валидации конфига: ${text.replace("Config validation failed:", "").trim()}`; + } + return text; +} + +function formatUpdatedTimestamp(value) { + if (!value) { + return { date: "дата: н/д", time: "время: н/д" }; + } + const text = String(value); + const match = text.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})?$/); + if (!match) { + return { date: `дата: ${text}`, time: "время: н/д" }; + } + const datePart = `${match[3]}.${match[2]}.${match[1]}`; + const zone = match[5] || ""; + const zoneLabel = zone === "Z" || zone === "+00:00" ? " UTC" : zone ? ` ${zone}` : ""; + return { date: `дата: ${datePart}`, time: `время: ${match[4]}${zoneLabel}` }; +} + function hzToMhz(value) { if (value === null || value === undefined) return null; const numeric = Number(value); @@ -27,6 +101,25 @@ function hzToMhz(value) { return numeric / HZ_IN_MHZ; } +function parseMhzList(raw) { + const text = String(raw || "").trim(); + if (!text) return []; + const values = text + .split(",") + .map((part) => Number(part.trim())) + .filter((value) => Number.isFinite(value) && value > 0); + return Array.from(new Set(values)); +} + +function formatMhzList(values) { + if (!Array.isArray(values) || values.length === 0) return ""; + return values + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => value.toFixed(3).replace(/\.?0+$/, "")) + .join(", "); +} + function authHeaders() { const token = state.writeToken || ""; if (!token) return {}; @@ -50,33 +143,62 @@ function setMenuOpen(isOpen) { byId("menu-list").classList.toggle("menu-list-open", isOpen); } -function normalizeReceiverDraft(receiver) { - const filter = receiver?.input_filter || {}; +function normalizeInputFilter(filter) { + const source = filter || {}; + return { + enabled: Boolean(source.enabled), + min_frequency_mhz: Number(source.min_frequency_mhz ?? hzToMhz(source.min_frequency_hz) ?? 0), + max_frequency_mhz: Number(source.max_frequency_mhz ?? hzToMhz(source.max_frequency_hz) ?? 0), + min_rssi_dbm: Number(source.min_rssi_dbm ?? -200), + max_rssi_dbm: Number(source.max_rssi_dbm ?? 50), + }; +} + +function createReceiverDraft(index) { return { - receiver_id: receiver?.receiver_id || "", - source_url: receiver?.source_url || "", - input_filter: { - enabled: Boolean(filter.enabled), - min_frequency_mhz: filter.min_frequency_mhz ?? hzToMhz(filter.min_frequency_hz) ?? 0, - max_frequency_mhz: filter.max_frequency_mhz ?? hzToMhz(filter.max_frequency_hz) ?? 0, - min_rssi_dbm: filter.min_rssi_dbm ?? -200, - max_rssi_dbm: filter.max_rssi_dbm ?? 50, + receiver_id: `r${index}`, + source_url: "", + frequencies_mhz: [], + center: { x: 0, y: 0, z: 0 }, + }; +} + +function normalizeReceiverDraft(receiver, index) { + const center = receiver?.center || {}; + const access = receiver?.access || {}; + const frequencies = Array.isArray(receiver?.frequencies_mhz) + ? receiver.frequencies_mhz + : []; + return { + receiver_id: receiver?.receiver_id || `r${index}`, + source_url: String(receiver?.source_url || access.url || access.source_url || ""), + frequencies_mhz: frequencies + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0), + center: { + x: Number(center.x ?? 0), + y: Number(center.y ?? 0), + z: Number(center.z ?? 0), }, }; } +function updateReceiverCountBadge() { + byId("receiver-count").textContent = `входов: ${state.receiverDrafts.length}`; +} + function saveCurrentReceiverDraftFromInputs() { const idx = state.selectedReceiverIndex; if (!state.receiverDrafts[idx]) return; state.receiverDrafts[idx] = { ...state.receiverDrafts[idx], + receiver_id: byId("rx-id").value.trim() || `r${idx}`, source_url: byId("rx-url").value.trim(), - input_filter: { - enabled: byId("rx-filter-enabled").value === "true", - min_frequency_mhz: Number(byId("rx-min-freq").value), - max_frequency_mhz: Number(byId("rx-max-freq").value), - min_rssi_dbm: Number(byId("rx-min-rssi").value), - max_rssi_dbm: Number(byId("rx-max-rssi").value), + frequencies_mhz: parseMhzList(byId("rx-frequencies").value), + center: { + x: Number(byId("rx-center-x").value), + y: Number(byId("rx-center-y").value), + z: Number(byId("rx-center-z").value), }, }; } @@ -84,12 +206,12 @@ function saveCurrentReceiverDraftFromInputs() { function renderSelectedReceiverDraft() { const draft = state.receiverDrafts[state.selectedReceiverIndex]; if (!draft) return; + byId("rx-id").value = draft.receiver_id; byId("rx-url").value = draft.source_url; - byId("rx-filter-enabled").value = String(Boolean(draft.input_filter.enabled)); - byId("rx-min-freq").value = draft.input_filter.min_frequency_mhz; - byId("rx-max-freq").value = draft.input_filter.max_frequency_mhz; - byId("rx-min-rssi").value = draft.input_filter.min_rssi_dbm; - byId("rx-max-rssi").value = draft.input_filter.max_rssi_dbm; + byId("rx-frequencies").value = formatMhzList(draft.frequencies_mhz); + byId("rx-center-x").value = draft.center.x; + byId("rx-center-y").value = draft.center.y; + byId("rx-center-z").value = draft.center.z; } function fillReceiverSelect() { @@ -98,13 +220,106 @@ function fillReceiverSelect() { state.receiverDrafts.forEach((draft, index) => { const option = document.createElement("option"); option.value = String(index); - option.textContent = draft.receiver_id || `receiver_${index + 1}`; + option.textContent = draft.receiver_id || `вход_${index + 1}`; select.appendChild(option); }); if (state.selectedReceiverIndex >= state.receiverDrafts.length) { - state.selectedReceiverIndex = 0; + state.selectedReceiverIndex = Math.max(0, state.receiverDrafts.length - 1); } select.value = String(state.selectedReceiverIndex); + updateReceiverCountBadge(); +} + +function addReceiverDraft() { + saveCurrentReceiverDraftFromInputs(); + const nextIndex = state.receiverDrafts.length; + state.receiverDrafts.push(createReceiverDraft(nextIndex)); + state.selectedReceiverIndex = nextIndex; + fillReceiverSelect(); + renderSelectedReceiverDraft(); +} + +function removeReceiverDraft() { + if (state.receiverDrafts.length <= 3) { + byId("servers-state").textContent = "серверы: необходимо минимум 3 входа"; + return; + } + state.receiverDrafts.splice(state.selectedReceiverIndex, 1); + state.selectedReceiverIndex = Math.max(0, state.selectedReceiverIndex - 1); + fillReceiverSelect(); + renderSelectedReceiverDraft(); +} + +function createOutputDraft(index) { + return { + name: `выход_${index + 1}`, + ip: "", + }; +} + +function normalizeOutputDraft(output, index) { + const source = output || {}; + return { + name: String(source.name || `выход_${index + 1}`), + ip: String(source.ip || ""), + }; +} + +function updateOutputCountBadge() { + byId("output-count").textContent = `выходов: ${state.outputDrafts.length}`; +} + +function saveCurrentOutputDraftFromInputs() { + const idx = state.selectedOutputIndex; + if (!state.outputDrafts[idx]) return; + state.outputDrafts[idx] = { + ...state.outputDrafts[idx], + name: byId("out-name").value.trim() || `выход_${idx + 1}`, + ip: byId("out-ip").value.trim(), + }; +} + +function renderSelectedOutputDraft() { + const draft = state.outputDrafts[state.selectedOutputIndex]; + if (!draft) return; + byId("out-name").value = draft.name; + byId("out-ip").value = draft.ip; +} + +function fillOutputSelect() { + const select = byId("output-select"); + select.innerHTML = ""; + state.outputDrafts.forEach((draft, index) => { + const option = document.createElement("option"); + option.value = String(index); + option.textContent = draft.name || `выход_${index + 1}`; + select.appendChild(option); + }); + if (state.selectedOutputIndex >= state.outputDrafts.length) { + state.selectedOutputIndex = Math.max(0, state.outputDrafts.length - 1); + } + select.value = String(state.selectedOutputIndex); + updateOutputCountBadge(); +} + +function addOutputDraft() { + saveCurrentOutputDraftFromInputs(); + const nextIndex = state.outputDrafts.length; + state.outputDrafts.push(createOutputDraft(nextIndex)); + state.selectedOutputIndex = nextIndex; + fillOutputSelect(); + renderSelectedOutputDraft(); +} + +function removeOutputDraft() { + if (state.outputDrafts.length <= 1) { + byId("servers-state").textContent = "серверы: необходим минимум 1 выход"; + return; + } + state.outputDrafts.splice(state.selectedOutputIndex, 1); + state.selectedOutputIndex = Math.max(0, state.selectedOutputIndex - 1); + fillOutputSelect(); + renderSelectedOutputDraft(); } async function getJson(url) { @@ -132,16 +347,18 @@ async function postJson(url, payload) { function render() { const data = state.result?.data; const delivery = state.result?.output_delivery || state.frequencies?.output_delivery; - byId("updated-at").textContent = `updated: ${state.result?.updated_at_utc || "n/a"}`; - byId("health-status").textContent = `health: ${state.health?.status || "n/a"}`; - byId("delivery-status").textContent = `delivery: ${delivery?.status || "n/a"}`; + const updated = formatUpdatedTimestamp(state.result?.updated_at_utc); + setTextWithPulse("updated-date", updated.date); + setTextWithPulse("updated-time", updated.time); + setTextWithPulse("health-status", `состояние: ${localizeStatus(state.health?.status)}`); + setTextWithPulse("delivery-status", `доставка: ${localizeStatus(delivery?.status)}`); if (!data) { - byId("selected-freq").textContent = "-"; - byId("pos-x").textContent = "-"; - byId("pos-y").textContent = "-"; - byId("pos-z").textContent = "-"; - byId("rmse").textContent = "-"; + setTextWithPulse("selected-freq", "-"); + setTextWithPulse("pos-x", "-"); + setTextWithPulse("pos-y", "-"); + setTextWithPulse("pos-z", "-"); + setTextWithPulse("rmse", "-"); byId("receivers-list").textContent = "Нет данных"; byId("delivery-details").textContent = JSON.stringify(delivery || {}, null, 2); byId("freq-table").querySelector("tbody").innerHTML = ""; @@ -149,12 +366,11 @@ function render() { } const selectedMhz = data.selected_frequency_mhz ?? hzToMhz(data.selected_frequency_hz); - byId("selected-freq").textContent = - selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} MHz`; - byId("pos-x").textContent = fmt(data.position?.x); - byId("pos-y").textContent = fmt(data.position?.y); - byId("pos-z").textContent = fmt(data.position?.z); - byId("rmse").textContent = fmt(data.rmse_m); + setTextWithPulse("selected-freq", selectedMhz === null ? "-" : `${fmt(selectedMhz, 3)} МГц`); + setTextWithPulse("pos-x", fmt(data.position?.x)); + setTextWithPulse("pos-y", fmt(data.position?.y)); + setTextWithPulse("pos-z", fmt(data.position?.z)); + setTextWithPulse("rmse", fmt(data.rmse_m)); const receivers = data.receivers || []; byId("receivers-list").textContent = JSON.stringify(receivers, null, 2); @@ -164,14 +380,14 @@ function render() { const tbody = byId("freq-table").querySelector("tbody"); tbody.innerHTML = rows .map( - (row) => ` - + (row, index) => ` + ${fmt(row.frequency_mhz ?? hzToMhz(row.frequency_hz), 3)} ${fmt(row.position?.x)} ${fmt(row.position?.y)} ${fmt(row.position?.z)} ${fmt(row.rmse_m)} - ${row.exact ? "yes" : "no"} + ${row.exact ? "да" : "нет"} ` ) .join(""); @@ -200,11 +416,11 @@ async function loadConfig() { state.config = config.config || null; byId("config-editor").value = JSON.stringify(config.config, null, 2); fillServerForm(); - byId("config-state").textContent = "config: loaded"; - byId("servers-state").textContent = "servers: loaded"; + byId("config-state").textContent = "конфиг: загружен"; + byId("servers-state").textContent = "серверы: загружены"; } catch (err) { - byId("config-state").textContent = `config: ${err.message}`; - byId("servers-state").textContent = `servers: ${err.message}`; + byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`; + byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`; } } @@ -214,33 +430,50 @@ async function saveConfig() { const parsed = JSON.parse(raw); const result = await postJson("/config", parsed); state.config = parsed; + const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : ""; byId("config-state").textContent = result.restart_required - ? "config: saved, restart required" - : "config: saved"; + ? `конфиг: сохранён, требуется перезапуск${saveSuffix}` + : `конфиг: сохранён${saveSuffix}`; } catch (err) { - byId("config-state").textContent = `config: ${err.message}`; + byId("config-state").textContent = `конфиг: ${localizeErrorMessage(err.message)}`; } } function fillServerForm() { const cfg = state.config; if (!cfg) return; + const receivers = cfg.input?.receivers || []; - state.receiverDrafts = receivers.map((receiver) => normalizeReceiverDraft(receiver)); + state.receiverDrafts = receivers.map((receiver, index) => normalizeReceiverDraft(receiver, index)); + if (state.receiverDrafts.length < 3) { + while (state.receiverDrafts.length < 3) { + state.receiverDrafts.push(createReceiverDraft(state.receiverDrafts.length)); + } + } fillReceiverSelect(); renderSelectedReceiverDraft(); - const out = cfg.runtime?.output_server || {}; + + const sharedFilterSource = + cfg.input?.default_input_filter || cfg.input?.receivers?.[0]?.input_filter || {}; + state.sharedFilterDraft = normalizeInputFilter(sharedFilterSource); + byId("shared-filter-enabled").value = String(Boolean(state.sharedFilterDraft.enabled)); + byId("shared-min-freq").value = state.sharedFilterDraft.min_frequency_mhz; + byId("shared-max-freq").value = state.sharedFilterDraft.max_frequency_mhz; + byId("shared-min-rssi").value = state.sharedFilterDraft.min_rssi_dbm; + byId("shared-max-rssi").value = state.sharedFilterDraft.max_rssi_dbm; + + const runtime = cfg.runtime || {}; + const outputServers = Array.isArray(runtime.output_servers) + ? runtime.output_servers + : [runtime.output_server || {}]; + state.outputDrafts = outputServers.map((output, index) => normalizeOutputDraft(output, index)); + if (state.outputDrafts.length < 1) { + state.outputDrafts.push(createOutputDraft(0)); + } + fillOutputSelect(); + renderSelectedOutputDraft(); + byId("write-token").value = ""; - byId("out-enabled").value = String(Boolean(out.enabled)); - byId("out-freq-filter-enabled").value = String(Boolean(out.frequency_filter_enabled)); - const minMhz = out.min_frequency_mhz ?? hzToMhz(out.min_frequency_hz) ?? 0; - const maxMhz = out.max_frequency_mhz ?? hzToMhz(out.max_frequency_hz) ?? 0; - byId("out-min-freq").value = minMhz; - byId("out-max-freq").value = maxMhz; - byId("out-ip").value = out.ip || ""; - byId("out-port").value = out.port ?? 8080; - byId("out-path").value = out.path || "/triangulation"; - byId("out-timeout").value = out.timeout_s ?? 3.0; } async function saveServers() { @@ -248,36 +481,62 @@ async function saveServers() { if (!state.config) { await loadConfig(); } + saveCurrentReceiverDraftFromInputs(); + saveCurrentOutputDraftFromInputs(); + + if (state.receiverDrafts.length < 3) { + throw new Error("at least 3 input servers are required"); + } + if (state.outputDrafts.length < 1) { + throw new Error("at least 1 output server is required"); + } + + const sharedFilter = { + enabled: byId("shared-filter-enabled").value === "true", + min_frequency_mhz: Number(byId("shared-min-freq").value), + max_frequency_mhz: Number(byId("shared-max-freq").value), + min_rssi_dbm: Number(byId("shared-min-rssi").value), + max_rssi_dbm: Number(byId("shared-max-rssi").value), + }; + const cfg = structuredClone(state.config); cfg.input = cfg.input || {}; - cfg.input.receivers = cfg.input.receivers || [{}, {}, {}]; - cfg.runtime = cfg.runtime || {}; - cfg.runtime.output_server = cfg.runtime.output_server || {}; + cfg.input.default_input_filter = { ...sharedFilter }; + cfg.input.receivers = state.receiverDrafts.map((draft, index) => ({ + receiver_id: draft.receiver_id || `r${index}`, + center: { + x: Number(draft.center.x), + y: Number(draft.center.y), + z: Number(draft.center.z), + }, + access: { + url: draft.source_url, + }, + frequencies_mhz: Array.isArray(draft.frequencies_mhz) + ? draft.frequencies_mhz + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value) && value > 0) + : [], + })); - for (let i = 0; i < cfg.input.receivers.length; i += 1) { - const draft = state.receiverDrafts[i] || normalizeReceiverDraft(cfg.input.receivers[i]); - cfg.input.receivers[i].source_url = draft.source_url; - cfg.input.receivers[i].input_filter = { ...draft.input_filter }; - } - cfg.runtime.output_server.enabled = byId("out-enabled").value === "true"; - cfg.runtime.output_server.frequency_filter_enabled = - byId("out-freq-filter-enabled").value === "true"; - cfg.runtime.output_server.min_frequency_mhz = Number(byId("out-min-freq").value); - cfg.runtime.output_server.max_frequency_mhz = Number(byId("out-max-freq").value); - cfg.runtime.output_server.ip = byId("out-ip").value.trim(); - cfg.runtime.output_server.port = Number(byId("out-port").value); - cfg.runtime.output_server.path = byId("out-path").value.trim() || "/triangulation"; - cfg.runtime.output_server.timeout_s = Number(byId("out-timeout").value); + cfg.runtime = cfg.runtime || {}; + cfg.runtime.output_servers = state.outputDrafts.map((draft, index) => ({ + name: draft.name || `выход_${index + 1}`, + ip: draft.ip, + })); + cfg.runtime.output_server = { ...cfg.runtime.output_servers[0] }; const result = await postJson("/config", cfg); state.config = cfg; byId("config-editor").value = JSON.stringify(cfg, null, 2); + + const saveSuffix = result.save_error ? " (применено, но сохранить файл не удалось)" : ""; byId("servers-state").textContent = result.restart_required - ? "servers: saved, restart required" - : "servers: saved"; + ? `серверы: сохранены, требуется перезапуск${saveSuffix}` + : `серверы: сохранены${saveSuffix}`; } catch (err) { - byId("servers-state").textContent = `servers: ${err.message}`; + byId("servers-state").textContent = `серверы: ${localizeErrorMessage(err.message)}`; } } @@ -287,31 +546,43 @@ function bindUi() { byId("save-config").addEventListener("click", saveConfig); byId("load-servers").addEventListener("click", loadConfig); byId("save-servers").addEventListener("click", saveServers); - byId("write-token").addEventListener("input", (event) => { - state.writeToken = event.target.value; - }); + + byId("add-receiver").addEventListener("click", addReceiverDraft); + byId("remove-receiver").addEventListener("click", removeReceiverDraft); byId("receiver-select").addEventListener("change", (event) => { saveCurrentReceiverDraftFromInputs(); state.selectedReceiverIndex = Number(event.target.value); renderSelectedReceiverDraft(); }); + + byId("add-output-server").addEventListener("click", addOutputDraft); + byId("remove-output-server").addEventListener("click", removeOutputDraft); + byId("output-select").addEventListener("change", (event) => { + saveCurrentOutputDraftFromInputs(); + state.selectedOutputIndex = Number(event.target.value); + renderSelectedOutputDraft(); + }); + + byId("write-token").addEventListener("input", (event) => { + state.writeToken = event.target.value; + }); + byId("menu-toggle").addEventListener("click", () => { const open = !byId("menu-list").classList.contains("menu-list-open"); setMenuOpen(open); }); + document.querySelectorAll(".menu-item").forEach((item) => { item.addEventListener("click", () => { setActiveSection(item.dataset.section); setMenuOpen(false); }); }); + document.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof Element)) return; - if ( - target.closest("#menu-toggle") || - target.closest("#menu-list") - ) { + if (target.closest("#menu-toggle") || target.closest("#menu-list")) { return; } setMenuOpen(false); @@ -327,5 +598,5 @@ async function boot() { } boot().catch((err) => { - byId("health-status").textContent = `health: ${err.message}`; + setTextWithPulse("health-status", `состояние: ${localizeErrorMessage(err.message)}`); }); diff --git a/web/index.html b/web/index.html index 21259d6..247a591 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - Triangulation Control Panel + Панель Триангуляции @@ -12,63 +12,64 @@

-

RF Positioning Dashboard

-

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

+

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

+

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

- +
-

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

+

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

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

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

+

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

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

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

+

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

-

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

-

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

+

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

+

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

+ +

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

-
+ +

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

+
+ -
+ +

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

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

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

+

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

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