From 8013781e09ef41187be3f1fbec1e9cc9fb23e928 Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Tue, 29 Sep 2026 14:42:41 +0300 Subject: [PATCH] =?UTF-8?q?ha(H5):=20README+HACS,=20=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D0=BF=D1=82=20=D0=BF=D1=80=D0=B8=D1=91=D0=BC=D0=BA=D0=B8?= =?UTF-8?q?,=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D0=BD=D1=8B=D0=B9=20=D1=87?= =?UTF-8?q?=D0=B5=D0=BA-=D0=BB=D0=B8=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - custom_components/fglair/README.md: HACS-установка, cloud/ручной/импорт, превью шаблона, сущности, ключ для ESPHome (reconfigure/CLI), диагностика и troubleshoot; заглушки screenshots/step-1..8.png (сгенерированы) - tests/acceptance/test_esphome_ha.py: quick/burst/long через REST API HA (long-lived token), адаптивная матрица по capabilities, возврат исходного состояния, CSV-отчёт long, опциональный ESPHome-канал (aioesphomeapi) - self-test приёмки на моке HA REST (6 тестов, без железа) - hacs.json; README: раздел Home Assistant; docs/RELEASE_HA.md: публикация pyfglair на PyPI (wheel/sdist, auditwheel), HACS-релиз, приёмка - scripts/py-ci.sh: tests/acceptance; план H5 отмечен --- README.md | 10 +- custom_components/fglair/README.md | 134 +++++++ .../fglair/screenshots/step-1.png | Bin 0 -> 2178 bytes .../fglair/screenshots/step-2.png | Bin 0 -> 2223 bytes .../fglair/screenshots/step-3.png | Bin 0 -> 2218 bytes .../fglair/screenshots/step-4.png | Bin 0 -> 2240 bytes .../fglair/screenshots/step-5.png | Bin 0 -> 2260 bytes .../fglair/screenshots/step-6.png | Bin 0 -> 2290 bytes .../fglair/screenshots/step-7.png | Bin 0 -> 2162 bytes .../fglair/screenshots/step-8.png | Bin 0 -> 2382 bytes docs/PLAN_HOME_ASSISTANT.md | 2 +- docs/RELEASE_HA.md | 55 +++ hacs.json | 4 + scripts/py-ci.sh | 2 +- tests/acceptance/test_acceptance.py | 169 ++++++++ tests/acceptance/test_esphome_ha.py | 366 ++++++++++++++++++ 16 files changed, 739 insertions(+), 3 deletions(-) create mode 100644 custom_components/fglair/README.md create mode 100644 custom_components/fglair/screenshots/step-1.png create mode 100644 custom_components/fglair/screenshots/step-2.png create mode 100644 custom_components/fglair/screenshots/step-3.png create mode 100644 custom_components/fglair/screenshots/step-4.png create mode 100644 custom_components/fglair/screenshots/step-5.png create mode 100644 custom_components/fglair/screenshots/step-6.png create mode 100644 custom_components/fglair/screenshots/step-7.png create mode 100644 custom_components/fglair/screenshots/step-8.png create mode 100644 docs/RELEASE_HA.md create mode 100644 hacs.json create mode 100644 tests/acceptance/test_acceptance.py create mode 100644 tests/acceptance/test_esphome_ha.py diff --git a/README.md b/README.md index dabccea..3d602f1 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ src/ayla/ Ayla LAN protocol: crypto, envelope, HTTP, session loop src/aircon/ property templates A/B/F, conversions, API implementation pyfglair/ Python bindings (cffi) for Home Assistant (docs/PLAN_HOME_ASSISTANT.md) components/ ESPHome external component (planned, docs/PLAN_ESPHOME.md) -custom_components/ Home Assistant custom component (planned, docs/PLAN_HOME_ASSISTANT.md) +custom_components/ Home Assistant custom component (docs/PLAN_HOME_ASSISTANT.md) tests/ ayla (protocol), aircon (conversions), pyfglair, tools examples/cli/ fglctl — sample CLI tools/ fglair-discover, protocol probes @@ -93,6 +93,14 @@ python -m pyfglair monitor --config config_home.json --duration 60 Тесты (без железа: mock-модуль + мок-облако): `scripts/py-ci.sh --setup && scripts/py-ci.sh` (или `pytest tests/pyfglair`). +## Home Assistant + +Компонент `custom_components/fglair/` (HACS custom repository) — climate, +switch/select/sensor/binary_sensor, config flow с превью шаблона, repair +для несовпадения ключа и диагностика. Инструкция: +[`custom_components/fglair/README.md`](custom_components/fglair/README.md); +приёмка на живом стенде — `tests/acceptance/test_esphome_ha.py`. + ## Getting the key (`lanip_key`) The key is static (baked into the AC module; only served by the Ayla cloud): diff --git a/custom_components/fglair/README.md b/custom_components/fglair/README.md new file mode 100644 index 0000000..5796e05 --- /dev/null +++ b/custom_components/fglair/README.md @@ -0,0 +1,134 @@ +# FGLair — интеграция Home Assistant + +Локальное управление кондиционерами Fujitsu General (FGLair / Ayla LAN) +без облака в рантайме. Вся протокольная логика — в C++-ядре +[`fgl-aircon`](../../README.md) через python-пакет `pyfglair` (cffi). + +> Скриншоты `screenshots/step-N.png` — заглушки-плейсхолдеры: владелец +> заменит их реальными снимками, описания «что должно быть видно» даны +> рядом с каждым шагом. + +## Требования + +* Home Assistant ≥ 2025.1 (Linux x86_64/aarch64). +* Пакет `pyfglair` (манифест ставит его автоматически из PyPI). + До публикации на PyPI установите wheel вручную в python-окружение HA: + `pip install pyfglair-*.whl` (см. `docs/RELEASE_HA.md`). +* Модуль кондиционера в той же LAN. Модуль поддерживает **2 LAN-сессии**: + телефон с FGLair и HA уживаются; третья (например, ESPHome) получит 503. + +## Установка через HACS + +1. HACS → ⋮ → **Custom repositories** → URL репозитория, категория + **Integration** → **Add**. + + ![шаг 1](screenshots/step-1.png) + + *Должно быть видно: диалог добавления custom repository с заполненным URL + и выбранной категорией Integration.* + +2. Найдите **FGLair** в HACS → **Download** → перезапустите Home Assistant. + + ![шаг 2](screenshots/step-2.png) + + *Должно быть видно: страница загрузки интеграции с кнопкой Download и + версией.* + +## Добавление устройства + +3. Settings → Devices & Services → **Add Integration** → «FGLair». + + ![шаг 3](screenshots/step-3.png) + + *Должно быть видно: диалог поиска интеграции с введённым «FGLair» и + выделенным результатом.* + +4. **Вход в облако** (шаг 1A): e-mail/пароль FGLair и регион. Облако + используется один раз — получить статический LAN-ключ модуля. + + ![шаг 4](screenshots/step-4.png) + + *Должно быть видно: форма с заполненными регионом EU и e-mail, пароль + скрыт.* + +5. **Выбор устройства**: список найденных кондиционеров (имя, модель, IP). + + ![шаг 5](screenshots/step-5.png) + + *Должно быть видно: список с одним устройством (имя, модель, host).* + +6. **Проверка шаблона с превью** (шаг 3): форма-предпросмотр рассчитанных + значений (режимы, диапазон температур, пример конверсии) и, при + необходимости, ручные коэффициенты/диапазон уставки. Сверьте с + приложением FGLair и подтвердите. + + ![шаг 6](screenshots/step-6.png) + + *Должно быть видно: таблица превью — режимы, диапазон 16–30 °C, шаг, + текущая температура, capabilities, кнопки Submit.* + +7. **Готово**: карточка устройства с сущностями climate/switch/select/ + sensor/binary_sensor. + + ![шаг 7](screenshots/step-7.png) + + *Должно быть видно: страница устройства с созданными сущностями.* + +## Где взять ключ для ESPHome + +Download diagnostics на странице устройства показывает `dsn`, +`lanip_key_id`, `host` и **маску** ключа. Полный ключ (для +`secrets.yaml` ESPHome) получите явным действием: + +* **Настроить заново** (Reconfigure) на карточке интеграции — вход в + облако FGLair покажет/сохранит актуальный ключ; либо +* `python -m pyfglair discover --region eu --email you@example.com + --format esphome-secrets` на хосте HA. + + ![шаг 8](screenshots/step-8.png) + + *Должно быть видно: страница Diagnostics с полями dsn/lanip_key_id/host + и маской lanip_key.* + +Альтернатива без облака — ручной ввод параметров (host, dsn, lanip_key, +lanip_key_id) или импорт `config_*.json` от `fglair-discover`/`fglctl`. + +## Сущности + +| Платформа | Что создаётся | +|---|---| +| climate | режим (off/cool/dry/fan/heat/auto), скорость, swing (верт./гориз.), preset ECO/BOOST, уставка, текущая температура | +| switch | economy, powerful, coil dry, min heat, outdoor low noise, human det auto save, Wi-Fi LED, indoor fan control (по шаблону и capabilities) | +| select | положение вертикальной/горизонтальной заслонки (0…N−1) | +| sensor | температура в помещении, код ошибки, диагностическое состояние связи | +| binary_sensor | connectivity + флаги op_status (defrost, maintenance, oil recovery, pump down, check operation, …) | + +Список зависит от шаблона устройства (A/B/F) и маски `device_capabilities`; +режимы, которыми прибор не управляет, не создаются. + +## Диагностика и известные ситуации + +* **503 / оба слота заняты** — в LAN уже две сессии (телефон + ESPHome). + Освободите один слот и перезапустите интеграцию. +* **key_error** — LAN-ключ не совпал (`key_id` изменился). В HA появится + Repair; нажмите **Настроить заново** и повторите вход в облако (или + вставьте новый `config_*.json`). +* **unavailable** — модуль недоступен (сеть/питание) или нет слотов; + диагностический сенсор «Состояние связи» показывает детали + (idle/registering/online/recovering/offline/key_error). +* Записи не эхо-подтверждаются модулем: состояние обновляется + оптимистично и подтверждается push-обновлением. + +## Приёмка на живом стенде + +`tests/acceptance/test_esphome_ha.py` (stdlib) прогоняет матрицу изменений +через REST API HA по long-lived token, с возвратом к исходному состоянию: + +```sh +python tests/acceptance/test_esphome_ha.py quick \ + --ha-url http://homeassistant.local:8123 --ha-token TOKEN \ + --entity climate.ac +python tests/acceptance/test_esphome_ha.py long \ + --ha-url ... --ha-token ... --entity climate.ac \ + --hours 24 --interval 3600 --report acceptance.csv +``` diff --git a/custom_components/fglair/screenshots/step-1.png b/custom_components/fglair/screenshots/step-1.png new file mode 100644 index 0000000000000000000000000000000000000000..122df2b1ea522ac0a71c78fd307066f598e6b606 GIT binary patch literal 2178 zcmb_eJxc>Y5S@z`JfqQ=B1LS1#4e4NRz~7SP(ug`mcho-O0i7r1RFs_u&}YP5b-BO zODzNo#ehFSNN4NJ?B2L{my35`kzsaj_V&Fuvo|@e*H+T0d

