From 157df4e7951c55bbbb21c4d4189b16d2fc51e29a Mon Sep 17 00:00:00 2001 From: Petr Polezhaev Date: Thu, 17 Sep 2026 19:44:49 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BD=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=86=D0=B8=D1=8F=20LAN-=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D0=B0=20FGLair,=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D0=B8=D0=B7=20legacy,=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B=20fglair-core/HA/ESPHome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PROTOCOL.md: полная спецификация (KDF, envelope/CBC-цепочка, local_reg, key exchange, commands.json 206/200, datapoint push, тайминги, таблицы свойств шаблонов A/B/F, облачный provisioning). Факты из APK помечены [APK], проверенные живыми экспериментами на AP-WC1E — [ПРОВЕРЕНО НА ПРИБОРЕ]: mDNS только :10276; лимит 2 LAN-сессии (3-я -> 503); принудительный re-key при возрасте сессии >= ~44с (единственный механизм самолечения десинхрона — 400/401 модуль игнорирует); записи не эхируются; delete_session освобождает слот. - LEGACY_ANALYSIS.md: причины рассинхрона (keep-alive 1200с вместо 10-15с + seq_no-фильтр) и перегрузки модуля; требования к новой реализации. - PLAN_CORE_LIBRARY.md: план C++20-библиотеки fglair-core (Linux + ESP-IDF), ключ lanip_key считается статичным, ротация — только ошибка + ручной перепровижининг. - PLAN_HOME_ASSISTANT.md: pyfglair (cffi wheel) + custom component, облачный provisioning только в config flow, ключ виден в диагностике для копирования в ESPHome. - PLAN_ESPHOME.md: external component, только ESP-IDF framework. - tools/probe_reference.py: эталонный клиент протокола (проверен на приборе end-to-end); tools/probe_mdns.py — mDNS-проба. - legacy/: снимок скрипта (апстрим gyro-labs/AirCon, вложенный клон не версионируется); apk/*.apk исключены из версионирования. --- .gitignore | 11 + apk/icon.png | Bin 0 -> 5650 bytes apk/manifest.json | 1 + docs/LEGACY_ANALYSIS.md | 150 ++++++++++ docs/PLAN_CORE_LIBRARY.md | 273 +++++++++++++++++ docs/PLAN_ESPHOME.md | 153 ++++++++++ docs/PLAN_HOME_ASSISTANT.md | 146 +++++++++ docs/PROTOCOL.md | 503 +++++++++++++++++++++++++++++++ docs/README.md | 66 ++++ legacy/__init__.py | 0 legacy/aircon.py | 583 ++++++++++++++++++++++++++++++++++++ legacy/app_mappings.py | 74 +++++ legacy/config.py | 73 +++++ legacy/config_kata.json | 12 + legacy/control_value.py | 104 +++++++ legacy/discovery.py | 170 +++++++++++ legacy/error.py | 11 + legacy/main.py | 314 +++++++++++++++++++ legacy/mqtt_client.py | 92 ++++++ legacy/notifier.py | 126 ++++++++ legacy/properties.py | 525 ++++++++++++++++++++++++++++++++ legacy/query_handlers.py | 157 ++++++++++ legacy/requirements.txt | 7 + legacy/run.sh | 14 + tools/probe_mdns.py | 83 +++++ tools/probe_reference.py | 268 +++++++++++++++++ 26 files changed, 3916 insertions(+) create mode 100644 .gitignore create mode 100644 apk/icon.png create mode 100644 apk/manifest.json create mode 100644 docs/LEGACY_ANALYSIS.md create mode 100644 docs/PLAN_CORE_LIBRARY.md create mode 100644 docs/PLAN_ESPHOME.md create mode 100644 docs/PLAN_HOME_ASSISTANT.md create mode 100644 docs/PROTOCOL.md create mode 100644 docs/README.md create mode 100644 legacy/__init__.py create mode 100644 legacy/aircon.py create mode 100644 legacy/app_mappings.py create mode 100644 legacy/config.py create mode 100644 legacy/config_kata.json create mode 100644 legacy/control_value.py create mode 100644 legacy/discovery.py create mode 100644 legacy/error.py create mode 100644 legacy/main.py create mode 100644 legacy/mqtt_client.py create mode 100644 legacy/notifier.py create mode 100644 legacy/properties.py create mode 100644 legacy/query_handlers.py create mode 100644 legacy/requirements.txt create mode 100755 legacy/run.sh create mode 100644 tools/probe_mdns.py create mode 100644 tools/probe_reference.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcdac92 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Первоисточник для анализа — бинарники APK не версионируем (файлы лежат локально) +apk/*.apk + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# Вложенный клон апстрима legacy-скрипта (https://github.com/gyro-labs/AirCon) +legacy/aircon/ diff --git a/apk/icon.png b/apk/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8ebed03f693030b3903813c1ecfa882df1b2710b GIT binary patch literal 5650 zcmaKQS2WxY(DrW&HtGsdB3La5(QCBTOSI_mS5}A;5+ceH>@JJwC8CSoYa&7rHHbus z7A2xvmL!O}O7``Am+!@UF*E1P#mt%W%*=CUVoZ&7>8P($0{}p$ucu{pX}kWPz$h=Z z*}NAO09>2Z*HX7Y&;81~f@Xf0J+QbX%?gb+=Y)MqSVhdJf4gSUqsKwWQWZb-TQn``Dx3{RfnTOphphc7X?wp=-==Y| za;vebwQBo9ZS>f0;@}?fAGzcBq+^Q`k)p?cHAai~|1$TjbB!ExTvYdnUK)u=ga9gJ z;S1n%&mlVAB-S;+!JADXB7U?FMiB!fJ8T?M;wA>q$L|t>S^sdHp*UGyylq4m#4G%P zR;q&!ObZ1-1+J$BU}*MtSS~#w@N>NQzaPtC#I{PioiOvR^^f2ii983Sm#x=m|3ud% zlGjb->bBbDcOWsO2q}2Dtq#OkbN{Q`^L9U}Rq;91*Gg?m+CB?J>uT1WrD?ZCZvPemx;-1({gjXHe(a zn>ojDO$oJn)3R>`DVuQRzV>U`?%JJ$(-|x2B7>Ve31HFVjZ=b=@oqdnk!wB11{l3yXM|*@CP8~a2$&q_0b^aDz@r=y9!tPaJnG)5$@qQx z*{$S*df?#1`>L_&}EcrDVP?FEvZ+i9naSSmWsHU8Ie-7Kofc65R5p* zC6o0DIQ3)Z3b#-skbEGog4HP>Yn4$`zRbbH2=Ie1qs>~<2h->=gJco_X0TowI7JVN zwVWKFS=#2;yPv&k|nXzwq5nN61>bOxojCr1pky!@gXg#lA@Q?ydm+j86nj z$i=2oh%i_y0U*$T+)p3&4PI2b|C^d_MUBTAS9q7)!SNzXm_KW+E{2Sh42vgFR@c+$ zGM-kYPSRF^9!$U07ZW_vTr4{O+S(srg5l%BhJOdX*lh#M`x9Pf4hb?X}0XM;b z+oqa{fA0zRwqt{;%EI8I2qje_uOK5($@+B+vrg%VS#6bl*sHlnr2~fp!oR_V#0>k# zHKbDb$!VL@c>ei2cO84gDwXHGe~p3ygbeVDfo}tvdDlh!aiR=>^g<`v=Je&G7`Oy{ zu;$1s;(8Y7Rf3NONqoq%|AZ2c6%89;IHY@r(h*KJF8-ylpXF#9Z@svLE9(CLKKfN+ zwVkQz>^xl!9QuT%^mB`x0q($xBwLqUS>xLdt4e0=*rThxZq6Z7$^I(hukaHg;f4ju z?|(h#HSREDv$z9NSI4;l&Y}En^}}HQ;;Wdx$1Cujeokjd)ejbLRd8YfYbP=Kw0QDnQz*3`xl~L@ zd~aq8(#6|(WGoKJH@nfWBb>5c$|ba~_*jWaayLk={ho@2KB zwyREpe3!{2MyAr_Lb0y^ zaHYZydAG;@%`<&w4FijIj|XLT(r6jPgls4j5dQcal){hz9D2FrQJPLvbp$RD_#EW( z1qTD#G#Z{hVKL;;6r9##sx>Qp^TnI?!d+vX(pI7vhH{3z7CtniLo#-Cv|`$GZkvwS zEEs!Jf>0)}?6<<_hiEfN0aP+gW9+UTG+i;PwGE#8>h20Y8Z8Upvrd&VbSIA*kzv75 zzD<rn-iMd`#rz)g61PPwIwP{vGA@C=D1Fx*2k-^KHMa z`Vs{P1VI>%A&>?it+f*rJsolbwG^}O# z0(_8a+8Hh-ck&Kge2R_mkDJiCL^dB`!NEmob)9jE(iabWH}^EB1_ zuswSNvx*q`6WZ-#!Q!`H=e_>q?nS8}B>buIWf)>1L)&n@r5mM_4f$Wd%>6*q{m%u= zd3E`+>Dq!K!~}6ABlauc`n9=Y%4C-I;6uWm>?6+rkJiM3hsQ!w*~bF`=(>J8#|bX< z)J*`D#Qh_x#d0~Dn6cSvsUG)g%}=VMqRfixbXf7B7?3_!0P9!g9QL-Tzmagq*N%$= z28dZWahN~mPBF`7nltw^Heh}K&MIG&-XK)0qH4Q{cSg@C>~)o;+0fKeJJmx!#>Aie zH5|rKZK^FX#zVxX;z?6(r+RKiHRj@ej**pfh#$zL-fhO?n-QDI;&V4WLyL0%mnz=?ggMINy z3cQM7XLMiA^qa4yYm$hqZ}J5ttbqHZ+xGWvt5@d^aTP_fFxtAj9QJoYH7W#V2ah2R z9iW5($doT8HDw<I}+E+f$_P-ABB5ng{%xFOR z-;$CMxB~|j80&F2Y^NhuVkz{PnmGKuzc-eV+K7D8GT0xG|C#*#lJG+%L-qVmBN6;X zX_@&hp}^#jcz(i|&GA*#m0?-3Vt@)u%q{ z7ShY1x<{O!qGSXIn!tR;S2SWN>YWtTK*?Odi;JMKAzMF}Pv2LEHKyKY zl9(LJ`7sKL3BU^z8|2EbBn?Pm%DY^3Nr4Z6D$9}LeyJjzuV z!G22ce}GSZQpAyd#%G1+E7_}KG=(Ani}=ipzwP%Ol>f7ZVw-@1q7+v0+V=~N^Y+cCk%g$!X@ZNUQ<%xV+TVa2)W7!?{qf}r z{X)`hGQ|Vrso%tK_R;r9d;Dh&;Pd3$mD}zlG=+59O`dxan4_s_Nwxzph;K5c)TD9y zBENEyQ(-_nNmpj=_4LS=q)1@S(AGdx{wmCADI#PZ_TF<+3uU-Dy`+>k@| zbb`Bzw0!-EN`!$YeAmY0;~_N!o8^5JqFH2AA_gxL@ZGnz3hbJ<&fyz5WzuOa+~k#y z;|ld12?;Tm9(CPo`tMGptvadt+V;PCrn=x=sA#JQRoS)YqkwOKeiS??zVeFb6(Acn z!THggxfa>=jy9?J@z1S4KYZ)jUQKaTiiNM=)ZI715Yo14RLy5R@t>q?#R%?K^@v%XQUi~IIu%3BqTfD}dZ;fYuo-A>< zY+Ob|wN*}cxeuG&=&&~*1qa{=!X`yMbt#RQ(UE%%rHZ@-Iyw2E-MD@{x6)awq3ejd zbXbjsGoMSGHUAM!+WEl6Z)-?Fp%1}Y#7|rLf#3koZ0Zd!x-3bbUloLk|gHH zL0OLQzO+RZ8-%`PR^iY*U;Dh%Q5I~+iGfy}G`%H>s1Z@pEoRq?`3>_W!VS+p^gOF& z*-9v1iNKOS=?KHK|IM^}{3J|QX~#&Q&Y1U~)y|ctn3bwNBQ$QN-BJwW;sz8vy@QEE zi#nRUqbEDAHjM&rxQ8+klMMA2&*>o=RdG9yA29N}n$*14r{-sAM_2lLa~Z`{!bZN# zB{wCGSU^R}h;KXJ=N%-SD&9k~u7isc5YtBn4GP^7?(G)Vz z8Veomn5MMcUj4!NWtvux%nVCN0#NnJXRZIyinqM6>XIv)Q{^pv5nk}X=z)#58W(Vr zYhpb}khzszNTHaOq;Y$^>9DYfE#YIC@!2Bls#+5WNZ5bkMvP$}!Xr`gSCsU-`=kEc zUwFm(h4@o*M0(iJn;CFF(Qfo@HLAafcRKAcmt-A+0{YxQ-8emvyC)JhQ9W=(H5II( zmsf{5!J9(dndnYN-%)g_A2s06k16X|2AsnrH!wS6c1JcwiJIGkxOpck0BuCcQg=C2 zVUzSEc`m8}Jf+<3orRmx*e10m1$r>`#YmwVOzc( z_|L~yh4EVVB!vNb>APzssn5OZN$)<@0op#H5CK%sr0oh_U$?Sj>iQuewfVR8^0XdG z1v_O3Qq+I-Ah|Gugdhg!8oTP(8}+lB^1P507-0706U#>~mD$yRcB74>a@TuUGlT&y zb$lD+;oNxf+gxK&@bt7wt~i*S%Ytl?35z2g9Ub+uv$MOs-2Mdu;-Jys-L}%K{`nzPrL_12RJAIZn~(sxw)n_1tuM=AKS2h{@0;= zKd;rRE%kiLxy#Jd^1aJ}4lc39O-+XpLqkLAYs$}FaD?*HA)cs#bE02SPtWl~ewaq( z!FzRKY9S|mvlSH;=v*(|af^hn`N_eCD<4YIKfkL2l~A$d%6eM;e*gH#La9lD8Nx{C zNApfMjy%tHWR0W*ao38;f&m9#AFp^e$(0X3+XCxv9y0>^2}df`P6K#>W#;m`ld5x zFu$RizY`fr!>T$nK0b~#E`h-(VDIF*#x}Ua^ZG{be&mNBlJ&-N3|aL~*^I~OeDzz^ zALzn#qt@;${0SqiXVkN)g`LmvXhy|<9vS&#G9pt!<2UBC&+XE1$mYj&PHEe5dX@g4 zYiLUwU*eI^K)L-mX-^m14F|Mfxr~TigD21K{@F-VetmbvM(=y#UBouwg)56&$k--l z>T-rQzJwQdw>W|UPd6_B(F)@h2IwQtQh(*-Qmo6Ch*JAiO#=F*$T}!Pws|FlH%kp6 ziQxxel+TCG;e~NC0$(pKsQt0F92S8ohs&4$33j2k-)Fq5+_?+K#!3ZJS zn!Pvmp1?+ly&9zc3bvHIeqlgT!=ZLyh~Jch&B{<@5IL!^O}$tp+ON4Hq!w$Qc{q5^ zF91VteptRObf!jfHmAm3h5EkKl~x5IfeKu~yG22P4Tw7^+;$)uXpjdC@_6hM>dW_9 zgpkQZ{V~)F7qo+T<8=^t__V~P7+2RXKZnn~l8AHRT-|;Y+?bgZZz47M>wi5`+qTFQ aYyg{xb0pOptCY)MFQBh&tW~Fh3jZHe$9oI_ literal 0 HcmV?d00001 diff --git a/apk/manifest.json b/apk/manifest.json new file mode 100644 index 0000000..0b5e9d5 --- /dev/null +++ b/apk/manifest.json @@ -0,0 +1 @@ +{"xapk_version":2,"package_name":"com.fujitsu.fglair","name":"FGLair","version_code":"30162","version_name":"3.4.3","min_sdk_version":"24","target_sdk_version":"36","permissions":["android.permission.INTERNET","android.permission.WRITE_EXTERNAL_STORAGE","android.permission.RECORD_AUDIO","android.permission.RECORD_VIDEO","android.permission.READ_EXTERNAL_STORAGE","android.permission.ACCESS_COARSE_LOCATION","android.permission.ACCESS_FINE_LOCATION","android.permission.ACCESS_NETWORK_STATE","android.permission.VIBRATE","android.permission.CHANGE_NETWORK_STATE","android.permission.ACCESS_WIFI_STATE","android.permission.CHANGE_WIFI_MULTICAST_STATE","android.permission.CHANGE_WIFI_STATE","android.permission.BLUETOOTH","android.permission.BLUETOOTH_ADMIN","com.fujitsu.fglair.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION"],"split_configs":["config.en","config.fr","config.mdpi"],"total_size":20439665,"icon":"icon.png","split_apks":[{"file":"com.fujitsu.fglair.apk","id":"base"},{"file":"config.en.apk","id":"config.en"},{"file":"config.fr.apk","id":"config.fr"},{"file":"config.mdpi.apk","id":"config.mdpi"}]} \ No newline at end of file diff --git a/docs/LEGACY_ANALYSIS.md b/docs/LEGACY_ANALYSIS.md new file mode 100644 index 0000000..b5a50e0 --- /dev/null +++ b/docs/LEGACY_ANALYSIS.md @@ -0,0 +1,150 @@ +# Анализ legacy-скрипта (`legacy/`): соответствие протоколу и найденные проблемы + +Скрипт — форк проекта hisense_ac (deiger), адаптированный под FGLair. Общая логика +протокола воспроизведена верно, но есть критические расхождения с APK и поведением +модуля (проверено живыми экспериментами на приборе), которые объясняют оба +наблюдаемых симптома: «рассинхронизацию ключей» и перегрузку модуля. + +> Живые проверки прибора (AP-WC1E) показали: модуль игнорирует 400/401 на свои +> POST, восстанавливается только принудительным re-key по `local_reg` (порог +> возраста сессии ≈44 с); максимум 2 LAN-сессии; записи не эхируются. +> Подробности — PROTOCOL.md §4.4, §5.3, §6.3, §10. + +## 1. Что воспроизведено корректно + +| Часть | Файл | Оценка | +|-------|------|--------| +| KDF ключей (app/dev, suffix 0/1/2) | `config.py` | Точно совпадает с `AylaEncryption.generateSessionKeys`; подтверждено на приборе | +| AES-256-CBC, zero-pad, HMAC-sign | `query_handlers.py` | Совпадает; CBC-цепочка подтверждена на приборе (несколько последовательных сообщений) | +| CBC-цепочка в рамках сессии | `config.py` (один объект cipher) | Совпадает с Java (persist state) | +| Роуты `/local_lan/*` | `main.py` | Совпадают с `AylaHttpServer.addMappings` | +| Формат commands.json (по одной команде, seq_no, `{}` при пустой очереди) | `query_handlers.py` | Совпадает. **Но**: нет 206/200-различения (см. 2.6) | +| Формат datapoint push и GET-ответов | `query_handlers.py` | Совпадает | +| local_reg body/методы POST/PUT | `notifier.py` | Совпадает (формат некритичен — проверено) | +| Оптимистичное обновление при записи | `aircon.py` (property_updater) | Верно: записи не эхируются (проверено на приборе) | +| Облачный discovery (sign_in/devices/lan.json, секреты) | `discovery.py`, `app_mappings.py` | Совпадает (проверено: EU secret = base64url из SECRET_MAP) | +| Таблица свойств FGL (шаблон A) | `properties.py` | Частично; много свойств отсутствует (op_status, error_code, powerful_mode, min_heat, coil_dry, device_capabilities, …) — см. PROTOCOL.md §8.2 | + +## 2. Расхождения с APK/прибором (= баги) + +### 2.1. [ГЛАВНАЯ ПРИЧИНА «РАССИНХРОНИЗАЦИИ»] Keep-alive 1200 с вместо 10–15 с + +`notifier.py:_KEEP_ALIVE_INTERVAL = 1200.0`. APK: 10 с (или `lan.json:keepAlive/3`). +Проверено на приборе: **единственный механизм восстановления после расхождения +CBC-цепочек — принудительный re-key, который модуль делает при получении +`local_reg` для сессии старше ≈44 с. Ответы 400/401 модуль игнорирует.** +Следствие для legacy: любая потерянная пара запрос-ответ/обрыв соединения → +обе стороны «глохнут» на срок до 20 минут (до следующего local_reg). Наблюдаемый +симптом «перестаёт понимать кондиционер» с самопроизвольным восстановлением — +именно это. + +Дополнительно: длинные паузы между local_reg держат сессию «полуживой» +(модуль не видит keep-alive, но слот может удерживаться), и конфликт за +2 доступных слота с телефоном/вторым клиентом становится вероятнее. + +### 2.2. [ТЕОРЕТИЧЕСКОЕ] Неверная обработка смены `lanip_key_id` + +`config.py:update` бросает `KeyIdReplaced` → `key_exchange_handler` отвечает +**404 Not Found** вместо **412 Precondition Failed** (APK) и никогда не +перечитывает `lan.json`. За 5 лет эксплуатации ротация ключа не наблюдалась +ни разу (ключ, по-видимому, статичен и зашит в модуль), так что на практике +благополучен — но код вводит в заблуждение и чинится тривиально. + +### 2.3. Drop легитимных обновлений по seq_no + +`aircon.py:is_update_valid` отбрасывает обновления с `seq_no` меньше последнего +(кроме 0). На приборе: seq_no модуля сбрасывается в 0 **при каждом re-key** и +растёт внутри сессии. При штатных (для legacy — раз в 1200 с) re-key'ах фильтр +пропускает только первый push сессии (seq 0) и отбрасывает все последующие (1, 2, +… < накопленного максимума). APK не проверяет seq_no входящих вообще. Итог: +пропущенные обновления состояния после каждого re-key — второй вклад в +«скрипт не видит изменений». + +### 2.4. 400 вместо 401 при ошибке расшифровки + +`query_handlers.property_update_handler` возвращает **400**, APK — **401**. +На приборе модуль игнорирует оба кода, так что это НЕ причина рассинхрона +(первоначальная гипотеза опровергнута экспериментом). Исправить стоит для +APK-совместимости, потому что код ответа — часть интерфейса. + +### 2.5. Мелочи шифрования + +* Паддинг: скрипт НЕ добавляет обязательный завершающий NUL (Java добавляет + `len+1`). На приборе работает оба варианта; для совместимости повторить Java. +* `t_fan_speed`/`t_control_value` (AcDevice/Hisense-свойства) для FGLair-устройств + не используются — кодовая basePath висит мёртвым грузом. + +### 2.6. Отсутствие 206-ответов + +`command_handler` всегда отвечает 200. APK отвечает 206, пока очередь не пуста. +Без 206 модуль вынужден либо перепрашивать local_reg, либо опрашивать вслепую — +вероятный вклад в перегрузку. + +### 2.7. Нет DELETE-команды сессии при завершении + +Скрипт не отправляет `delete_session` — модуль держит полумёртвую сессию в одном +из 2 слотов. + +## 3. Причины перегрузки модуля (спам → модуль отключается от Wi-Fi) + +### 3.1. Статусный цикл: 33 GET-команды каждые 600 с + +`main.py:query_status_device` ставит в очередь **по одной GET-команде на каждое +свойство** (поля dataclass) каждые 600 с, плюс ещё раз при старте. APK запрашивает +все свойства **один раз** при установке сессии (`fetchPropertiesLAN`) и далее +живёт на push-обновлениях; поллит отдельные свойства только после команд с +побочными эффектами. Постоянный циклический опрос — лишние сотни HTTP-транзакций +и AES-операций на приборе, у которого слабый CPU. + +### 3.2. local_reg на каждую команду без debounce + +Каждый `queue_command` → `_queue_listener()` → немедленный `local_reg notify=1`. +Действие из HA (mode+temp+fan) = 3 команды = до 3 local_reg подряд. APK шлёт +**один** local_reg на пакет команд (AylaLocalNetwork.performRequest). + +### 3.3. Агрессивный цикл Notifier при непустой очереди + +`notifier.py:start`: пока `qsize > 1` — sleep всего 60 с и повторная отправка +local_reg. Если модуль «застрял» (не забирает команды), очередь растёт +(см. 3.1), local_reg продолжает долбить каждые 60 с + retry-логика tenacity +(6 попыток, экспоненциально). Мёртвый цикл под нагрузкой. На приборе подтверждён +паттерн: после серий неудачных попыток регистрации модуль может «зависать» в +режиме «KE без активации» — долбить его повторными local_reg бесполезно, нужен +backoff и пауза (PROTOCOL.md §4.4 п.6). + +### 3.4. Странный старт + +При старте: `query_status_device` немедленно (без начальной задержки) наполняет +очередь 33 GET-командами, а `Notifier.start` в первой же итерации отправляет +`local_reg` (таймер `last_timestamp=0` срабатывает сразу). Возникает гонка: +`notify` в первом POST/PUT зависит от того, успела ли очередь наполниться, и +модуль сразу получает «тяжёлый» старт — массовая выдача 33 команд новой сессии. +Правильная последовательность (APK): local_reg notify=0 → key exchange → один +пакет GET-запросов → далее только push. + +## 4. Прочие замечания + +* MQTT: подписка на `$SYS/broker/log/M/subscribe/#` — hack для перепосылки статуса + новым подписчикам; в HA-интеграции не понадобится. +* `f_temp_in`/`t_power`-мэппинги — код Hisense-ветки, для FGL не нужен. +* Потокобезопасность: pycryptodome cipher используется из одного event-loop — ок, + но при любом выносе в треды потребует сериализации (CBC-цепочка!). + +## 5. Требования к новой реализации, вытекающие из анализа + +1. Keep-alive по APK-таймингам: 10–15 с. Это одновременно и период + самолечения десинхрона (модуль сам сделает re-key на ≈44-й секунде). +2. Воспроизводить Java-поведение в кодах ответов: 401 при ошибках расшифровки, + 412 при несовпадении key_id, 206/200 в commands.json, NUL-паддинг. +3. Начальная синхронизация: один пакет GET всех нужных свойств после key + exchange; далее — push-driven. Периодический опрос — только как diagnosка + с большим интервалом и по требованию. +4. Записи не эхируются: оптимистичное обновление + при необходимости GET-подтверждение. +5. Не проверять seq_no входящих сообщений. +6. Debounce команд: копить 100–300 мс, отправлять одним пакетом; один local_reg + notify=1 на пакет. Не более одного local_reg в ~1 с. +7. Rate-limit очереди, backoff при ошибках (включая режим «KE без poll» — + пауза, а не долбёжка), корректное завершение (delete_session) для + освобождения слота. +8. Считать lanip_key статичным: при несовпадении key_id — устойчивая ошибка + и перепровижининг вручную (облако не дергать в рантайме). diff --git a/docs/PLAN_CORE_LIBRARY.md b/docs/PLAN_CORE_LIBRARY.md new file mode 100644 index 0000000..4d92b67 --- /dev/null +++ b/docs/PLAN_CORE_LIBRARY.md @@ -0,0 +1,273 @@ +# План: кросс-платформенная библиотека `fglair-core` (C++20, Linux + ESP-IDF) + +Целевая аудитория документа — агенты-реализаторы. Протокольные детали — в +`PROTOCOL.md` (ссылки вида «§N», факты помечены [ПРОВЕРЕНО НА ПРИБОРЕ]). +Причины проектных решений — `LEGACY_ANALYSIS.md`. + +## 1. Цели и не-цели + +Цели: +1. Реализовать LAN-протокол FGLair/Ayla (сторона «приложения») по спецификации + `PROTOCOL.md` с поведением, максимально близким к официальному APK и + подтверждённому живыми тестами прибора. +2. Портативность: сборка как C++20-библиотека (Linux, CMake) и как компонент + ESP-IDF (esp32/esp32s3/esp32c3 — везде ESP-IDF, Arduino-фреймворк не + поддерживаем и не тестируем). +3. Малый footprint и детерминированное использование памяти: без heap после + инициализации (все буферы — члены/статические), никаких исключений наружу + (внутри — `expected`/коды), логирование через callback. +4. Интеграция «как у climate-модулей ESPHome»: простой асинхронный API + (set/get свойства, колбэки обновлений, статус сессии). +5. Устойчивость: переживать re-key (модуль сам ротирует ключи каждые ≈44–60 с), + перезагрузку модуля, конфликт слотов (503), режим «KE без активации»; + жёсткий rate-control, чтобы не «завалить» модуль. + +Не-цели (первая версия): +* Облако внутри C++-библиотеки. **lanip_key считается статичным, зашитым в + модуль** (5 лет эксплуатации без ротаций). Провижининг — отдельный Python + CLI (`fglair-discover`), см. §8. При несовпадении `key_id` библиотека + переходит в устойчивое состояние ошибки и ждёт смены конфига вручную. +* Узловые устройства (`node/*`), setup-режим (RSA `sec`), OTA. +* Hisense-свойства (`t_power` и пр.) — только FGLair-шаблоны A/B/F. + +## 2. Архитектура + +``` +┌────────────────────────────────────────────────────────────┐ +│ Приложение: HA-интеграция / ESPHome-компонент / CLI │ +└───────────────▲────────────────────────────────────────────┘ + │ include/fgl/*.h — публичный API + │ C++-классы + тонкий extern "C"-шейм (для cffi/HA) +┌───────────────┴────────────────────────────────────────────┐ +│ core (портативный C++20, без исключений/RTTI/heap): │ +│ session — машина состояний, re-key, keep-alive, слоты │ +│ crypto — KDF, AES-256-CBC (цепочка!), HMAC-SHA256 │ +│ envelope — pack/unpack {"enc","sign"}, seq_no, паддинг │ +│ property — таблицы свойств шаблонов A/B/F, конверсии │ +│ cmdq — очередь команд с coalescing + pacing │ +│ json — минимальный streaming JSON reader/writer │ +│ httpd — минимальный HTTP/1.1 server (роутинг по IP) │ +│ httpc — клиент local_reg │ +├────────────────────────────────────────────────────────────┤ +│ platform layer (интерфейс fgl/platform.h, 2 реализации): │ +│ posix : sockets, std::thread, timerfd, getrandom │ +│ esp-idf: lwip sockets, esp_timer/FreeRTOS, esp_random │ +├────────────────────────────────────────────────────────────┤ +│ crypto backend: mbedtls (в ESP-IDF встроен; на Linux — │ +│ системный или vendored) │ +└────────────────────────────────────────────────────────────┘ +``` + +Правила: +* Ядро не знает про ОС: сокеты/таймеры/логи/случайность — через тонкие + платформенные заголовки, реализуемые слоем ниже. +* Ядро однопоточное: один внутренний поток/задача владеет сессией и шифрами + (CBC-цепочка требует строгой сериализации). Вызовы API извне — через + потокобезопасный mailbox (lock-free SPSC или мьютекс). Все колбэки + исполняются из этого потока. +* C++20 разрешён и приветствуется (enum class, span, chrono, concepts), + но: без исключений, RTTI, виртуальных иерархий в горячем пути и heap после + `init()`. `std::function` в API не использовать (функция+user-data). +* Запрет на `printf`; логирование через injectable `fgl_log_fn`. + +## 3. Публичный API (эскиз, `include/fgl/`) + +```cpp +// fgl/types.h +enum class fgl_state { idle, registering, online, recovering, offline, key_error }; +enum class fgl_prop { operation_mode, fan_speed, adjust_temperature, + display_temperature, af_vertical_direction, af_vertical_swing, + af_horizontal_direction, af_horizontal_swing, economy_mode, + powerful_mode, coil_dry_mode, min_heat, outdoor_low_noise, + indoor_fan_control, human_det_auto_save, wifi_led_enable, + op_status, error_code, device_capabilities, demand_control, + get_prop, device_name, building_name, /* ...по PROTOCOL.md §8.2 */ }; +struct fgl_value { enum kind { boolean, integer, string } type; + union { bool b; int32_t i; }; const char* s; }; + +struct fgl_config { + const char* device_ip; // "192.168.88.3" + const char* dsn; // "AC000W002879281" + const char* lanip_key; // base64-строка как есть + uint32_t lanip_key_id; // 62888 + fgl_template template_; // A / B / F + uint16_t listen_port; // 0 => 10275 + uint32_t keepalive_ms; // 0 => 15000 (рекомендация; APK: 10с) + uint8_t max_queue; // 0 => 16 +}; +struct fgl_callbacks { + void (*on_state)(void* user, fgl_state st, int err); + void (*on_property)(void* user, fgl_prop p, const fgl_value* v); + void (*on_log)(void* user, int level, const char* msg, size_t len); + void* user; +}; + +// fgl/session.h (C++-класс; в fgl/c_api.h — extern "C" шейм для cffi) +class FglSession { + public: + static FglSession* create(const fgl_config&, const fgl_callbacks&); + int start(); int stop(); // stop() шлёт delete_session, ждёт ≤2с + fgl_state state() const; + // Управление (ставится в очередь с coalescing): + int set_bool(fgl_prop, bool); int set_int(fgl_prop, int32_t); + int get_prop(fgl_prop); // запросить refresh + int batch_begin(); int batch_commit(); // атомарный пакет команд + bool cached(fgl_prop, fgl_value* out) const; +}; +``` + +Таблица свойств — `const` массивы в rodata по шаблонам (имя, base_type, +read-only, диапазоны), см. PROTOCOL.md §8. + +## 4. Поведенческие требования (обязательны к точной реализации) + +Ссылки на PROTOCOL.md; всё, что ниже, согласовано с живыми тестами прибора. + +### 4.1. Установка сессии +1. `start()`: HTTP-сервер слушает `listen_port` (по умолчанию 10275). +2. Отправить `POST /local_reg.json?dsn=` с `notify=0` (§4.1). Ответы: + 202 — ок; **503 — нет свободных слотов** (2 заняты, например телефоном и + другим сервером) → состояние `offline` с ошибкой `FGL_E_NO_SLOT`, повтор + с backoff 30–60 с; таймаут/отказ соединения → `offline`, backoff 1→60 с. +3. Дождаться `POST /local_lan/key_exchange.json` (обычно <1 с). Проверить + `ver==1, proto==1, sec пустой` (иначе 426/400), сверить `key_id` + (несовпадение → **412** + состояние `key_error` до смены конфига вручную; + облако НЕ дёргается — ключ статичен). Сгенерировать `random_2` (16 симв. + `[A-Za-z0-9]`), `time_2` (любое int64, напр. наносекунды аптайма), + вывести ключи (§3.2), ответить 200 `{"random_2":...,"time_2":...}`. +4. После ответа модуль в течение ~0.5 с делает «пустой» опрос commands.json — + это сигнал активации. **Если в течение 5 с опроса нет — сессия не + активировалась** (наблюдавшийся режим зависания модуля): закрыть серверную + сторону молча, уйти в `recovering` с паузой 30–60 с (НЕ долбить + повторными local_reg — ухудшает состояние модуля). +5. После активации: поставить пакет GET-команд нужных свойств (подписанное + подмножество таблицы, по умолчанию — все состояния + capabilities) и + отправить ОДИН `local_reg` с `notify=1`. Значения придут push'ами. + +### 4.2. Keep-alive и re-key (ядро надёжности) +* Таймер `keepalive_ms` (по умолчанию **15000**). По истечении — `PUT local_reg` + с `notify = (очередь непуста)`. +* **Модуль сам ротирует ключи**: очередной `local_reg` при возрасте сессии + ≥ ~44 с приходит вместе с новым key exchange. Обработать его как обычный + KE (перегенерация шифров, цепочки сбрасываются), НЕ пересоздавая сессию; + seq_no приложения продолжает глобальный счётчик. После re-key НЕ нужна + повторная начальная синхронизация (значения уже в кэше). +* Каждый обслуженный `GET /commands.json` перезапускает таймер keep-alive + (APK-поведение). Анти-спам: не более одного `local_reg` в ~1 с; notify=1 + отправляется один раз на пакет команд. +* Модель времени: хранить `last_ke_time`; при `local_reg` предсказывать, + будет ли re-key (age ≥ 44 c) — для телеметрии/диагностики. + +### 4.3. Очередь команд и pacing +* Ограничение очереди `max_queue` (16). Coalescing: новый write того же + свойства замещает предыдущий незабранный; GET-дубликаты отбрасываются. +* `commands.json`: отдать **одну** команду из головы; 206, если очередь + непуста, иначе 200. Шифрование строго последовательно (CBC-цепочка), + паддинг — Java-вариант (≥1 NUL). +* Записи не эхируются (проверено): после выдачи write обновить кэш + оптимистично; опционально (по конфигу) подтвердить GET-ом через 1–2 с. + +### 4.4. Обработка сообщений модуля +* Datapoint push: расшифровать, проверить подпись; **seq_no не проверять**. + Парсить query `?cmd_id=N&status=200` для сопоставления с ожиданиями GET. + При ошибке расшифровки ответить **401** (APK-совместимость; модуль это + игнорирует, но таковы интерфейсные контракты) и пометить сессию + `recovering` — ждать ближайшего re-key по keep-alive (≤15 с). +* Повторный key exchange на живой сессии — штатное событие (§4.2), не ошибка. + +### 4.5. Завершение +* `stop()`: поставить команду DELETE `local_reg.json`/`delete_session`, + дождаться выдачи (≤2 с), закрыть сервер. Освобождает слот немедленно + (проверено) — важно из-за лимита в 2 сессии. + +### 4.6. HTTP-сервер +* Минимальный HTTP/1.1: GET/POST, `Content-Length`, keep-alive, query-параметры. + Один поток, последовательная обработка, RST-обрывы от модуля — норма. +* Роутинг на сессию по IP клиента (как `deviceWithLanIP` в APK). Мульти-сессии + (несколько устройств) — через `FglHub` (M6). + +## 5. Криптография + +* mbedtls: AES-256-CBC c сохранением IV-состояния между сообщениями + (mbedtls_aes_crypt_cbc обновляет iv-буфер на месте — использовать его же + как персистентное состояние), HMAC-SHA256 через `mbedtls_md`. +* KDF по §3.2 PROTOCOL. Тестовые векторы — эталон `tools/probe_reference.py` + (проверен на приборе) + генератор векторов на Python. +* `random_2`/`id` — из CSPRNG платформы. `time_2` — наносекунды аптайма. + +## 6. Таблица свойств + +`src/property.cpp` + `include/fgl/props.def`: на каждый шаблон — `constexpr` +массив `{enum, имя, base_type, RO, min/max}`; конверсии `adjust_temperature` +(×0.1 °C), `display_temperature` ((v−5000)/100), направления 0..num_dir−1; +битовые декодеры `op_status` / `device_capabilities` (§8.4–8.5). + +## 7. Структура репозитория + +``` +fglair-core/ + include/fgl/ # публичные заголовки (C++20 + c_api.h extern "C") + src/ # ядро (портативное) + platform/posix/ # sockets/std::thread/timerfd/getrandom + platform/esp-idf/ # lwip/esp_timer/esp_random (+ idf_component CMakeLists) + test/unit/ # KDF, envelope, json, property, cmdq (doctest/catch2) + test/integration/ # python mock-модуль (эталон probe_reference.py) + runner + tools/probe_reference.py# эталонный клиент, проверенный на приборе + tools/fglair-discover # CLI: облачный discovery -> печать/сохранение конфига + examples/cli/ # fglctl (linux): status/set/monitor + CMakeLists.txt # linux build + tests + README.md +``` + +Зависимости: mbedtls (IDF встроен; Linux — системный или FetchContent 3.x), +Python 3 (тесты/инструменты). Оценка ресурсов (ESP32, IDF): RAM < 20 КБ на +сессию, код ядра ~35–50 КБ + mbedtls. + +## 8. Провижининг (вне библиотеки) + +* `fglair-discover` (Python): вход e-mail/пароль/регион → облачные endpoints + (PROTOCOL.md §7) → печать `dsn, ip, oem_model, lanip_key, lanip_key_id` и + сохранение json-конфига (формат `config_kata.json`). Тот же код кладётся в + HA-интеграцию (config flow) и используется standalone для ESPHome-пользователей. +* Рантайм-обновления ключа НЕТ. Несовпадение `key_id` = `key_error`, + лечение — редактирование конфига вручную (для HA — repair-флоу с повторным + облаком; для ESPHome — копирование ключа из диагностики HA или повторный + запуск CLI). + +## 9. Тестирование + +1. **Unit**: KDF-векторы; envelope roundtrip (оба варианта паддинга); CBC- + цепочка (3 сообщения подряд); JSON writer/reader fuzz; coalescing; таблицы. +2. **Mock-модуль** (`test/integration/mock_ac.py`, развитие probe_reference.py): + сценарии: обычная сессия; re-key по возрасту 44 с (ускоренный таймер); + 503-слоты; «KE без poll»; потеря сообщения → 401 → восстановление на + следующем keep-alive; delete_session. +3. **On-device** (чек-лист, фактически повторяет проведённые пробы): + старт/активация ≤5 с; GET всех свойств; запись + оптимистичное обновление; + 24 ч uptime с keep-alive 15 с (лог: re-key каждые 45–60 с, 0 потерь); + параллельно телефон; перезапуск прибора питанием. +4. CI: gcc+clang -Wall -Werror, asan/ubsan (unit+mock), idf-сборка esp32. + +## 10. Этапы (milestones) + +| # | Содержимое | Критерии приёмки | +|---|------------|------------------| +| M0 | Каркас, платслой, логирование, CMake+IDF, CI | Собирается на linux и esp-idf; пустой HTTP-сервер отвечает 404 | +| M1 | Крипто: KDF + envelope + векторы | Векторы зелёные; совместимость с probe_reference.py | +| M2 | Сессия с mock-модулем: local_reg→KE→активация (poll)→GET→push; keep-alive | Mock-сценарий «обычная сессия»; на приборе: активация ≤5 с, свойства читаются | +| M3 | Очередь с coalescing, 206/200, batch, записи+оптимистичный кэш | На приборе: batch из 5 команд = 1 local_reg notify; значения применяются | +| M4 | re-key по возрасту, 401-обработка, 503/слоты, режим «KE без poll» (backoff), delete_session | Mock-сценарии + 2 ч на приборе без рассинхрона; stop() освобождает слот | +| M5 | Таблицы A/B/F + конверсии; fglctl; fglair-discover; probe_reference.py в tools/ | 24 ч на приборе: 0 рассинхронов, re-key каждые 45–60 с | +| M6 | (Опционально) mDNS-обнаружение (запрос на :10276), FglHub на N устройств | Устройство найдено без статического IP | + +## 11. Риски и открытые вопросы + +* Режим «KE без poll» (PROTOCOL.md §4.4 п.6) — причина не идентифицирована; + стратегия (пауза 30–60 с) подобрана эмпирически; заложить телеметрию для + уточнения. +* `display_temperature` → °C: формула (v−5000)/100 c округлением к 0.25; + расхождение с таблицей приложения ≤ 0.25 °C. +* Порог re-key ≈44 с измерен в границах 39–44 с — для надёжности опираться + не на точное значение, а на факт «KE может прийти с любым local_reg». +* В ESPHome/Arduino-сборках esp_http_server может быть занят портом 80 — + ядро использует собственный мини-httpd на lwip-сокетах, конфликтов нет. diff --git a/docs/PLAN_ESPHOME.md b/docs/PLAN_ESPHOME.md new file mode 100644 index 0000000..09a993a --- /dev/null +++ b/docs/PLAN_ESPHOME.md @@ -0,0 +1,153 @@ +# План: внешний компонент ESPHome (`fglair`) + +Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`; ядро — +`docs/PLAN_CORE_LIBRARY.md` (`fglair-core`, C++20). Компонент строится по +образцу штатных climate-модулей ESPHome (midea, hisense-ac, tuya), но протокол +вынесен в переиспользуемое C++-ядро. + +Требования к среде: **только ESP-IDF framework** (Arduino-фреймворк ESPHome +считается устаревшим и не поддерживается). Ядро — C++20 без исключений/RTTI, +что совместимо с дефолтными флагами сборки ESPHome для IDF. + +## 1. Распределение кода + +``` +esphome-fglair/ (external component, установка через + components/fglair/ external_components: - source: github://...) + __init__.py # FglairHub: Component; владеет FglSession ядра + climate.py # FglairClimate : climate::Climate + sensor.py # комнатная температура, error_code, op_status-флаги + switch.py # economy/powerful/coil_dry/min_heat/... + select.py # положения заслонок (v2) + binary_sensor.py # connectivity + config_validation.py + const.py + core/ # git-subtree/symlink fglair-core (src+include+platform/esp-idf) + CMakeLists.txt # STATIC LIBRARY; REQUIRES lwip esp_timer mbedtls + translations/ +``` + +Ядро компилируется как статическая библиотека через CMakeLists компонента; +платформенный слой — `platform/esp-idf` (lwip-сокеты, esp_timer, esp_random). + +## 2. YAML-конфигурация + +```yaml +external_components: + - source: github:///esphome-fglair@main + components: [fglair] + +fglair: + devices: + - id: ac_living + ip_address: 192.168.88.3 # либо dsn + mdns: true (запрос на :10276) + dsn: AC000W002879281 + lanip_key: 1uWOP3nGbSz2ysEfjJJVu+P0fxAQTg== + lanip_key_id: 62888 + template: A # A|B|F + # port: 10275 # локальный порт сервера (default) + # keepalive: 15s + +climate: + - platform: fglair + device_id: ac_living + name: "Кондиционер" + # swing: both # off|vertical|horizontal|both + +sensor: + - platform: fglair + device_id: ac_living + room_temperature: {name: "Температура в комнате"} + error_code: {name: "Код ошибки"} + +switch: + - platform: fglair + device_id: ac_living + economy: {name: "Эко"} + powerful: {name: "Мощный"} + coil_dry: {name: "Осушка змеевика"} + min_heat: {name: "Мин. обогрев"} + outdoor_low_noise: {name: "Тихий наружный блок"} + wifi_led: {name: "LED Wi-Fi"} +``` + +**Откуда брать ключ**: у пользователя обычно уже есть HA-интеграция (или +запускается `fglair-discover` CLI из репозитория ядра). HA-диагностика +устройства показывает `lanip_key`/`lanip_key_id` — значения копируются в YAML +вручную. Ключ статичен (зашит в модуль), автоматическая синхронизация не +предусмотрена. + +**Поведение при смене ключа**: ядро отвечает 412 и переходит в `key_error`; +компонент логирует ошибку с текстом «lanip_key устарел, обновите конфиг» и +останавливает сессию (не долбит модуль). Обновление — правка YAML вручную. + +## 3. Компонент `fglair` (hub, `__init__.py`) + +* `FglairHub : public Component` — на каждое `device` создаёт `FglSession` + (API ядра) при `setup()`; колбэки ядра приходят из его внутренней задачи — + мост в main-loop ESPHome через `Component::defer()`. +* `dump_config()`: версия ядра, состояние, статистика (re-keys, команды, + lost-push), измеренный возраст re-key. +* `loop()`: поллинг mailbox (транзакции из main-loop в ядро — тоже через + mailbox ядра, ядро однопоточное внутри). +* Зависимости: `network`; старт сессии только после `network::is_connected()`; + при смене IP самой ESP ядро перерегистрируется само (local_reg с новым ip). +* Доступность: таймаут watchdog 60 с без push и без успешного local_reg → + entities в NaN/unavailable; восстановление ядром (backoff) возвращает. + +## 4. `climate.py` + +* `FglairClimate : public climate::Climate, public Component`: + * `traits()`: modes OFF/COOL/HEAT/DRY/FAN_ONLY/AUTO (фильтр по + `device_capabilities`); fan quiet/low/medium/high/auto; swing off/vertical/ + horizontal/both; step 0.5 °C (шаблон B — 1.0 °C); min/max 16–30 °C; + * `control(const ClimateCall&)`: все изменения вызова — в ОДИН + `batch_begin()/batch_commit()` ядра (один local_reg notify на действие); + OFF → `operation_mode=0`; turn_on → `operation_mode=1`; + * `current_temperature` ← `display_temperature`; остальные значения из кэша + ядра; push-колбэк ядра → `publish_state()`; + * записи не эхируются (PROTOCOL.md §5.3) — optimistic update в кэше ядра, + state публикуется сразу; + * presets: `CLIMATE_PRESET_ECO` / `CLIMATE_PRESET_BOOST` → economy_mode / + powerful_mode. + +## 5. Остальные платформы + +* `sensor.py`: `room_temperature` (°C, точность 0.25), `error_code`, + `op_status` (text-флаги defrost/oil_recovery/pump_down/maintenance/ + check_operation/запреты — по битовой маске PROTOCOL.md §8.4). +* `switch.py`: bool-свойства; `write_state` → `set_bool` ядра. +* `select.py` (v2): `af_vertical_direction`/`af_horizontal_direction`, N опций + из `af_*_num_dir`. +* `binary_sensor.py`: `connectivity` = ONLINE. + +## 6. Ограничения и требования + +* Платы: esp32/esp32s3/esp32c3 (lwip + ≥ 25–30 КБ свободной RAM под сессию + и буферы ядра; для c3 проверить стек задачи ядра). +* Порт 10275 (или настраиваемый) должен быть свободен; конфликт с `api` + (6053)/`ota` исключён. +* **Одно устройство на ESP** в v1 (мульти-устройства — M6 ядра/FglHub). +* Колбэки ядра — не в main-loop; мост через `defer()` обязателен. +* OTA-обновление ESPHome поверх живой сессии: `stop()` в `on_shutdown`- + триггере не гарантируется — слот на модуле освободится сам по таймауту + (< 2 мин); ядро переживает это штатно (проверено на приборе). + +## 7. Тесты и приёмка + +1. CI: `esphome compile` для тестовых конфигов (esp32-idf, esp32s3-idf). +2. Mock-модуль ядра + локальная сборка компонента — smoke: старт, online, + одна запись, публикация state. +3. На приборе: чек-лист M5 ядра + OTA-ребут поверх сессии + 24 ч uptime + под управлением из HA через native API. +4. Приёмка: 24 ч без рассинхронов; параллельно телефон (2 слота); изменение + с пульта отражается в HA < 1 с. + +## 8. Этапы + +| # | Содержимое | +|---|-----------| +| E1 | каркас external component, компиляция ядра (esp-idf), hub + connectivity | +| E2 | climate (mode/fan/temp/swing, batch-запись, optimistic state) | +| E3 | sensor/switch, capabilities-фильтры, translations, README с YAML | +| E4 | select (заслонки), mdns-опция (:10276), CI, релиз | diff --git a/docs/PLAN_HOME_ASSISTANT.md b/docs/PLAN_HOME_ASSISTANT.md new file mode 100644 index 0000000..eab663f --- /dev/null +++ b/docs/PLAN_HOME_ASSISTANT.md @@ -0,0 +1,146 @@ +# План: интеграция для Home Assistant (`fglair` custom component) + +Аудитория — агенты-реализаторы. Протокол — `docs/PROTOCOL.md`, библиотека — +`docs/PLAN_CORE_LIBRARY.md` (`fglair-core`, C++20). + +## 1. Архитектура + +``` +Home Assistant (custom component `fglair`) + │ использует python-пакет pyfglair (cffi-bindings к libfglair-core.so) + ▼ +pyfglair (wheel: linux x86_64/aarch64; cffi; собирает fglair-core через cmake) + │ + ▼ +fglair-core (C++) ←—— mock-тесты; тот же код, что и на ESP32 (ESP-IDF) +``` + +Компонент — тонкий: переводит API библиотеки в сущности HA. Вся протокольная +логика (сессия, шифрование, pacing, re-key, восстановление) — в C-ядре. + +Обоснование: пользователь требует переиспользования библиотеки из п.2; +cffi-wheel — рабочий путь (HA-контейнеры: x86_64/aarch64 debian; cibuildwheel +покрывает). Fallback (если сборка wheel станет блокером): libfglair собирается +в docker при старте интеграции один раз (dev-mode) — не рекомендуется для +продакшна; чисто-Python повторная реализация протокола — запрещена (дубль +логики, расползание с ESP32-веткой). + +## 2. Состав репозиториев + +1. **`pyfglair`** (python-пакет): + * `pyfglair/_corebuild.py` — cmake-сборка при упаковке wheel; + * `pyfglair/_cffi.py` — cffi-декларации поверх `include/fgl/c_api.h` + (extern "C"-шейм ядра); + * `pyfglair/session.py` — pythonic-обёртка: `Session(cfg)`, колбэки ядра + мостятся в `asyncio` через `loop.call_soon_threadsafe`; + * `pyfglair/provision.py` — облачный discovery (перенос `legacy/discovery.py` + на современный aiohttp): вход e-mail/пароль/регион → dsn, ip, oem_model, + lanip_key, lanip_key_id; + * CLI: `python -m pyfglair discover` / `python -m pyfglair monitor`. +2. **`ha-fglair`** (custom component, HACS-совместимый): + * `custom_components/fglair/…` (см. §4); + * `manifest.json`: `requirements: ["pyfglair>=1.0.0"]`, `domain: fglair`, + `config_flow: true`, `iot_class: local_push`. + +## 3. Конфигурация (config flow) + +Вариант A — облачный (удобный): +1. Пользователь вводит e-mail/пароль FGLair + регион EU/US/CN. +2. Интеграция через `pyfglair.provision` получает список устройств + (name, model, ip, dsn, lanip_key, lanip_key_id) и показывает их. +3. Выбор устройства → `ConfigEntry` (те же поля, что `config_kata.json`) + → шаг «пробное подключение» (старт сессии, ожидание ONLINE ≤ 10 с). + +Вариант B — ручной: ввод ip/dsn/lanip_key/lanip_key_id по полям или импорт +существующего `config_*.json` (миграция с legacy-скрипта). + +Ключ считается **статичным** (см. PLAN_CORE_LIBRARY §8): облако используется +только здесь, при настройке. В рантайме — никогда. + +Repair-флоу (редкий, теоретический): +* Состояние ядра `key_error` (несовпадение `key_id`) → repair «Ключ устройства + изменён — перепровижинируйте». Повторный вход в облако по требованию + пользователя, обновление полей ConfigEntry. Никаких автоматических + перечитываний облака. + +**Диагностика**: Device-страница HA показывает `lanip_key`, `lanip_key_id`, +`dsn`, ip — чтобы пользователь мог скопировать их в конфиг ESPHome (см. +PLAN_ESPHOME). В redacted-дамп lanip_key маскируется, в полном — виден. + +## 4. Структура компонента + +``` +custom_components/fglair/ + manifest.json + config_flow.py # flow + options flow + repair + const.py + fglair_client.py # FglairClient: владеет FglSession (фоновый поток) + coordinator.py # push-driven coordinator (polling-интервал только watchdog) + climate.py # ClimateEntity + sensor.py # комнатная температура, error_code, op_status-флаги + switch.py # economy, powerful, coil_dry, min_heat, outdoor_low_noise, + # human_det_auto_save, wifi_led, indoor_fan_control + select.py # af_vertical/horizontal_direction (если N>1), demand_control + binary_sensor.py # connectivity + diagnostics.py + translations/{en,ru,...}.json +``` + +## 5. Маппинг сущностей (шаблон A; B/F — по capabilities) + +**Climate**: +* HVAC: `OFF`↔`operation_mode=0`; `COOL/HEAT/DRY/FAN_ONLY/AUTO`↔3/6/4/5/2; + `turn_on` → `operation_mode=1`. +* `target_temperature` ↔ `adjust_temperature` (×0.1 °C, шаг 0.5 °C); +* `current_temperature` ← `display_temperature` ((v−5000)/100); +* `fan_mode` ↔ `fan_speed` (quiet/low/medium/high/auto); +* `swing_mode` ↔ `af_vertical_swing`+`af_horizontal_swing` (off/vertical/ + horizontal/both); +* presets: `ECO`→economy_mode, `BOOST`→powerful_mode (взаимоисключающие); +* capabilities из `device_capabilities` скрывают недоступные режимы. + +**Sensors**: room temp, error_code (с текстами), op_status (флаги defrost / +oil recovery / pump down / maintenance / check operation / запреты). +**Binary sensor**: connectivity (ONLINE). **Switch/Select** — по списку §4. + +Записи идут batch'ем на действие пользователя (одно `batch_commit()` на вызов +`set_temperature`/`set_hvac_mode`/…) — ядро само склеит в один local_reg notify. + +## 6. Runtime-модель + +* Один `FglairClient` на `ConfigEntry`: фоновый daemon-thread с сессией ядра; + колбэки → `asyncio` → coordinator push-update. +* Состояния ядра транслируются: `online` → entities available; `recovering` — + доступны (последние значения), помечено diagnostic-сенсором; `offline` → + unavailable; `key_error` → unavailable + repair. +* Keep-alive/rotация ключей/переподключение — полностью в ядре (15 с; + самолечение десинхрона ≤15 с — PROTOCOL.md §4.4). +* Выгрузка: `stop()` (delete_session, освобождает слот на модуле). + +## 7. Требования к надёжности (acceptance) + +1. 24 ч непрерывной работы: 0 рассинхронов; в логе ядра — штатные re-key + каждые 45–60 с; сущности обновляются < 1 с после изменений с пульта. +2. Параллельная работа с приложением на телефоне: обе сессии живут (2 слота); + без взаимных сбоев. +3. Вкл/выкл питания модуля: восстановление ≤ 120 с (backoff). +4. Burst 10 изменений уставки из UI: ≤ 2 local_reg notify. +5. `key_error` (симуляция смены lanip_key_id): repair отрабатывает. +6. Перезапуск HA: корректный delete_session, повторная регистрация. + +## 8. Тесты + +* `pytest` + mock `pyfglair` (fake session, scripted callbacks): flow, entities, + repair, unload. +* Интеграционный тест с mock-модулем (`test/integration/mock_ac.py` ядра) через + настоящий wheel: полный цикл в CI. +* Ручной чек-лист на реальном приборе (совпадает с M5 ядра). + +## 9. Этапы + +| # | Содержимое | +|---|-----------| +| H1 | wheel `pyfglair`: сборка, cffi-bindings, обёртка Session, CLI discover/monitor | +| H2 | компонент: manifest, config_flow (облако + ручной + импорт), client/coordinator, climate | +| H3 | sensor/switch/select/binary_sensor, capabilities, translations, диагностика с lanip_key | +| H4 | repair-флоу, unload, тесты, публикация в HACS (custom) | diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..1959ecb --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,503 @@ +# FGLair / Ayla LAN-протокол — спецификация + +Документ реконструирован по декомпилированному APK FGLair 3.4.3 (`com.fujitsu.fglair`, +SDK `com.aylanetworks.aylasdk`, см. `AylaLanModule.java`, `AylaEncryption.java`, +`AylaLanMessage.java`, `CreateDatapointCommand.java`, `AylaHttpServer.java`, +`com.cafbit.netlib.dns.NetThread`) и сверён с существующим скриптом `legacy/`. +Всё, что помечено **[APK]**, подтверждено кодом приложения; **[LEGACY]** — известно +только из скрипта; **[ПРОВЕРЕНО НА ПРИБОРЕ]** — проверено живыми экспериментами +на AP-WC1E (сентябрь 2026, см. §10); **[HYP]** — правдоподобная гипотеза, +требует проверки на приборе. + +## 1. Обзор + +Кондиционер (модуль Wi-Fi, далее «модуль») работает с облаком Ayla +(`ads-eu.aylanetworks.com` для EU). Приложение FGLair дополнительно умеет работать +с модулем напрямую в локальной сети («LAN mode»), не выходя в облако. + +Протокол — HTTP/1.1 JSON поверх TCP, где **модуль сам инициирует почти всё общение**: + +``` + (1) local_reg (POST/PUT) (2) key_exchange (POST) + Приложение ------------------------------> Модуль (порт 80) + (HTTP-сервер <------------------------------- ... + :10275) 202 Accepted (3) commands.json (GET) ------> + <------------------------------- (4) datapoint.json (POST) ----> + (5) datapoint/ack.json (POST)-> +``` + +Роли: +* **Приложение** (наш будущий код) — HTTP-сервер на порту **10275** (fallback: + любой свободный, номер сообщается модулю в `local_reg`) и HTTP-клиент для + `local_reg`. +* **Модуль** — HTTP-сервер на порту **80** и HTTP-клиент для запросов (3)–(5) + к приложению. + +Модуль поддерживает **до 2 одновременных LAN-сессий** [ПРОВЕРЕНО НА ПРИБОРЕ] — +например, телефон с приложением + сервер умного дома; третья регистрация +отклоняется (HTTP 503). + +## 2. Обнаружение устройства + +1. **Облако**: `GET https://ads-eu.aylanetworks.com/apiv1/devices.json` содержит + `lan_ip` каждого устройства. Плюс `GET /apiv1/dsns//lan.json` отдаёт + `{ "lanip": { "lanip_key": ..., "lanip_key_id": ..., "keepAlive": ..., "autoSync": ... } }`. **[APK]** +2. **mDNS**: приложение опрашивает A-запись `.local` (например + `AC000W002879281.local`), отправляя DNS-query на `224.0.0.251:5353` **и на + `224.0.0.251:10276`** (нестандартный порт Ayla). **[ПРОВЕРЕНО НА ПРИБОРЕ: + модуль отвечает ТОЛЬКО на :10276, на :5353 — нет.** A-ответ, TTL 10, + имя `AC000W002879281.local` → IP модуля. Проба: `tools/probe_mdns.py`.] +3. **Кэш** приложения хранит последние lan_ip/lanip_key. **[APK]** + +Для библиотеки минимумом является статическая конфигурация вида `config_kata.json` +(ip, lanip_key, lanip_key_id, dsn); mDNS — опциональное улучшение +(запрос только на порт 10276). + +## 3. Шифрование + +### 3.1. Обмен ключами + +Модуль отправляет на сервер приложения: + +``` +POST /local_lan/key_exchange.json +{"key_exchange":{"ver":1,"proto":1,"key_id":62888,"random_1":"<16 алфанум. симв.>","time_1":,"sec":""}} +``` + +* `ver` и `proto` обязаны быть `1` (AES-256-CBC + HMAC-SHA256). Иначе — **426 Upgrade Required**. **[APK]** +* `sec` непустой только для RSA-режима первичной настройки (setup); в LAN-режее + должен отсутствовать/быть пустым. **[APK]** +* `key_id` — номер `lanip_key`, полученного из облака (`lan.json`). Если не совпал + с локальным — **412 Precondition Failed** + приложение обязано перечитать + `lan.json` из облака (`refreshLanConfig`) и разрешить LAN заново. **[APK]** + +Приложение отвечает (HTTP 200): + +``` +{"random_2":"<16 алфанум. симв.>","time_2":} +``` + +`time_2` в Java — `System.nanoTime()`; значения time НЕ синхронизируются и не +проверяются — это просто материал для KDF. **[APK]** + +### 3.2. Вывод сессионных ключей (KDF) + +Обозначим: `K = lanip_key.encode('utf-8')` (строка base64 как есть, НЕ декодированная), +`R1, R2, T1, T2` — utf-8 байты `random_1, random_2, str(time_1), str(time_2)`. + +``` +msg_app = R1 | R2 | T1 | T2 | X # X — один байт: 0x30, 0x31 или 0x32 +msg_dev = R2 | R1 | T2 | T1 | X # те же варианты X + +key = HMAC_SHA256(K, HMAC_SHA256(K, msg) || msg) # 32 байта +``` + +* X=0x30 → `sign_key` (ключ HMAC для подписи сообщений) +* X=0x31 → `crypto_key` (ключ AES-256) +* X=0x32 → `iv_seed` = первые **16 байт** результата (начальный IV) + +Направления: +* **app-ключи** (приложение шифрует/подписывает, модуль проверяет) — из `msg_app`; +* **dev-ключи** (модуль шифрует, приложение проверяет) — из `msg_dev`. + +Совпадает с `legacy/config.py`. **[APK: AylaEncryption.generateSessionKeys]** + +### 3.3. Формат защищённого сообщения (envelope) + +Все сообщения после key exchange в обе стороны — JSON: + +``` +{"enc":"","sign":""} +``` + +Открытый текст: `{"seq_no":,"data":}`. + +* **AES-256-CBC без стандартизированного паддинга**. Паддинг нулями до кратности + 16 байт, причём Java-реализация добавляет **как минимум один нулевой байт** + (C-string терминатор: `len+1`, затем до кратности 16). При чтении — обрезаются + все завершающие нулевые байты. Реализация должна корректно принимать оба + варианта паддинга. **[APK: encryptEncapsulateSign / unencodeDecrypt]** +* `sign` — HMAC-SHA256 с `sign_key` соответствующего направления поверх **байт + открытого текста без паддинга** (включая `seq_no` и `data`). +* `seq_no` приложения — статический счётчик, инкрементируется на каждое исходящее + сообщение (никогда не сбрасывается, в т.ч. между сессиями). **[APK]** + `seq_no` модуля — свой счётчик; периодически сбрасывается в 0 **[LEGACY]**. + +### 3.4. КРИТИЧНО: цепочность CBC + +AES-CBC объект создаётся один раз на сессию, и **каждое следующее сообщение +продолжает цепочку CBC с того места, где закончилось предыдущее** (в Java — +повторные вызовы `Cipher.update`; в pycryptodome — повторные `encrypt`/`decrypt` +одного объекта). Начальный IV — только `iv_seed`. + +Следствия: +* Потеря или повреждение любого сообщения в канале (таймаут, обрыв соединения, + перезагрузка одной из сторон, отклонённое сообщение) **безвозвратно разводит + цепочки сторон** — все последующие сообщения не расшифровываются. +* Единственный механизм восстановления — новый key exchange (см. §6.3). +* Реализация обязана строго сериализовать все шифрования/расшифровки сессии. + +## 4. Канал управления (приложение → модуль) + +### 4.1. Регистрация / keep-alive: `local_reg.json` + +``` +POST http://<модуль>/local_reg.json?dsn= # первый раз (sessionType не активна) +PUT http://<модуль>/local_reg.json # далее, пока сессия жива +Content-Type: application/json + +{"local_reg":{"ip":"","port":10275,"uri":"/local_lan","notify":0|1}} +``` + +* `notify=1` — «у меня есть команды, забери». `notify=0` — просто keep-alive. **[APK]** +* Успешный ответ модуля — `202 Accepted` **[ПРОВЕРЕНО НА ПРИБОРЕ]**. +* **Слоты сессий: максимум 2 одновременные LAN-сессии** (на приборе: 3-я + регистрация получает `HTTP 503`) **[ПРОВЕРЕНО НА ПРИБОРЕ]**. Т.е. телефон + + сервер умного дома уживаются; третий клиент — нет. +* Формат тела/заголовков некритичен (проверены компактный/spaced JSON, с + полным набором заголовков и без) **[ПРОВЕРЕНО НА ПРИБОРЕ]**. +* Параметр `?dsn=` добавляется только пока сессия ещё не активна. **[APK]** +* Для setup-устройств добавляется поле `key` (RSA public key) — вне scope. **[APK]** + +### 4.2. Выборка команд: `commands.json` + +После `local_reg` (особенно с `notify=1`) модуль опрашивает: + +``` +GET http://:<порт>/local_lan/commands.json +``` + +Приложение возвращает **ровно одну** команду из очереди (из головы) в envelope: + +```json +{"seq_no":123,"data":{"properties":[{"property":{"base_type":"integer","name":"fan_speed","value":3,"id":"<8 симв.>","dsn":"","metadata":...}}]}} +``` + +или запрос свойства: + +```json +{"seq_no":124,"data":{"cmds":[{"cmd":{"cmd_id":5,"method":"GET","resource":"property.json?name=fan_speed","data":"","uri":"/local_lan/property/datapoint.json"}}]}} +``` + +или `{}` («пусто»): `{"seq_no":125,"data":{}}`. + +* HTTP-статус: **206 Partial Content**, если в очереди остались ещё команды; иначе **200 OK**. Модуль сам продолжает опрос при 206. **[APK: getResponseCode]** +* `cmd_id` — инкрементальный id GET-команд; ответ модуля на GET придёт в + `datapoint.json` с query-параметром `?cmd_id=5` (см. §5.1). **[APK]** +* `id` внутри property-команды — случайные 8 символов; нужен только если свойства + включён `ack_enabled` (для FGLair-свойств ack не используется); по нему + сопоставляется ack. **[APK: CreateDatapointCommand]** +* Удаление сессии — тоже команда: `{"cmds":[{"cmd":{"cmd_id":0,"method":"DELETE","resource":"local_reg.json","data":"delete_session","uri":"/local_lan"}}]}`. **[APK: DeleteSessionCommand]** + +### 4.3. Тайминги (по APK; уточнено на приборе) + +* Keep-alive: приложение отправляет `local_reg` каждые **10 с** по умолчанию; если + `lan.json` вернул `keepAlive` (секунды), интервал = `keepAlive / 3`. **[APK]** +* При постановке команд в очередь приложение шлёт `local_reg` с `notify=1` + **немедленно** — но один на пакет команд, не на каждую команду. **[APK: + AylaLocalNetwork.performRequest — registerCommands() + sendLocalRegistration()]** +* Каждый обработанный `commands.json` **перезапускает таймер keep-alive** + (`startKeepalive()` после выдачи команды) — во время активного опроса + дополнительный keep-alive не отправляется. **[APK]** +* Ожидание ответа GET-команды: `max(50 s, n * 1.5 s)` на пакет из n команд, без + ретраев. Ack-таймаут datapoint — 10 с (по умолчанию). **[APK]** +* Чтение свойств: в официальном приложении стартовые значения приходят из облака + или кэша; по LAN полный слепок можно получить пакетом из n GET-команд + (`fetchPropertiesLAN` — все имена одним пакетом, ответ придёт push'ами). + Далее приложение полагается на push-обновления (§5). Опрос конкретного + свойства — по необходимости. **[APK + JS]** + +### 4.4. Политика re-key и жизненный цикл сессии [ПРОВЕРЕНО НА ПРИБОРЕ] + +Ключевое эмпирическое поведение модуля (AP-WC1E, fw 2.6.17-fgl2): + +1. `local_reg` от endpoint'а **без** живой сессии → модуль отправляет key + exchange (если есть свободный слот), затем **сразу** (≈0.2–0.5 с) делает + один «пустой» опрос `commands.json` — это признак принятой сессии. +2. `local_reg` от endpoint'а с живой сессией **моложе ~40 с** → только + keep-alive, без key exchange. +3. `local_reg` от endpoint'а с сессией **старше ~44 с** → модуль принудительно + инициирует новый key exchange (ротация сессионных ключей). Т.е. при штатном + keep-alive каждые 10–15 с ключи ротируются примерно каждые 45–60 с. + `time_1` модуля — тикающий счётчик с шагом ≈10 нс (аптайм); порог, + вероятно, 44 с в этих единицах либо просто 4.4e9 тиков. +4. **Ответы 401/400 на POST модуля игнорируются**: сессия продолжает работать, + re-key не вызывается. Единственный механизм восстановления после расхождения + CBC-цепочек — принудительный re-key по `local_reg` (п. 3). Поэтому интервал + keep-alive = интервал потенциального «зависания» при десинхроне. +5. `delete_session` освобождает слот немедленно; следующий `local_reg` того же + endpoint'а создаёт новую сессию. +6. Наблюдавшийся (не воспроизведённый повторно) режим отказа: модуль отвечает + key exchange'ом, но не делает «пустой» опрос и не забирает команды; сессия + не активируется. Возникал после серий неудачных key exchange (возможно, + «застрявшие» слоты); проходил сам через ~10–20 минут покоя. При реализации: + детектировать отсутствие poll'а в течение N секунд после KE и уходить в + backoff, а не долбить повторными local_reg. +7. `seq_no` модуля инкрементируется на каждый push в рамках сессии + (0, 1, 2, …) и сбрасывается в 0 при каждом re-key. Проверять его на + монотонность **нельзя** (см. также §9.5). + +## 5. Канал телеметрии (модуль → приложение) + +### 5.1. Обновление свойства + +``` +POST http://:<порт>/local_lan/property/datapoint.json?cmd_id=N&status=200 +Content-Type: application/json +{"enc":"...","sign":"..."} +``` + +* Query-параметры **[ПРОВЕРЕНО НА ПРИБОРЕ]**: ответ на GET-команду приходит с + `?cmd_id=N&status=200` (статус применения команды; `cmd_id` соответствует + id запроса). Спонтанные обновления — без параметров. +* Открытый текст `data`: + +```json +{"name":"operation_mode","value":3,"metadata":{...},"dsn":"","dev_time_ms":0} +``` + +* `metadata`, `dsn` (для узловых устройств), `dev_time_ms` — опциональны. **[APK]** +* Ответ приложения: 200/206 с пустым телом. Java при ошибке расшифровки отвечает + **401 Unauthorized**; **на приборе доказано, что модуль игнорирует и 401, и 400** + (сессия продолжает работать) — это НЕ механизм восстановления, см. §4.4. +* Варианты путей: `/local_lan/node/property/datapoint.json` — то же для узлов + (гейтвей), вне scope. **[APK]** + +### 5.2. Ack на datapoint + +``` +POST .../local_lan/property/datapoint/ack.json +data: {"id":"","ack_status":200,"ack_message":0,"dsn":"..."} +``` + +`ack_status != 200` — ошибка применения. Только для свойств с `ack_enabled`. +Для проверенных свойств FGLair (wifi_led_enable, get_prop) ack не приходит. +**[частично ПРОВЕРЕНО НА ПРИБОРЕ]** + +### 5.3. Эхо на записи НЕТ [ПРОВЕРЕНО НА ПРИБОРЕ] + +Запись свойства (`properties`-команда, §4.2) забирается модулем и применяется, +но **не эхируется** в LAN: ни datapoint-push с новым значением, ни ack. +(Именно поэтому legacy-скрипт обновляет состояние оптимистично в момент выдачи +команды.) Если нужно подтверждение — запросить свойство GET-командой через +короткую задержку. Спонтанные push'и приходят только на изменения, инициированные +самим прибором/пультом (и, вероятно, для свойств с побочными эффектами). + +### 5.3. Прочие callback-пути (для полноты) + +* `/local_lan/node/conn_status.json` — статус узлов гейтвея. +* `/local_lan/status.json`, `/local_lan/connect_status`, `/local_lan/wifi_scan*.json`, + `/local_lan/regtoken.json`, `/local_lan/wifi_stop_ap.json` — режим setup (не нужны + в рабочей сессии). + +## 6. Сессия + +### 6.1. Установка [ПРОВЕРЕНО НА ПРИБОРЕ] + +``` +приложение: POST local_reg.json (notify=0|1) # регистрирует свой ip:port (202) +модуль: POST /local_lan/key_exchange.json # генерирует сессионные ключи +приложение: 200 {"random_2","time_2"} +модуль: GET /local_lan/commands.json # «пустой» опрос сразу (≈0.5с) +приложение: (пакет GET-команд) + local_reg notify=1 # начальная синхронизация +модуль: GET commands.json (цикл по 206) + POST datapoint.json × n +...далее: push-обновления свойств + опрос commands.json после notify=1 +``` + +### 6.2. Поддержание + +Приложение шлёт `local_reg` каждые 10–15 с (APK: 10 с по умолчанию / +`keepAlive/3` из lan.json). **Сессия живёт, пока приходят local_reg**; при +возрасте сессии ≥ ~44 с очередной local_reg вызывает принудительный re-key +(ротацию ключей) — это штатный режим (§4.4). При пропадании модуля (сеть/питание) +— повторные попытки с backoff, mDNS-переобнаружение. + +### 6.3. Разрыв и восстановление [ПРОВЕРЕНО НА ПРИБОРЕ] + +* **Потеря CBC-цепочки** (§3.4): модуль не может расшифровать ответ приложения / + приложение не может расшифровать push модуля. Ответы 401/400 на POST модуля + **игнорируются** — модуль продолжает слать в «сломанный» канал. Восстановление + происходит только когда очередной `local_reg` (по возрасту ≥ ~44 с или от + нового endpoint'а) вызовет новый key exchange. Следствие: **интервал + keep-alive = максимальное время «мёртвой» сессии при десинхроне** + (10–15 с — незаметно; 1200 с как в legacy-скрипте — 20 минут глухоты). +* **Смена lanip_key** (`key_id` не совпал): теоретический путь по APK — 412 + + `refreshLanConfig()` из облака. За 5 лет эксплуатации прибора ротации ключа + не наблюдалось ни разу; ключ, по-видимому, зашит в модуль, облако лишь хранит + его копию. Реализация: 412 + переход в устойчивое состояние ошибки до + перепровижининга вручную (см. планы). +* Явное завершение: команда DELETE `local_reg.json`/`delete_session` (§4.2) — + освобождает слот немедленно. **Рекомендуется слать при штатном выключении**, + чтобы не занимать один из 2 слотов модуля. + +## 7. Облачная часть (для provisioning/discovery) + +Серверы (Field) **[APK: ServiceUrls.java]**: + +| Регион | User-сервис | Device-сервис | +|--------|-------------|---------------| +| EU | `user-field-eu.aylanetworks.com` | `ads-eu.aylanetworks.com` | +| US | `user-field.aylanetworks.com` | `ads-field.aylanetworks.com` | +| CN | `user-field.ayla.com.cn` | `ads-field.ayla.com.cn` | + +Аутентификация приложения **[APK: AylaNetworkWrapper.java]**: + +* EU: `app_id=FGLair-eu-id`, `app_secret=FGLair-eu-gpFbVBRoiJ8E3QWJ-QRULLL3j3U` +* US: `app_id=CJIOSP-id`, `app_secret=CJIOSP-Vb8MQL_lFiYQ7DKjN0eCFXznKZE` +* CN: `app_id=FGLairField-cn-id`, `app_secret=FGLairField-cn-zezg7Y60YpAvy3HPwxvWLnd4Oh4` + +`app_secret` = `-`; байты секрета — в +`legacy/app_mappings.py` (проверено: EU совпадает). + +Endpoints: + +``` +POST https:///users/sign_in.json +{"user":{"email":"...","password":"...","application":{"app_id":"...","app_secret":"..."}}} +→ {"access_token":"...", ...} + +GET https:///apiv1/devices.json (Authorization: auth_token ) +GET https:///apiv1/dsns//lan.json → {"lanip":{lanip_key, lanip_key_id, keepAlive,...}} +GET https:///apiv1/dsns//properties.json[?names[]=..&..] → описание свойств +``` + +`properties.json` для FGLair-устройств возвращает объекты Ayla-свойств: +`name, base_type, read_only, ack_enabled, direction, display_name, ...` +(см. `AylaProperty.java`). Для LAN-only работы библиотека может хранить таблицу +свойств статически (§8). + +## 8. Модель свойств FGLair + +### 8.1. Типы устройств (oem_model → шаблон) [APK: FGLDeviceTemplateType.java + JS template_types] + +| Шаблон | Модели | +|--------|--------| +| A | AP-WA1E…WA6E, AP-WC1E…WC4E, AP-WD1E | +| B | AP-WB1E…WB4E | +| F | AP-WF1E…WF4E | + +`config_kata.json` → `AP-WC1E` → **шаблон A**. + +### 8.2. Список свойств по шаблонам + +**A**: operation_mode, fan_speed, adjust_temperature, af_vertical_direction, +af_vertical_swing, af_horizontal_direction, af_horizontal_swing, +outdoor_low_noise, indoor_fan_control, human_det_auto_save, min_heat, +powerful_mode, coil_dry_mode, economy_mode, master_timer_on_off_1, +master_timer_on_off_2, error_code, demand_control, filter_sign_reset_display, +op_status, device_name, building_name, wifi_led_enable, service_contact_name, +service_contact_phone, service_contact_email, af_horizontal_num_dir, +af_vertical_num_dir, device_capabilities, display_temperature, get_prop, +human_det, refresh. + +**B**: operation_mode, fan_speed, adjust_temperature, af_vertical_move_step1, +af_horizontal_move_step1, economy_mode, master_timer_on_off_1/2, error_code, +demand_control, filter_sign_reset_display, op_status, device_name, +building_name, wifi_led_enable, service_contact_*, device_capabilities, refresh. + +**F**: как A + monitor1, filter_sign_reset (вместо filter_sign_reset_display). + +### 8.3. Семантика значений (шаблон A; подтверждено JS-бандлом приложения) + +| Свойство | Тип | Значения | +|----------|-----|----------| +| `operation_mode` | int | 0=OFF, 1=ON, 2=AUTO, 3=COOL, 4=DRY, 5=FAN, 6=HEAT. Вкл/выкл питания — запись 0/1. | +| `fan_speed` | int | 0=Quiet, 1=Low, 2=Medium, 3=High, 4=Auto | +| `adjust_temperature` | int | Уставка, единица 0.1 °C (250 = 25.0). Диапазон таблицы приложения: −10.0…45.0 (−100…450); фактически прибор ограничен 16…30 [LEGACY]. Шаг UI: 0.5 °C (шаблон B — 1.0 °C). | +| `display_temperature` | int, ro | Температура в помещении, единица 0.01 °C, смещение 5000 (5000 = 50.00 °C), шаг 25. Приближение: `T = (v − 5000)/100`. Приложение использует таблицу соответствия display↔adjust (221 строка, −10…45 °C). | +| `af_vertical_direction`, `af_horizontal_direction` | int | Положение заслонки 0…N−1, где N = `af_vertical_num_dir` / `af_horizontal_num_dir` (если N>15 → поддерживается только 0). | +| `af_vertical_swing`, `af_horizontal_swing` | int | 0=выкл, 1=вкл | +| `economy_mode`, `powerful_mode`, `coil_dry_mode`, `min_heat`, `outdoor_low_noise`, `human_det_auto_save`, `wifi_led_enable`, `indoor_fan_control` | bool(int) | 0/1 | +| `op_status` | int, ro | Битовая маска, см. §8.4 | +| `device_capabilities` | int, ro | Битовая маска, см. §8.5 | +| `error_code` | int, ro | Код ошибки (0 — нет; таблица приложения до 4095) | +| `demand_control` | int | Деманд-контроль (ограничение мощности) | +| `get_prop` | int | Триггер: запись 1 → прибор обновит `display_temperature` (свойство вернётся в 0) | +| `refresh` | int | Триггер полной синхронизации (приложение пишет через облако, value "1") | +| `master_timer_on_off_1/2` | int | Таймеры вкл/выкл (2 шт.) | +| `filter_sign_reset_display` | int | Сброс индикации замены фильтра | + +### 8.4. `op_status` — биты + +| Бит | Значение | +|-----|----------| +| 0–18 | Запреты (центральное управление): 0 все операции, 1 таймер, 2 уставка температуры, 3 режим, 4 старт/стоп, 5 старт, 6 сброс фильтра, 7 работа, 8 температура, 9 auto, 10 cool, 11 dry, 12 heat, 13 fan, 14 level1-работа, 15 level1-старт/стоп, 16 level2-работа, 17 level2-таймер, 18 level2-локальные настройки | +| 21 | (только B) разморозка/масло/разные режимы | +| 22 | обслуживание (maintenance) | +| 24 | разморозка (defrost) | +| 25 | «разные режимы» (одновременные операции) | +| 28 | oil recovery | +| 29 | pump down | +| 30 | check operation | + +### 8.5. `device_capabilities` — биты + +| Бит | Возможность | +|-----|-------------| +| 0 | cool | +| 1 | dry | +| 2 | fan | +| 3 | heat | +| 4 | auto | +| 5 | fan auto | +| 6 | fan high | +| 7 | fan medium | +| 8 | fan low | +| 9 | fan quiet | +| 10 | вертикальный swing | +| 11 | горизонтальный swing | +| 12 | economy | +| 13 | minimum heat | +| 14 | energy swing fan (indoor_fan_control) | +| 16 | powerful | +| 17 | outdoor low noise | +| 18 | coil dry | + +### 8.6. Особые последовательности приложения (для справки) + +* Включение питания = запись `operation_mode = 1`, выключение = `operation_mode = 0` + (сохранённый режим восстанавливается прибором сам). +* min_heat ON → прибор сам меняет режим на heat и уставку 24 °C; приложение + дополнительно поллит `operation_mode`/`min_heat`/`adjust_temperature` до стабилизации. +* Изменение `af_*_direction` при включённом swing → ждёт обновления swing. +* После команд с побочными эффектами приложение поллит 1 свойство с интервалом + 1 с, таймаут 30–600 с (облако); в LAN-режиме поллинг не нужен — приходит push. + +## 9. Ограничения и наблюдения для реализации + +1. **Модуль чувствителен к частоте запросов**: официальное приложение отправляет + `local_reg` ≤ 1/10 с и всегда «пакетом», никогда — по одному на команду. Спам + `local_reg`/большими пачками команд перегружает модуль до отвала Wi-Fi + (подтверждено опытом legacy-скрипта, см. `LEGACY_ANALYSIS.md`). +2. Ответ модуля на `local_reg`: 202 (успех), **503 — нет свободных слотов** + (2 сессии), при недоступности — таймаут/отказ соединения. +3. `commands.json` возвращает одну команду за запрос; батч реализуется цепочкой + 206-ответов. Не следует отдавать несколько команд в одном ответе — формат + это формально позволяет (`cmds`/`properties` — массивы), но приложение так не + делает; модуль, вероятно, применяет только первую [HYP]. +4. Zero-padding без NUL работает (на приборе), но для совместимости лучше + повторять Java-вариант (всегда ≥ 1 нулевой байт). +5. seq_no модуля сбрасывается при каждом re-key и растёт внутри сессии — не + отбрасывайте «устаревшие» обновления из-за seq_no (в Java seq_no входящих + вообще не проверяется; «прилипший» фильтр по seq_no в legacy-скрипте — + источник потерянных обновлений, см. LEGACY_ANALYSIS §2.4). +6. Записи не эхируются — обновляйте локальное состояние оптимистично и/или + подтверждайте GET-ом (§5.3). +7. Максимальное окно «глухоты» при десинхроне = интервал keep-alive (§6.3). + +## 10. Проверено на приборе / осталось неизвестным + +Проверено на AP-WC1E (fw 2.6.17-fgl2, ключ из `config_kata.json`), см. также §2, +§4.1, §4.4, §5.1, §5.3, §6: полный цикл сессии, KDF/CBC-цепочка/подписи в обе +стороны, GET/запись свойств, re-key, 401/400-игнорирование, 2 слота + 503, +delete_session, mDNS :10276. Рабочий эталонный клиент: `tools/probe_reference.py`. + +Осталось неизвестным / требует проверки: +* Точная семантика `status=` в query ответов на GET-команды (видели только 200). +* Таймаут фактического освобождения слота при пропадении приложения без + delete_session (ориентировочно ≤ 60–120 с; re-key-порог 44 с измерен точно). +* Реакция модуля на несколько команд в одном `commands.json`-ответе. +* Причина редкого режима «KE без активации сессии» (§4.4 п.6) — воспроизводится + только после серий неудачных попыток. +* Ровно ли 44 с порог re-key (измерено в границах 39–44 с; принято «≈44 с», + возможно 4.4e9 тиков внутреннего счётчика). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..33eaba1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,66 @@ +# aircon / FGLair local control — документация + +Реконструкция LAN-протокола FGLair (Fujitsu General, платформа Ayla) и планы +реализации стека локального управления кондиционером. + +## Состав + +| Файл | Назначение | +|------|-----------| +| `PROTOCOL.md` | Спецификация LAN-протокола: шифрование, эндпоинты, машина состояний, тайминги, свойства FGLair. Для людей и агентов. Факты помечены `[APK]` / `[LEGACY]` / `[ПРОВЕРЕНО НА ПРИБОРЕ]` / `[HYP]`. | +| `LEGACY_ANALYSIS.md` | Разбор legacy-скрипта: что верно, баги, причины «рассинхронизации ключей» и перегрузки модуля. | +| `PLAN_CORE_LIBRARY.md` | План C++20-библиотеки `fglair-core` (Linux + ESP-IDF). | +| `PLAN_HOME_ASSISTANT.md` | План HA-интеграции (`pyfglair` wheel + custom component). | +| `PLAN_ESPHOME.md` | План external component для ESPHome (только ESP-IDF framework). | +| `../tools/probe_reference.py` | Эталонный клиент протокола (проверен на приборе). | +| `../tools/probe_mdns.py` | mDNS-проба (`.local`, порт 10276). | + +## Краткая выжимка протокола + +* Модуль кондиционера (порт 80) сам подключается к серверу приложения (порт 10275): + `local_reg.json` (keep-alive/notify) → `key_exchange.json` → poll + `commands.json` + push `property/datapoint.json`. +* Шифрование: AES-256-CBC (no-padding, zero-pad) + HMAC-SHA256; ключи выводятся + из облачного `lanip_key` и двух пар (random, time). **CBC-цепочка непрерывна + в рамках сессии**. +* **Ключевая механика надёжности** (проверено на приборе): модуль игнорирует + 400/401-ответы; единственное самолечение — принудительный re-key, который + модуль делает при получении `local_reg` для сессии старше ≈44 с. Поэтому + keep-alive должен быть 10–15 с — тогда любая рассинхронизация живет секунды, + а не 20 минут (как в legacy-скрипте с интервалом 1200 с). +* Максимум 2 LAN-сессии (телефон + сервер уживаются), третья — HTTP 503. +* Записи свойств не эхируются — состояние обновляется оптимистично, + подтверждение через GET. +* Свойства FGLair (шаблоны A/B/F по oem_model): `operation_mode` (0..6), + `fan_speed` (0..4), `adjust_temperature` (×0.1 °C), `display_temperature` + ((v−5000)/100 °C), swing/заслонки, флаги economy/powerful/…, битмаски + `op_status`, `device_capabilities`. Полные таблицы — в PROTOCOL.md §8. + +## Ключевые решения (по уточнениям владельца) + +* `lanip_key` **статичен** (зашит в модуль; за 5 лет ротаций не было). + Облако используется только для первового provisioning'а (HA config flow или + CLI `fglair-discover`); при несовпадении `key_id` — устойчивая ошибка, + лечение правкой конфига вручную. Для ESPHome ключ копируется из диагностики + HA или получается CLI-той. +* ESP-IDF везде (Arduino-фреймворк ESPHome не поддерживаем), язык ядра — C++20 + (без исключений/RTTI/heap после init), public API — C++-классы + extern "C" + шейм для cffi-bindings HA. + +## Порядок реализации + +1. `fglair-core` (M0–M5) — ядро, mock-тесты, эталон уже проверен на приборе. +2. `pyfglair` + HA-интеграция (H1–H4) — параллельно с E1–E2. +3. ESPHome-компонент (E1–E4). +4. Уточнение оставшихся неизвестных (PROTOCOL.md §10) по мере эксплуатации. + +## Источники + +* APK FGLair 3.4.3 (`apk/com.fujitsu.fglair.apk`): классы + `com.aylanetworks.aylasdk.lan.*`, `com.fujitsugeneral.aylasdk.*`, + `com.cafbit.netlib.dns.NetThread`, JS-бандл `assets/www/dist/build.js`. +* Legacy-скрипт (`legacy/`) — форк hisense_ac; вложенный клон апстрима: + https://github.com/gyro-labs/AirCon (лежит в `legacy/aircon/`, не версионируется). +* Живые эксперименты на AP-WC1E (сентябрь 2026): сессии, re-key, 401/400, + слоты/503, delete_session, записи, mDNS. Пробы: `tools/probe_*.py` + (история — сессия анализа; рабочие артефакты оставлены в tools/). diff --git a/legacy/__init__.py b/legacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legacy/aircon.py b/legacy/aircon.py new file mode 100644 index 0000000..f50ce16 --- /dev/null +++ b/legacy/aircon.py @@ -0,0 +1,583 @@ +from copy import deepcopy +from dataclasses import dataclass, field, fields +import enum +import logging +import random +import re +import string +import threading +import time +from typing import Any, Callable, Dict, List +import queue +from Crypto.Cipher import AES + +from . import control_value +from .config import Config, Encryption +from .error import Error +from .properties import (AcProperties, AirFlow, AirFlowState, Economy, FanSpeed, FastColdHeat, + FglProperties, FglBProperties, HumidifierProperties, Properties, Power, + AcWorkMode, Quiet, TemperatureUnit, SleepMode) + + +@dataclass(order=True) +class Command: + priority: int + timestamp: int # Aligns equal priority commands in FIFO. + command: Dict = field(compare=False) + updater: Callable = field(compare=False) + + +class Device(object): + + _FGL_DEVICES = re.compile(r'AP-W[ACDF]\dE') + _FGLB_DEVICES = re.compile(r'AP-WB\dE') + _HUMI_DEVICES = re.compile(r'0001-0401-000[12]') + + def __init__(self, config: Dict[str, str], properties: Properties, notifier: Callable[[None], + None]): + self.name = config['name'] + self.app = config['app'] + self.model = config['model'] + self.sw_version = config['sw_version'] + self.mac_address = config['mac_address'] + self.ip_address = config['ip_address'] + self.temp_type = (TemperatureUnit.CELSIUS + if config.get('temp_type') == 'C' else TemperatureUnit.FAHRENHEIT) + self._config = Config(config['lanip_key'], config['lanip_key_id']) + self._properties = properties + self._properties_lock = threading.RLock() + self._queue_listener = notifier + self._available = None + self.topics = {} + self.work_modes = [] + self.fan_modes = [] + + self._next_command_id = 0 + + self.commands_queue = queue.PriorityQueue() + self._commands_seq_no = 0 + self._commands_seq_no_lock = threading.Lock() + + self._updates_seq_no = 0 + self._updates_seq_no_lock = threading.Lock() + + self._property_change_listeners = [] # type List[Callable[[str, Any], None]] + + @classmethod + def create(cls, config: Dict[str, str], notifier: Callable[[None], None]): + model = config['model'] + if cls._FGL_DEVICES.fullmatch(model): + return FglDevice(config, notifier) + if cls._FGLB_DEVICES.fullmatch(model): + return FglBDevice(config, notifier) + if cls._HUMI_DEVICES.fullmatch(model): + return HumidifierDevice(config, notifier) + return AcDevice(config, notifier) + + @property + def is_fahrenheit(self) -> bool: + return self.temp_type == TemperatureUnit.FAHRENHEIT + + @property + def available(self) -> bool: + # Return False if was not set yet. + return self._available or False + + @available.setter + def available(self, value: bool): + if self._available != value: + self._available = value + self._notify_listeners('available', 'online' if value else 'offline', retain=True) + + def add_property_change_listener(self, listener: Callable[[str, Any], None]): + self._property_change_listeners.append(listener) + + def remove_property_change_listener(self, listener: Callable[[str, Any], None]): + self._property_change_listeners.remove(listener) + + def _notify_listeners(self, prop_name: str, value, retain: bool = False): + for listener in self._property_change_listeners: + listener(self.mac_address, prop_name, value, retain) + + def get_all_properties(self) -> Properties: + with self._properties_lock: + return deepcopy(self._properties) + + def get_property(self, name: str): + """Get a stored property (or None if doesn't exist).""" + with self._properties_lock: + return getattr(self._properties, name, None) + + def get_property_type(self, name: str): + return self._properties.get_type(name) + + def parse_property(self, name: str, value): + return self._properties.parse_attr(name, value) + + def update_property(self, name: str, value, notify_value=None) -> None: + """Update the stored properties, if changed.""" + # Update value precision for value sent from the A/C + if name == "adjust_temperature": + value = round(value * 0.1) + else: + precision = self._properties.get_update_precision(name) + if precision != 1: + value = round(value * precision) + + if notify_value is None: + notify_value = value + + with self._properties_lock: + old_value = getattr(self._properties, name) + logging.debug(f"Updating {self}.{name} to {value}") + if value != old_value: + setattr(self._properties, name, value) + # logging.debug('Updated properties: %s' % self._properties) + if name == 't_control_value': + self._update_controlled_properties(value) + logging.debug(f"Updated {self}.{name} to {getattr(self._properties, name)}") + self._notify_listeners(name, notify_value) + + def _update_controlled_properties(self, control: int): + raise NotImplementedError() + + def get_command_seq_no(self) -> int: + with self._commands_seq_no_lock: + seq_no = self._commands_seq_no + self._commands_seq_no += 1 + return seq_no + + def is_update_valid(self, cur_update_no: int) -> bool: + with self._updates_seq_no_lock: + # Every once in a while the sequence number is zeroed out, so accept it. + if self._updates_seq_no > cur_update_no and cur_update_no > 0: + logging.error('Stale update found %d. Last update used is %d.', cur_update_no, + self._updates_seq_no) + return False # Old update + self._updates_seq_no = cur_update_no + return True + + def queue_command(self, name: str, value) -> None: + if self._properties.get_read_only(name): + raise Error('Cannot update read-only property "{}".'.format(name)) + data_type = self._properties.get_type(name) + + # Device mode is set using t_control_value + if issubclass(data_type, enum.Enum): + data_value = data_type[value] + elif data_type is int and type(value) is str and '.' in value: + # Round rather than fail if the input is a float. + # This is commonly the case for temperatures converted by HA from Celsius. + data_value = round(float(value)) + else: + data_value = data_type(value) + + # If device has set t_control_value it is being controlled by this field. + if name != 't_control_value' and self.get_property('t_control_value') and name != 't_sleep': + self._convert_to_control_value(name, data_value) + return + + # Update value precision for value to be sent to the A/C + precision = self._properties.get_precision(name) + if name == 'adjust_temperature': + data_value = data_value * 10 + elif precision != 1: + data_value = round(data_value / precision) + + typed_value = data_value + if issubclass(data_type, enum.Enum): + data_value = data_value.value + typed_value = data_type[value] + + command = self._build_command(name, data_value) + # There are (usually) no acks on commands, so also queue an update to the + # property, to be run once the command is sent. + property_updater = lambda: self.update_property(name, typed_value) + # Add as a high priority command. + self.commands_queue.put_nowait(Command(10, time.time_ns(), command, property_updater)) + + self._queue_listener() + + def _build_command(self, name: str, data_value: int): + base_type = self._properties.get_base_type(name) + return { + 'properties': [{ + 'property': { + 'base_type': base_type, + 'name': name, + 'value': data_value, + 'id': ''.join(random.choices(string.ascii_letters + string.digits, k=8)), + } + }] + } + + def _convert_to_control_value(self, name: str, value) -> int: + raise NotImplementedError() + + def queue_status(self) -> None: + for data_field in fields(self._properties): + command = { + 'cmds': [{ + 'cmd': { + 'method': 'GET', + 'resource': 'property.json?name=' + data_field.name, + 'uri': '/local_lan/property/datapoint.json', + 'data': '', + 'cmd_id': self._next_command_id, + } + }] + } + self._next_command_id += 1 + # Add as a lower-priority command. + self.commands_queue.put_nowait(Command(100, time.time_ns(), command, None)) + self._queue_listener() + + def update_key(self, key: dict) -> dict: + return self._config.update(key) + + def get_app_encryption(self) -> Encryption: + return self._config.app + + def get_dev_encryption(self) -> Encryption: + return self._config.dev + + +class AcDevice(Device): + + def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]): + super().__init__(config, AcProperties(), notifier) + self.topics = { + 'env_temp': 'f_temp_in', + 'fan_speed': 't_fan_speed', + 'work_mode': 't_work_mode', + 'power': 't_power', + 'swing_mode': 't_fan_power', + 'temp': 't_temp' + } + self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto'] + self.fan_modes = ['auto', 'lower', 'low', 'medium', 'high', 'higher'] + + # @override to add special support for t_power. + def update_property(self, name: str, value) -> None: + with self._properties_lock: + # HomeAssistant expects an 'off' work mode when the AC is off. + notify_value = 'off' if name == 't_work_mode' and self.get_power() == Power.OFF else None + super().update_property(name, value, notify_value) + # HomeAssistant doesn't listen to changes in t_power, so notify also on a t_work_mode change. + if name == 't_power': + work_mode = 'off' if value == Power.OFF else self.get_work_mode() + self._notify_listeners('t_work_mode', work_mode) + + # @override to add special support for t_power. + def queue_command(self, name: str, value) -> None: + # HomeAssistant doesn't have a designated turn on button in climate.mqtt. + # Furthermore, turn_on doesn't send the right command... + if name == 't_work_mode': + if value == 'OFF': + # Pass the command to t_power instead of t_work_mode. + name = 't_power' + else: + # Also turn on the AC (if it hasn't already). + super().queue_command('t_power', 'ON') + + # Run base. + super().queue_command(name, value) + + # Handle turning on FastColdHeat + if name == 't_temp_heatcold' and value == 'ON': + super().queue_command('t_fan_speed', 'AUTO') + super().queue_command('t_fan_mute', 'OFF') + super().queue_command('t_sleep', 'STOP') + super().queue_command('t_temp_eight', 'OFF') + + def get_env_temp(self) -> int: + return self.get_property('f_temp_in') + + def set_power(self, setting: Power) -> None: + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_power(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_power', setting) + + def get_power(self) -> Power: + control = self.get_property('t_control_value') + if (control): + return control_value.get_power(control) + else: + return self.get_property('t_power') + + def set_temperature(self, setting: int) -> None: + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_temp(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_temp', setting) + + def get_temperature(self) -> int: + control = self.get_property('t_control_value') + if (control): + return control_value.get_temp(control) + else: + return self.get_property('t_temp') + + def set_sleep(self, setting: SleepMode) -> None: + self.queue_command('t_control_value', setting) + + def get_sleep(self) -> SleepMode: + self.get_property('t_sleep') + + def set_work_mode(self, setting: AcWorkMode) -> None: + control = self.get_property('t_control_value') + if (control): + if control_value.get_power(control) == Power.OFF: + control = control_value.set_power(control, Power.ON) + control = control_value.set_work_mode(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_work_mode', setting) + + def get_work_mode(self) -> AcWorkMode: + control = self.get_property('t_control_value') + if (control): + return control_value.get_work_mode(control) + else: + return self.get_property('t_work_mode') + + def set_fan_speed(self, setting: FanSpeed) -> None: + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_fan_speed(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_fan_speed', setting) + + def get_fan_speed(self) -> FanSpeed: + control = self.get_property('t_control_value') + if (control): + return control_value.get_fan_speed(control) + else: + return self.get_property('t_fan_speed') + + def set_fan_vertical(self, setting: AirFlow) -> None: + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_fan_power(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_fan_power', setting) + + def get_fan_vertical(self) -> AirFlow: + control = self.get_property('t_control_value') + if (control): + return control_value.get_fan_power(control) + else: + return self.get_property('t_fan_power') + + def set_fan_horizontal(self, setting: AirFlow) -> None: + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_fan_lr(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_fan_leftright', setting) + + def get_fan_horizontal(self) -> AirFlow: + control = self.get_property('t_control_value') + if (control): + return control_value.get_fan_lr(control) + else: + return self.get_property('t_fan_leftright') + + def set_fan_mute(self, setting: Quiet) -> None: + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_fan_mute(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_fan_mute', setting) + + def get_fan_mute(self) -> Quiet: + control = self.get_property('t_control_value') + if (control): + return control_value.get_fan_mute(control) + else: + return self.get_property('t_fan_mute') + + def set_fast_heat_cold(self, setting: FastColdHeat): + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_heat_cold(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_temp_heatcold', setting) + + def get_fast_heat_cold(self) -> FastColdHeat: + control = self.get_property('t_control_value') + if (control): + return control_value.get_heat_cold(control) + else: + return self.get_property('t_temp_heatcold') + + def set_eco(self, setting: Economy) -> None: + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_eco(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_eco', setting) + + def get_eco(self) -> Economy: + control = self.get_property('t_control_value') + if (control): + return control_value.get_eco(control) + else: + return self.get_property('t_eco') + + def set_temptype(self, setting: TemperatureUnit) -> None: + control = self.get_property('t_control_value') + control = control_value.clear_up_change_flags(control) + if (control): + control = control_value.set_temptype(control, setting) + self.queue_command('t_control_value', control) + else: + self.queue_command('t_temptype', setting) + + def get_temptype(self) -> TemperatureUnit: + control = self.get_property('t_control_value') + if (control): + return control_value.get_temptype(control) + else: + return self.get_property('t_temptype') + + def set_swing(self, setting: AirFlowState) -> None: + control = self.get_property("t_control_value") + control = control_value.clear_up_change_flags(control) + if control: + if setting == AirFlowState.OFF: + control = control_value.set_fan_power(control, AirFlow.OFF) + control = control_value.set_fan_lr(control, AirFlow.OFF) + elif setting == AirFlowState.VERTICAL_ONLY: + control = control_value.set_fan_power(control, AirFlow.ON) + control = control_value.set_fan_lr(control, AirFlow.OFF) + elif setting == AirFlowState.HORIZONTAL_ONLY: + control = control_value.set_fan_power(control, AirFlow.OFF) + control = control_value.set_fan_lr(control, AirFlow.ON) + elif setting == AirFlowState.VERTICAL_AND_HORIZONTAL: + control = control_value.set_fan_power(control, AirFlow.ON) + control = control_value.set_fan_lr(control, AirFlow.ON) + self.queue_command("t_control_value", control) + else: + if setting == AirFlowState.OFF: + self.queue_command("t_fan_speed", AirFlow.OFF) + self.queue_command("t_fan_leftright", AirFlow.OFF) + elif setting == AirFlowState.VERTICAL_ONLY: + self.queue_command("t_fan_speed", AirFlow.ON) + self.queue_command("t_fan_leftright", AirFlow.OFF) + elif setting == AirFlowState.HORIZONTAL_ONLY: + self.queue_command("t_fan_speed", AirFlow.OFF) + self.queue_command("t_fan_leftright", AirFlow.ON) + elif setting == AirFlowState.VERTICAL_AND_HORIZONTAL: + self.queue_command("t_fan_speed", AirFlow.ON) + self.queue_command("t_fan_leftright", AirFlow.ON) + + def _convert_to_control_value(self, name: str, value) -> int: + if name == 't_power': + return self.set_power(value) + elif name == 't_fan_speed': + return self.set_fan_speed(value) + elif name == 't_work_mode': + return self.set_work_mode(value) + elif name == 't_temp_heatcold': + return self.set_fast_heat_cold(value) + elif name == 't_eco': + return self.set_eco(value) + elif name == 't_temp': + return self.set_temperature(value) + elif name == 't_fan_power': + return self.set_fan_vertical(value) + elif name == 't_fan_leftright': + return self.set_fan_horizontal(value) + elif name == 't_fan_mute': + return self.set_fan_mute(value) + elif name == 't_temptype': + return self.set_temptype(value) + else: + logging.error('Cannot convert to control value property {}'.format(name)) + raise ValueError() + + def _update_controlled_properties(self, control: int): + power = control_value.get_power(control) + self.update_property('t_power', power) + + fan_speed = control_value.get_fan_speed(control) + self.update_property('t_fan_speed', fan_speed) + + work_mode = control_value.get_work_mode(control) + self.update_property('t_work_mode', work_mode) + + temp_heatcold = control_value.get_heat_cold(control) + self.update_property('t_temp_heatcold', temp_heatcold) + + eco = control_value.get_eco(control) + self.update_property('t_eco', eco) + + temp = control_value.get_temp(control) + self.update_property('t_temp', temp) + + fan_power = control_value.get_fan_power(control) + self.update_property('t_fan_power', fan_power) + + fan_horizontal = control_value.get_fan_lr(control) + self.update_property('t_fan_leftright', fan_horizontal) + + fan_mute = control_value.get_fan_mute(control) + self.update_property('t_fan_mute', fan_mute) + + temptype = control_value.get_temptype(control) + self.update_property('t_temptype', temptype) + + +class FglDevice(Device): + + def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]): + super().__init__(config, FglProperties(), notifier) + self.topics = { + 'fan_speed': 'fan_speed', + 'work_mode': 'operation_mode', + 'temp': 'adjust_temperature', + 'display_temperature': 'display_temperature', + } + self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto'] + self.fan_modes = ['auto', 'quiet', 'low', 'medium', 'high'] + + +class FglBDevice(Device): + + def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]): + super().__init__(config, FglBProperties(), notifier) + self.topics = { + 'fan_speed': 'fan_speed', + 'work_mode': 'operation_mode', + 'temp': 'adjust_temperature', + 'display_temperature': 'display_temperature', + } + self.work_modes = ['off', 'fan_only', 'heat', 'cool', 'dry', 'auto'] + self.fan_modes = ['auto', 'quiet', 'low', 'medium', 'high'] + + +class HumidifierDevice(Device): + + def __init__(self, config: Dict[str, str], notifier: Callable[[None], None]): + super().__init__(config, HumidifierProperties(), notifier) + self.topics = {'env_temp': 'temp', 'power': 'switch'} diff --git a/legacy/app_mappings.py b/legacy/app_mappings.py new file mode 100644 index 0000000..4c173b4 --- /dev/null +++ b/legacy/app_mappings.py @@ -0,0 +1,74 @@ +AYLA_USER_SERVERS = { + 'us': 'user-field.aylanetworks.com', + 'eu': 'user-field-eu.aylanetworks.com', + 'cn': 'user-field.ayla.com.cn', +} +AYLA_DEVICES_SERVERS = { + 'us': 'ads-field.aylanetworks.com', + 'eu': 'ads-eu.aylanetworks.com', + 'cn': 'ads-field.ayla.com.cn', +} +SECRET_MAP = { + 'oem-us': + b'\x1dgAPT\xd1\xa9\xec\xe2\xa2\x01\x19\xc0\x03X\x13j\xfc\xb5\x91', + 'mid-us': + b'\xdeCx\xbe\x0cq8\x0b\x99\xb4Z\x93>\xfc\xcc\x9ag\x98\xf8\x14', + 'tornado-us': + b'\x87O\xf2.&;X\xfb\xf6L\xfdRq\'\x0f\t6\x0c\xfd)', + 'wwh-us': + b'(\xcb9w\xc5\xc9\xb7\xab{*k8T!Yb\xaa\xcf\xd0\x85', + 'winia-us': + b'\xeb_\xce\xb2\xc6\xff`\xa9\xfa\xa8r\x1c\x0bH\xf8\xe27\xa7U\xec', + 'york-us': + b'\xc6A\x7fHyV<\xb2\xa2\xde<\x1f{c\xa9\rt\x9fy\xef', + 'beko-eu': + b'\xa9C\n\xdb\xf7+\x01\xe2X\ne\x85\x06\x89\xaa\x88ZP+\x07>~s{\xd3\x1f\x05\x91&\x8c\x81\x84&\xe11\xef=s"*\xa4', + 'oem-eu': + b'a\x1ez\xf5\xc4\x0f\x18~\xe5\xeb\xb1\x9f\xe4\xf5&B\xfe#\x88\xcb>\x06O,y\xc1\x06c\x9d\x99J\xc2x\xac\xeb\x82\x93\xe5\r\x89d', + 'mid-eu': + b'\x05$\xe6\xecW\xa3\xd1B\xa0\x84\xab*\xf0\x04\x80\xce\xae\xe5`\xc4>w\xf8\xc4\xf3X\xf6<\xd2\xd2I\x14!\xd0\x98\xed\xf2\xab\xae\xc6\x03', + 'haxxair': + b'\xd8\xaf\x89--\x00\xabI\x93\x83j\xab\x9acX\xac^\x90f;', + 'fglair-cn': + b'\xcd\xec\xe0\xed\x8e\xb4b\x90/\xcbq\xcf\xc3\x1b\xd6.wx:\x1e', + 'fglair-eu': + b'\x82\x91[T\x14h\x88\x9f\x04\xdd\x05\x89\xf9\x04T,\xb2\xf7\x8fu', + 'fglair-us': + b'U\xbf\x0c@\xbf\xe5\x16&\x10\xec2\xa37G\x82\x15|\xe7)\x91', + 'field-us': + b'\xc8b\x08\xfa\xce8\xf8\xf1\x81\xa5\x81\x8fX\xb4\x80\xc0\xdc\xf5\ny', + 'huihe-us': + b'\xa2\xbcZ3\xbch\xfa7.`\xbc\xef0\xa3p\xa1\xf0\xaf\xf4\xd4', + 'denali-us': + b'\xf1\'\xb0K \xdbZ\xd84;\xeb\x02\xa2\xee\x008\xda\x95\xfd\x93', + 'hisense-eu': + b'\xc0\xedK,\xff+X\xfa\xf6p\x87\xaa\xbcV\x88\xfbI\xb4\xcf\xad', + 'hisense-us': + b'x\x04\xdf\xef6\x08\x8e\x06\n\x97\xfc\xed4m\xd8\xc7\xa3=\xce\x9f', + 'hismart-eu': + b'0\x07\xe9\x04a\xa6e\xc4\x1c\x08+"\r\x84w\x91\x8f\xa8)\x98', + 'hismart-us': + b'\xd6+\x1f\xb0b\t\x19G\x87\x8c\xaak\xd0\xf8y\xf5\x933\xafp', +} +SECRET_ID_MAP = { + 'haxxair': 'HAXXAIR', + 'field-us': 'pactera-field-f624d97f-us', + 'fglair-cn': 'FGLairField-cn', + 'fglair-eu': 'FGLair-eu', + 'fglair-us': 'CJIOSP', + 'huihe-us': 'huihe-d70b5148-field-us', + 'denali-us': 'DenaliAire', + 'hisense-eu': 'Hisense', + 'hisense-us': 'APP1', + 'hismart-eu': 'Hismart', + 'hismart-us': 'App1', +} +SECRET_ID_EXTRA_MAP = { + 'denali-us': 'iA', + 'hisense-eu': 'mw', + 'hisense-us': 'pg', + 'hismart-eu': 'fA', + 'hismart-us': 'Lg', +} +# Most ACs are using Fahrenheit in their API. These do not: +CELSIUS_BASED_APPS = {'fglair-eu', 'hisense-eu', 'hismart-eu'} diff --git a/legacy/config.py b/legacy/config.py new file mode 100644 index 0000000..4ea0478 --- /dev/null +++ b/legacy/config.py @@ -0,0 +1,73 @@ +from Crypto.Cipher import AES +from dataclasses import dataclass +import hmac +import random +import string +import time + +from .error import KeyIdReplaced + + +@dataclass +class LanConfig: + lanip_key: str + lanip_key_id: int + random_1: str + time_1: int + random_2: str + time_2: int + + +@dataclass +class Encryption: + sign_key: bytes + crypto_key: bytes + iv_seed: bytes + cipher: AES + + def __init__(self, lanip_key: bytes, msg: bytes): + self.sign_key = self._build_key(lanip_key, msg + b'0') + self.crypto_key = self._build_key(lanip_key, msg + b'1') + self.iv_seed = self._build_key(lanip_key, msg + b'2')[:AES.block_size] + self.cipher = AES.new(self.crypto_key, AES.MODE_CBC, self.iv_seed) + + @classmethod + def _build_key(cls, lanip_key: bytes, msg: bytes) -> bytes: + return cls.hmac_digest(lanip_key, cls.hmac_digest(lanip_key, msg) + msg) + + @staticmethod + def hmac_digest(key: bytes, msg: bytes) -> bytes: + return hmac.digest(key, msg, 'sha256') + + +@dataclass +class Config: + _lan_config: LanConfig + app: Encryption + dev: Encryption + + def __init__(self, lanip_key: str, lanip_key_id: int): + self._lan_config = LanConfig(lanip_key, lanip_key_id, '', 0, '', 0) + self._update_encryption() + + def update(self, key: dict): + """Updates the stored lan config, and encryption data.""" + self._lan_config.random_1 = key['random_1'] + self._lan_config.time_1 = key['time_1'] + if key['key_id'] != self._lan_config.lanip_key_id: + raise KeyIdReplaced( + 'The key_id has been replaced!!', + 'Old ID was {}; new ID is {}.'.format(self._lan_config.lanip_key_id, key['key_id'])) + self._lan_config.random_2 = ''.join(random.choices(string.ascii_letters + string.digits, k=16)) + self._lan_config.time_2 = time.monotonic_ns() + self._update_encryption() + return {'random_2': self._lan_config.random_2, 'time_2': self._lan_config.time_2} + + def _update_encryption(self): + lanip_key = self._lan_config.lanip_key.encode('utf-8') + random_1 = self._lan_config.random_1.encode('utf-8') + random_2 = self._lan_config.random_2.encode('utf-8') + time_1 = str(self._lan_config.time_1).encode('utf-8') + time_2 = str(self._lan_config.time_2).encode('utf-8') + self.app = Encryption(lanip_key, random_1 + random_2 + time_1 + time_2) + self.dev = Encryption(lanip_key, random_2 + random_1 + time_2 + time_1) diff --git a/legacy/config_kata.json b/legacy/config_kata.json new file mode 100644 index 0000000..0f31caa --- /dev/null +++ b/legacy/config_kata.json @@ -0,0 +1,12 @@ +{ + "name": "AC000W002879281", + "app": "fglair-eu", + "model": "AP-WC1E", + "sw_version": "bc 2.6.17-fgl2 06/29/20 09:03:57 ID jre/mfg/2dfbf5d", + "dsn": "AC000W002879281", + "temp_type": "C", + "mac_address": "a0c9a00d61c9", + "ip_address": "192.168.88.3", + "lanip_key": "1uWOP3nGbSz2ysEfjJJVu+P0fxAQTg==", + "lanip_key_id": 62888 +} diff --git a/legacy/control_value.py b/legacy/control_value.py new file mode 100644 index 0000000..f4c10ed --- /dev/null +++ b/legacy/control_value.py @@ -0,0 +1,104 @@ +from .properties import (AcWorkMode, AirFlow, Economy, FanSpeed, FastColdHeat, Quiet, Power, + TemperatureUnit) + + +def clear_up_change_flags(control: int) -> int: + return control & 2868817502 + + +def get_fan_speed(control: int) -> FanSpeed: + int_val = (control >> 1) & 15 + return FanSpeed(int_val) + + +def set_fan_speed(control: int, value: FanSpeed) -> None: + int_val = value.value + return (control & ~31) | ((int_val << 1) | 1) + + +def get_power(control: int) -> Power: + int_val = (control >> 6) & 1 + return Power(int_val) + + +def set_power(control: int, value: Power) -> None: + int_val = value.value + return (control & ~(3 << 5)) | (((int_val << 1) | 1) << 5) + + +def get_work_mode(control: int) -> AcWorkMode: + int_val = (control >> 9) & 7 + return AcWorkMode(int_val) + + +def set_work_mode(control: int, value: AcWorkMode) -> None: + int_val = value.value + return (control & ~(15 << 8)) | (((int_val << 1) | 1) << 8) + + +def get_heat_cold(control: int) -> FastColdHeat: + int_val = (control >> 13) & 1 + return FastColdHeat(int_val) + + +def set_heat_cold(control: int, value: FastColdHeat) -> None: + int_val = value.value + return (control & ~(3 << 12)) | (((int_val << 1) | 1) << 12) + + +def get_eco(control: int) -> Economy: + int_val = (control >> 15) & 1 + return Economy(int_val) + + +def set_eco(control: int, value: Economy) -> None: + int_val = value.value + return (control & ~(3 << 14)) | (((int_val << 1) | 1) << 14) + + +def get_temp(control: int) -> int: + return (control >> 17) & 63 + + +def set_temp(control: int, value: int) -> None: + return (control & ~(127 << 16)) | (((value << 1) | 1) << 16) + + +def get_fan_power(control: int) -> AirFlow: + int_val = (control >> 25) & 1 + return AirFlow(int_val) + + +def set_fan_power(control: int, value: AirFlow) -> None: + int_val = value.value + return (control & ~(3 << 24)) | (((int_val << 1) | 1) << 24) + + +def get_fan_lr(control: int) -> AirFlow: + int_val = (control >> 27) & 1 + return AirFlow(int_val) + + +def set_fan_lr(control: int, value: AirFlow) -> None: + int_val = value.value + return (control & ~(3 << 26)) | (((int_val << 1) | 1) << 26) + + +def get_fan_mute(control: int) -> Quiet: + int_val = (control >> 29) & 1 + return Quiet(int_val) + + +def set_fan_mute(control: int, value: Quiet) -> None: + int_val = value.value + return (control & ~(3 << 28)) | (((int_val << 1) | 1) << 28) + + +def get_temptype(control: int) -> TemperatureUnit: + int_val = (control >> 31) & 1 + return TemperatureUnit(int_val) + + +def set_temptype(control: int, value: TemperatureUnit) -> None: + int_val = value.value + return (control & ~(3 << 30)) | (((int_val << 1) | 1) << 30) diff --git a/legacy/discovery.py b/legacy/discovery.py new file mode 100644 index 0000000..a566f5d --- /dev/null +++ b/legacy/discovery.py @@ -0,0 +1,170 @@ +import aiohttp +import base64 +from getmac import get_mac_address +from http import HTTPStatus +import json +import logging +import ssl +import sys + +from .app_mappings import * + +_USER_AGENT = 'Dalvik/2.1.0 (Linux; U; Android 9.0; SM-G850F Build/LRX22G)' + + +async def _sign_in(user: str, passwd: str, user_server: str, app_id: str, app_secret: str, + session: aiohttp.ClientSession, ssl_context: ssl.SSLContext): + query = { + 'user': { + 'email': user, + 'password': passwd, + 'application': { + 'app_id': app_id, + 'app_secret': app_secret + } + } + } + headers = { + 'Accept': 'application/json', + 'Connection': 'Keep-Alive', + 'Authorization': 'none', + 'Content-Type': 'application/json', + 'User-Agent': _USER_AGENT, + 'Host': user_server, + 'Accept-Encoding': 'gzip' + } + logging.debug('POST /users/sign_in.json, body=%r, headers=%r', json.dumps(query), headers) + async with session.request('POST', + f'https://{user_server}/users/sign_in.json', + json=query, + headers=headers, + ssl=ssl_context) as resp: + if resp.status != HTTPStatus.OK.value: + logging.error('Failed to login to Hisense server:\nStatus %d: %r', resp.status, resp.reason) + sys.exit(1) + resp_data = await resp.text() + try: + tokens = json.loads(resp_data) + except UnicodeDecodeError: + logging.exception('Failed to parse login tokens to Hisense server:\nData: %r', resp_data) + sys.exit(1) + return tokens['access_token'] + + +async def _get_devices(devices_server: str, access_token: str, headers: dict, + session: aiohttp.ClientSession, ssl_context: ssl.SSLContext): + logging.debug('GET /apiv1/devices.json, headers=%r', headers) + async with session.get(f'https://{devices_server}/apiv1/devices.json', + headers=headers, + ssl=ssl_context) as resp: + if resp.status != HTTPStatus.OK.value: + logging.error('Failed to get devices data from Hisense server:\nStatus %d: %r', resp.status, + resp.reason) + sys.exit(1) + resp_data = await resp.text() + try: + devices = json.loads(resp_data) + except UnicodeDecodeError: + logging.exception('Failed to parse devices data from Hisense server:\nData: %r', resp_data) + sys.exit(1) + if not devices: + logging.error('No device is configured! Please configure a device first.') + sys.exit(1) + return devices + + +async def _get_lanip(devices_server: str, dsn: str, headers: dict, session: aiohttp.ClientSession, + ssl_context: ssl.SSLContext): + logging.debug(f'GET /apiv1/dsns/{dsn}/lan.json, headers=%r', headers) + async with session.get(f'https://{devices_server}/apiv1/dsns/{dsn}/lan.json', + headers=headers, + ssl=ssl_context) as resp: + if resp.status != HTTPStatus.OK.value: + logging.error('Failed to get device data from Hisense server: %r', resp) + sys.exit(1) + resp_data = await resp.text() + return json.loads(resp_data)['lanip'] + + +async def _get_device_properties(devices_server: str, dsn: str, headers: dict, + session: aiohttp.ClientSession, ssl_context: ssl.SSLContext): + logging.debug(f'GET /apiv1/dsns/{dsn}/properties.json, headers=%r', headers) + async with session.get(f'https://{devices_server}/apiv1/dsns/{dsn}/properties.json', + headers=headers, + ssl=ssl_context) as resp: + if resp.status != HTTPStatus.OK.value: + logging.error('Failed to get properties data from Hisense server: %r', resp) + sys.exit(1) + resp_data = await resp.text() + return json.loads(resp_data) + + +async def perform_discovery(session: aiohttp.ClientSession, + app: str, + user: str, + passwd: str, + device_filter: str = None, + properties_filter: bool = False) -> dict: + if app in SECRET_ID_MAP: + app_prefix = SECRET_ID_MAP[app] + else: + app_prefix = 'a-Hisense-{}-field'.format(app) + + if app in SECRET_ID_EXTRA_MAP: + app_id = '-'.join((app_prefix, SECRET_ID_EXTRA_MAP[app], 'id')) + else: + app_id = '-'.join((app_prefix, 'id')) + + secret = base64.b64encode(SECRET_MAP[app]).decode('utf-8').rstrip('=').replace('+', '-').replace( + '/', '_') + app_secret = '-'.join((app_prefix, secret)) + + # Extract the region from the app ID (and fallback to US) + region = app[-2:] + if region not in AYLA_USER_SERVERS: + region = 'us' + user_server = AYLA_USER_SERVERS[region] + devices_server = AYLA_DEVICES_SERVERS[region] + + ssl_context = ssl.SSLContext() + ssl_context.verify_mode = ssl.CERT_NONE + ssl_context.check_hostname = False + ssl_context.load_default_certs() + + access_token = await _sign_in(user, passwd, user_server, app_id, app_secret, session, ssl_context) + + result = [] + headers = { + 'Accept': 'application/json', + 'Connection': 'Keep-Alive', + 'Authorization': 'auth_token ' + access_token, + 'User-Agent': _USER_AGENT, + 'Host': devices_server, + 'Accept-Encoding': 'gzip' + } + devices = await _get_devices(devices_server, access_token, headers, session, ssl_context) + logging.debug('Found devices: %r', devices) + for device in devices: + device_data = device['device'] + if device_filter and device_filter != device_data['product_name']: + continue + dsn = device_data['dsn'] + lanip = await _get_lanip(devices_server, dsn, headers, session, ssl_context) + properties_text = '' + if properties_filter: + props = await _get_device_properties(devices_server, dsn, headers, session, ssl_context) + device_data['properties'] = props + + device_data['lanip_key'] = lanip['lanip_key'] + device_data['lanip_key_id'] = lanip['lanip_key_id'] + device_data['temp_type'] = 'C' if app in CELSIUS_BASED_APPS else 'F' + # If the server doesn't know the MAC address, fetch it from the local network. + if not device_data.get('mac'): + mac = get_mac_address(ip=device_data['lan_ip']) + if not mac or mac == '00:00:00:00:00:00': + logging.error(f'Failed to fetch MAC address for AC on IP address {device_data["lan_ip"]}.' + + '\nAre you sure it is connected? Skipping...') + continue + device_data['mac'] = mac.replace(':', '') + result.append(device_data) + return result diff --git a/legacy/error.py b/legacy/error.py new file mode 100644 index 0000000..c2765cf --- /dev/null +++ b/legacy/error.py @@ -0,0 +1,11 @@ +class Error(Exception): + """Error class for AC handling.""" + pass + + +class KeyIdReplaced(Exception): + """Error class for key id replacement""" + + def __init__(self, title, message): + self.title = title + self.message = message diff --git a/legacy/main.py b/legacy/main.py new file mode 100644 index 0000000..9ee4888 --- /dev/null +++ b/legacy/main.py @@ -0,0 +1,314 @@ +import aiohttp +from aiohttp import web +import argparse +import asyncio +import base64 +from http import HTTPStatus +from http.client import HTTPConnection, InvalidURL +from http.server import HTTPServer, BaseHTTPRequestHandler +import json +import logging +import logging.handlers +import os +import paho.mqtt.client as mqtt +from retry import retry +import signal +import socket +import sys +try: + from systemd.journal import JournalHandler +except: + JournalHandler = None +import textwrap +import threading +import time +import _thread +from urllib.parse import parse_qs, urlparse, ParseResult + +from aircon.app_mappings import SECRET_MAP +from aircon.config import Config +from aircon.error import Error +from aircon.aircon import Device +from aircon.discovery import perform_discovery +from aircon.mqtt_client import MqttClient +from aircon.notifier import Notifier +from aircon.query_handlers import QueryHandlers + + +async def query_status_device(device: Device): + _STATUS_UPDATE_INTERVAL = 600.0 + _WAIT_FOR_EMPTY_QUEUE = 10.0 + while True: + # In case the AC is stuck, and not fetching commands, avoid flooding + # the queue with status updates. + while device.commands_queue.qsize() > 10: + await asyncio.sleep(_WAIT_FOR_EMPTY_QUEUE) + device.queue_status() + await asyncio.sleep(_STATUS_UPDATE_INTERVAL) + + +async def query_status_worker(devices: [Device]): + await asyncio.wait([asyncio.create_task(query_status_device(device)) for device in devices]) + + +def ParseArguments() -> argparse.Namespace: + """Parse command line arguments.""" + arg_parser = argparse.ArgumentParser(description='JSON server for HiSense air conditioners.', + allow_abbrev=False) + arg_parser.add_argument('--log_level', + default='WARNING', + choices={'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG'}, + help='Minimal log level.') + arg_parser.add_argument('--stderr', + default=False, + action='store_true', + help='Output log to stderr') + subparsers = arg_parser.add_subparsers(dest='cmd', help='Determines what server should do') + subparsers.required = True + + parser_run = subparsers.add_parser('run', help='Runs the server to control the device') + parser_run.add_argument('-p', '--port', required=True, type=int, help='Port for the server.') + parser_run.add_argument('--local_ip', + required=False, + default=None, + help='The local IP address to report to the AC unit(s) as target server. Useful in case the server running this application has multiple IP addresses (e.g. in multiple VLANs), since some/most(?) AC units will refuse to report to an IP address outside of their subnet.') + group_device = parser_run.add_argument_group('Device', 'Arguments that are related to the device') + group_device.add_argument('--config', required=True, action='append', help='LAN Config file.') + group_device.add_argument('--type', + required=False, + action='append', + choices={'ac', 'fgl', 'fgl_b', 'humidifier'}, + help='Device type. Deprecated, now decided based on OEM model.') + + group_mqtt = parser_run.add_argument_group('MQTT', 'Settings related to the MQTT') + group_mqtt.add_argument('--mqtt_host', default=None, help='MQTT broker hostname or IP address.') + group_mqtt.add_argument('--mqtt_port', type=int, default=1883, help='MQTT broker port.') + group_mqtt.add_argument('--mqtt_client_id', default=None, help='MQTT client ID.') + group_mqtt.add_argument('--mqtt_user', default=None, help=' for the MQTT channel.') + group_mqtt.add_argument('--mqtt_topic', default='hisense_ac', help='MQTT topic.') + group_mqtt.add_argument('--mqtt_discovery_prefix', + default='homeassistant', + help='MQTT discovery prefix for HomeAssistant.') + + parser_discovery = subparsers.add_parser('discovery', help='Runs the device discovery') + parser_discovery.add_argument('app', choices=set(SECRET_MAP), help='The app used for the login.') + parser_discovery.add_argument('user', help='Username for the app login.') + parser_discovery.add_argument('passwd', help='Password for the app login.') + parser_discovery.add_argument('-d', + '--device', + default=None, + help='Device name to fetch data for. If not set, takes all.') + parser_discovery.add_argument('--prefix', + required=False, + default='config_', + help='Config file prefix.') + parser_discovery.add_argument('--properties', + action='store_true', + help='Fetch the properties for the device.') + return arg_parser.parse_args() + + +def setup_logger(log_level, use_stderr=False): + if use_stderr or os.environ.get('PLATFORM') == 'docker': + logging_handler = logging.StreamHandler(sys.stderr) + elif JournalHandler: + logging_handler = JournalHandler() + # Fallbacks when JournalHandler isn't available. + elif sys.platform == 'linux': + logging_handler = logging.handlers.SysLogHandler(address='/dev/log') + elif sys.platform == 'darwin': + logging_handler = logging.handlers.SysLogHandler(address='/var/run/syslog') + elif sys.platform.lower() in ['windows', 'win32']: + logging_handler = logging.handlers.SysLogHandler() + else: # Unknown platform, revert to stderr + logging_handler = logging.StreamHandler(sys.stderr) + logging_handler.setFormatter( + logging.Formatter(fmt='{levelname[0]}{asctime}.{msecs:03.0f} ' + '{filename}:{lineno}] {message}', + datefmt='%m%d %H:%M:%S', + style='{')) + logger = logging.getLogger() + logger.setLevel(log_level) + logger.addHandler(logging_handler) + + +async def setup_and_run_http_server(parsed_args, devices: [Device]): + query_handlers = QueryHandlers(devices) + app = web.Application() + app.add_routes([ + web.get('/hisense/status', query_handlers.get_status_handler), + web.get('/hisense/command', query_handlers.queue_command_handler), + web.post('/local_lan/key_exchange.json', query_handlers.key_exchange_handler), + web.get('/local_lan/commands.json', query_handlers.command_handler), + web.post('/local_lan/property/datapoint.json', query_handlers.property_update_handler), + web.post('/local_lan/property/datapoint/ack.json', query_handlers.property_update_handler), + web.post('/local_lan/node/property/datapoint.json', query_handlers.property_update_handler), + web.post('/local_lan/node/property/datapoint/ack.json', + query_handlers.property_update_handler), + # TODO: Handle these if needed. + # '/local_lan/node/conn_status.json': query_handlers.connection_status_handler, + # '/local_lan/connect_status': query_handlers.module_request_handler, + # '/local_lan/status.json': query_handlers.setup_device_details_handler, + # '/local_lan/wifi_scan.json': query_handlers.module_request_handler, + # '/local_lan/wifi_scan_results.json': query_handlers.module_request_handler, + # '/local_lan/wifi_status.json': query_handlers.module_request_handler, + # '/local_lan/regtoken.json': query_handlers.module_request_handler, + # '/local_lan/wifi_stop_ap.json': query_handlers.module_request_handler + ]) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, port=parsed_args.port) + await site.start() + + +async def mqtt_loop(mqtt_client: MqttClient): + _MQTT_LOOP_TIMEOUT = 1 + while True: + mqtt_client.loop() + await asyncio.sleep(_MQTT_LOOP_TIMEOUT) + + +async def run(parsed_args): + notifier = Notifier(parsed_args.port, parsed_args.local_ip) + devices = [] + for i in range(len(parsed_args.config)): + with open(parsed_args.config[i], 'rb') as f: + config = json.load(f) + device = Device.create(config, notifier.notify) + notifier.register_device(device) + devices.append(device) + + mqtt_client = None + if parsed_args.mqtt_host: + mqtt_topics = { + 'pub': + '/'.join((parsed_args.mqtt_topic, '{}', '{}', 'status')), + 'sub': + '/'.join((parsed_args.mqtt_topic, '{}', '{}', 'command')), + 'lwt': + '/'.join((parsed_args.mqtt_topic, 'LWT')), + 'discovery': + '/'.join((parsed_args.mqtt_discovery_prefix, 'climate', '{}', 'hvac', 'config')) + } + mqtt_client = MqttClient(parsed_args.mqtt_client_id, mqtt_topics, devices) + if parsed_args.mqtt_user: + mqtt_client.username_pw_set(*parsed_args.mqtt_user.split(':', 1)) + mqtt_client.will_set(mqtt_topics['lwt'], payload='offline', retain=True) + mqtt_client.connect(parsed_args.mqtt_host, parsed_args.mqtt_port) + mqtt_client.publish(mqtt_topics['lwt'], payload='online', retain=True) + for device in devices: + config = { + 'name': device.name, + 'unique_id': device.mac_address, + 'device': { + 'identifiers': [f'hisense_ac_{device.mac_address}'], + 'manufacturer': f'Hisense ({device.app})', + 'model': device.model, + 'name': device.name, + 'sw_version': device.sw_version + }, + 'availability': [ + { + 'topic': mqtt_topics['lwt'] + }, + { + 'topic': mqtt_topics['pub'].format(device.mac_address, 'available') + }, + ], + 'precision': 1.0, + 'temperature_unit': 'F' if device.is_fahrenheit else 'C' + } + topics = device.topics + if 'env_temp' in topics: + config['current_temperature_topic'] = mqtt_topics['pub'].format( + device.mac_address, topics['env_temp']) + if 'fan_speed' in topics: + config['fan_mode_command_topic'] = mqtt_topics['sub'].format(device.mac_address, + topics['fan_speed']) + config['fan_mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address, + topics['fan_speed']) + config['fan_modes'] = device.fan_modes + if 'work_mode' in topics: + config['mode_command_topic'] = mqtt_topics['sub'].format(device.mac_address, + topics['work_mode']) + config['mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address, + topics['work_mode']) + config['modes'] = device.work_modes + if 'swing_mode' in topics: + config['swing_mode_command_topic'] = mqtt_topics['sub'].format( + device.mac_address, topics['swing_mode']) + config['swing_mode_state_topic'] = mqtt_topics['pub'].format(device.mac_address, + topics['swing_mode']) + config['swing_modes'] = ['on', 'off'] + if 'temp' in topics: + config['temperature_command_topic'] = mqtt_topics['sub'].format( + device.mac_address, topics['temp']) + config['temperature_state_topic'] = mqtt_topics['pub'].format(device.mac_address, + topics['temp']) + config['max_temp'] = '86' if device.is_fahrenheit else '30' + config['min_temp'] = '61' if device.is_fahrenheit else '16' + if 'display_temperature' in topics: + config['display_temperature_state_topic'] = mqtt_topics['pub'].format(device.mac_address, + topics['display_temperature']) + mqtt_client.publish(mqtt_topics['discovery'].format(device.mac_address), + payload=json.dumps(config), + retain=True) + device.add_property_change_listener(mqtt_client.mqtt_publish_update) + + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(connect=5.0)) as session: + await asyncio.gather(mqtt_loop(mqtt_client), setup_and_run_http_server(parsed_args, devices), + query_status_worker(devices), notifier.start(session)) + + +def _escape_name(name: str): + safe_name = name.replace(' ', '_').lower() + return ''.join(x for x in safe_name if x.isalnum()) + + +async def discovery(parsed_args): + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(connect=5.0)) as session: + try: + all_configs = await perform_discovery(session, parsed_args.app, parsed_args.user, + parsed_args.passwd, parsed_args.device, + parsed_args.properties) + except Exception as e: + print(f'Error occurred:\n{e!r}') + sys.exit(1) + + for config in all_configs: + properties_text = '' + if 'properties' in config.keys(): + properties_text = f'Properties:\n{json.dumps(config["properties"], indent=2)}' + print( + textwrap.dedent(f"""Device {config['product_name']} has: + IP address: {config['lan_ip']} + lanip_key: {config['lanip_key']} + lanip_key_id: {config['lanip_key_id']} + {properties_text} + """)) + + file_content = { + 'name': config['product_name'], + 'app': parsed_args.app, + 'model': config['oem_model'], + 'sw_version': config['sw_version'], + 'dsn': config['dsn'], + 'temp_type': config['temp_type'], + 'mac_address': config['mac'], + 'ip_address': config['lan_ip'], + 'lanip_key': config['lanip_key'], + 'lanip_key_id': config['lanip_key_id'], + } + with open(parsed_args.prefix + _escape_name(config['product_name']) + '.json', 'w') as f: + f.write(json.dumps(file_content)) + + +if __name__ == '__main__': + parsed_args = ParseArguments() # type: argparse.Namespace + + if parsed_args.cmd == 'run': + setup_logger(parsed_args.log_level, use_stderr=parsed_args.stderr) + asyncio.run(run(parsed_args)) + elif parsed_args.cmd == 'discovery': + setup_logger(parsed_args.log_level, use_stderr=True) + asyncio.run(discovery(parsed_args)) diff --git a/legacy/mqtt_client.py b/legacy/mqtt_client.py new file mode 100644 index 0000000..9a989cb --- /dev/null +++ b/legacy/mqtt_client.py @@ -0,0 +1,92 @@ +from dataclasses import fields +import enum +import logging +import paho.mqtt.client as mqtt + +from .aircon import Device +from .properties import AcWorkMode, FglOperationMode + + +class MqttClient(mqtt.Client): + + def __init__(self, client_id: str, mqtt_topics: dict, devices: [Device]): + super().__init__(client_id=client_id, clean_session=True) + self._mqtt_topics = mqtt_topics + self._devices = devices + + self.on_connect = self.mqtt_on_connect + self.on_message = self.mqtt_on_message + + def mqtt_on_connect(self, client: mqtt.Client, userdata, flags, rc): + for device in self._devices: + topics_fmt = [(self._mqtt_topics['sub'].format(device.mac_address, data_field.name), 0) + for data_field in fields(device.get_all_properties())] + logging.debug(f"Subscribing to topics{topics_fmt} for device {device}") + client.subscribe(topics_fmt) + # Subscribe to subscription updates. + client.subscribe('$SYS/broker/log/M/subscribe/#') + + # Publish current status of all properties for available devices. + for device in self._devices: + if device.available: + for prop_name in fields(device.get_all_properties()): + self.mqtt_publish_update(device.mac_address, + prop_name, + device.get_property(prop_name), + retain=False) + + def mqtt_on_message(self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage): + logging.info('MQTT message Topic: {}, Payload {}'.format(message.topic, message.payload)) + if message.topic.startswith('$SYS/broker/log/M/subscribe'): + return self.mqtt_on_subscribe(message.payload) + mac_address = message.topic.rsplit('/', 3)[1] + prop_name = message.topic.rsplit('/', 3)[2] + payload = message.payload.decode('utf-8') + if prop_name == 't_work_mode': + if payload == 'fan_only': + payload = 'FAN' + + for device in self._devices: + if device.mac_address != mac_address: + continue + chosen_device = device + + try: + chosen_device.queue_command(prop_name, payload.upper()) + except Exception: + logging.exception('Failed to parse value {} for property {}'.format( + payload.upper(), prop_name)) + + def mqtt_on_subscribe(self, payload: bytes): + # The last segment in the space delimited string is the topic. + topic = payload.decode('utf-8').rsplit(' ', 1)[-1] + if topic not in self._mqtt_topics['pub']: + return + mac_address = topic.rsplit('/', 3)[1] + prop_name = topic.rsplit('/', 3)[2] + + for device in self._devices: + if device.mac_address != mac_address: + continue + chosen_device = device + + self.mqtt_publish_update(chosen_device.mac_address, + prop_name, + chosen_device.get_property(prop_name), + retain=False) + + def mqtt_publish_update(self, + mac_address: str, + property_name: str, + value, + retain: bool = False) -> None: + if isinstance(value, enum.Enum): + payload = 'fan_only' if (value is AcWorkMode.FAN or + value is FglOperationMode.FAN_ONLY) else value.name.lower() + else: + payload = str(value) + topic = self._mqtt_topics['pub'].format(mac_address, property_name) + logging.info('Sending MQTT update Topic: {}, Payload {}'.format(topic, payload)) + self.publish(topic, + payload=payload.encode('utf-8'), + retain=retain) diff --git a/legacy/notifier.py b/legacy/notifier.py new file mode 100644 index 0000000..0e15cf1 --- /dev/null +++ b/legacy/notifier.py @@ -0,0 +1,126 @@ +import aiohttp +import asyncio +import concurrent +from dataclasses import dataclass +from http import HTTPStatus +import json +import logging +import socket +import sys +from tenacity import retry, retry_if_exception_type, wait_exponential, stop_after_attempt +import time +import threading + +from .aircon import Device + +if sys.version_info < (3, 8): + TimeoutError = concurrent.futures.TimeoutError +else: + TimeoutError = asyncio.exceptions.TimeoutError + + +@dataclass +class _NotifyConfiguration: + device: Device + headers: dict + last_timestamp: int + + +def _run_after_failure(retry_state): + config = retry_state.kwargs['config'] + config.device.available = False + return 0 + + +class Notifier: + _KEEP_ALIVE_INTERVAL = 1200.0 + _TIME_TO_HANDLE_REQUESTS = 60.0 + + def __init__(self, port: int, local_ip: str): + self._configurations = [] + self._condition = asyncio.Condition() + + self._running = False + + local_ip = local_ip or self._get_local_ip() + self._json = {'local_reg': {'ip': local_ip, 'notify': 0, 'port': port, 'uri': '/local_lan'}} + + def _get_local_ip(self): + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.connect(('10.255.255.255', 1)) + return sock.getsockname()[0] + finally: + if sock: + sock.close() + + def register_device(self, device: Device): + if device not in (conf.device for conf in self._configurations): + headers = { + 'Accept': 'application/json', + 'Connection': 'keep-alive', + 'Content-Type': 'application/json', + 'Host': device.ip_address, + 'Accept-Encoding': 'gzip' + } + self._configurations.append(_NotifyConfiguration(device, headers, 0)) + + async def _notify(self): + async with self._condition: + self._condition.notify_all() + + def notify(self): + loop = asyncio.get_event_loop() + asyncio.run_coroutine_threadsafe(self._notify(), loop) + + async def start(self, session: aiohttp.ClientSession): + self._running = True + async with self._condition: + while self._running: + queue_sizes = await asyncio.gather(*(self._perform_request(session=session, config=config) + for config in self._configurations)) + if max(queue_sizes) <= 1: + logging.debug('[KeepAlive] Waiting for notification or timeout') + try: + await asyncio.wait_for(self._condition.wait(), timeout=self._KEEP_ALIVE_INTERVAL) + except TimeoutError: + pass + else: + # give some time to clean up the queues + await asyncio.sleep(self._TIME_TO_HANDLE_REQUESTS) + + async def stop(self): + self._running = False + await self._notify() + + @retry(retry=retry_if_exception_type(ConnectionError), + retry_error_callback=_run_after_failure, + wait=wait_exponential(exp_base=1.6, max=10), + stop=stop_after_attempt(6)) + async def _perform_request(self, session: aiohttp.ClientSession, + config: _NotifyConfiguration) -> int: + now = time.time() + queue_size = config.device.commands_queue.qsize() + if (queue_size == 0 or + not config.device.available) and now - config.last_timestamp < self._KEEP_ALIVE_INTERVAL: + return 0 + method = 'PUT' if config.device.available else 'POST' + self._json['local_reg']['notify'] = int(config.device.commands_queue.qsize() > 0) + url = f'http://{config.device.ip_address}/local_reg.json' + logging.debug(f'[KeepAlive] Sending {method} {url} {json.dumps(self._json)}') + try: + async with session.request(method, url, json=self._json, headers=config.headers) as resp: + if resp.status != HTTPStatus.ACCEPTED.value: + resp_data = await resp.text() + logging.error(f'[KeepAlive] Sending local_reg failed: {resp.status}, {resp_data}') + raise ConnectionError(f'Sending local_reg failed: {resp.status}, {resp_data}') + except (aiohttp.client_exceptions.ClientConnectorError, + aiohttp.client_exceptions.ClientConnectionError) as e: + logging.error(f'Failed to connect to {config.device.ip_address}, maybe it is offline?') + raise ConnectionError( + f'Failed to connect to {config.device.ip_address}, maybe it is offline?') + config.last_timestamp = now + config.device.available = True + return queue_size diff --git a/legacy/properties.py b/legacy/properties.py new file mode 100644 index 0000000..ac086b2 --- /dev/null +++ b/legacy/properties.py @@ -0,0 +1,525 @@ +from dataclasses import dataclass, field +from dataclasses_json import dataclass_json +import enum + + +class AirFlowState(enum.IntEnum): + OFF = 0 + VERTICAL_ONLY = 1 + HORIZONTAL_ONLY = 2 + VERTICAL_AND_HORIZONTAL = 3 + + +class FanSpeed(enum.IntEnum): + AUTO = 0 + LOWER = 5 + LOW = 6 + MEDIUM = 7 + HIGH = 8 + HIGHER = 9 + + +class SleepMode(enum.IntEnum): + STOP = 0 + ONE = 1 + TWO = 2 + THREE = 3 + FOUR = 4 + + +class StateMachine(enum.IntEnum): + FANONLY = 0 + HEAT = 1 + COOL = 2 + DRY = 3 + AUTO = 4 + FAULTSHIELD = 5 + POWEROFF = 6 + OFFLINE = 7 + READONLYSHARED = 8 + + +class AcWorkMode(enum.IntEnum): + FAN = 0 + HEAT = 1 + COOL = 2 + DRY = 3 + AUTO = 4 + + +class AirFlow(enum.Enum): + OFF = 0 + ON = 1 + + +class DeviceErrorStatus(enum.Enum): + NORMALSTATE = 0 + FAULTSTATE = 1 + + +class Dimmer(enum.Enum): + ON = 0 + OFF = 1 + + +class DoubleFrequency(enum.Enum): + OFF = 0 + ON = 1 + + +class Economy(enum.Enum): + OFF = 0 + ON = 1 + + +class EightHeat(enum.Enum): + OFF = 0 + ON = 1 + + +class FastColdHeat(enum.Enum): + OFF = 0 + ON = 1 + + +class Power(enum.Enum): + OFF = 0 + ON = 1 + + +class Quiet(enum.Enum): + OFF = 0 + ON = 1 + + +class TemperatureUnit(enum.Enum): + CELSIUS = 0 + FAHRENHEIT = 1 + + +class HumidifierWorkMode(enum.Enum): + NORMAL = 0 + NIGHTLIGHT = 1 + SLEEP = 2 + + +class HumidifierWater(enum.Enum): + OK = 0 + NO_WATER = 1 + + +class Mist(enum.Enum): + SMALL = 1 + MIDDLE = 2 + BIG = 3 + + +class MistState(enum.Enum): + OFF = 0 + ON = 1 + + +class FglOperationMode(enum.IntEnum): + OFF = 0 + ON = 1 + AUTO = 2 + COOL = 3 + DRY = 4 + FAN_ONLY = 5 + HEAT = 6 + + +class FglFanSpeed(enum.IntEnum): + QUIET = 0 + LOW = 1 + MEDIUM = 2 + HIGH = 3 + AUTO = 4 + + +class Properties(object): + + @classmethod + def _get_metadata(cls, attr: str): + return cls.__dataclass_fields__[attr].metadata + + @classmethod + def get_type(cls, attr: str): + return cls.__dataclass_fields__[attr].type + + @classmethod + def parse_attr(cls, attr, value): + """If a field supplies a parser function in its metadata, use it to parse its value from the raw data.""" + # Retrieve the desired type from the class attribute type hinting + native_type = cls.__dataclass_fields__[attr].type + value_fmt = native_type(value) + + # Detect parser for this attribute + parser = cls.__dataclass_fields__[attr].metadata.get('parser') + if parser: + value_fmt = parser(value) + + return value_fmt + + @classmethod + def get_base_type(cls, attr: str): + return cls._get_metadata(attr)['base_type'] + + @classmethod + def get_precision(cls, attr: str): + return cls._get_metadata(attr).get('precision', 1) + + @classmethod + def get_update_precision(cls, attr: str): + metadata = cls._get_metadata(attr) + return metadata.get('update_precision', metadata.get('precision', 1)) + + @classmethod + def get_read_only(cls, attr: str): + return cls._get_metadata(attr)['read_only'] + + +@dataclass_json +@dataclass +class AcProperties(Properties): + # ack_cmd: bool = field(default=None, metadata={'base_type': 'boolean', 'read_only': False}) + f_electricity: int = field(default=100, metadata={'base_type': 'integer', 'read_only': True}) + f_e_arkgrille: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_incoiltemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_incom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_indisplay: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_ineeprom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_inele: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_infanmotor: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_inhumidity: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_inkeys: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_inlow: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_intemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_invzero: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_outcoiltemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_outeeprom: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_outgastemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_outmachine2: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_outmachine: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_outtemp: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_outtemplow: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_e_push: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_filterclean: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_humidity: int = field(default=50, metadata={ + 'base_type': 'integer', + 'read_only': True + }) # Humidity + f_power_display: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': True}) + f_temp_in: float = field(default=81.0, metadata={ + 'base_type': 'decimal', + 'read_only': True + }) # EnvironmentTemperature (Fahrenheit) + f_voltage: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True}) + t_backlight: Dimmer = field(default=Dimmer.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: Dimmer[x] + } + }) # DimmerStatus + t_control_value: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False}) + t_device_info: bool = field(default=0, metadata={'base_type': 'boolean', 'read_only': False}) + t_display_power: bool = field(default=None, metadata={'base_type': 'boolean', 'read_only': False}) + t_eco: Economy = field(default=Economy.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: Economy[x] + } + }) + t_fan_leftright: AirFlow = field(default=AirFlow.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: AirFlow[x] + } + }) # HorizontalAirFlow + t_fan_mute: Quiet = field(default=Quiet.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: Quiet[x] + } + }) # QuietModeStatus + t_fan_power: AirFlow = field(default=AirFlow.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: AirFlow[x] + } + }) # VerticalAirFlow + t_fan_speed: FanSpeed = field(default=FanSpeed.AUTO, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: FanSpeed[x] + } + }) # FanSpeed + t_ftkt_start: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False}) + t_power: Power = field(default=Power.ON, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: Power[x] + } + }) # PowerStatus + t_run_mode: DoubleFrequency = field(default=DoubleFrequency.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: DoubleFrequency[x] + } + }) # DoubleFrequency + t_setmulti_value: int = field(default=None, metadata={'base_type': 'integer', 'read_only': False}) + t_sleep: SleepMode = field(default=SleepMode.STOP, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: SleepMode[x] + } + }) # SleepMode + t_temp: int = field(default=81, metadata={ + 'base_type': 'integer', + 'read_only': False + }) # CurrentTemperature + t_temptype: TemperatureUnit = field(default=TemperatureUnit.FAHRENHEIT, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: TemperatureUnit[x] + } + }) # CurrentTemperatureUnit + t_temp_eight: EightHeat = field(default=EightHeat.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: EightHeat[x] + } + }) # EightHeatStatus + t_temp_heatcold: FastColdHeat = field(default=FastColdHeat.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: FastColdHeat[x] + } + }) # FastCoolHeatStatus + t_work_mode: AcWorkMode = field(default=AcWorkMode.AUTO, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: AcWorkMode[x] + } + }) # WorkModeStatus + + +@dataclass_json +@dataclass +class HumidifierProperties(Properties): + humi: int = field(default=0, metadata={'base_type': 'integer', 'read_only': False}) + mist: Mist = field(default=Mist.SMALL, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: Mist[x] + } + }) + mistSt: MistState = field(default=MistState.OFF, + metadata={ + 'base_type': 'integer', + 'read_only': True, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: MistState[x] + } + }) + realhumi: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True}) + remain: int = field(default=0, metadata={'base_type': 'integer', 'read_only': True}) + switch: Power = field(default=Power.ON, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: Power[x] + } + }) + temp: int = field(default=81, metadata={'base_type': 'integer', 'read_only': True}) + timer: int = field(default=-1, metadata={'base_type': 'integer', 'read_only': False}) + water: HumidifierWater = field(default=HumidifierWater.OK, + metadata={ + 'base_type': 'boolean', + 'read_only': True, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: HumidifierWater[x] + } + }) + workmode: HumidifierWorkMode = field(default=HumidifierWorkMode.NORMAL, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: HumidifierWorkMode[x] + } + }) + + +@dataclass_json +@dataclass +class FglProperties(Properties): + operation_mode: FglOperationMode = field(default=FglOperationMode.AUTO, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: FglOperationMode[x] + } + }) + fan_speed: FglFanSpeed = field(default=FglFanSpeed.AUTO, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: FglFanSpeed[x] + } + }) + adjust_temperature: int = field(default=25, + metadata={ + 'base_type': 'integer', + 'precision': 0.1, + 'update_precision': 1.0, + 'read_only': False + }) + display_temperature: float = field(default=25, + metadata={ + 'base_type': 'integer', + 'read_only': True, + 'parser': lambda x: round((x-5000)/50)/2, + }) + af_vertical_direction: int = field(default=3, + metadata={ + 'base_type': 'integer', + 'read_only': False + }) + af_vertical_swing: AirFlow = field(default=AirFlow.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: AirFlow[x] + } + }) # HorizontalAirFlow + af_horizontal_direction: int = field(default=3, + metadata={ + 'base_type': 'integer', + 'read_only': False + }) + af_horizontal_swing: AirFlow = field(default=AirFlow.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: AirFlow[x] + } + }) # HorizontalAirFlow + economy_mode: Economy = field(default=Economy.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: Economy[x] + } + }) + + +@dataclass_json +@dataclass +class FglBProperties(Properties): + operation_mode: FglOperationMode = field(default=FglOperationMode.AUTO, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: FglOperationMode[x] + } + }) + fan_speed: FglFanSpeed = field(default=FglFanSpeed.AUTO, + metadata={ + 'base_type': 'integer', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: FglFanSpeed[x] + } + }) + adjust_temperature: int = field(default=25, + metadata={ + 'base_type': 'integer', + 'precision': 0.1, + 'read_only': False + }) + display_temperature: float = field(default=25, + metadata={ + 'base_type': 'float', + 'read_only': True, + 'parser': lambda x: round((x-5000)/50)/2, + }) + af_vertical_move_step1: int = field(default=3, + metadata={ + 'base_type': 'integer', + 'read_only': False + }) + af_horizontal_move_step1: int = field(default=3, + metadata={ + 'base_type': 'integer', + 'read_only': False + }) + economy_mode: Economy = field(default=Economy.OFF, + metadata={ + 'base_type': 'boolean', + 'read_only': False, + 'dataclasses_json': { + 'encoder': lambda x: x.name, + 'decoder': lambda x: Economy[x] + } + }) diff --git a/legacy/query_handlers.py b/legacy/query_handlers.py new file mode 100644 index 0000000..2feb3ce --- /dev/null +++ b/legacy/query_handlers.py @@ -0,0 +1,157 @@ +from aiohttp import web +import base64 +from Crypto.Cipher import AES +from http import HTTPStatus +import json +import math +import logging +import queue +import random +import string +import time +from typing import Callable + +from .config import Config, Encryption +from .aircon import Device +from .error import Error, KeyIdReplaced + + +class QueryHandlers: + + def __init__(self, devices: [Device]): + self._devices_map = {} + for device in devices: + self._devices_map[device.ip_address] = device + + async def key_exchange_handler(self, request: web.Request) -> web.Response: + """Handles a key exchange. + Accepts the AC's random and time and pass its own. + Note that a key encryption component is the lanip_key, mapped to the + lanip_key_id provided by the AC. This secret part is provided by HiSense + server. Fortunately the lanip_key_id (and lanip_key) are static for a given + AC. + """ + updated_keys = {} + post_data = await request.text() + print(post_data) + data = json.loads(post_data) + try: + key = data['key_exchange'] + if key['ver'] != 1 or key['proto'] != 1 or key.get('sec'): + logging.error(f'Invalid key exchange: {data}') + raise web.HTTPBadRequest(reason=f'Invalid key exchange: {data}') + updated_keys = self._devices_map[request.remote].update_key(key) + except KeyIdReplaced as e: + logging.error(f'{e.title}\n{e.message}') + return web.Response(status=HTTPStatus.NOT_FOUND.value, reason=f'{e.title}\n{e.message}') + print(updated_keys) + return web.json_response(updated_keys) + + async def command_handler(self, request: web.Request) -> web.Response: + """Handles a command request. + Request arrives from the AC. takes a command from the queue, + builds the JSON, encrypts and signs it, and sends it to the AC. + """ + command = {} + device = self._devices_map[request.remote] + command['seq_no'] = device.get_command_seq_no() + try: + command_entry = device.commands_queue.get_nowait() + command['data'], property_updater = command_entry.command, command_entry.updater + except queue.Empty: + command['data'], property_updater = {}, None + if property_updater: + property_updater() #TODO: should be async as well? + return web.json_response(self._encrypt_and_sign(device, command)) + + async def property_update_handler(self, request: web.Request) -> web.Response: + """Handles a property update request. + Decrypts, validates, and pushes the value into the local properties store. + """ + device = self._devices_map[request.remote] + post_data = await request.text() + data = json.loads(post_data) + try: + update = self._decrypt_and_validate(device, data) + except Error: + logging.exception('Failed to parse property.') + return web.Response(status=HTTPStatus.BAD_REQUEST.value, reason='Failed to parse property.') + response = web.Response() + if not device.is_update_valid(update['seq_no']): + return response + try: + if not update['data']: + logging.info('Unsupported update message = {}'.format(update['seq_no'])) + return response + name = update['data']['name'] + # Fix A/C typos. + if name == 'f_votage': + name = 'f_voltage' + value = device.parse_property(name, update['data']['value']) + logging.debug(f"Updating {device}.{name} to {value} ({update['data']['value']})") + device.update_property(name, value) + logging.debug(f"Updated{device}: {device.get_all_properties()})") + except Exception as ex: + logging.error('Failed to handle {}. Exception = {}'.format(update, ex)) + #TODO: Should return internal error? + return response + + async def get_status_handler(self, request: web.Request) -> web.Response: + """Handles get status request (by a smart home hub). + Returns the current internally stored state of the AC. + """ + devices = [] + for device in self._devices_map.values(): + if 'device_ip' in request.query.keys() and device.ip_address != request.query['device_ip']: + continue + devices.append({'ip': device.ip_address, 'props': device.get_all_properties().to_dict()}) + return web.json_response({'devices': devices}) + + async def queue_command_handler(self, request: web.Request) -> web.Response: + """Handles queue command request (by a smart home hub). + """ + device = self._devices_map.get(request.query.get('device_ip')) + if not device: + if len(self._devices_map) == 1: + device = list(self._devices_map.values())[0] + else: + raise web.HTTPBadRequest(reason=f'Device "{request.query.get("device_ip")}" not found.') + try: + device.queue_command(request.query['property'], request.query['value']) + except Exception as ex: + logging.exception('Failed to queue command.') + raise web.HTTPBadRequest(f'Failed to queue command:\n{ex!r}') + return web.json_response({'queued_commands': device.commands_queue.qsize()}) + + def _encrypt_and_sign(self, device: Device, data: dict) -> dict: + text = json.dumps(data) + logging.debug('Encrypting: {}'.format(text)) + text = text.encode('utf-8') + encryption = device.get_app_encryption() + return { + "enc": base64.b64encode(encryption.cipher.encrypt(self.pad(text))).decode('utf-8'), + "sign": base64.b64encode(Encryption.hmac_digest(encryption.sign_key, text)).decode('utf-8') + } + + def _decrypt_and_validate(self, device: Device, data: dict) -> dict: + encryption = device.get_dev_encryption() + text = self.unpad(encryption.cipher.decrypt(base64.b64decode(data['enc']))) + sign = base64.b64encode(Encryption.hmac_digest(encryption.sign_key, text)).decode('utf-8') + if sign != data['sign']: + raise Error(f'Invalid signature for:\n{text.decode("utf-8", errors="backslashreplace")}!') + logging.debug('Decrypted: %s', text.decode('utf-8')) + try: + return json.loads(text.decode('utf-8')) + except Exception as ex: + raise Error(f'Failed to decode message, {ex!r}:\n{text.decode("utf-8")}') + + @staticmethod + def pad(data: bytes): + """Zero padding for AES data encryption (non standard).""" + new_size = math.ceil(len(data) / AES.block_size) * AES.block_size + return data.ljust(new_size, bytes([0])) + + @staticmethod + def unpad(data: bytes): + """Remove Zero padding for AES data encryption (non standard).""" + return data.rstrip(bytes([0])) diff --git a/legacy/requirements.txt b/legacy/requirements.txt new file mode 100644 index 0000000..1901521 --- /dev/null +++ b/legacy/requirements.txt @@ -0,0 +1,7 @@ +aiohttp==3.13.1 +dataclasses_json +pycryptodome +paho-mqtt==1.6.1 +tenacity +get-mac +retry diff --git a/legacy/run.sh b/legacy/run.sh new file mode 100755 index 0000000..d6de18d --- /dev/null +++ b/legacy/run.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -e +. /opt/aircon/.venv/bin/activate +python /opt/aircon/main.py \ + --log_level DEBUG \ + --stderr \ + run \ + --port 3355 \ + --mqtt_host 192.168.88.1 \ + --mqtt_user "aircon:ic6ahrahbaijaVe3eTip" \ + --local_ip 192.168.88.1 \ + --config /opt/aircon/config_kata.json \ + "$@" diff --git a/tools/probe_mdns.py b/tools/probe_mdns.py new file mode 100644 index 0000000..690d559 --- /dev/null +++ b/tools/probe_mdns.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Пассивная mDNS-проба модуля кондиционера: A-запрос .local +на 224.0.0.251:5353 и :10276 (нестандартный порт Ayla из APK).""" +import socket, struct, sys, time + +DSN = "AC000W002879281" +HOST = DSN + ".local" + +def qname(name): + out = b"" + for lbl in name.split("."): + out += bytes([len(lbl)]) + lbl.encode() + return out + b"\x00" + +def query_packet(txid): + # standard query, RD=1, one question: A, class IN + header = struct.pack(">HHHHHH", txid, 0x0100, 1, 0, 0, 0) + return header + qname(HOST) + struct.pack(">HH", 1, 1) + +def parse_name(buf, off): + parts = [] + while True: + l = buf[off] + if l == 0: + off += 1 + break + if l & 0xC0 == 0xC0: + ptr = struct.unpack(">H", buf[off:off+2])[0] & 0x3FFF + parts.append(parse_name(buf, ptr)[0]) + off += 2 + break + parts.append(buf[off+1:off+1+l].decode("latin1")) + off += 1 + l + return ".".join(parts), off + +def parse_answers(buf): + try: + qd = struct.unpack(">H", buf[4:6])[0] + an = struct.unpack(">H", buf[6:8])[0] + off = 12 + for _ in range(qd): + _, off = parse_name(buf, off) + off += 4 + found = [] + for _ in range(an): + name, off = parse_name(buf, off) + rtype, rclass, ttl, rdlen = struct.unpack(">HHIH", buf[off:off+10]) + off += 10 + rdata = buf[off:off+rdlen] + if rtype == 1 and rdlen == 4: + ip = ".".join(str(b) for b in rdata) + found.append((name, ip, rclass, ttl)) + off += rdlen + return found + except Exception as e: + return [("parse-error", str(e), 0, 0)] + +def probe(port, txid, wait=4.0): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + s.bind(("", 0)) + pkt = query_packet(txid) + for _ in range(3): + s.sendto(pkt, ("224.0.0.251", port)) + time.sleep(0.5) + s.settimeout(wait) + try: + while True: + data, addr = s.recvfrom(4096) + print(f" [:{port}] from {addr}: {len(data)} bytes") + for rec in parse_answers(data): + print(f" {rec}") + except socket.timeout: + pass + finally: + s.close() + +if __name__ == "__main__": + print("mDNS A?", HOST) + print("-- querying :5353") + probe(5353, 0x1234) + print("-- querying :10276") + probe(10276, 0x1235) diff --git a/tools/probe_reference.py b/tools/probe_reference.py new file mode 100644 index 0000000..dccc01b --- /dev/null +++ b/tools/probe_reference.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Эталонный клиент LAN-протокола FGLair (сторона «приложения»). + +Проверен на реальном модуле AP-WC1E (fw 2.6.17-fgl2). Соответствует +docs/PROTOCOL.md, включая поведение, подтверждённое живыми тестами: + - keep-alive каждые 15 c (модуль сам инициирует re-key при возрасте + сессии >= ~44 c — это штатная ротация, обрабатывается прозрачно); + - 206/200 в commands.json, NUL-паддинг (Java-вариант); + - 401 при ошибке расшифровки, 412 при несовпадении key_id; + - записи не эхируются: оптимистичное обновление + GET-подтверждение; + - delete_session при выходе (освобождает один из 2 слотов модуля). + +Использование: + python probe_reference.py config_kata.json monitor 60 + python probe_reference.py config_kata.json get operation_mode fan_speed + python probe_reference.py config_kata.json set fan_speed 3 +Зависимости: pycryptodome.""" +import base64, hmac, json, random, socket, string, sys, threading, time +import http.client +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from Crypto.Cipher import AES + +T0 = time.time() +log = lambda *a: print(f"+{time.time()-T0:7.1f}s", *a, flush=True) + +def rand_token(n=16): + return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(n)) + +class Crypto: + """Ключи/цепочки по PROTOCOL.md §3. ВНИМАНИЕ: CBC-состояние непрерывно + в рамках сессии (одно сообщение — следующее продолжает цепочку).""" + def __init__(self, lanip_key, rnd1, rnd2, t1, t2): + k = lanip_key.encode() + b1, b2, s1, s2 = rnd1.encode(), rnd2.encode(), str(t1).encode(), str(t2).encode() + def m(msg, suf): + msg = msg + bytes([suf]) + return hmac.digest(k, hmac.digest(k, msg, "sha256") + msg, "sha256") + A, D = b1 + b2 + s1 + s2, b2 + b1 + s2 + s1 + self.app_sign, self.dev_sign = m(A, 0x30), m(D, 0x30) + self._e = AES.new(m(A, 0x31), AES.MODE_CBC, m(A, 0x32)[:16]) + self._d = AES.new(m(D, 0x31), AES.MODE_CBC, m(D, 0x32)[:16]) + self.seq = 0 + def enc_sign(self, data) -> bytes: + self.seq += 1 + raw = json.dumps({"seq_no": self.seq - 1, "data": data}, + separators=(",", ":")).encode() + n = ((len(raw) + 1 + 15) // 16) * 16 # >=1 NUL, кратно 16 + sign = base64.b64encode(hmac.digest(self.app_sign, raw, "sha256")).decode() + enc = base64.b64encode(self._e.encrypt(raw.ljust(n, b"\x00"))).decode() + return json.dumps({"enc": enc, "sign": sign}, separators=(",", ":")).encode() + def decrypt_validate(self, body: dict): + ptb = self._d.decrypt(base64.b64decode(body["enc"])).rstrip(b"\x00") + ok = base64.b64encode(hmac.digest(self.dev_sign, ptb, "sha256")).decode() == body.get("sign") + return ok, ptb + +class ReferenceClient: + def __init__(self, cfg_path, port=10275, keepalive=15.0): + cfg = json.load(open(cfg_path)) + self.ip, self.dsn = cfg["ip_address"], cfg["dsn"] + self.key, self.key_id = cfg["lanip_key"], cfg["lanip_key_id"] + self.port, self.keepalive = port, keepalive + self.crypto = None + self.queue = [] # [(payload, note)] + self.cmd_id = 0 + self.props = {} # кэш значений + self.pushes = 0 + self.online = threading.Event() + self.lock = threading.Lock() + self._ka_stop = threading.Event() + + # ---------------- HTTP-сервер (входящие от модуля) ---------------- + def _handler(self): + cli = self + class H(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + def log_message(self, *a): pass + def _body(self): + n = int(self.headers.get("Content-Length") or 0) + return self.rfile.read(n) if n else b"" + def _send(self, code, body=b""): + self.send_response(code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if body: self.wfile.write(body) + def do_POST(self): + body, path = self._body(), self.path.split("?")[0] + if path == "/local_lan/key_exchange.json": + ke = json.loads(body)["key_exchange"] + if ke.get("ver") != 1 or ke.get("proto") != 1: + self._send(426, b'{"error":"Unsupported crypto version"}'); return + if ke.get("key_id") != cli.key_id: + log(f"KEY ROTATED: {ke.get('key_id')} != {cli.key_id} -> 412") + self._send(412, b'{"error":"Keys do not match"}'); return + rnd2, t2 = rand_token(), time.monotonic_ns() + cli.crypto = Crypto(cli.key, ke["random_1"], rnd2, ke["time_1"], t2) + log(f"KEY_EXCHANGE (re-key ok, random_1={ke['random_1']!r})") + self._send(200, json.dumps( + {"random_2": rnd2, "time_2": t2}).encode()); return + if path.endswith("/local_lan/property/datapoint.json"): + if cli.crypto is None: + self._send(401, b'{"error":"Decryption failed"}'); return + ok, ptb = cli.crypto.decrypt_validate(json.loads(body)) + if not ok: + log("DATAPPOINT: подпись/расшифровка НЕ сошлись -> 401") + self._send(401, b'{"error":"Decryption failed"}'); return + cli.pushes += 1 + try: + d = json.loads(ptb)["data"] + with cli.lock: + cli.props[d["name"]] = d.get("value") + log(f"PUSH {d['name']} = {d.get('value')}" + f" (query={self.path.split('?', 1)[-1] or '-'})") + except Exception as e: + log("PUSH parse error:", e) + self._send(200); return + if path.endswith("/ack.json"): + log("ACK", body[:120]); self._send(200); return + self._send(404) + def do_GET(self): + if self.path.split("?")[0] == "/local_lan/commands.json": + if cli.crypto is None: + self._send(401, b'{"error":"Decryption failed"}'); return + with cli.lock: + payload, note = cli.queue.pop(0) if cli.queue else ({}, "empty") + rest = len(cli.queue) + code = 206 if rest else 200 + log(f"COMMANDS -> {note} [{code}]") + self._send(code, cli.crypto.enc_sign(payload)); return + self._send(404) + return H + + # ---------------- исходящие ---------------- + def local_reg(self, notify, first=False): + method = "POST" if first else "PUT" + url = f"/local_reg.json" + (f"?dsn={self.dsn}" if first else "") + body = json.dumps({"local_reg": {"ip": self._my_ip(), "notify": 1 if notify else 0, + "port": self.port, "uri": "/local_lan"}}) + c = http.client.HTTPConnection(self.ip, timeout=10) + try: + c.request(method, url, body=body, headers={ + "Accept": "application/json", "Connection": "keep-alive", + "Content-Type": "application/json", "Accept-Encoding": "gzip"}) + r = c.getresponse(); r.read() + if r.status == 503: + log("local_reg -> 503: нет свободных слотов (заняты 2 сессии)") + return r.status + finally: + c.close() + + @staticmethod + def _my_ip(): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.connect(("10.255.255.255", 1)); return s.getsockname()[0] + finally: + s.close() + + def queue_get(self, prop): + with self.lock: + self.cmd_id += 1 + cid = self.cmd_id + self.queue.append(({"cmds": [{"cmd": { + "method": "GET", "resource": "property.json?name=" + prop, + "uri": "/local_lan/property/datapoint.json", "data": "", + "cmd_id": cid}}]}, f"GET {prop}")) + + def queue_set(self, prop, value): + with self.lock: + self.queue.append(({"properties": [{"property": { + "base_type": "integer", "name": prop, "value": value, + "id": rand_token(8)}}]}, f"SET {prop}={value}")) + self.props[prop] = value # оптимистично: эха нет + + def queue_delete_session(self): + with self.lock: + self.queue.append(({"cmds": [{"cmd": {"cmd_id": 0, "method": "DELETE", + "resource": "local_reg.json", "data": "delete_session", + "uri": "/local_lan"}}]}, "DELETE session")) + + # ---------------- жизненный цикл ---------------- + def start(self, timeout=15): + srv = ThreadingHTTPServer(("0.0.0.0", self.port), self._handler()) + srv.handle_error = lambda *a: None # RST от модуля — норма + threading.Thread(target=srv.serve_forever, daemon=True).start() + self._srv = srv + st = self.local_reg(notify=0, first=True) + t0 = time.time() + while time.time() - t0 < timeout: + if self.crypto and self.pushes >= 0 and self._activated: + break + time.sleep(0.05) + if not self._activated: + raise RuntimeError("сессия не активировалась (нет опроса commands.json после KE)") + threading.Thread(target=self._keepalive_loop, daemon=True).start() + self.online.set() + + _activated = False + def notify_activation(self): + self._activated = True + + def _keepalive_loop(self): + while not self._ka_stop.wait(self.keepalive): + try: + notify = bool(self.queue) + self.local_reg(notify=notify) + except Exception as e: + log("keep-alive error:", e) + + def stop(self): + self._ka_stop.set() + try: + self.queue_delete_session() + self.local_reg(notify=True) + time.sleep(2) + except Exception: + pass + self._srv.shutdown() + +# ------------------------------------------------------------------ +def patch_activation(cli): + """Активация = первый GET commands.json после KE.""" + orig = cli._handler + def wrapper(): + H = orig() + class H2(H): + def do_GET(self): + cli.notify_activation() + H.do_GET(self) + return H2 + cli._handler = wrapper + +def main(): + if len(sys.argv) < 3: + print(__doc__); return + cfg, cmd = sys.argv[1], sys.argv[2] + cli = ReferenceClient(cfg) + patch_activation(cli) + cli.start() + log("сессия установлена") + try: + if cmd == "get": + for p in sys.argv[3:]: + cli.queue_get(p) + cli.local_reg(notify=True) + time.sleep(8) + elif cmd == "set": + prop, val = sys.argv[3], int(sys.argv[4]) + cli.queue_set(prop, val) + time.sleep(0.3) + cli.local_reg(notify=True) + time.sleep(3) + cli.queue_get(prop) # GET-подтверждение (эха нет) + cli.local_reg(notify=True) + time.sleep(5) + elif cmd == "monitor": + for p in ("operation_mode", "fan_speed", "adjust_temperature", + "display_temperature", "wifi_led_enable"): + cli.queue_get(p) + cli.local_reg(notify=True) + time.sleep(int(sys.argv[3]) if len(sys.argv) > 3 else 60) + log("кэш свойств:", json.dumps(cli.props, ensure_ascii=False)) + finally: + cli.stop() + log("сессия закрыта (delete_session)") + +if __name__ == "__main__": + main()