4)zwM^V5k6KJvs@D zTv+p008X`9DQ_NjzuwAeD2&+;PvzPCsWnkNyY8$vhTdP6hoP`_I|Wdj+y+RD!a~ym zrvzZHOoRT&p2`lX0#pYUq_;Qr`S>jtSN9#gTqIN{sSD^{L|qSC-(9YmTFCvT3iD&%rDHX{w}KT{ zUk+(}YIg&TIv8uI(D3v?nXw`=1du0k_fHQa33rE#n6M<{?a{vZ&zZQhEgpsNDQQrk rQptFYG~1{gv80TIBd*?^ajal=<74=>*Sg%_#s3yiU8+^?7Mn*uX5F2e literal 0 HcmV?d00001 diff --git a/custom_components/fglair/screenshots/step-2.png b/custom_components/fglair/screenshots/step-2.png new file mode 100644 index 0000000000000000000000000000000000000000..4c6d9f97cba8af593c2f8984b313c8355bf83277 GIT binary patch literal 2223 zcmd5;ziSjh6n;DIhP|u@O9F?)W)&m|i*T(F*}a_Tp+t!(4$>@$prwrnX>N{KSlWmd zCXFBnhAZ+1oR+3jFcPe-Oq)g!-!uq|kB+~kO^D9AA3vwk?iqzPJJj1du>#CeK#wG(4Zb(rd8vrndDkxEVtR{5FQqnsYLul@Xuaa?Sc+lof? zfy5G&4C(nX|B|c`BMxy3zo2A@PKf zi>cStaHOq;K|hp*gJ!9yR`V^Sro^XdDmm7m)W8a6OmT(m2$PW~`%#6`^ub8H=mTrb z1HXcu+Q->pb=z*%C6tNP!yvFJ%l9GxVD=s+_6xQJI zzWtOv=Lx!Zrz&Go~3P5dJQz3Xe;y-qg#3uQX4 AH2?qr literal 0 HcmV?d00001 diff --git a/custom_components/fglair/screenshots/step-3.png b/custom_components/fglair/screenshots/step-3.png new file mode 100644 index 0000000000000000000000000000000000000000..b496618c924aee2b9122e8ab1f4dcba2a4809015 GIT binary patch literal 2218 zcmcImF>4f25T1SRhP`Y8>wya@!YZb_Uto0?F6a>mA{wrUa)_0M5D1z^xU~q$A4sg! zh=mZ4tF0ExV69+dCpKxUk}fmz-oBfCw?|wt#hYVh=e_x6zIktM=kChw`PnP805IRX z)4d09AqDWhgbFRMy<2Yq!u4LabAR*4(YO7Jkk0v?!~MrkKg`9OUk{$V*qV8>eKmsN z$Mv5xkah5iA z6K#RvvQ2m>Q%3pN>QT3~KjKzSunaNZo#2!e#9S+R`J6(tiH(ZKZZKAnf8KqoO(mx|07NhlKD~Bkd7ZBi?`;$7_EcUVFZW|3RR)w9@^wICymmqeGm% literal 0 HcmV?d00001 diff --git a/custom_components/fglair/screenshots/step-4.png b/custom_components/fglair/screenshots/step-4.png new file mode 100644 index 0000000000000000000000000000000000000000..4f341bf6a2eb301bd1ca9616787d28d58c5f6aa5 GIT binary patch literal 2240 zcmd^By=xRv5TD&_%;vs0M3xv(Hj3T-1y*;-`Ed$Sg2jmt7O@aJ8?n+oAsDTaM(m7W zArivcsnwx^n8rp#VlP?*YdhyR@58ruS#nocS-j!RyxE!G%=~uWKDfAerdpaU5mB{s zw!K6&5fgcDL!T{=y^~Lf!uy?e>(cF=-7hbz{kR^qwx3?T@vQPZ{jt9M=J3bYzjl)k zK5cw{_2~d@toD7XcZvKmH4~5Gj3|gGWh>OMLDZWR)jCZpoAxvh!c#v8PyOJ?PByyA zxpyST$-z00uE;fkTC&%C4Y23C1kH#L4%<$;{6l)H{*!5jW(o2%E^Bp1_+=i2D;R`-o>#fPaOC%r z4Ps*tThx(c&A}SB2(-KzTHb<+MzPpn>E_J2I~VfJ(>+Bcw!ylruBukmC`#JbZNZ6F zRO^6buwc|UfZ7a9hN2WZR1D5RgA{(bne=A1A6De-+SeL;u#7YK?Qc`@@_x#8_YfE% zC{`=qyxt^a5HX?w8|%ZlisLfNxOu|TWAAQpr!TMfu1_GaGbp7rlR1#mG+~bOFqZ_Z z3IGL7KsLiPJ%xF=29Y}jQQ}M(8wi`Bpvo1n+AxI`q{yLBIR%XZkS^c+`NB>U8_d>D z+`bhc!x-<06v#rgPjAfI3=q-|Eo2UWZ{J;8gn)PGW&pzUPvIE$DyWu07A0e5!rSY` z`8pPT#cLy6QQQLaLFY;f?lH&7y~0NN>VxxR;{LC0ADW$th*oN+cUo7Duf5;oKNISl LT5NC4C-?pUxSqSD literal 0 HcmV?d00001 diff --git a/custom_components/fglair/screenshots/step-5.png b/custom_components/fglair/screenshots/step-5.png new file mode 100644 index 0000000000000000000000000000000000000000..f9b88828b3c74e64275bf85cf19fbb52dce7a9b4 GIT binary patch literal 2260 zcmd5;F>4e-6n;CIg}odHYXVmY!lsBet3N;t+#MJ73?w23uCUl5jg8ufLYnM>g+;(Z z4na76h+bm3l+hDHn9@?-puUo+}<8uK*1u9d~e>&``-87&fM+ut7mGn zhi8eX);Zh0Kr|B&Ij=kc&pqel10wH!r`^1Gb7$}U^F!qO%gs-ZyW6jWJ1ZxyUAb|3 zX4idIBWk_;b(E+n>$H^!^1DQ$N))>k42j@n*oYkRLIty+72`9JW+O+MjU1V7oHi&s zUFR%6$qKS~7Sc@$n$rcQ#H_Npz;T|1vde=ko~*+kgVOBd>8z7Rf-IiXwMX^v()GcY zbIr&R)!6kyzw3oKRG@RBszIEzG+@UTK%59!*I*6~1!O%K@~V)1b_7HIqxcpBFVtWW z4y0hvh!!+p2LsNj2sEOKA^?yQ$k?O=5Gwlt<`DUmX@C=&#_PEeRdr})P?D-GH_W3F zDpJNQj(NZ@cDaou#{rlBu{qsY0q^?hwqzDuQX3D5$L{6h%iJT_$ zh@4ELAZMl^mzw3CAg9tz6QK3pyC+{wz@1V6jU;1<{#4GK_4ZEPp3$*jJG`yt|vBk=4F4O;p0C6q2<0r5#G1UYY;8S zUnV zVUBq;uu>o}#zrUSSwrv4!-FdL9YP}YI{goq>l=U4vsliKy$2~_sz-!dWIwQ}K$G7G m?!e#Yfo>h>#Y=E4lQ@2A_ipebIr0_1R;aVQ+J4%KdcOf0IJ5Qu literal 0 HcmV?d00001 diff --git a/custom_components/fglair/screenshots/step-6.png b/custom_components/fglair/screenshots/step-6.png new file mode 100644 index 0000000000000000000000000000000000000000..c5b98afc6e5f816f3a045488df0ae8e45e935e00 GIT binary patch literal 2290 zcmcImJ!lj`6n?wen7zac%L#|1+JF)w2NJLf1opy-9>GM!;=mM(T4-k}2y%NOXcf{N z3KC0EP>x_DD6DA&wF`)(G4@^~7#j<}H#0jow|i^u3X6U0d-Hz2@6FqpeRy&8Tyb({ zl8B1+^R-JvV?L3!gC&e-7hOe@{iWuf2=OdY+4g1&V1i}?2KKR zKD|iv{OUW4f>Jb2euu~@Q!}popg3t zb8sfZDM_r*8|Ctx44nOpm|&7_R3`%`LUS@1P6kf48s+kwM&2H=hu>~&+?od|dj-#w z*Wd@}vKG0-%gwli$L1G^tOemIbJmg*`j2G6%j4Upuw~K74!VICEu1p48KSK;1yI8k zD0)JY6igGkZUwxxC`b(xj+V1fFAQQ_3jycB?1G~$SaZtkZCdhh_tWtj9Gjt|nTV$2 zv*JD(!mV(_dC#N(KHg8oWdB3_;`0VN#JL$0AWJ%aCkST~g}s?_0m($9ITFoyxi=rG zqYfI)fCV~MWCfs)?k9>-E>)XLWNM2sWHti8?<4J{Zh_TSyv0q4mMj3b@9Z5zU016% z+Tk3$o1?L?E{C) zVpXaVO1HpeQ^*o1=xi2B5JqVzyIWiqK>LuPgV}nAP#?sl#XU=LxjQe~R1rkmsYPzn zK%)EW*Wd2^VhG|ZkdvZz%TRbO&BvG&9&2%K^d<$=<*@77QR5;9NhwK9V{Umm)Yr+Y z836nxVE)x{jXZ~+pN12nb1tYnC*yP;?#B4!{f8^SK*zVo;CrIa$+*vQuW=kR`qJZ* lW29CNg=5ry$J4kFOBe2KumAi*Pp;u#hU#ZmYp<5W)?feE(`f(z literal 0 HcmV?d00001 diff --git a/custom_components/fglair/screenshots/step-7.png b/custom_components/fglair/screenshots/step-7.png new file mode 100644 index 0000000000000000000000000000000000000000..32f5f047f85830fd204f3e7c6632b33387e18bef GIT binary patch literal 2162 zcmcguy=xRv5TAY7g}u8RE-6+>A)6wFvY@e2n|yFV4}53t-7LxjOu>_YETUGhiG{XIn1x}|QKK3rvw_=BguNZUaPG{ovS%YC zW12$sEZ@d9QGo67GAK>`fgb^c^p$WaMN5i!T6@qaqG)MqrmlGp6?Z_tTI!5=MBps} z&D1c)O=YckI+lRir}lWWCNfdTm+I}GD8>D+;HZ@HI*81QQeJzA!tS=__H6859t;yyOr=-cz1*^eNR-4Sn(3kpQxtVSFW7_)+yZ$Df&o%$1r9P8v1G lj9JMv88RYEv5Vei$L`0$$?NvL(`EcG0sZxj-p<lk3rQAOC_Bx< zhSg*tdnH+0$uBD#8~4t<`Q9Hh8CzMr#l82ud(XM=_3qnQn42y%cQ*q-p)^xm1Zdy@ zxf@kM&tY!t1VHO36|JR>r|N@M0N%>1=hLp;JEy7b=&~@PHC~Lb8vv8LAEN*k>jcMF zKraI*EdXvFxCek~Otndk>*homRk6s*u#4}bd4b&D2jEG5! zNZ@y#G1$pQ|v%m zo!n!%!0pAAuQOD`&7(N-ig4&LZH>HNMy)1AYDP!^;%o$gkQy2DQ^#m%GUTqluFPXLj?vJ>WTyUzyB+SGQ?g3U>_aJ* zJx{xt%RONtWvu=1.0.0`; штатный installer HA +резолвит требования только через PyPI. + +```sh +# на каждой целевой архитектуре (linux x86_64/aarch64): +.venv/bin/python -m build --wheel # py3-none-linux_.whl с .so и mbedtls +.venv/bin/python -m build --sdist # sdist с C++-исходниками (MANIFEST.in) +twine upload dist/* +``` + +Проверки перед публикацией: + +* `scripts/py-ci.sh` — 28 pyfglair + 47 components + 6 acceptance self-test; +* `scripts/ci.sh` — ядро (gcc/clang, ASan/UBSan) 11/11; +* `readelf -d pyfglair/libfgl-aircon.so*` — нет внешней `libmbedcrypto` + (mbedtls встроен); +* `auditwheel show dist/pyfglair-*.whl` — платформа совместима с manylinux + (зависимости: libstdc++/libc); +* установка wheel в чистый venv вне репозитория: `import pyfglair.session`. + +При невозможности публикации wheel для какой-то архитектуры в манифесте +можно указать прямую ссылку на wheel, но это лишает HACS-пользователей +других архитектур; предпочтителен PyPI. + +## 2. HACS + +* Репозиторий содержит `custom_components/fglair/` и `hacs.json` (name). +* Заполнить `codeowners` в `custom_components/fglair/manifest.json` + (GitHub-хендлы владельцев; сейчас пустой список — допустимо для custom + repository, но не для выкладки в HACS по умолчанию). +* Проверить `documentation`/`issue_tracker` в манифесте (актуальные URL). +* Заменить заглушки `custom_components/fglair/screenshots/step-N.png` + реальными скриншотами (описания «что должно быть видно» — в README). +* Добавить brand-ассеты (`custom_components/fglair/brand/icon.png`, + `logo.png`) при выкладке в HACS. +* Создать git-тег (semver) и указать его в HACS-релизе. + +## 3. Приёмка на живом стенде + +* `tests/acceptance/test_esphome_ha.py quick` — матрица изменений + (HA; при наличии ESPHome-компонента — обе стороны), с возвратом. +* `--long --hours 24` — суточный прогон с CSV-отчётом. +* Проверить сценарии: 503 (два слота заняты), key_error и Repair, + offline/восстановление, несколько устройств (разные порты прослушивания), + reconfigure без потери конверсий. + +## 4. Ограничения текущего стенда + +* Реального железа/облака в CI нет: протокол проверен mock-модулем + (`tests/ayla/mock_ac.py`), облако — мок-сервером. +* Суточный soak и работа с реальными re-key — только на приборе. diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..8aa4a88 --- /dev/null +++ b/hacs.json @@ -0,0 +1,4 @@ +{ + "name": "FGLair", + "render_readme": true +} diff --git a/scripts/py-ci.sh b/scripts/py-ci.sh index e064c5d..f2a3210 100644 --- a/scripts/py-ci.sh +++ b/scripts/py-ci.sh @@ -27,7 +27,7 @@ if [ ! -x "$VENV/bin/python" ]; then exit 1 fi -"$VENV/bin/python" -m pytest tests/pyfglair -q +"$VENV/bin/python" -m pytest tests/pyfglair tests/acceptance -q if [ -x "$HA_VENV/bin/python" ]; then "$HA_VENV/bin/python" -m pytest tests/components -q diff --git a/tests/acceptance/test_acceptance.py b/tests/acceptance/test_acceptance.py new file mode 100644 index 0000000..44ed403 --- /dev/null +++ b/tests/acceptance/test_acceptance.py @@ -0,0 +1,169 @@ +"""Self-test приёмочного скрипта на моке HA REST API (без железа).""" +from __future__ import annotations + +import json +import pathlib +import socket +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +from test_esphome_ha import ( # noqa: E402 + AcceptanceError, + HaRest, + build_steps, + main, +) + + +def free_port() -> int: + sock = socket.socket() + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + return port + + +class MockHa: + def __init__(self) -> None: + self.lock = threading.Lock() + self.fail_next = False + self.state = { + "entity_id": "climate.test", + "state": "heat", + "attributes": { + "hvac_modes": ["off", "cool", "heat"], + "fan_modes": ["low", "high"], + "swing_modes": ["off", "on"], + "temperature": 22.0, + "min_temp": 16.0, + "max_temp": 30.0, + "fan_mode": "low", + "swing_mode": "off", + }, + } + + def handler(self): + mock = self + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def _json(self, status: int, payload) -> None: + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Connection", "close") + self.end_headers() + self.close_connection = True + self.wfile.write(body) + + def do_GET(self): + if self.path == "/api/states/climate.test": + with mock.lock: + self._json(200, json.loads(json.dumps(mock.state))) + else: + self._json(404, {"message": "not found"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length") or 0) + data = json.loads(self.rfile.read(length) or b"{}") + with mock.lock: + if mock.fail_next: + mock.fail_next = False + self._json(500, {"message": "boom"}) + return + service = self.path.rsplit("/", 1)[-1] + attrs = mock.state["attributes"] + if service == "set_hvac_mode": + mock.state["state"] = data["hvac_mode"] + elif service == "set_fan_mode": + attrs["fan_mode"] = data["fan_mode"] + elif service == "set_swing_mode": + attrs["swing_mode"] = data["swing_mode"] + elif service == "set_temperature": + attrs["temperature"] = data["temperature"] + self._json(200, []) + + return Handler + + +@pytest.fixture +def mock_ha(): + mock = MockHa() + server = ThreadingHTTPServer(("127.0.0.1", 0), mock.handler()) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + port = server.server_address[1] + yield mock, f"http://127.0.0.1:{port}" + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def test_build_steps_supported_only(): + state = { + "state": "heat", + "attributes": { + "hvac_modes": ["off"], + "fan_modes": [], + "temperature": 20.0, + "min_temp": 16.0, + "max_temp": 30.0, + }, + } + steps = build_steps(state) + assert [step.name for step in steps] == ["temperature"] + + +def test_quick_success(mock_ha, capsys): + _, url = mock_ha + assert main(["quick", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test"]) == 0 + out = capsys.readouterr().out + assert "restore: expected=heat observed=heat" in out + + +def test_quick_burst_success(mock_ha): + _, url = mock_ha + assert main(["quick", "--burst", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test"]) == 0 + + +def test_quick_service_error(mock_ha, capsys): + mock, url = mock_ha + mock.fail_next = True + assert main(["quick", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test"]) == 2 + assert "HTTP 500" in capsys.readouterr().err + + +def test_long_report(mock_ha, tmp_path, capsys): + _, url = mock_ha + report = tmp_path / "acceptance.csv" + rc = main([ + "long", "--ha-url", url, "--ha-token", "t", + "--entity", "climate.test", + "--hours", "0.000003", "--interval", "0.01", + "--report", str(report), + ]) + assert rc == 0 + text = report.read_text() + assert "timestamp,step,expected,observed,result" in text + assert "ok" in text + assert "long: ok=1 fail=0" in capsys.readouterr().out + + +def test_wait_state_timeout(mock_ha): + _, url = mock_ha + ha = HaRest(url, "t", timeout=1) + with pytest.raises(AcceptanceError): + ha.wait_state("climate.test", lambda s: False, timeout=0.2) diff --git a/tests/acceptance/test_esphome_ha.py b/tests/acceptance/test_esphome_ha.py new file mode 100644 index 0000000..6607c46 --- /dev/null +++ b/tests/acceptance/test_esphome_ha.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Приёмка HA (и опционально ESPHome) на живом кондиционере. + +Полуавтоматический скрипт: третью LAN-сессию НЕ открывает (оба слота заняты +HA + ESPHome); работает через REST API Home Assistant по long-lived token. + + Профиль → Security → Long-lived access tokens → создать токен. + +Запуск: + python tests/acceptance/test_esphome_ha.py quick \ + --ha-url http://homeassistant.local:8123 --ha-token TOKEN \ + --entity climate.ac + python tests/acceptance/test_esphome_ha.py --long \ + --ha-url ... --ha-token ... --entity climate.ac \ + --hours 24 --interval 3600 --report acceptance.csv + +Режимы: + quick — матрица изменений (hvac/fan/уставка/swing), последовательно и + «burst» (без пауз), каждое с возвратом к исходному состоянию; + --long — одно изменение в --interval секунд (по умолчанию 3600) на + протяжении --hours часов, проверка и возврат, CSV-отчёт. + +ESPHome-сторона (опционально): --esphome-host/--esphome-key/--esphome-entity +и установленный `aioesphomeapi` — те же изменения отправляются через ESPHome, +результат сверяется по состоянию в HA. +""" +from __future__ import annotations + +import argparse +import csv +import datetime as dt +import json +import sys +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any, Callable, Optional + +DEFAULT_TIMEOUT = 15.0 +SETTLE_TIMEOUT = 30.0 + + +class AcceptanceError(RuntimeError): + pass + + +class HaRest: + """Минимальный клиент REST API Home Assistant (stdlib).""" + + def __init__(self, base_url: str, token: str, timeout: float = DEFAULT_TIMEOUT): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + def _request(self, method: str, path: str, payload: Any = None) -> Any: + data = json.dumps(payload).encode() if payload is not None else None + request = urllib.request.Request( + self.base_url + path, data=data, headers=self.headers, method=method + ) + try: + with urllib.request.urlopen(request, timeout=self.timeout) as resp: + body = resp.read().decode() or "null" + return resp.status, json.loads(body) + except urllib.error.HTTPError as err: + try: + body = err.read().decode(errors="replace") + finally: + err.close() + raise AcceptanceError( + f"{method} {path}: HTTP {err.code}: {body[:200]}" + ) from err + except OSError as err: + raise AcceptanceError(f"{method} {path}: {err}") from err + + def state(self, entity_id: str) -> dict: + _, data = self._request("GET", f"/api/states/{entity_id}") + if not isinstance(data, dict): + raise AcceptanceError(f"{entity_id}: неожиданный ответ /api/states") + return data + + def call(self, domain: str, service: str, data: dict) -> None: + self._request("POST", f"/api/services/{domain}/{service}", data) + + def wait_state( + self, + entity_id: str, + predicate: Callable[[dict], bool], + timeout: float = SETTLE_TIMEOUT, + ) -> dict: + deadline = time.monotonic() + timeout + last: Optional[dict] = None + while time.monotonic() < deadline: + last = self.state(entity_id) + if predicate(last): + return last + time.sleep(0.5) + raise AcceptanceError( + f"{entity_id}: состояние не достигнуто за {timeout} с: " + f"{last and last.get('state')}" + ) + + +@dataclass +class Step: + name: str + domain: str + service: str + service_data: dict + attribute: str + expected: Any + + def predicate(self) -> Callable[[dict], bool]: + def check(state: dict) -> bool: + if self.attribute == "state": + return state.get("state") == self.expected + return state.get("attributes", {}).get(self.attribute) == self.expected + + return check + + +def _pick(options: list, preferred: str) -> Optional[str]: + if not options: + return None + if preferred in options: + return preferred + return options[0] + + +def build_steps(state: dict) -> list[Step]: + """Адаптивная матрица: только поддерживаемые режимы/диапазоны.""" + attrs = state.get("attributes", {}) + steps: list[Step] = [] + + hvac_modes = [m for m in attrs.get("hvac_modes", []) if m != "off"] + mode = _pick(hvac_modes, "cool") + if mode: + steps.append( + Step("hvac_mode", "climate", "set_hvac_mode", + {"hvac_mode": mode}, "state", mode) + ) + + fan_mode = _pick(attrs.get("fan_modes", []), "low") + if fan_mode: + steps.append( + Step("fan_mode", "climate", "set_fan_mode", + {"fan_mode": fan_mode}, "fan_mode", fan_mode) + ) + + swing = _pick(attrs.get("swing_modes", []), "on") + if swing: + steps.append( + Step("swing_mode", "climate", "set_swing_mode", + {"swing_mode": swing}, "swing_mode", swing) + ) + + temperature = attrs.get("temperature") + low = attrs.get("min_temp", 16.0) + high = attrs.get("max_temp", 30.0) + if temperature is not None and low is not None and high is not None: + target = temperature + 1.0 + if target > high: + target = max(low, high - 1.0) + steps.append( + Step("temperature", "climate", "set_temperature", + {"temperature": target}, "temperature", target) + ) + return steps + + +def _restore(ha: HaRest, entity: str, original: dict, entity_id: str) -> None: + attrs = original.get("attributes", {}) + state = original.get("state") + if state == "off" and "off" in attrs.get("hvac_modes", []): + ha.call("climate", "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": "off"}) + return + if state and state != "off": + ha.call("climate", "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": state}) + if attrs.get("fan_mode") is not None: + ha.call("climate", "set_fan_mode", + {"entity_id": entity_id, "fan_mode": attrs["fan_mode"]}) + if attrs.get("temperature") is not None: + ha.call("climate", "set_temperature", + {"entity_id": entity_id, "temperature": attrs["temperature"]}) + if attrs.get("swing_mode") is not None: + ha.call("climate", "set_swing_mode", + {"entity_id": entity_id, "swing_mode": attrs["swing_mode"]}) + + +def _observed(state: Optional[dict], step: Step) -> Any: + if state is None: + return None + if step.attribute == "state": + return state.get("state") + return state.get("attributes", {}).get(step.attribute) + + +def _matches(observed: Any, expected: Any) -> bool: + if isinstance(expected, (int, float)) and isinstance(observed, (int, float)): + return abs(float(observed) - float(expected)) < 0.11 + return observed == expected + + +def run_quick(ha: HaRest, entity_id: str, *, burst: bool = False) -> list[dict]: + original = ha.state(entity_id) + steps = build_steps(original) + if not steps: + raise AcceptanceError("climate-сущность не поддерживает ни одного шага") + results = [] + if burst: + for step in steps: + ha.call(step.domain, step.service, + {"entity_id": entity_id, **step.service_data}) + for step in steps: + state = ha.wait_state(entity_id, step.predicate()) + results.append({ + "step": step.name, + "expected": step.expected, + "observed": _observed(state, step), + }) + else: + for step in steps: + ha.call(step.domain, step.service, + {"entity_id": entity_id, **step.service_data}) + state = ha.wait_state(entity_id, step.predicate()) + results.append({ + "step": step.name, + "expected": step.expected, + "observed": _observed(state, step), + }) + _restore(ha, entity_id, original, entity_id) + time.sleep(1.0) + restored = ha.state(entity_id) + results.append( + { + "step": "restore", + "expected": original.get("state"), + "observed": restored.get("state"), + } + ) + return results + + +def run_long( + ha: HaRest, + entity_id: str, + *, + hours: float, + interval: float, + report_path: Optional[str], +) -> tuple[int, int]: + original = ha.state(entity_id) + steps = build_steps(original) + if not steps: + raise AcceptanceError("climate-сущность не поддерживает ни одного шага") + iterations = max(1, int(hours * 3600 / interval)) + ok = failed = 0 + rows = [] + for index in range(iterations): + step = steps[index % len(steps)] + timestamp = dt.datetime.now(dt.timezone.utc).isoformat() + try: + ha.call(step.domain, step.service, + {"entity_id": entity_id, **step.service_data}) + state = ha.wait_state(entity_id, step.predicate()) + observed = ( + state.get("state") + if step.attribute == "state" + else state.get("attributes", {}).get(step.attribute) + ) + passed = observed == step.expected + _restore(ha, entity_id, original, entity_id) + time.sleep(1.0) + except AcceptanceError as err: + printed = str(err) + passed = False + observed = printed + ok += 1 if passed else 0 + failed += 0 if passed else 1 + rows.append([timestamp, step.name, step.expected, observed, + "ok" if passed else "fail"]) + if index + 1 < iterations: + time.sleep(interval) + if report_path: + with open(report_path, "w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow(["timestamp", "step", "expected", "observed", + "result"]) + writer.writerows(rows) + return ok, failed + + +def _esphome_client(args: argparse.Namespace): + if not args.esphome_host: + return None + try: + import aioesphomeapi # noqa: PLC0415 + except ImportError as err: + raise AcceptanceError( + "--esphome-host задан, но aioesphomeapi не установлен" + ) from err + return aioesphomeapi, args + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("mode", nargs="?", choices=["quick", "long"], + default="quick") + parser.add_argument("--long", dest="long_mode", action="store_true", + help="алиас режима long") + parser.add_argument("--ha-url", required=True) + parser.add_argument("--ha-token", required=True) + parser.add_argument("--entity", required=True, + help="entity_id climate-сущности FGLair") + parser.add_argument("--burst", action="store_true", + help="quick: без пауз между изменениями") + parser.add_argument("--hours", type=float, default=24.0) + parser.add_argument("--interval", type=float, default=3600.0) + parser.add_argument("--report", help="CSV-отчёт режима long") + parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT) + parser.add_argument("--esphome-host") + parser.add_argument("--esphome-key", default="") + parser.add_argument("--esphome-entity") + return parser + + +def main(argv: Optional[list[str]] = None) -> int: + args = _build_parser().parse_args(argv) + if args.long_mode: + args.mode = "long" + try: + _esphome_client(args) + ha = HaRest(args.ha_url, args.ha_token, args.timeout) + if args.mode == "quick": + results = run_quick(ha, args.entity, burst=args.burst) + failed = [ + row for row in results + if not _matches(row.get("observed"), row.get("expected")) + ] + for row in results: + print(f"{row['step']}: expected={row['expected']} " + f"observed={row.get('observed', '-')}") + if failed: + print(f"ПРОВАЛЕНО: {len(failed)}", file=sys.stderr) + return 1 + return 0 + ok, failed = run_long( + ha, args.entity, + hours=args.hours, interval=args.interval, + report_path=args.report, + ) + print(f"long: ok={ok} fail={failed}" + + (f", отчёт: {args.report}" if args.report else "")) + return 1 if failed else 0 + except AcceptanceError as err: + print(f"приёмка: {err}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main())