From 0a9a5f551c4683a83ca414590120e048eef77db4 Mon Sep 17 00:00:00 2001 From: Terdious Date: Sat, 3 May 2025 13:32:22 +0200 Subject: [PATCH 01/69] Add Tessie integration with initial service setup and UI components --- .../src/assets/integrations/cover/tessie.jpg | Bin 0 -> 20615 bytes .../cover/tessie.jpg:Zone.Identifier | 2 + front/src/components/app.jsx | 9 + front/src/config/i18n/en.json | 83 +- front/src/config/i18n/fr.json | 85 +- front/src/config/integrations/devices.json | 7 +- .../all/tessie/TessieDeviceBox.jsx | 427 ++++++++ .../integration/all/tessie/TessiePage.jsx | 72 ++ .../all/tessie/device-page/DeviceTab.jsx | 141 +++ .../all/tessie/device-page/EmptyState.jsx | 23 + .../tessie/device-page/StateConnection.jsx | 45 + .../all/tessie/device-page/index.js | 137 +++ .../all/tessie/device-page/style.css | 7 + .../all/tessie/discover-page/DiscoverTab.jsx | 135 +++ .../all/tessie/discover-page/EmptyState.jsx | 13 + .../tessie/discover-page/StateConnection.jsx | 49 + .../all/tessie/discover-page/index.js | 133 +++ .../all/tessie/discover-page/style.css | 7 + .../all/tessie/setup-page/SetupTab.jsx | 220 ++++ .../all/tessie/setup-page/StateConnection.jsx | 40 + .../all/tessie/setup-page/index.js | 310 ++++++ .../all/tessie/setup-page/style.css | 35 + .../routes/integration/all/tessie/style.css | 5 + server/services/matter/package-lock.json | 234 ++++ server/services/tessie/.eslintrc.json | 5 + .../services/tessie/api/tessie.controller.js | 134 +++ server/services/tessie/index.js | 50 + .../lib/device/netatmo.buildFeaturesCommon.js | 83 ++ .../device/netatmo.buildFeaturesCommonTemp.js | 34 + .../netatmo.buildFeaturesSpecifEnergy.js | 129 +++ .../netatmo.buildFeaturesSpecifWeather.js | 187 ++++ .../lib/device/netatmo.convertDeviceEnergy.js | 114 ++ .../netatmo.convertDeviceNotSupported.js | 40 + .../device/netatmo.convertDeviceWeather.js | 162 +++ .../lib/device/netatmo.deviceMapping.js | 111 ++ .../tessie/lib/device/netatmo.updateNRV.js | 71 ++ server/services/tessie/lib/index.js | 93 ++ .../services/tessie/lib/netatmo.disconnect.js | 24 + .../tessie/lib/netatmo.discoverDevices.js | 64 ++ .../tessie/lib/netatmo.getAccessToken.js | 36 + .../tessie/lib/netatmo.getConfiguration.js | 31 + .../tessie/lib/netatmo.getRefreshToken.js | 37 + .../services/tessie/lib/netatmo.getStatus.js | 18 + server/services/tessie/lib/netatmo.init.js | 20 + .../tessie/lib/netatmo.loadDeviceDetails.js | 118 +++ .../tessie/lib/netatmo.loadDevices.js | 161 +++ .../lib/netatmo.loadThermostatDetails.js | 62 ++ .../lib/netatmo.loadWeatherStationDetails.js | 64 ++ .../tessie/lib/netatmo.pollRefreshingToken.js | 29 + .../lib/netatmo.pollRefreshingValues.js | 57 + .../tessie/lib/netatmo.refreshingTokens.js | 73 ++ .../tessie/lib/netatmo.retrieveTokens.js | 83 ++ .../tessie/lib/netatmo.saveConfiguration.js | 34 + .../services/tessie/lib/netatmo.saveStatus.js | 113 ++ .../services/tessie/lib/netatmo.setTokens.js | 32 + .../services/tessie/lib/netatmo.setValue.js | 84 ++ .../tessie/lib/netatmo.updateValues.js | 65 ++ server/services/tessie/lib/tessie.connect.js | 33 + .../tessie/lib/update/netatmo.updateNAMain.js | 113 ++ .../lib/update/netatmo.updateNAModule1.js | 75 ++ .../lib/update/netatmo.updateNAModule2.js | 93 ++ .../lib/update/netatmo.updateNAModule3.js | 66 ++ .../lib/update/netatmo.updateNAModule4.js | 94 ++ .../tessie/lib/update/netatmo.updateNAPlug.js | 53 + .../lib/update/netatmo.updateNATherm1.js | 78 ++ .../tessie/lib/update/netatmo.updateNRV.js | 71 ++ .../lib/utils/tessie.buildScopesConfig.js | 26 + .../tessie/lib/utils/tessie.constants.js | 107 ++ server/services/tessie/package-lock.json | 39 + server/services/tessie/package.json | 19 + server/test/services/tessie/.eslintrc.json | 6 + .../controllers/netatmo.controller.test.js | 132 +++ server/test/services/tessie/index.test.js | 58 + .../netatmo.convertDeviceEnergy.test.js | 263 +++++ .../netatmo.convertDeviceNotSupported.test.js | 93 ++ .../netatmo.convertDeviceWeather.test.js | 262 +++++ .../lib/device/netatmo.deviceMapping.test.js | 104 ++ server/test/services/tessie/lib/index.test.js | 25 + .../tessie/lib/netatmo.connect.test.js | 49 + .../tessie/lib/netatmo.disconnect.test.js | 59 ++ .../lib/netatmo.discoverDevices.test.js | 103 ++ .../tessie/lib/netatmo.getAccessToken.test.js | 71 ++ .../lib/netatmo.getConfiguration.test.js | 73 ++ .../lib/netatmo.getRefreshToken.test.js | 89 ++ .../tessie/lib/netatmo.getStatus.test.js | 34 + .../services/tessie/lib/netatmo.init.test.js | 98 ++ .../lib/netatmo.loadDeviceDetails.test.js | 321 ++++++ .../tessie/lib/netatmo.loadDevices.test.js | 309 ++++++ .../lib/netatmo.loadThermostatDetails.test.js | 143 +++ .../netatmo.loadWeatherStationDetails.test.js | 144 +++ .../lib/netatmo.pollRefreshingTokens.test.js | 155 +++ .../lib/netatmo.pollRefreshingValues.test.js | 129 +++ .../lib/netatmo.refreshingTokens.test.js | 211 ++++ .../tessie/lib/netatmo.retrieveToken.test.js | 236 +++++ .../lib/netatmo.saveConfiguration.test.js | 73 ++ .../tessie/lib/netatmo.saveStatus.test.js | 298 ++++++ .../tessie/lib/netatmo.setTokens.test.js | 89 ++ .../tessie/lib/netatmo.setValue.test.js | 207 ++++ .../tessie/lib/netatmo.updateValues.test.js | 166 +++ .../lib/update/netatmo.updateNAMain.test.js | 142 +++ .../update/netatmo.updateNAModule1.test.js | 117 ++ .../update/netatmo.updateNAModule2.test.js | 130 +++ .../update/netatmo.updateNAModule3.test.js | 113 ++ .../update/netatmo.updateNAModule4.test.js | 129 +++ .../lib/update/netatmo.updateNAPlug.test.js | 117 ++ .../lib/update/netatmo.updateNATherm1.test.js | 111 ++ .../lib/update/netatmo.updateNRV.test.js | 118 +++ .../netatmo.convertDevices.mock.test.json | 996 ++++++++++++++++++ .../netatmo.discoverDevices.mock.test.json | 108 ++ .../netatmo.getThermostat.mock.test.json | 63 ++ .../netatmo.getWeatherStation.mock.test.json | 149 +++ .../tessie/netatmo.homesdata.mock.test.json | 181 ++++ .../tessie/netatmo.homestatus.mock.test.json | 176 ++++ .../tessie/netatmo.loadDevices.mock.test.json | 805 ++++++++++++++ ...netatmo.loadDevicesComplete.mock.test.json | 979 +++++++++++++++++ .../netatmo.loadDevicesDetails.mock.test.json | 740 +++++++++++++ ...tatmo.loadThermostatDetails.mock.test.json | 126 +++ ...o.loadWeatherStationDetails.mock.test.json | 578 ++++++++++ .../test/services/tessie/netatmo.mock.test.js | 64 ++ 119 files changed, 15443 insertions(+), 3 deletions(-) create mode 100644 front/src/assets/integrations/cover/tessie.jpg create mode 100644 front/src/assets/integrations/cover/tessie.jpg:Zone.Identifier create mode 100644 front/src/routes/integration/all/tessie/TessieDeviceBox.jsx create mode 100644 front/src/routes/integration/all/tessie/TessiePage.jsx create mode 100644 front/src/routes/integration/all/tessie/device-page/DeviceTab.jsx create mode 100644 front/src/routes/integration/all/tessie/device-page/EmptyState.jsx create mode 100644 front/src/routes/integration/all/tessie/device-page/StateConnection.jsx create mode 100644 front/src/routes/integration/all/tessie/device-page/index.js create mode 100644 front/src/routes/integration/all/tessie/device-page/style.css create mode 100644 front/src/routes/integration/all/tessie/discover-page/DiscoverTab.jsx create mode 100644 front/src/routes/integration/all/tessie/discover-page/EmptyState.jsx create mode 100644 front/src/routes/integration/all/tessie/discover-page/StateConnection.jsx create mode 100644 front/src/routes/integration/all/tessie/discover-page/index.js create mode 100644 front/src/routes/integration/all/tessie/discover-page/style.css create mode 100644 front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx create mode 100644 front/src/routes/integration/all/tessie/setup-page/StateConnection.jsx create mode 100644 front/src/routes/integration/all/tessie/setup-page/index.js create mode 100644 front/src/routes/integration/all/tessie/setup-page/style.css create mode 100644 front/src/routes/integration/all/tessie/style.css create mode 100644 server/services/matter/package-lock.json create mode 100644 server/services/tessie/.eslintrc.json create mode 100644 server/services/tessie/api/tessie.controller.js create mode 100644 server/services/tessie/index.js create mode 100644 server/services/tessie/lib/device/netatmo.buildFeaturesCommon.js create mode 100644 server/services/tessie/lib/device/netatmo.buildFeaturesCommonTemp.js create mode 100644 server/services/tessie/lib/device/netatmo.buildFeaturesSpecifEnergy.js create mode 100644 server/services/tessie/lib/device/netatmo.buildFeaturesSpecifWeather.js create mode 100644 server/services/tessie/lib/device/netatmo.convertDeviceEnergy.js create mode 100644 server/services/tessie/lib/device/netatmo.convertDeviceNotSupported.js create mode 100644 server/services/tessie/lib/device/netatmo.convertDeviceWeather.js create mode 100644 server/services/tessie/lib/device/netatmo.deviceMapping.js create mode 100644 server/services/tessie/lib/device/netatmo.updateNRV.js create mode 100644 server/services/tessie/lib/index.js create mode 100644 server/services/tessie/lib/netatmo.disconnect.js create mode 100644 server/services/tessie/lib/netatmo.discoverDevices.js create mode 100644 server/services/tessie/lib/netatmo.getAccessToken.js create mode 100644 server/services/tessie/lib/netatmo.getConfiguration.js create mode 100644 server/services/tessie/lib/netatmo.getRefreshToken.js create mode 100644 server/services/tessie/lib/netatmo.getStatus.js create mode 100644 server/services/tessie/lib/netatmo.init.js create mode 100644 server/services/tessie/lib/netatmo.loadDeviceDetails.js create mode 100644 server/services/tessie/lib/netatmo.loadDevices.js create mode 100644 server/services/tessie/lib/netatmo.loadThermostatDetails.js create mode 100644 server/services/tessie/lib/netatmo.loadWeatherStationDetails.js create mode 100644 server/services/tessie/lib/netatmo.pollRefreshingToken.js create mode 100644 server/services/tessie/lib/netatmo.pollRefreshingValues.js create mode 100644 server/services/tessie/lib/netatmo.refreshingTokens.js create mode 100644 server/services/tessie/lib/netatmo.retrieveTokens.js create mode 100644 server/services/tessie/lib/netatmo.saveConfiguration.js create mode 100644 server/services/tessie/lib/netatmo.saveStatus.js create mode 100644 server/services/tessie/lib/netatmo.setTokens.js create mode 100644 server/services/tessie/lib/netatmo.setValue.js create mode 100644 server/services/tessie/lib/netatmo.updateValues.js create mode 100644 server/services/tessie/lib/tessie.connect.js create mode 100644 server/services/tessie/lib/update/netatmo.updateNAMain.js create mode 100644 server/services/tessie/lib/update/netatmo.updateNAModule1.js create mode 100644 server/services/tessie/lib/update/netatmo.updateNAModule2.js create mode 100644 server/services/tessie/lib/update/netatmo.updateNAModule3.js create mode 100644 server/services/tessie/lib/update/netatmo.updateNAModule4.js create mode 100644 server/services/tessie/lib/update/netatmo.updateNAPlug.js create mode 100644 server/services/tessie/lib/update/netatmo.updateNATherm1.js create mode 100644 server/services/tessie/lib/update/netatmo.updateNRV.js create mode 100644 server/services/tessie/lib/utils/tessie.buildScopesConfig.js create mode 100644 server/services/tessie/lib/utils/tessie.constants.js create mode 100644 server/services/tessie/package-lock.json create mode 100644 server/services/tessie/package.json create mode 100644 server/test/services/tessie/.eslintrc.json create mode 100644 server/test/services/tessie/controllers/netatmo.controller.test.js create mode 100644 server/test/services/tessie/index.test.js create mode 100644 server/test/services/tessie/lib/device/netatmo.convertDeviceEnergy.test.js create mode 100644 server/test/services/tessie/lib/device/netatmo.convertDeviceNotSupported.test.js create mode 100644 server/test/services/tessie/lib/device/netatmo.convertDeviceWeather.test.js create mode 100644 server/test/services/tessie/lib/device/netatmo.deviceMapping.test.js create mode 100644 server/test/services/tessie/lib/index.test.js create mode 100644 server/test/services/tessie/lib/netatmo.connect.test.js create mode 100644 server/test/services/tessie/lib/netatmo.disconnect.test.js create mode 100644 server/test/services/tessie/lib/netatmo.discoverDevices.test.js create mode 100644 server/test/services/tessie/lib/netatmo.getAccessToken.test.js create mode 100644 server/test/services/tessie/lib/netatmo.getConfiguration.test.js create mode 100644 server/test/services/tessie/lib/netatmo.getRefreshToken.test.js create mode 100644 server/test/services/tessie/lib/netatmo.getStatus.test.js create mode 100644 server/test/services/tessie/lib/netatmo.init.test.js create mode 100644 server/test/services/tessie/lib/netatmo.loadDeviceDetails.test.js create mode 100644 server/test/services/tessie/lib/netatmo.loadDevices.test.js create mode 100644 server/test/services/tessie/lib/netatmo.loadThermostatDetails.test.js create mode 100644 server/test/services/tessie/lib/netatmo.loadWeatherStationDetails.test.js create mode 100644 server/test/services/tessie/lib/netatmo.pollRefreshingTokens.test.js create mode 100644 server/test/services/tessie/lib/netatmo.pollRefreshingValues.test.js create mode 100644 server/test/services/tessie/lib/netatmo.refreshingTokens.test.js create mode 100644 server/test/services/tessie/lib/netatmo.retrieveToken.test.js create mode 100644 server/test/services/tessie/lib/netatmo.saveConfiguration.test.js create mode 100644 server/test/services/tessie/lib/netatmo.saveStatus.test.js create mode 100644 server/test/services/tessie/lib/netatmo.setTokens.test.js create mode 100644 server/test/services/tessie/lib/netatmo.setValue.test.js create mode 100644 server/test/services/tessie/lib/netatmo.updateValues.test.js create mode 100644 server/test/services/tessie/lib/update/netatmo.updateNAMain.test.js create mode 100644 server/test/services/tessie/lib/update/netatmo.updateNAModule1.test.js create mode 100644 server/test/services/tessie/lib/update/netatmo.updateNAModule2.test.js create mode 100644 server/test/services/tessie/lib/update/netatmo.updateNAModule3.test.js create mode 100644 server/test/services/tessie/lib/update/netatmo.updateNAModule4.test.js create mode 100644 server/test/services/tessie/lib/update/netatmo.updateNAPlug.test.js create mode 100644 server/test/services/tessie/lib/update/netatmo.updateNATherm1.test.js create mode 100644 server/test/services/tessie/lib/update/netatmo.updateNRV.test.js create mode 100644 server/test/services/tessie/netatmo.convertDevices.mock.test.json create mode 100644 server/test/services/tessie/netatmo.discoverDevices.mock.test.json create mode 100644 server/test/services/tessie/netatmo.getThermostat.mock.test.json create mode 100644 server/test/services/tessie/netatmo.getWeatherStation.mock.test.json create mode 100644 server/test/services/tessie/netatmo.homesdata.mock.test.json create mode 100644 server/test/services/tessie/netatmo.homestatus.mock.test.json create mode 100644 server/test/services/tessie/netatmo.loadDevices.mock.test.json create mode 100644 server/test/services/tessie/netatmo.loadDevicesComplete.mock.test.json create mode 100644 server/test/services/tessie/netatmo.loadDevicesDetails.mock.test.json create mode 100644 server/test/services/tessie/netatmo.loadThermostatDetails.mock.test.json create mode 100644 server/test/services/tessie/netatmo.loadWeatherStationDetails.mock.test.json create mode 100644 server/test/services/tessie/netatmo.mock.test.js diff --git a/front/src/assets/integrations/cover/tessie.jpg b/front/src/assets/integrations/cover/tessie.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2fb89b9a8bb760fd4ec11ee580aaf16ebc2cb19c GIT binary patch literal 20615 zcmeIZXH=72v@RM&MG-~1A`qnrQdPPF`7D5d5CH+H5s@xUq=!UNLHY*>C#^6IcMxU?%rd3D_P_n$y?T3>zU7*^H~el7;6r6 z#z0qJ7j)>*A1!CbrVmd)?&LEJH5l99E0v!i_;{mY&M~8sdzZBLa=r-u^p+kS( z|GlssW&2Z(9XY~wl>Hbx`=5IJ#K{xKk8>PnXXiM@!EurkDC{RrbDiQm{pa}4P5xZ| z=PKaMd7SKH;?g}IIxTP@?CqG`1!c_t+Hl* z(*cr%(&K00CpZKI&z=*Kyd))kSw>k!RZU&v`t3V9x_Wo@4a^>xTUb7{vUYsp^cNu`@hWkAC~<;?BWLOIt+}# z5%xcJ9XcEcR5tD-M=xGI#&gqz-NBbv?ApuYe76!mmo=XdS288>KYljAAt0fQmn8o& z?O!bWUo$NH|H!icVc5UfH38yeI|K|K8#f37Vj@?vqe0UD)PMTm9}N71fqyXY4+j3h zz&{xHFN6VE4?GLhni)Eej`}pvpz&qaLns$JRZ|^X?^rL|ol}1m6e!jxOMx(A=z273 z>Nl)Q{XNoj>?+J0-m0NfxO2tfqHS&`qyhMPAqI#$^|#l(!PD>uQNQ&8d|a zarxQbCf6`e5Xpvf=Eb=5h!_5z3c_iaK`_zhlZc`8;XAN^zqHmVJkgAL@SHc7#pexD z-}9xk_TJvW8T}Q8c+VE(LURx{Yp|r>?$#F@YOtr%xC(mqCxU$dR*n)vIwVAZMHbQt z)}-E&rd(D1Vdc#o!z3*AeO~u6gEX?u)z(iHdli}FeT+I_u)I(;>iJD?)U}QUnk|DR zxY(Z{A3OEkfzJa=E4d6O$cA-wGR&mOq2ldd%8Va4F&6!|-g)m;CWO19KWlohKu7AR zy>m-`;0WSwz8TYcwPrvcm3`Hc1;Tp#1ja%ceuosJMoD`Kw{b2Snbk+RFm`LeB?$jG z{Kwg2#ORjc$YP1KSr2gVt9fBGA3Cz_mVHtT(*L*5j(HK0gJ$SiV-aYvaXrf63G;Cw zE=IbxUSG8GNqQZJjECLz`TdsGBSTkLxKHx#Tj~Q4d8w^NwvisBgXiOJ2j2G1gS8HGYy6R2V}Q7VG*P=0E?{$S49uhnl_3$H`Gkn#^`ajokx^HgeBThLM}nEi%! zxoym5O5%2Kr0^4Ul@Mxvh|V^oPR*sQYGw8a=1KosaHZVK21R^*Pmk85^?|L;cpiEa z#&LJYwx$a44lcq1g+GByQS~J(d0fkbQ1@ew*m{_re8WC*MnJJwU$km-G&S*|HI|bb z%XuaLPP__7e$FS54`>HHsvh{``nLO5e>)wdycVp_$O`_h5&F|_R%oUDda#k86n$s_ zo}D?{i@v=ba~F7wEOl_Xb*ndQe)H7CTBw?ihp?X!uiBoy?PXZLF5P1Z9gnPl#X(%; zpFgBl=+q9i73(w(GPg$8!P|Z2`%qybJ|g%q#XBN|G=_~UGc`VSO@vjLh#c7(&OJn9SKl;-ZH=z}FLV(q!RUFdU z4Q8Xj-xvQW>#pKBm|{PIaqo{2I5?GNqC9h(*3`rWXE)#kcDvvROi4}sQy>Zb_48jdQQiCN5uk+I_f->|iM11x zsk>LXNJV@PYV+zY>z72dAO5S}QAcOTB-)?PoQKau2lTI1&)H+ zcdzLlzd*Vv{0O&*q4T|>2tz9Jq9dc|YF^mpkl}WC?m1@H$L&S%f%DWmt#1!@j$+JY zx#nG3Go|KdUsOR)t*7?*=dO{Ic|Wfy4ZNc`sXq!ma|#X>^>GHjEnjt5cCt`=zBplr z+HfyNeQ3I5Z;CAjGd7rY(+7(I-tRDkH871%|eSVJiv*e*I| zcnK`=ZtF=?oGuW`I@e_5(Rf=6fl{GL(k`J#y*fSgYi2sFf1^QLEHlIet+TDGuZ(-J zdk^TMW!3+TFx3WpEZhiw1Qv~~6sN`?fYl61_Y4ErIE$vg%Ys;-7x)FrRdfmB3$7PT zG626>pW_-=#hM0DW`YWTdm7x*>s^f4$_5*i{RZ#;!VT^+n(@ZBg)1VZ?Lv1gE7KT_ zB*sAb?st0VyoJ9UR=dRIw<$c41*!?8Eh1CGqop;Y85gK$vK!FzNTS$m#qhdXymCex zX`z%`^LB0vPT(8wFWWXH2`SCPO^<7Y!vo?m`2G$E2ilt-t6EB#_c3^tD!N}7AKe`$QW>rr6|&C%i*x0lJ}kMi-#m@s^JVCRZ@zJYu2 zywXG`L@(=KarkeaRx{FMMDkqRB}0aX+0ahL*$DgjE&PQzL~$EOdW!h*@4e&llVka} z%#vdHCs*QxWUdep3zSe#ZMAW`9<(s;BCm{K3Onr_= z%g;D|URHW^?cvv2?53#nPgiJp(p|8rM1}K%R!~-DZhgBEtG(YH*tvu;C`c0^&)9Tv4tyyy$Q2O@lr5MGzwrtt-XVNGd|p`U zTbqJ);wUeS54Kic+GKR8Dd$a4#ks`!#wxB5oQX6cL9i7`mBw{&Ma1y= zQ0vw$suGwF-3LnM99HbGANJMJUL+`80Mll1HJcjKd|ms?w29rV7?6xB_&tx+S1ct-Ri%^=9vN=Jqkt9 z>An-2S@O?)myN8LLV3>Vklb&Dq?jBZNS0>i+nP}S$Gvo=RZGpVfE=z-jHD~t z@gyOQ$JC-=W0@(f!UBEimP&g|^1G0~$>qkSugLg*ml0nE5ZOz((C~w^bYylZaydU$ zk#XWE3^o~vKY!7i`Vb-bW4FH0D&ls{Qdrq;n&bp2h+t z>TRSf6N#jNHXwvMqbz`>Zzq0Xfqp$0JzWAc{{~emR{%!6cWOw{Rk^3SpRoAy0JmVr zpW+Qx!qTtvr4?qdK$8QxkRx0Ebb}FapDYXH>9WQSIBFU45Uc3$@GNCW?*VTp^TjQDcB)g_BiquJKXua}7m7zD{ikPD?9)T9{ z^`6#)U@H~RJBnv6u0&;VCWM2CU7DwncG|*l^#KGARi?F-2`-C-4pxq}`Lv#;k0)3> zer71DFOhx_&xY5;#kd#uK+qca>>bnOy@{2SjqJLrm8yD}TE|DDR8qUzi!SLF^(cAU z$Bk_VHTB6WsG`9x92G!XE0Br6x!v%ar`$-Zbx!Z`ys0VuRw8v3AD>}St^9*PbYkW} z+F3D+6S`^=Yo27#C0TCxnLcY>WiTRQUH|=RR*?6^R*y*3+sn!)6Ja#~n`{EMbn2nX&d} z5ygAv@>{;1%)5-DW}!i=EU`2_V;1On5-da0K3W>RD1PwpU!$P^OrMN<04f5R(-YoU z%!!9oL%a0zT<2C**EwWG3X1N;yp%xh{5rHPH4=I5Tf~v{?0Ls6gv+&qsni)bJA~2bmq|yCPl-M zp@+xB412U>nP(ZMx_7IH4z9ebH>6E!SBJ7d8sxnm*)# z0k?o8PSZqZ5Zw^o?ulZYDDUWp+(EC5rK|BJY^cqzAuJHj+-@frozjLF03TLHPC)Rw zxp+?}P&_ek()Q@jRI~gWmtg)mYSZrH1e->7LH z@Yx5QzTIY?1DWq|i;_zie*cNtx5JjV$uN9G3CU$%v;?2|Y>2;aX_X zWlk<19lsH}mUWzgillC#?>ieKts!e=`T*mNZl%mnoV77I$w;LF`rwy|4u9e&6CFN~ zr5#CeuC$7F6K*sFm;cndWbe+k-Th-fAdw3ro7J6z@_TXZE`L|^ku_KUUkN%-)`_U5_bP&AyY0T#y|jumYCo6jRUsgd@FBZgKFd7;!IOHYbk*US4;+A0;Yn&I^G4ge z`@BqdW3YySQS}V5aSX8Y*CRd?m@;#c1tOWqpQkw2WZeAK-t?W|#>=?rIePF&JmP(P z$&~Kty9+5tT)O(Ue+rnqPE`Y|9M{|Q&Nd8~b=nvG3QZ;`-Mi=WURuM5(G}>OZ8yGx zstP2+@U!EFczXKRA~8Gn8iSs%Zf~jS2LOeB>QJ9rexbhbHy2^H4Jo(aMPq@b`vl=SdtXk2JrM7&)~axWW!ucsw~#x87-_*l~U6Re$zyf;s)TaA-jG zRzQ4~1wU41O;uei zP>U8#m<8(Z078eC)@f9n8o-f2D4B@4%c{z%hI2!6O(ykKMi~iB7`!MW9=S{~WPyH^7}7nukxx&_ zQvA!Q7A2$^e{a69Lda!Lr}{)8MZ&h`M*i8=gba0J`o356zGmP#J>JO7qc1<7cJgu^ zs!U4{9H3~YvA2}B;^JIlOB5_a4FaKEMeacMg^tm#+)*5Fr934I64eNwb6kDD`25JW znOuFj^)#nXP4#-&3g0v6ry~hvd5lzu3vwK6Si=InrkO@Q96iT2&$N1A&mPrTzk_}`IC6pH@MUCfCFLNan%Yl%FK$)_J0?&vMlo7&oB3%&EJCtj zR3)?vy(@pqOnJL|GF=xk^o{KW=rHE~;t!uCyT~@<*B*~Ou$g{EE!?4@jW9y!x`+67 z(^Ido1hg)Ht{#`JZPQZHyVQdnSZ<~ECRhadq~v*6gg2LK^|zXyWn_z%1dK{|!;lLx zJir8>0pnt-QWc=w@QXP4xx|-OZA!-LzGX%}n9>i*Si38t8lN+lzPmYwro=TdGMiM~ z(qK=68X$$nsdWvUvvola2|vYY1Is>ILE@iPQcuQSv3le5rqAbHU>?;wt1wjV87o0f6+>yx}z1qB0$8I<)AjaN(Up zj_)w?r7(Bre8u`Yj4F%xfCtzi$i;e4QO--{6wKHcUOgzg=;*ZNzHT5jr+ijPU!wO0dsKxz;WvX3%PT!LQtVcMa-^t7CZ z8h?G1o>K6(*6a>52+ldjoR6lctBJQdlKDLd=ndDKefnO5tJ}EW)oI@s$(Wu>}@5*42$9V~owSqi^c#_s!FZ+OI& zc@@#6t>Ir*`)kKe<;jgBd3REO`LRGoV)yli6Qij-OlFwrfk#n6Zf$L?pG0|gdzP-0 zWRzwSR72%d$Th;EvF&@?Q^$vxmZzO@8FlsBWy{!bTb#DkIcxJ91>>+Tt8sXLZ4(f| zG+(I%3s5y0Oh}RDHk~s{n$~`HCo@8;cEX=7s67&{hMs=|za8qNnh~th*k*du*K0{b zJkg-?>1mF?gO7+_h3O)jRaqdn7}P7&xe_=e-~BL{gPQV__sf=N#OPeY^iQRhq#qYG z51-}T)4dEl&S*!TOOH{g`1RLsd1vNudBoPf{MfbDXOk%}v4=jG3-9$THyR_~^!Se3 z`ZY9h#J>A3V+J*>@|UJS%ZGjkHYD@Df3$ts>FnzDGT)`w6Qf<@1;P89dqwa)AQ4?& zo)p#k@c=e~9-COU4JHoQnAX{&pSfFo! zGpyc6_pQ9SC?#~f53DT4JFuITR4)cIH}(~@-<~Lz}mH5Q9is; zigJtUm`i`c$N^OEMmNc1!(jz5k-KA2Nat>rQGMhEo)!vWOyC zChTkUDl*%9kPPYX=v3JqZL#^na|kD!qlSkfeP5>=4O#Aw=is*#&X>gXtUg|I^e8a0 z1ITtUNBaQavrvi#3xtYOWPuJ>DU#9LjB4fCmjc>?1N|)pH6FSvF;(G)A1>0gTYJt` zM9kKl4;AQl-|vgwkL`)ciD@-F{q4cUUP{hMeUE|J+biWv!BjzRp-n++IL6tm0ajT3 z85pS!)PsN1m;O!r{6G0F3=L#Q!0JeNWY?TcJvfqiy-D%&y*on21vj?8mf;-|6X<1H z{0UPl24j^MJwbEV#i%U4?Q;A*!1EAU4l6y1aJJlh z{ww#Vyl`~6o>+>L!{0x4k603BiR(0FwT&L~77beUCara%!~fJp7U<1m$88n=|_chY~>-!*1@UkT}^Mv z*3#LS%>Lhv~aPmylj?Y9X}B5SyOIk(O-a^ONNyPQ4aE3IpD%SKT$Y!VVv+aFcv^5G-dd(Qf1K=kC&9>Qz-Ffd`4Yu^W! zJ;W(nj51H8lVSvN#giW!p?E+MLhkKO&VaeVk5Wz@DU*?C5a=Vapi+QZL$R+N%{odM$ofNSvY= zgHGVAQ+}%c`1-lK8s=KZ&QjR7xlD#jRpM@i)_-5BtE)@QO?R_xNU9f3eH<*2oMdE# zUdJ_E9{s&5Z?NVuNn5oJjfr|+nS2*3_Y-QWU+7O)>zHzjo!9hhv)ps*SUUssRo5<3 zK1q)vb)1wcc(J*V&p%`I*}Bw$BUs^eoXv7RWvod0?D!Act9qf>8jwtHJExec4$?5Fp$nZZ85kN+vjne-_aL z-g=hB2GHd$iWzS`=g|a2KnshS>@1MOI)ouywc-TX97bZny}}x4v`iSOXUk;>OtMPY z7or2_*r%Zmgw4rDh*AU&QiXoa0y&pJZ`Y8eBE40*R9thDjzye(vUQ7}lk2^nU29%$ z&>me&4%}`!pv#kltX?>EREv_UJu(3iUYv?QxckaGtjI3lIo9_Edd_3TK3=OMI_&awGx&gzH~a;hojM=G z9{ZfDyeqE0D(s>{q8|g75Xx10RH+?BGM;@6t5l2lGSVcxet*?ZHNY@$I;^@MX;-fM z(+c{{3H!Nt5x?Em-XqmM)Mm4h*8`y+t)W)BiK-toyz{eFUV#y9(jg%=b07AnKMvSM zV!2+aj!L>u;UlP5AkDh%hXscYknu)N?RvE z=d3s-;)-RSb$XG0fv&-7Nje8Znvp`k4cuZ8qzT( zYd2;@vAX;F-jiz;VH^p$ArD~HksCzXUntr35c!gn;jsin#TVHt{a*$)W!MXx+QT?& zy(4}5Gvu&^o{Z?i(XFKc1R^g=^g_RnN7|L<>rr8aUVGb%BTW0qWutSwh;aBHjm!ZK zp*G@EN1M*BC%SFj%l-7Ajuw!i0dB3A9LmCHfTz)KnBws3quRXGK3eO+?#I;YUs0F3 z9(#qgUS8?eJuV)83OhK zEjbM6@Nc{P0{>6p7=!ye3-s=`BI89GpuN9A1X`|SUGxSt;%_0y0@BA*)oM&k{|*NQ zO_lPdXFs450Qm?(N{RrSyM1b$QNS2I%S{7)~s%SCDgQ-h}9qdDad+803+3C?_ z$nJ0Cf#Ho=sw|fNaS3?H<3$Yfoh`11{~&>8&YWPLWMm@5Csq&_NmZ1pFyB0ENJZ1v+Tr72s9 ziLJ(6?emd-8-wvyx+%g(^7#4PF6Sa|Jf!kO(>9r+Iiz}K6tf6eU`$2P&EW$ySHys} z2qOm`nCEUUOzI?U2v~eQVKu0nPK8lnL5gM%9dmxdq zUp*Q*vKZXw7*$&E_=;(+NZ7K(;J%et&@@2gm%^_6k=Fhrs(I%5hjk+We)Cv%lEKa5NkIH&(qWx`Nv0>f7U#{HW&Cr-FIp( zKGuVx2E|00e~VZfb^R@0>S*;iRduRdoU@2+>dehvq-TWoQPyo_sar{vr|3nT{w zYYSD(tuQ&{#h`>1s zQ6uwd{W<9Q+w-rCg7@s>TW*!f@g}GdJ~PV9C~xcKz~dt zu{CetXwV1}W%bp@MzO=Z`7wW6XOnt8TI5SJ-sNaGY(S-uR`GdCzCaiq*P1$K0S~jg|bIdZja;0wpX7io_G-p@)ndZCUp$nNhpf zICS;QlNMiL5WUK_tmycH%=rDESy9GF_{W2yQhEPEQtx*QnLfB8Ttf zfj5<0en(R$Fn>2V5nMhjfydRY5KA$mv+kj4tKc`m(-cU7rZk>EZ4OyU=4KF`^7b6+ z?8~S3tpb4<3d0An{r92NpX2{x;Z#@!5U3_qB@x=~*|rEt_pI$*+>;ZsWT+K1RRS|? zi3Msp6{h#L%=>Mb>L!>WCe=q0PNN8b2Vn;f-ntfGD!d@eGv2hhA?K>3DXwc8;{8V5 zx*qev*4mg4+yF6)-$*!%=o3{U3ATzzbzHBGkw-^Xm7tvJuH|^m{YNYcnN&D@Mm&L{l zCK>y1MJD2XN63}XY=uKt-52RoAXYY za*h-c&oAR!S6R$h*c=_LN*|eC3GfvNHIprhERaS!URLk3o>8OPqi(EFej}U*?4;Ue zUokIH)zDpEscE`RgI+ljT-TKryV=R&3>?f~!cw)+?%Of_xkEEe7F9y;zOIhM#HOwm zscg9nnwD^UGrwFpPjQ=ueAt-%Hj8can5)5-96Nub08_3qx$;!yyco7SQ^MyZWoPD1 z&0!4!uu}CL==`prTYWe?l5wN3dkyaIsd3{Ueqy?g&-;IMvOt;7z$7SYEUiscm70=6 z?P(M8??}Nn@;q;>q1|)VP&sFI>a33DzQecngd?1s3-er588p@q5;c2nLsKiaU!)iH z`eM?#+}kN01sosvL}|CgXsY-LjB$<-)t(d088%p;PLq`1ajQ-qS4I(LVSVcc@lr9; zJx87KCqi|r$G>FQ)K^ghh8F7p!jXsEh1~H%E}rROfgS-8#NcOG=E#9KAnZv;z-WgQ znJFCf-{=9&TeF9N6Lwf2Graowr``-CYK5N&1ET7EP1I2&&Le>#nciU0Sj0R5tR6`v z&!G>9zQ8ts3d?R1jvhzC>~5i$?-Fw6x%TC&=%ECvL;_KGmRI|>?zFctK8O_+Re$O6JmBz7fK*aypy$K7%_wzO+ zXy(36uF{|-6MGael=aFci#525C~2+lTF5&ww5j$Eq^%A)*}cBJfan{zn2>IdiG7Q<9omDrd~w{<$I#FIDhGD|66RcG@*-P`8A;DC zOTNX1%{zxV6U37wf5ecsER=RHHHD+6H5EHkEnDA*cv|}lR;Cs2!k#~E(jy?mTokYM z7Y!SeUo9kjd5(maGJ%z8%N6$4H^C@k7?}bdubC6AnSVUM0=dc|orWB}_dWbC-EkCY z*;+vQp9*n80E=hMkC>V@aoShin8zzJ0#JDMJ2->9$odbr{npF1K7|PS!no$8c##eZ z{=Pl|1&YtW^Jy@ezdcNRBZw;!&?l6lAG#H_XanBSDvDBbrT72sFKR+q%bVPz9o3s17w2D49J@(G|XUVOu6^4HOSS4BV2#Ni<|jEp9= zRi<=9S%yhP#AVOr2!WHAy7)>hHJ=Cz3%d;a=Of)&p!oaD0hn-=H^22*Z{Ea=oXO22 z#em>TcyP3=!&vF%kb2j;y`N49Y=Vl;M>=mK)-$q3Vf-acwC9?D^wI#qAuHu+efBof z#86^2;})P8DDNDuZaN<{3X1^t*F1-uxy@l~ls$VpBvGTK+tFuIpZL*HB}T{1;7dX8 zgI5*C>m%HzAKh=ZgnTV#oZE}~Lw;_4GW#g5((y7D=;j@D7O3+9ibU-NGou!9OwJ(| zD9*BZ#}eRxwtMr6`%Db!IC4u0i)_>C#4H0e^aIdCy1+rmR)R9}Kq?JJRe~~A?Cod( znr;DIJYj=(p^FDJolGzYsEa2Mlc(i2Pyb~6;Px^0F%IFm+6!;idFBY@SeotlDEwUGU+%rauJhbdR~#=&CiX=Ip9G0X?hstnRV0mc@eW^P7GYS`P3i%H?Y}? zfBsUSMRG1jbb9EPjt(Y$1fH(h06DzKS142QD#ON4zYCBkGrM-Ri~$t7G&E#+6crV4 zRv5{qC0R;IzR%K^`j{RbEjurq5$G}|kyOq>)IN^*B`U+nYA|Dz;N$d*7m`o5NXzoY0eNtGL=FRiOONvd4LR>4s7fv1~+mVuGf4 zr#P((te5(Ps@a4pZyqwS^Q_!cdm>1`UUQvmjKTX19M ze#Xz)gvNTX!jz-taupn>!@ufm)g%kvU{=V|)bsW3l<-$i9%Lb4DR`3o(AwJ6*0YeB zqFH38Y#9T^ROlgz6HZ6YcRn4u?$|sc>`cJc<}C_|KPo*>>Gk~UJT&`wU;Ix zP zGY@J-=kH=R(;3Imn_Q=r^3r{NpXXHzmfq?a6(!DQ=tDlxVc2nlo{9}!QH}xc4!A)Hq_sE}*w{{hw72b2IFXy=jc)HxNA%tsSZ1tXr3$bNFy$etx%rL^vy&G2|_-hTQP4%l^cNd zkxa)np;z8bqiD&-_scnV7vGAus*AQ@OdxYbD4xk5kaFG6j+}8K{n3gJgTn>O&iBd@ zM<2@|XVKl)ndao}>;8Tm=bpgojWzq}rU9^L*2uTaWRltC2PBn>bMo$nDIt3FN%~=6 zLiqum1m=}bz~O(1$`uB9nj^K1dYjTm`Xpx|^1#+@YmPxAeZR>9A^Bx@`!L+VVgZZ~ zJBON({V`EVT$~(RuYPuKykm^MID_2$h$FTmEvam=%iUMcIx{)a_6=lrW9ZN&BP_L0*p&n3KVe00-r-&~X9gRt2jP z1yrvxZa(hv@H-L0I6Bz^`3jH*DwY1|0}u#+vFav8mk~PqCpnbd{b_feyaGpISLo1F z)Ccsd^)(b#PB8j04&!nBX?Ak(z;ilHNe(fOIp{^?3!y_Y* zM@P=sgy7IA)B3kwKDuts_iF}q`j!OUaD zSXxU{Db)$!3VW&{p)5Pp7S(o*A^Kv*VV5Z{`{JYw)!R*+6JXwc4?lCOuWk#oorKU{I}Z`tpQ}t z3o-hX<*^%Yul?i;E(>~l6B%Lxdlh)&O0cM747}Ew7xl%4!4+JpNC+gd;UVLM7VqMq zB8y09Bzxxz5uD&tNVqgsE+mw5;cpFeBtjk8?5sjWoGYdu{nYxY?MMEw3Xg(U*;-k` zG(fOHow9^k5&A7)KM6J76V_&3MJX$=4Wq>r+)If6;qp6wBL@_~PX(m#pv5$YM=J|u zlo7~>ub-M|2KK3jKN)5bbP?>lMB&mP + + + + diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index a3711adad9..59a102844a 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -707,6 +707,87 @@ "conflictError": "Current topic is already in use." } }, + "tessie": { + "title": "Tessie", + "description": "Control Tessie devices", + "deviceTab": "Devices", + "documentation": "Tessie documentation", + "discoverDeviceDescr": "Automatically scan Tessie devices", + "status": { + "notConfigured": "The Tessie service is not configured", + "disconnect": "Gladys is not connected to Tessie", + "notConnected": "Gladys failed to connect to the Tessie account, please verify your credentials on the ", + "setupPageLink": "Tessie Setup Page.", + "connect": "Gladys is connected to Tessie", + "connecting": "Configuration saved. Connecting to your Tessie account...", + "processingToken": "Connecting to your Tessie account... Retrieving access token.", + "getDevicesValues": "Data recovery in progress...", + "dicoveringDevices": "Device recovery in progress...", + "errorConnecting": { + "other_error": "Error during authorization. You can inspect the error in your browser's console with a right click. If the error persists, please post the logs on the forum.", + "access_denied": "Authorization declined, please try again and accept the request for access to your data.", + "invalid_client": "Invalid client ID. Please verify your Tessie account information and that the account is not banned or disabled on My App.", + "get_access_token_fail": "Incorrect information entered or account disabled. Please check your Tessie account information and that the account is not banned or disabled My App." + }, + "weatherApiNotConfigured": "Please go to the \"Configuration\" tab to activate access to the \"Weather\" API in order to have access to the full information of your station.", + "energyApiNotConfigured": "Please go to the \"Configuration\" tab to activate access to the \"Energy\" API in order to have access to the full information of your devices." + }, + "device": { + "title": "Tessie Devices in Gladys", + "descriptionInformation": "State retrieval is currently done every 2 minutes (Tessie API limitation).", + "noDeviceFound": "No Tessie devices have been added yet.", + "sidLabel": "Tessie device SID", + "nameLabel": "Device name", + "namePlaceholder": "Enter your device name", + "modelLabel": "Model", + "roomLabel": "Room", + "connectedPlugLabel": "Connected to bridge", + "roomTessieApiLabel": "Room in Tessie API", + "featuresLabel": "Features", + "saveButton": "Save", + "saveNotAllowButton": "Save not possible", + "deleteButton": "Delete", + "noValueReceived": "No value received." + }, + "discover": { + "title": "Your Tessie Devices Compatible with Gladys", + "description": "Your Tessie devices must be added to your Tessie account before being added to Gladys, which currently only supports Thermostats and the valves associated with their Plug (API Energy) as well as weather stations and their modules (API Weather). If you are missing devices, please check that you have selected the correct APIs on the configuration page. The first discovery is automatic. Press 'Refresh' if you wish to retrieve new features of your devices.", + "descriptionCompatibility": "For any requests to add devices and/or features, please visit the Gladys Assistant Forum - Feature Requests Category.", + "descriptionInformation": "State retrieval is currently done every 2 minutes (Tessie API limitation).", + "noDeviceFound": "No Tessie devices were found. If you are certain that you have a Tessie device supported by Gladys, have you completed all the steps in the documentation to register your devices on the Tessie API?", + "alreadyCreatedButton": "Already Created", + "unmanagedModelButton": "Unsupported model", + "updateButton": "Update", + "scan": "Scan", + "refresh": "Refresh" + }, + "setup": { + "title": "Tessie Setup", + "description": "You can connect Gladys to your Tessie account to control the associated devices.", + "descriptionCreateAccount": "You need to create an account on Tessie Connect.", + "descriptionCreateProject": "You will then need to create an \"application\" in your Tessie developer account via the My App.", + "descriptionGetKeys": "You will then have access to two keys: \"client ID\" and \"client secret\" to copy below.", + "descriptionScopeInformation": "When you connect Gladys to your dedicated Tessie App, you grant it permission to access your data in read and write modes (referred to as \"read\" and \"write\" scopes). You don't have to configure anything, these scopes are automatically integrated and will be presented to you during the connection request (see documentation if needed). Of course, since Gladys is installed locally, none of these data is exposed.", + "titleAdditionalInformationEnergyApi": "Additional Information on the Operation of the \"Energy\" API:", + "descriptionAdditionalInformationEnergyApi": "You must select the \"Energy\" API at the bottom of the page to access the corresponding devices. Temperature commands are performed at the room level. Therefore, both pieces of information are available as features for more control. If you have other temperature sensors in the same room as your Thermostat, it's the average temperature of that room that will be considered to trigger the heating control switch.", + "titleAdditionalInformationWeatherApi": "Additional Information on the Operation of the \"Weather\" API:", + "descriptionAdditionalInformationWeatherApi": "You must select the \"Weather\" API at the bottom of the page to access the corresponding devices. If you also have devices belonging to the \"Energy\" API (Plug, thermostat or valve) you will have access to an additional functionality on indoor hygrometer devices to obtain the average temperature of the corresponding room. So don't forget to select the \"Energy\" API to access this functionality.", + "clientIdLabel": "Client ID", + "clientIdPlaceholder": "Client ID from My Apps in Tessie Connect", + "clientSecretLabel": "Client Secret", + "clientSecretPlaceholder": "Client secret from My Apps in Tessie Connect", + "connectionInfoLabel": "When you change this information, a new token is required. Disconnecting will also erase your access token. A new connection request will therefore be made to the Tessie API when you click on these buttons.", + "energyApiLabel": "Enable \"Energy\" API (retrieve Tessie Plug, Thermostat, and Valve devices)", + "weatherApiLabel": "Enable \"Weather\" API (retrieve Tessie Weather Station devices)", + "saveLabel": "Save and connect", + "disconnectLabel": "Disconnect" + }, + "error": { + "defaultError": "An error occurred while registering the device.", + "defaultDeletionError": "An error occurred while deleting the device.", + "conflictError": "The current device is already in Gladys." + } + }, "tpLink": { "title": "TP-Link", "description": "Control TP-Link Bulbs and Plugs in Gladys", @@ -3616,4 +3697,4 @@ "upgradeYearlyError": "An error occured while switching to the annual plan, please contact us by email.", "upgradeYearlySuccess": "Thanks for switching to the annual plan!" } -} +} \ No newline at end of file diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index dd80800f01..3fc898863c 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -835,6 +835,89 @@ "conflictError": "Le topic actuel est déjà utilisé." } }, + "tessie": { + "title": "Tessie", + "description": "Contrôler les équipements Tessie", + "deviceTab": "Appareils", + "discoverTab": "Découverte Tessie", + "setupTab": "Configuration", + "documentation": "Documentation Tessie", + "discoverDeviceDescription": "Scanner automatiquement les appareils Tessie", + "status": { + "notConfigured": "Le service Tessie n'est pas configuré", + "disconnect": "Gladys n'est pas connectée à Tessie", + "notConnected": "Gladys n'a pas réussi à se connecter au compte Tessie, veuillez vérifier vos informations d'identification sur la page de configuration.", + "setupPageLink": "Page de configuration Tessie.", + "connect": "Gladys est connectée à Tessie", + "connecting": "Configuration sauvegardée. Connexion à votre compte Tessie en cours...", + "processingToken": "Connexion à votre compte Tessie... Récupération du token d'accès.", + "getDevicesValues": "Récupération des données en cours...", + "dicoveringDevices": "Récupération des appareils en cours...", + "errorConnecting": { + "other_error": "Erreur lors de l'authorisation. Vous pouvez inspecter par un clique droit l'erreur dans la console de votre navigateur. Si l'erreur persiste, veuillez poster les logs sur le forum.", + "access_denied": "Autorisation déclinée, Veuillez tenter de nouveau et accepter la demande d'accès à vos données.", + "invalid_client": "Identifiant client invalide. Veuillez vérifier les informations de votre compte Tessie et que le compte n'est pas bani ou désactivé My App.", + "get_access_token_fail": "Informations renseignées erronnées ou compte désactivé. Veuillez vérifier les informations de votre compte Tessie et que le compte n'est pas bani ou désactivé My App." + }, + "weatherApiNotConfigured": "Veuillez vous rendre dans l'onglet \"Configuration\" pour activer l'accès à l'API \"Weather\" dans le but d'avoir accès à la totalité des informations de votre station.", + "energyApiNotConfigured": "Veuillez vous rendre dans l'onglet \"Configuration\" pour activer l'accès à l'API \"Energy\" dans le but d'avoir accès à la totalité des informations de vos appareils." + }, + "device": { + "title": "Appareils Tessie dans Gladys", + "descriptionInformation": "La récupération des états se fait actuellement toutes les 2 minutes (limitation de l'API Tessie).", + "noDeviceFound": "Aucun appareil Tessie n'a encore été ajouté.", + "sidLabel": "SID Tessie de l'appareil", + "nameLabel": "Nom de l'appareil", + "namePlaceholder": "Entrez le nom de votre appareil", + "modelLabel": "Modèle", + "roomLabel": "Pièce", + "connectedPlugLabel": "Connecté au pont", + "roomTessieApiLabel": "Pièce dans l'API Tessie", + "featuresLabel": "Fonctionnalités", + "saveButton": "Sauvegarder", + "saveNotAllowButton": "Sauvegarde impossible", + "deleteButton": "Supprimer", + "noValueReceived": "Aucune valeur reçue." + }, + "discover": { + "title": "Vos appareils Tessie compatibles avec Gladys", + "description": "Vos appareils Tessie doivent être ajoutés à votre compte Tessie avant d'être ajoutés à Gladys qui ne supporte actuellement que les Thermostats et les vannes associés à leur Plug (API Energy) ainsi que les stations météo et leurs modules (API Weather). S'il vous manque des appareils, veuillez vérifier que vous avez bien sélectionné les bonnes API dans la page de configuration. La 1ère découverte se fait automatiquement. Appuyer sur 'Rafraichir' si vous souhaitez récupérer les nouvelles fonctionnalités de vos appareils.", + "descriptionCompatibility": "Pour toute demande d'ajout d'appareils et/ou de fonctionnalités, veuillez vous rendre sur le Forum Gladys Assistant - Catégorie \"Demande de fonctionnalités\".", + "descriptionInformation": "La récupération des états se fait actuellement toutes les 2 minutes (limitation de l'API Tessie).", + "noDeviceFound": "Aucun appareil Tessie n'a été trouvé. Si vous êtes certains de posséder un appareil Tessie supporté par Gladys, avez-vous bien réalisé toutes les étapes de la documentation pour enregistrer vos appareils sur l'API Tessie ?", + "alreadyCreatedButton": "Déjà créé", + "updateButton": "Mettre à jour", + "unmanagedModelButton": "Modèle non pris en charge", + "scan": "Scanner", + "refresh": "Rafraîchir" + }, + "setup": { + "title": "Configuration Tessie", + "description": "Vous pouvez connecter Gladys à votre compte Tessie pour commander les appareils associés.", + "descriptionCreateAccount": "Vous avez besoin de créer un compte sur Tessie Connect.", + "descriptionCreateProject": "Vous devrez ensuite créer une \"application\" dans votre compte développeur Tessie via le menu My App.", + "descriptionGetKeys": "Vous aurez alors accès aux deux clés : \"client ID\" et \"client secret\" à copier ci-dessous.", + "descriptionScopeInformation": "Lorsque vous connectez Gladys à votre App Tessie dédiée, vous lui donnez l'autorisation d'accéder à vos données en lecture et en écriture (appelé scopes \"read\" et \"write\"). Vous n'avez rien à configurer, ces scopes sont automatiquement intégrés et vous seront exposés lors de la demande de connexion (voir documentation au besoin). Bien entendu, Gladys étant installée en local, aucune de ces données ne se retrouve exposée.", + "titleAdditionalInformationEnergyApi": "Informations complémentaires sur le fonctionnement de l'API \"Energy\" :", + "descriptionAdditionalInformationEnergyApi": "Vous devez sélectionner l'API \"Energy\" en bas de page pour accéder aux appareils correspondants. Les commandes de températures sont effectuées au niveau de la pièce. Les 2 informations sont donc disponibles en fonctionnalités pour plus de contrôle. Si vous possédez d'autres capteurs de température dans la même pièce que votre Thermostat, c'est la température moyenne de cette pièce qui sera prise en compte pour déclencher le commutateur de contrôle de chauffe.", + "titleAdditionalInformationWeatherApi": "Informations complémentaires sur le fonctionnement de l'API \"Weather\" :", + "descriptionAdditionalInformationWeatherApi": "Vous devez sélectionner l'API \"Weather\" en bas de page pour accéder aux appareils correspondants. Si vous avez également des appareils appartenant à l'API \"Energy\" (Plug, thermostat ou vanne) vous aurez accès à une fonctionnalité supplémentaire sur les appareils hygromètres intérieurs pour obtenir la température moyenne de la pièce correspondante. N'oubliez donc pas de sélectionner l'API \"Energy\" pour accéder à cette fonctionnalité.", + "clientIdLabel": "Identifiant client", + "clientIdPlaceholder": "client ID de My Apps dans Tessie Connect", + "clientSecretLabel": "Secret client", + "clientSecretPlaceholder": "client secret de My Apps dans Tessie Connect", + "connectionInfoLabel": "Lorsque vous modifiez ces informations, un nouveau jeton est requis. La déconnexion effacera également votre jeton d'accès. Une nouvelle demande de connexion sera donc faite à l'API Tessie lorsque vous cliquerez sur ces boutons.", + "energyApiLabel": "Activer l'API \"Energy\" (récupérer les appareils Plug, Thermostat et Vannes Tessie)", + "weatherApiLabel": "Activer l'API \"Weather\" (récupérer les appareils de la station météo Tessie)", + "saveLabel": "Sauvegarder et connecter", + "disconnectLabel": "Déconnecter" + }, + "error": { + "defaultError": "Une erreur s'est produite lors de l'enregistrement de l'appareil.", + "defaultDeletionError": "Une erreur s'est produite lors de la suppression de l'appareil.", + "conflictError": "L'appareil actuel est déjà dans Gladys." + } + }, "tpLink": { "title": "TP-Link", "description": "Contrôler les lumières et prises TP-Link.", @@ -3616,4 +3699,4 @@ "upgradeYearlyError": "Une erreur est survenue lors de la mise à jour vers le plan annuel, merci de nous contacter par email ou sur le forum.", "upgradeYearlySuccess": "Merci d'être passé au plan annuel !" } -} +} \ No newline at end of file diff --git a/front/src/config/integrations/devices.json b/front/src/config/integrations/devices.json index 16fb313d8b..dfd0c51ee0 100644 --- a/front/src/config/integrations/devices.json +++ b/front/src/config/integrations/devices.json @@ -94,5 +94,10 @@ "key": "airplay", "link": "airplay", "img": "/assets/integrations/cover/airplay.jpg" + }, + { + "key": "tessie", + "link": "tessie", + "img": "/assets/integrations/cover/tessie.jpg" } -] +] \ No newline at end of file diff --git a/front/src/routes/integration/all/tessie/TessieDeviceBox.jsx b/front/src/routes/integration/all/tessie/TessieDeviceBox.jsx new file mode 100644 index 0000000000..6683378c70 --- /dev/null +++ b/front/src/routes/integration/all/tessie/TessieDeviceBox.jsx @@ -0,0 +1,427 @@ +import { Component } from 'preact'; +import { Text, Localizer, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; +import { connect } from 'unistore/preact'; +import dayjs from 'dayjs'; +import get from 'get-value'; +import DeviceFeatures from '../../../../components/device/view/DeviceFeatures'; +import BatteryLevelFeature from '../../../../components/device/view/BatteryLevelFeature'; +import { DEVICE_FEATURE_CATEGORIES } from '../../../../../../server/utils/constants'; +import { + GITHUB_BASE_URL, + PARAMS, + SUPPORTED_CATEGORY_TYPE +} from '../../../../../../server/services/tessie/lib/utils/tessie.constants'; +import styles from './style.css'; + +const createGithubUrl = device => { + const title = encodeURIComponent(`Tessie: Add device ${device.model}`); + const body = encodeURIComponent(`\`\`\`\n${JSON.stringify(device, null, 2)}\n\`\`\``); + return `${GITHUB_BASE_URL}?title=${title}&body=${body}`; +}; + +class TessieDeviceBox extends Component { + componentWillMount() { + this.setState({ + device: this.props.device, + user: this.props.user + }); + } + + componentWillReceiveProps(nextProps) { + this.setState({ + device: nextProps.device + }); + } + + updateName = e => { + this.setState({ + device: { + ...this.state.device, + name: e.target.value + } + }); + }; + + updateRoom = e => { + this.setState({ + device: { + ...this.state.device, + room_id: e.target.value + } + }); + }; + + saveDevice = async () => { + this.setState({ + loading: true, + errorMessage: null + }); + try { + const savedDevice = await this.props.httpClient.post(`/api/v1/device`, this.state.device); + this.setState({ + device: savedDevice, + isSaving: true + }); + } catch (e) { + let errorMessage = 'integration.tessie.error.defaultError'; + if (e.response.status === 409) { + errorMessage = 'integration.tessie.error.conflictError'; + } + this.setState({ + errorMessage + }); + } + this.setState({ + loading: false + }); + }; + + deleteDevice = async () => { + this.setState({ + loading: true, + errorMessage: null, + tooMuchStatesError: false, + statesNumber: undefined + }); + try { + if (this.state.device.created_at) { + await this.props.httpClient.delete(`/api/v1/device/${this.state.device.selector}`); + } + this.props.getTessieDevices(); + } catch (e) { + const status = get(e, 'response.status'); + const dataMessage = get(e, 'response.data.message'); + if (status === 400 && dataMessage && dataMessage.includes('Too much states')) { + const statesNumber = new Intl.NumberFormat().format(dataMessage.split(' ')[0]); + this.setState({ tooMuchStatesError: true, statesNumber }); + } else { + this.setState({ + errorMessage: 'integration.tessie.error.defaultDeletionError' + }); + } + } + this.setState({ + loading: false + }); + }; + + getDeviceProperty = () => { + const device = this.state.device; + if (!device.features) { + return null; + } + const batteryLevelDeviceFeature = device.features.find( + deviceFeature => deviceFeature.category === DEVICE_FEATURE_CATEGORIES.BATTERY + ); + const batteryLevel = get(batteryLevelDeviceFeature, 'last_value'); + let mostRecentValueAt = null; + device.features.forEach(feature => { + if (feature.last_value_changed && new Date(feature.last_value_changed) > mostRecentValueAt) { + mostRecentValueAt = new Date(feature.last_value_changed); + } + }); + + let roomNameTessie = null; + const roomNameParam = device.params.find(param => param.name === PARAMS.ROOM_NAME); + if (roomNameParam) { + roomNameTessie = roomNameParam.value; + } + + let plugName = null; + const plugNameParam = device.params.find(param => param.name === PARAMS.PLUG_NAME); + const plugIdParam = device.params.find(param => param.name === PARAMS.PLUG_ID); + if (plugNameParam) { + plugName = `${plugNameParam.value} (${plugIdParam.value})`; + } + + let categoryAPI = null; + let apiNotConfigured = null; + if (device.deviceTessie) { + categoryAPI = device.deviceTessie.categoryAPI; + apiNotConfigured = device.deviceTessie.apiNotConfigured; + } + const isDeviceReachable = (device, now = new Date()) => { + const isRecent = (date, time) => (now - new Date(date)) / (1000 * 60) <= time; + const hasRecentFeature = device.features.some(feature => isRecent(feature.last_value_changed, 15)); + const isTessieDeviceReachable = + device.deviceTessie && + (device.deviceTessie.reachable || + device.deviceTessie.room.reachable || + isRecent(device.deviceTessie.last_plug_seen * 1000, 180) || + isRecent(device.deviceTessie.last_therm_seen * 1000, 180)); + return hasRecentFeature || isTessieDeviceReachable; + }; + const online = isDeviceReachable(device); + + return { + batteryLevel, + mostRecentValueAt, + roomNameTessie, + plugName, + online, + categoryAPI, + apiNotConfigured + }; + }; + + render( + { + deviceIndex, + editable, + deleteButton, + saveButton, + updateButton, + alreadyCreatedButton, + showMostRecentValueAt, + housesWithRooms + }, + { device, user, loading, errorMessage, tooMuchStatesError, statesNumber } + ) { + const validModel = (device.features && device.features.length > 0) || !device.not_handled; + const { + batteryLevel, + mostRecentValueAt, + roomNameTessie, + plugName, + online, + categoryAPI, + apiNotConfigured + } = this.getDeviceProperty(); + const sidDevice = device.external_id.replace('tessie:', '') || (device.deviceTessie && device.deviceTessie.id); + const saveButtonCondition = + (saveButton && !alreadyCreatedButton) || (saveButton && !this.state.isSaving && alreadyCreatedButton); + const modelImage = `/assets/integrations/devices/tessie/tessie-${device.model}.jpg`; + return ( +
+
+
+ +
}> + +  {device.name} +
+
+ {showMostRecentValueAt && batteryLevel && ( +
+ +
+ )} +
+
+
+
+
+ {errorMessage && ( +
+ +
+ )} + {tooMuchStatesError && ( +
+ +
+ )} +
+ { + e.target.onerror = null; + e.target.src = '/assets/integrations/cover/tessie.jpg'; + }} + alt={`Image de ${device.name}`} + className={styles['device-image-container']} + /> +
+
+ + + } + disabled={!editable || !validModel} + /> + +
+ +
+ + +
+ {plugName && ( +
+ + +
+ )} + {sidDevice && ( +
+ + +
+ )} + {roomNameTessie && ( +
+ + +
+ )} + + {validModel && ( +
+ + +
+ )} + + {validModel && ( +
+ + +
+ )} + +
+ {validModel && this.state.isSaving && alreadyCreatedButton && ( + + )} + + {validModel && updateButton && ( + + )} + + {validModel && saveButtonCondition && !apiNotConfigured && ( + + )} + + {validModel && saveButtonCondition && apiNotConfigured && ( +
+ + +
+ )} + + {validModel && deleteButton && ( + + )} + + {!validModel && ( +
+
+ +
+ + + +
+ )} + + {validModel && showMostRecentValueAt && ( +

+ {mostRecentValueAt ? ( + + ) : ( + + )} +

+ )} +
+
+
+
+
+
+ ); + } +} + +export default connect('httpClient,user', {})(TessieDeviceBox); diff --git a/front/src/routes/integration/all/tessie/TessiePage.jsx b/front/src/routes/integration/all/tessie/TessiePage.jsx new file mode 100644 index 0000000000..0e0b058c7e --- /dev/null +++ b/front/src/routes/integration/all/tessie/TessiePage.jsx @@ -0,0 +1,72 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import DeviceConfigurationLink from '../../../../components/documentation/DeviceConfigurationLink'; + +const TessiePage = props => ( +
+
+
+
+
+
+

+ +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
{props.children}
+
+
+
+
+
+); + +export default TessiePage; diff --git a/front/src/routes/integration/all/tessie/device-page/DeviceTab.jsx b/front/src/routes/integration/all/tessie/device-page/DeviceTab.jsx new file mode 100644 index 0000000000..d322abd11f --- /dev/null +++ b/front/src/routes/integration/all/tessie/device-page/DeviceTab.jsx @@ -0,0 +1,141 @@ +import { Text, Localizer, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; + +import EmptyState from './EmptyState'; +import StateConnection from './StateConnection'; +import { RequestStatus } from '../../../../../utils/consts'; +import style from './style.css'; +import CardFilter from '../../../../../components/layout/CardFilter'; +import TessieDeviceBox from '../TessieDeviceBox'; +import debounce from 'debounce'; +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; + +class DeviceTab extends Component { + constructor(props) { + super(props); + this.debouncedSearch = debounce(this.search, 200).bind(this); + } + + componentWillMount() { + this.getTessieDevices(); + this.getHouses(); + } + + getTessieDevices = async () => { + this.setState({ + getTessieStatus: RequestStatus.Getting + }); + try { + const options = { + order_dir: this.state.orderDir || 'asc' + }; + if (this.state.search && this.state.search.length) { + options.search = this.state.search; + } + + const tessieDevices = await this.props.httpClient.get('/api/v1/service/tessie/device', options); + this.setState({ + tessieDevices, + getTessieStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + getTessieStatus: e.message + }); + } + }; + + async getHouses() { + this.setState({ + housesGetStatus: RequestStatus.Getting + }); + try { + const params = { + expand: 'rooms' + }; + const housesWithRooms = await this.props.httpClient.get(`/api/v1/house`, params); + this.setState({ + housesWithRooms, + housesGetStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + housesGetStatus: RequestStatus.Error + }); + } + } + + async search(e) { + await this.setState({ + search: e.target.value + }); + this.getTessieDevices(); + } + changeOrderDir(e) { + this.setState({ + orderDir: e.target.value + }); + this.getTessieDevices(); + } + + render(props, { orderDir, search, getTessieStatus, tessieDevices, housesWithRooms }) { + return ( +
+
+

+ +

+
+ + } + /> + +
+
+
+
+
+
+ +
+

+ +

+
+
+ {tessieDevices && + tessieDevices.length > 0 && + tessieDevices.map((device, index) => ( + + ))} + {!tessieDevices || (tessieDevices.length === 0 && )} +
+
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(DeviceTab); diff --git a/front/src/routes/integration/all/tessie/device-page/EmptyState.jsx b/front/src/routes/integration/all/tessie/device-page/EmptyState.jsx new file mode 100644 index 0000000000..8fef35b112 --- /dev/null +++ b/front/src/routes/integration/all/tessie/device-page/EmptyState.jsx @@ -0,0 +1,23 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import cx from 'classnames'; +import style from './style.css'; + +const EmptyState = () => ( +
+
+ + +
+ + + + +
+
+
+); + +export default EmptyState; diff --git a/front/src/routes/integration/all/tessie/device-page/StateConnection.jsx b/front/src/routes/integration/all/tessie/device-page/StateConnection.jsx new file mode 100644 index 0000000000..11f65af305 --- /dev/null +++ b/front/src/routes/integration/all/tessie/device-page/StateConnection.jsx @@ -0,0 +1,45 @@ +import { Text } from 'preact-i18n'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; + +const StateConnection = props => ( +
+ {!props.accessDenied && + ((props.connectTessieStatus === STATUS.DISCOVERING_DEVICES && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.GET_DEVICES_VALUES && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.CONNECTING && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.PROCESSING_TOKEN && ( +

+ +

+ )) || + (props.connected && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.DISCONNECTED && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.NOT_INITIALIZED && ( +

+ +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/tessie/device-page/index.js b/front/src/routes/integration/all/tessie/device-page/index.js new file mode 100644 index 0000000000..e8dc9fad44 --- /dev/null +++ b/front/src/routes/integration/all/tessie/device-page/index.js @@ -0,0 +1,137 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import DeviceTab from './DeviceTab'; +import TessiePage from '../TessiePage'; +import { RequestStatus } from '../../../../../utils/consts'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; + +class DevicePage extends Component { + loadStatus = async () => { + try { + const tessieStatus = await this.props.httpClient.get('/api/v1/service/tessie/status'); + this.setState({ + connectTessieStatus: tessieStatus.status, + connected: tessieStatus.connected, + configured: tessieStatus.configured + }); + } catch (e) { + this.setState({ + tessieConnectionError: RequestStatus.NetworkError, + errored: true + }); + console.error(e); + } finally { + this.setState({ + showConnect: true + }); + } + }; + + updateStatus = async state => { + let connected = false; + let configured = false; + if ( + state.status === STATUS.CONNECTED || + state.status === STATUS.GET_DEVICES_VALUES || + state.status === STATUS.DISCOVERING_DEVICES + ) { + connected = true; + configured = true; + } else if (state.status === STATUS.NOT_INITIALIZED) { + connected = false; + configured = false; + } else { + connected = false; + configured = true; + } + await this.setState({ + connectTessieStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + } + }; + + handleStateUpdateFromChild = newState => { + this.setState(newState); + }; + + componentDidMount() { + this.loadStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default connect('user,session,httpClient', {})(DevicePage); diff --git a/front/src/routes/integration/all/tessie/device-page/style.css b/front/src/routes/integration/all/tessie/device-page/style.css new file mode 100644 index 0000000000..5296d19690 --- /dev/null +++ b/front/src/routes/integration/all/tessie/device-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 35px; +} + +.tessieListBody { + min-height: 200px +} diff --git a/front/src/routes/integration/all/tessie/discover-page/DiscoverTab.jsx b/front/src/routes/integration/all/tessie/discover-page/DiscoverTab.jsx new file mode 100644 index 0000000000..efac388206 --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/DiscoverTab.jsx @@ -0,0 +1,135 @@ +import { Text, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; + +import EmptyState from './EmptyState'; +import StateConnection from './StateConnection'; +import style from './style.css'; +import TessieDeviceBox from '../TessieDeviceBox'; +import { connect } from 'unistore/preact'; +import { Component } from 'preact'; +import { RequestStatus } from '../../../../../utils/consts'; + +class DiscoverTab extends Component { + async componentWillMount() { + this.getDiscoveredDevices(); + this.getHouses(); + } + + async getHouses() { + this.setState({ + housesGetStatus: RequestStatus.Getting + }); + try { + const params = { + expand: 'rooms' + }; + const housesWithRooms = await this.props.httpClient.get(`/api/v1/house`, params); + this.setState({ + housesWithRooms, + housesGetStatus: RequestStatus.Success + }); + } catch (e) { + this.setState({ + housesGetStatus: RequestStatus.Error + }); + } + } + + getDiscoveredDevices = async () => { + this.setState({ + loading: true + }); + try { + const discoveredDevices = await this.props.httpClient.get('/api/v1/service/tessie/discover'); + this.setState({ + discoveredDevices, + loading: false, + errorLoading: false + }); + } catch (e) { + this.setState({ + discoveredDevices: [], + loading: false, + errorLoading: true + }); + } + }; + refreshDiscoveredDevices = async () => { + this.setState({ + loading: true + }); + try { + const discoveredDevices = await this.props.httpClient.get('/api/v1/service/tessie/discover', { refresh: true }); + this.setState({ + discoveredDevices, + loading: false, + errorLoading: false + }); + } catch (e) { + this.setState({ + discoveredDevices: [], + loading: false, + errorLoading: true + }); + } + }; + + render(props, { loading, errorLoading, discoveredDevices, housesWithRooms }) { + return ( +
+
+

+ +

+
+ +
+
+
+ +
+

+ +

+

+ +

+

+ +

+
+
+
+
+
+ {discoveredDevices && + discoveredDevices.map((device, index) => ( + + ))} + {!discoveredDevices || (discoveredDevices.length === 0 && )} +
+
+
+
+
+ ); + } +} + +export default connect('httpClient', {})(DiscoverTab); diff --git a/front/src/routes/integration/all/tessie/discover-page/EmptyState.jsx b/front/src/routes/integration/all/tessie/discover-page/EmptyState.jsx new file mode 100644 index 0000000000..c4094486bb --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/EmptyState.jsx @@ -0,0 +1,13 @@ +import { MarkupText } from 'preact-i18n'; +import cx from 'classnames'; +import style from './style.css'; + +const EmptyState = ({}) => ( +
+
+ +
+
+); + +export default EmptyState; diff --git a/front/src/routes/integration/all/tessie/discover-page/StateConnection.jsx b/front/src/routes/integration/all/tessie/discover-page/StateConnection.jsx new file mode 100644 index 0000000000..a57c04761f --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/StateConnection.jsx @@ -0,0 +1,49 @@ +import { Text } from 'preact-i18n'; +import { Link } from 'preact-router/match'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; + +const StateConnection = props => ( +
+ {!props.accessDenied && + ((props.connectTessieStatus === STATUS.DISCOVERING_DEVICES && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.GET_DEVICES_VALUES && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.CONNECTING && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.PROCESSING_TOKEN && ( +

+ +

+ )) || + (props.connected && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.DISCONNECTED && ( +

+ +

+ )) || + ((props.errorLoading || props.connectTessieStatus === STATUS.NOT_INITIALIZED) && ( +

+ + + + +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/tessie/discover-page/index.js b/front/src/routes/integration/all/tessie/discover-page/index.js new file mode 100644 index 0000000000..14fad047f0 --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/index.js @@ -0,0 +1,133 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import DiscoverTab from './DiscoverTab'; +import TessiePage from '../TessiePage'; +import { RequestStatus } from '../../../../../utils/consts'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; + +class TessieDiscoverPage extends Component { + loadStatus = async () => { + try { + const tessieStatus = await this.props.httpClient.get('/api/v1/service/tessie/status'); + this.setState({ + connectTessieStatus: tessieStatus.status, + connected: tessieStatus.connected, + configured: tessieStatus.configured + }); + } catch (e) { + this.setState({ + tessieConnectionError: RequestStatus.NetworkError, + errored: true + }); + console.error(e); + } + }; + + updateStatus = async state => { + let connected = false; + let configured = false; + if ( + state.status === STATUS.CONNECTED || + state.status === STATUS.GET_DEVICES_VALUES || + state.status === STATUS.DISCOVERING_DEVICES + ) { + connected = true; + configured = true; + } else if (state.status === STATUS.NOT_INITIALIZED) { + connected = false; + configured = false; + } else { + connected = false; + configured = true; + } + await this.setState({ + connectTessieStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + } + }; + + handleStateUpdateFromChild = newState => { + this.setState(newState); + }; + + componentDidMount() { + this.loadStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatus + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default connect('user,session,httpClient', {})(TessieDiscoverPage); diff --git a/front/src/routes/integration/all/tessie/discover-page/style.css b/front/src/routes/integration/all/tessie/discover-page/style.css new file mode 100644 index 0000000000..05974b8e4a --- /dev/null +++ b/front/src/routes/integration/all/tessie/discover-page/style.css @@ -0,0 +1,7 @@ +.emptyStateDivBox { + margin-top: 89px; +} + +.tessieListBody { + min-height: 200px; +} diff --git a/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx b/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx new file mode 100644 index 0000000000..c58d154c67 --- /dev/null +++ b/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx @@ -0,0 +1,220 @@ +import { Text, Localizer, MarkupText } from 'preact-i18n'; +import cx from 'classnames'; + +import style from './style.css'; +import StateConnection from './StateConnection'; +import { RequestStatus } from '../../../../../utils/consts'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; + +class SetupTab extends Component { + showClientSecretTimer = null; + async disconnectTessie(e) { + e.preventDefault(); + + await this.setState({ + tessieDisconnectStatus: RequestStatus.Getting + }); + try { + await this.props.httpClient.post('/api/v1/service/tessie/disconnect'); + this.props.updateStateInIndex({ connectTessieStatus: STATUS.DISCONNECTED }); + await this.setState({ + tessieDisconnectStatus: RequestStatus.Success + }); + } catch (e) { + await this.setState({ + tessieSaveSettingsStatus: RequestStatus.Error + }); + } + } + updateClientId = e => { + this.props.updateStateInIndex({ tessieClientId: e.target.value }); + }; + updateClientSecret = e => { + this.props.updateStateInIndex({ tessieClientSecret: e.target.value }); + }; + updateEnergyApi = () => { + if (this.props.tessieEnergyApi === true) { + this.props.updateStateInIndex({ tessieEnergyApi: false }); + } else { + this.props.updateStateInIndex({ tessieEnergyApi: true }); + } + }; + updateWeatherApi = () => { + if (this.props.tessieWeatherApi === true) { + this.props.updateStateInIndex({ tessieWeatherApi: false }); + } else { + this.props.updateStateInIndex({ tessieWeatherApi: true }); + } + }; + toggleClientSecret = () => { + const { showClientSecret } = this.state; + + if (this.showClientSecretTimer) { + clearTimeout(this.showClientSecretTimer); + this.showClientSecretTimer = null; + } + + this.setState({ showClientSecret: !showClientSecret }); + + if (!showClientSecret) { + this.showClientSecretTimer = setTimeout(() => this.setState({ showClientSecret: false }), 5000); + } + }; + + componentWillUnmount() { + if (this.showClientSecretTimer) { + clearTimeout(this.showClientSecretTimer); + this.showClientSecretTimer = null; + } + } + + render(props, state, { loading }) { + return ( +
+
+

+ +

+
+
+
+
+
+ +

+ + + + +

+

+ +

+

+ + +

+

+ + +

+ +
+
+ + + } + value={props.tessieClientId} + className="form-control" + autocomplete="off" + onInput={this.updateClientId} + /> + +
+ +
+ +
+ + } + value={props.tessieClientSecret} + className="form-control" + autocomplete="off" + onInput={this.updateClientSecret} + /> + + + + +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ {props.notOnGladysGateway && ( +
+ + + + {props.notOnGladysGateway && props.connected && ( + + )} +
+ )} + +
+
+
+
+ ); + } +} + +export default connect('user,session,httpClient', {})(SetupTab); diff --git a/front/src/routes/integration/all/tessie/setup-page/StateConnection.jsx b/front/src/routes/integration/all/tessie/setup-page/StateConnection.jsx new file mode 100644 index 0000000000..166da30ead --- /dev/null +++ b/front/src/routes/integration/all/tessie/setup-page/StateConnection.jsx @@ -0,0 +1,40 @@ +import { Text, MarkupText } from 'preact-i18n'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; + +const StateConnection = props => ( +
+ {props.accessDenied && ( +

+ +

+ )} + {!props.accessDenied && + ((props.connectTessieStatus === STATUS.CONNECTING && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.PROCESSING_TOKEN && ( +

+ +

+ )) || + (props.connected && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.DISCONNECTED && ( +

+ +

+ )) || + (props.connectTessieStatus === STATUS.NOT_INITIALIZED && ( +

+ +

+ )))} +
+); + +export default StateConnection; diff --git a/front/src/routes/integration/all/tessie/setup-page/index.js b/front/src/routes/integration/all/tessie/setup-page/index.js new file mode 100644 index 0000000000..c1b52fe435 --- /dev/null +++ b/front/src/routes/integration/all/tessie/setup-page/index.js @@ -0,0 +1,310 @@ +import { Component } from 'preact'; +import { connect } from 'unistore/preact'; +import { route } from 'preact-router'; +import SetupTab from './SetupTab'; +import TessiePage from '../TessiePage'; +import withIntlAsProp from '../../../../../utils/withIntlAsProp'; +import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants'; +import { STATUS } from '../../../../../../../server/services/tessie/lib/utils/tessie.constants'; +import { RequestStatus } from '../../../../../utils/consts'; + +class TessieSetupPage extends Component { + getRedirectUri = async () => { + try { + const result = await this.props.httpClient.post('/api/v1/service/tessie/connect'); + const redirectUri = `${result.authUrl}&redirect_uri=${encodeURIComponent(this.state.redirectUriTessieSetup)}`; + await this.setState({ + redirectUri + }); + } catch (e) { + console.error(e); + await this.setState({ errored: true }); + } + }; + + getSessionGatewayClient = async () => { + if (!this.props.session.gatewayClient) { + this.setState({ + notOnGladysGateway: true, + redirectUriTessieSetup: `${window.location.origin}/dashboard/integration/device/tessie/setup` + }); + } else return; + }; + + detectCode = async () => { + if (this.props.error) { + if (this.props.error === 'access_denied' || this.props.error === 'invalid_client') { + this.props.httpClient.post('/api/v1/service/tessie/status', { + statusType: STATUS.ERROR.CONNECTING, + message: this.props.error + }); + await this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + configured: true, + accessDenied: true, + messageAlert: this.props.error + }); + } else { + this.props.httpClient.post('/api/v1/service/tessie/status', { + statusType: STATUS.ERROR.CONNECTING, + message: 'other_error' + }); + await this.setState({ + accessDenied: true, + messageAlert: 'other_error', + errored: true + }); + console.error('Logs error', this.props); + } + } + if (this.props.code && this.props.state) { + let successfulNewToken = false; + try { + await this.setState({ + connectTessieStatus: STATUS.PROCESSING_TOKEN, + connected: false, + configured: true, + errored: false + }); + const response = await this.props.httpClient.post('/api/v1/service/tessie/token', { + codeOAuth: this.props.code, + redirectUri: this.state.redirectUriTessieSetup, + state: this.props.state + }); + if (response) successfulNewToken = true; + await this.props.httpClient.post('/api/v1/service/tessie/variable/TESSIE_CONNECTED', { + value: successfulNewToken + }); + await this.setState({ + connectTessieStatus: STATUS.CONNECTED, + connected: true, + configured: true, + errored: false + }); + await this.props.httpClient.get('/api/v1/service/tessie/discover', { refresh: true }); + setTimeout(() => { + route('/dashboard/integration/device/tessie/setup', true); + }, 100); + } catch (e) { + console.error(e); + this.props.httpClient.post('/api/v1/service/tessie/status', { + statusType: STATUS.PROCESSING_TOKEN, + message: 'other_error' + }); + await this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + configured: true, + errored: true + }); + } + } + }; + + saveConfiguration = async e => { + e.preventDefault(); + + try { + this.props.httpClient.post('/api/v1/service/tessie/configuration', { + clientId: this.state.tessieClientId, + clientSecret: this.state.tessieClientSecret, + energyApi: this.state.tessieEnergyApi, + weatherApi: this.state.tessieWeatherApi + }); + await this.setState({ + tessieSaveSettingsStatus: RequestStatus.Success + }); + } catch (e) { + await this.setState({ + tessieSaveSettingsStatus: RequestStatus.Error, + errored: true + }); + } + try { + await this.setState({ + connectTessieStatus: STATUS.CONNECTING, + connected: false, + configured: true + }); + await this.getRedirectUri(); + const redirectUri = this.state.redirectUri; + const regex = /dashboard|integration|device|tessie|setup/; + if (redirectUri && regex.test(this.state.redirectUri)) { + window.location.href = this.state.redirectUri; + await this.setState({ + connectTessieStatus: RequestStatus.Success, + connected: false, + configured: true + }); + } else { + console.error('Missing redirect URL'); + await this.setState({ + connectTessieStatus: STATUS.ERROR.CONNECTING, + connected: false + }); + } + } catch (e) { + console.error('Error when redirecting to tessie', e); + + await this.setState({ + connectTessieStatus: STATUS.ERROR.CONNECTING, + connected: false, + errored: true + }); + } + }; + + loadProps = async () => { + let configuration = {}; + try { + configuration = await this.props.httpClient.get('/api/v1/service/tessie/configuration'); + } catch (e) { + console.error(e); + await this.setState({ errored: true }); + } finally { + await this.setState({ + tessieClientId: configuration.clientId, + tessieClientSecret: configuration.clientSecret, + tessieEnergyApi: configuration.energyApi, + tessieWeatherApi: configuration.weatherApi, + clientSecretChanges: false + }); + } + }; + + loadStatus = async () => { + try { + const tessieStatus = await this.props.httpClient.get('/api/v1/service/tessie/status'); + await this.setState({ + connectTessieStatus: tessieStatus.status, + connected: tessieStatus.connected, + configured: tessieStatus.configured + }); + } catch (e) { + await this.setState({ + tessieConnectionError: RequestStatus.NetworkError, + errored: true + }); + console.error(e); + } + }; + + init = async () => { + await this.setState({ loading: true, errored: false }); + await Promise.all([this.getSessionGatewayClient(), this.detectCode()]); + await this.setState({ loading: false }); + }; + + updateStatus = async state => { + let connected = false; + let configured = false; + if ( + state.status === STATUS.CONNECTED || + state.status === STATUS.GET_DEVICES_VALUES || + state.status === STATUS.DISCOVERING_DEVICES + ) { + connected = true; + configured = true; + } else if (state.status === STATUS.NOT_INITIALIZED) { + connected = false; + configured = false; + } else { + connected = false; + configured = true; + } + await this.setState({ + connectTessieStatus: state.status, + connected, + configured + }); + }; + + updateStatusError = async state => { + switch (state.statusType) { + case STATUS.CONNECTING: + if (state.status !== 'other_error') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + case STATUS.PROCESSING_TOKEN: + if (state.status === 'get_access_token_fail') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else if (state.status === 'invalid_client') { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + accessDenied: true, + messageAlert: state.status + }); + } else { + this.setState({ + connectTessieStatus: STATUS.DISCONNECTED, + connected: false, + errored: true + }); + } + break; + } + }; + + handleStateUpdateFromChild = newState => { + this.setState(newState); + }; + + componentDidMount() { + this.init(); + this.loadProps(); + this.loadStatus(); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, this.updateStatusError); + this.props.session.dispatcher.addListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatusError + ); + } + componentWillUnmount() { + this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, this.updateStatus); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, + this.updateStatusError + ); + this.props.session.dispatcher.removeListener( + WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + this.updateStatusError + ); + } + + render(props, state, { loading }) { + return ( + + + + ); + } +} + +export default withIntlAsProp(connect('user,session,httpClient', {})(TessieSetupPage)); diff --git a/front/src/routes/integration/all/tessie/setup-page/style.css b/front/src/routes/integration/all/tessie/setup-page/style.css new file mode 100644 index 0000000000..2824209290 --- /dev/null +++ b/front/src/routes/integration/all/tessie/setup-page/style.css @@ -0,0 +1,35 @@ +.highlightText { + text-decoration: underline; + font-weight: bold; +} + +.italicText { + font-style: italic; +} + +.btnTextLineSpacing { + line-height: 1.5; +} + +.customSwitchIndicator { + margin-right: 10px; +} + +/* Default style for large screens */ +.buttonGroup { + display: flex; + justify-content: space-between; + margin-top: 1rem; +} + +/* Style for small screens */ +@media (max-width: 768px) { + .buttonGroup { + flex-direction: column; + align-items: stretch; + } + + .buttonGroup button:not(:last-child) { + margin-bottom: 1rem; + } +} \ No newline at end of file diff --git a/front/src/routes/integration/all/tessie/style.css b/front/src/routes/integration/all/tessie/style.css new file mode 100644 index 0000000000..8ae3456241 --- /dev/null +++ b/front/src/routes/integration/all/tessie/style.css @@ -0,0 +1,5 @@ +.device-image-container { + max-width: 75%; + margin: 0 auto; + display: block; +} \ No newline at end of file diff --git a/server/services/matter/package-lock.json b/server/services/matter/package-lock.json new file mode 100644 index 0000000000..741de9a1fb --- /dev/null +++ b/server/services/matter/package-lock.json @@ -0,0 +1,234 @@ +{ + "name": "matter", + "lockfileVersion": 3, + "requires": true, + "packages": { + "node_modules/@matter/general": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@matter/general/-/general-0.12.6.tgz", + "integrity": "sha512-LBFDgCb+LKF27iXCDzlUTHE2S1oEg9OyC0xtJxuWH7ZvNTHKxfsjnkNFRl6/WV9xFN1xKbpAF/0XBTq3Mrhf9A==", + "license": "Apache-2.0", + "dependencies": { + "@noble/curves": "^1.8.1" + } + }, + "node_modules/@matter/main": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@matter/main/-/main-0.12.6.tgz", + "integrity": "sha512-GVDDgg9vK7iqGKC1jKmgqwsbPV2CLz2TSs6KM1wi0sLepbdwANzqQqHGVN0zsWouVa6N4KKsMICPd7q2L1u+8Q==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.12.6", + "@matter/model": "0.12.6", + "@matter/node": "0.12.6", + "@matter/protocol": "0.12.6", + "@matter/types": "0.12.6", + "@noble/curves": "^1.8.1" + }, + "optionalDependencies": { + "@matter/nodejs": "0.12.6" + } + }, + "node_modules/@matter/model": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@matter/model/-/model-0.12.6.tgz", + "integrity": "sha512-Ry9VEbbyZgdo5mrkA9WcZ8e3B7e0P5Kvpsl5KEEw4ezqT9yzDwGOy/pwDMKZHx9u9eW8izx9e5S4Y98aaahjrw==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.12.6", + "@noble/curves": "^1.8.1" + } + }, + "node_modules/@matter/node": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@matter/node/-/node-0.12.6.tgz", + "integrity": "sha512-PX9+IQ8vxDI2tW17bViO6k07AslwqPuccg3dL6aCVbjh5S2XHJVXs0Yj0D32v900UY7VG/cklLL8ptlA4T+jrA==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.12.6", + "@matter/model": "0.12.6", + "@matter/protocol": "0.12.6", + "@matter/types": "0.12.6", + "@noble/curves": "^1.8.1" + } + }, + "node_modules/@matter/nodejs": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@matter/nodejs/-/nodejs-0.12.6.tgz", + "integrity": "sha512-hECQcOdP2U8nWrjtwPFhkJjjigPSpUTXESEb3x9I8JS0LyYZsPr/s3yrrs+k89Wj7h9IWSvtZjaDBvqpxwQZ/Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@matter/general": "0.12.6", + "@matter/node": "0.12.6", + "@matter/protocol": "0.12.6", + "@matter/types": "0.12.6", + "node-localstorage": "^3.0.5" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@matter/protocol": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@matter/protocol/-/protocol-0.12.6.tgz", + "integrity": "sha512-j4oIxkxBskzsPJhcrxBH4KOCZQlEV3oxE6F//UQwQ6gIpFhTE1Z1wSPTYRMZi75Givd+8kcZWJlJJEfUPDWomg==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.12.6", + "@matter/model": "0.12.6", + "@matter/types": "0.12.6", + "@noble/curves": "^1.8.1" + } + }, + "node_modules/@matter/types": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@matter/types/-/types-0.12.6.tgz", + "integrity": "sha512-IpOmp/KEfUJebxGIRgGI/bUZ5vCbN+zjSU32xgXDTM7Nr1QBFAO8E3ecnFgN/RHn1XW6NdTSewOXIZSW2eAxQA==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.12.6", + "@matter/model": "0.12.6", + "@noble/curves": "^1.8.1" + } + }, + "node_modules/@noble/curves": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.8.1.tgz", + "integrity": "sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.7.1" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.1.tgz", + "integrity": "sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@project-chip/matter.js": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@project-chip/matter.js/-/matter.js-0.12.6.tgz", + "integrity": "sha512-Py93Ymqr1jpsJQkLKyGk6vxOhkdl4ONLXEsUAoHemCsIddB1gVWtyhTju0UBTH+sbXmHFvTpJ6x3ru+XpGOvAA==", + "license": "Apache-2.0", + "dependencies": { + "@matter/general": "0.12.6", + "@matter/model": "0.12.6", + "@matter/node": "0.12.6", + "@matter/protocol": "0.12.6", + "@matter/types": "0.12.6", + "@noble/curves": "^1.8.1" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/node-localstorage": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/node-localstorage/-/node-localstorage-3.0.5.tgz", + "integrity": "sha512-GCwtK33iwVXboZWYcqQHu3aRvXEBwmPkAMRBLeaX86ufhqslyUkLGsi4aW3INEfdQYpUB5M9qtYf3eHvAk2VBg==", + "license": "MIT", + "optional": true, + "dependencies": { + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + } + } +} diff --git a/server/services/tessie/.eslintrc.json b/server/services/tessie/.eslintrc.json new file mode 100644 index 0000000000..fd0c53d560 --- /dev/null +++ b/server/services/tessie/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "no-underscore-dangle": "off" + } +} diff --git a/server/services/tessie/api/tessie.controller.js b/server/services/tessie/api/tessie.controller.js new file mode 100644 index 0000000000..0bb69a2717 --- /dev/null +++ b/server/services/tessie/api/tessie.controller.js @@ -0,0 +1,134 @@ +const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware'); + +module.exports = function NetatmoController(netatmoHandler) { + /** + * @api {get} /api/v1/service/tessie/configuration Get Tessie Configuration. + * @apiName getConfiguration + * @apiGroup Tessie + */ + async function getConfiguration(req, res) { + const configuration = await netatmoHandler.getConfiguration(); + res.json(configuration); + } + + /** + * @api {get} /api/v1/service/tessie/status Get Tessie Status. + * @apiName getStatus + * @apiGroup Tessie + */ + async function getStatus(req, res) { + const result = await netatmoHandler.getStatus(); + res.json(result); + } + + /** + * @api {post} /api/v1/service/tessie/configuration Save Tessie Configuration. + * @apiName saveConfiguration + * @apiGroup Tessie + */ + async function saveConfiguration(req, res) { + const result = await netatmoHandler.saveConfiguration(req.body); + res.json({ + success: result, + }); + } + + /** + * @api {post} /api/v1/service/tessie/status save Tessie connection status + * @apiName saveStatus + * @apiGroup Tessie + */ + async function saveStatus(req, res) { + const result = await netatmoHandler.saveStatus(req.body); + res.json({ + success: result, + }); + } + + /** + * @api {post} /api/v1/service/tessie/connect Connect tessie + * @apiName connect + * @apiGroup Tessie + */ + async function connect(req, res) { + await netatmoHandler.getConfiguration(); + const result = await netatmoHandler.connect(); + res.json(result); + } + + /** + * @api {post} /api/v1/service/tessie/token Retrieve access and refresh Tokens tessie with code of return + * @apiName retrieveTokens + * @apiGroup Tessie + */ + async function retrieveTokens(req, res) { + await netatmoHandler.getConfiguration(); + const result = await netatmoHandler.retrieveTokens(req.body); + res.json(result); + } + + /** + * @api {post} /api/v1/service/tessie/disconnect Disconnect tessie + * @apiName disconnect + * @apiGroup Tessie + */ + async function disconnect(req, res) { + await netatmoHandler.disconnect(); + res.json({ + success: true, + }); + } + + /** + * @api {get} /api/v1/service/tessie/discover Discover tessie devices from API. + * @apiName discover + * @apiGroup Tessie + */ + async function discover(req, res) { + let devices; + if (!netatmoHandler.discoveredDevices || req.query.refresh === 'true') { + devices = await netatmoHandler.discoverDevices(); + } else { + devices = netatmoHandler.discoveredDevices.filter((device) => { + const existInGladys = netatmoHandler.gladys.stateManager.get('deviceByExternalId', device.external_id); + return existInGladys === null; + }); + } + res.json(devices); + } + + return { + 'get /api/v1/service/tessie/configuration': { + authenticated: true, + controller: asyncMiddleware(getConfiguration), + }, + 'post /api/v1/service/tessie/configuration': { + authenticated: true, + controller: asyncMiddleware(saveConfiguration), + }, + 'get /api/v1/service/tessie/status': { + authenticated: true, + controller: asyncMiddleware(getStatus), + }, + 'post /api/v1/service/tessie/status': { + authenticated: true, + controller: asyncMiddleware(saveStatus), + }, + 'post /api/v1/service/tessie/connect': { + authenticated: true, + controller: asyncMiddleware(connect), + }, + 'post /api/v1/service/tessie/token': { + authenticated: true, + controller: asyncMiddleware(retrieveTokens), + }, + 'post /api/v1/service/tessie/disconnect': { + authenticated: true, + controller: asyncMiddleware(disconnect), + }, + 'get /api/v1/service/tessie/discover': { + authenticated: true, + controller: asyncMiddleware(discover), + }, + }; +}; diff --git a/server/services/tessie/index.js b/server/services/tessie/index.js new file mode 100644 index 0000000000..91df604126 --- /dev/null +++ b/server/services/tessie/index.js @@ -0,0 +1,50 @@ +const logger = require('../../utils/logger'); +const netatmoController = require('./api/tessie.controller'); + +const NetatmoHandler = require('./lib'); +const { STATUS } = require('./lib/utils/tessie.constants'); + +module.exports = function NetatmoService(gladys, serviceId) { + const netatmoHandler = new NetatmoHandler(gladys, serviceId); + + /** + * @public + * @description This function starts service. + * @example + * gladys.services.tessie.start(); + */ + async function start() { + logger.info('Starting Tessie service', serviceId); + await netatmoHandler.init(); + } + + /** + * @public + * @description This function stops the service. + * @example + * gladys.services.tessie.stop(); + */ + async function stop() { + logger.info('Stopping Tessie service'); + await netatmoHandler.disconnect(); + } + + /** + * @public + * @description Test if Tessie is running. + * @returns {Promise} Returns true if Tessie is used. + * @example + * const used = await gladys.services.tessie.isUsed(); + */ + async function isUsed() { + return netatmoHandler.status === STATUS.CONNECTED; + } + + return Object.freeze({ + start, + stop, + isUsed, + device: netatmoHandler, + controllers: netatmoController(netatmoHandler), + }); +}; diff --git a/server/services/tessie/lib/device/netatmo.buildFeaturesCommon.js b/server/services/tessie/lib/device/netatmo.buildFeaturesCommon.js new file mode 100644 index 0000000000..114ff46018 --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.buildFeaturesCommon.js @@ -0,0 +1,83 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureBattery(device_name, 'tessie:device_id'); + */ +function buildFeatureBattery(name, externalId) { + return { + name: `Battery - ${name}`, + external_id: `${externalId}:battery_percent`, + selector: `${externalId}:battery_percent`, + category: DEVICE_FEATURE_CATEGORIES.BATTERY, + type: DEVICE_FEATURE_TYPES.BATTERY.INTEGER, + unit: DEVICE_FEATURE_UNITS.PERCENT, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 100, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. + * Signal RF strength (no signal, weak, average, good or excellent). + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureRfStrength(device_name, 'tessie:device_id'); + */ +function buildFeatureRfStrength(name, externalId) { + return { + name: `Link RF quality - ${name}`, + external_id: `${externalId}:rf_strength`, + selector: `${externalId}:rf_strength`, + category: DEVICE_FEATURE_CATEGORIES.SIGNAL, + type: DEVICE_FEATURE_TYPES.SIGNAL.QUALITY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 100, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. + * Signal wifi strength (no signal, weak, average, good or excellent). + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureWifiStrength(device_name, 'tessie:device_id'); + */ +function buildFeatureWifiStrength(name, externalId) { + return { + name: `Link Wifi quality - ${name}`, + external_id: `${externalId}:wifi_strength`, + selector: `${externalId}:wifi_strength`, + category: DEVICE_FEATURE_CATEGORIES.SIGNAL, + type: DEVICE_FEATURE_TYPES.SIGNAL.QUALITY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 100, + }; +} + +module.exports = { + buildFeatureBattery, + buildFeatureRfStrength, + buildFeatureWifiStrength, +}; diff --git a/server/services/tessie/lib/device/netatmo.buildFeaturesCommonTemp.js b/server/services/tessie/lib/device/netatmo.buildFeaturesCommonTemp.js new file mode 100644 index 0000000000..08542172f7 --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.buildFeaturesCommonTemp.js @@ -0,0 +1,34 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. Current temperature. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @param {string} featureName - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureTemperature(device_name, 'tessie:device_id', 'temperature'); + */ +function buildFeatureTemperature(name, externalId, featureName) { + return { + name: `Temperature - ${name}`, + external_id: `${externalId}:${featureName}`, + selector: `${externalId}:${featureName}`, + category: DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: true, + keep_history: true, + has_feedback: false, + min: -10, + max: 50, + }; +} + +module.exports = { + buildFeatureTemperature, +}; diff --git a/server/services/tessie/lib/device/netatmo.buildFeaturesSpecifEnergy.js b/server/services/tessie/lib/device/netatmo.buildFeaturesSpecifEnergy.js new file mode 100644 index 0000000000..bf53c07fa3 --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.buildFeaturesSpecifEnergy.js @@ -0,0 +1,129 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. Temperature setpoint. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureThermSetpointTemperature(device_name, 'tessie:device_id'); + */ +function buildFeatureThermSetpointTemperature(name, externalId) { + return { + name: `Setpoint temperature - ${name}`, + external_id: `${externalId}:therm_setpoint_temperature`, + selector: `${externalId}:therm_setpoint_temperature`, + category: DEVICE_FEATURE_CATEGORIES.THERMOSTAT, + type: DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE, + unit: DEVICE_FEATURE_UNITS.CELSIUS, + read_only: false, + keep_history: true, + has_feedback: false, + min: 5, + max: 30, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. Heating boiler status. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureHeatingBoilerStatus(device_name, 'tessie:device_id'); + */ +function buildFeatureBoilerStatus(name, externalId) { + return { + name: `Boiler status - ${name}`, + external_id: `${externalId}:boiler_status`, + selector: `${externalId}:boiler_status`, + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 1, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. Heating power request. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureHeatingPowerRequest(device_name, 'tessie:device_id'); + */ +function buildFeatureHeatingPowerRequest(name, externalId) { + return { + name: `Heating power request - ${name}`, + external_id: `${externalId}:heating_power_request`, + selector: `${externalId}:heating_power_request`, + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 1, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. Plug connected boiler. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeaturePlugConnectedBoiler(device_name, 'tessie:device_id'); + */ +function buildFeaturePlugConnectedBoiler(name, externalId) { + return { + name: `${name} connected boiler`, + external_id: `${externalId}:plug_connected_boiler`, + selector: `${externalId}:plug_connected_boiler`, + category: DEVICE_FEATURE_CATEGORIES.SWITCH, + type: DEVICE_FEATURE_TYPES.SWITCH.BINARY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 1, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureOpenWindow(device_name, 'tessie:device_id'); + */ +function buildFeatureOpenWindow(name, externalId) { + return { + name: `Detecting open window - ${name}`, + external_id: `${externalId}:open_window`, + selector: `${externalId}:open_window`, + category: DEVICE_FEATURE_CATEGORIES.OPENING_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.BINARY, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 1, + }; +} + +module.exports = { + buildFeatureThermSetpointTemperature, + buildFeatureBoilerStatus, + buildFeatureHeatingPowerRequest, + buildFeaturePlugConnectedBoiler, + buildFeatureOpenWindow, +}; diff --git a/server/services/tessie/lib/device/netatmo.buildFeaturesSpecifWeather.js b/server/services/tessie/lib/device/netatmo.buildFeaturesSpecifWeather.js new file mode 100644 index 0000000000..9d979d517a --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.buildFeaturesSpecifWeather.js @@ -0,0 +1,187 @@ +const { + DEVICE_FEATURE_CATEGORIES, + DEVICE_FEATURE_TYPES, + DEVICE_FEATURE_UNITS, +} = require('../../../../utils/constants'); + +/** + * @description Transforms Tessie feature as Gladys feature. CO2. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureCo2(device_name, 'tessie:device_id'); + */ +function buildFeatureCo2(name, externalId) { + return { + name: `CO2 - ${name}`, + external_id: `${externalId}:co2`, + selector: `${externalId}:co2`, + category: DEVICE_FEATURE_CATEGORIES.CO2_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.INTEGER, + unit: DEVICE_FEATURE_UNITS.PPM, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 5000, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. Humidity. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureHumidity(device_name, 'tessie:device_id'); + */ +function buildFeatureHumidity(name, externalId) { + return { + name: `Humidity - ${name}`, + external_id: `${externalId}:humidity`, + selector: `${externalId}:humidity`, + category: DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + unit: DEVICE_FEATURE_UNITS.PERCENT, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 100, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. Noise decibel. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureNoise(device_name, 'tessie:device_id'); + */ +function buildFeatureNoise(name, externalId) { + return { + name: `Noise - ${name}`, + external_id: `${externalId}:noise`, + selector: `${externalId}:noise`, + category: DEVICE_FEATURE_CATEGORIES.NOISE_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.INTEGER, + unit: DEVICE_FEATURE_UNITS.DECIBEL, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 250, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. Current Pressure or Absolute pressure. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @param {string} featureName - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeaturePressure(device_name, 'tessie:device_id', 'pressure'); + */ +function buildFeaturePressure(name, externalId, featureName) { + return { + name, + external_id: `${externalId}:${featureName}`, + selector: `${externalId}:${featureName}`, + category: DEVICE_FEATURE_CATEGORIES.PRESSURE_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.INTEGER, + unit: DEVICE_FEATURE_UNITS.MILLIBAR, + read_only: true, + keep_history: true, + has_feedback: false, + min: -1000, + max: 2000, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. Speed of wind. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @param {string} featureName - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureWindStrength(device_name, 'tessie:device_id', 'wind_strength'); + */ +function buildFeatureWindStrength(name, externalId, featureName) { + return { + name, + external_id: `${externalId}:${featureName}`, + selector: `${externalId}:${featureName}`, + category: DEVICE_FEATURE_CATEGORIES.SPEED_SENSOR, + type: DEVICE_FEATURE_TYPES.SPEED_SENSOR.INTEGER, + unit: DEVICE_FEATURE_UNITS.KILOMETER_PER_HOUR, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 300, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature. Angle of wind. + * @param {string} name - Name device from Tessie. + * @param {string} externalId - Gladys external ID. + * @param {string} featureName - Gladys external ID. + * @returns {object} Gladys feature or undefined. + * @example + * buildFeatureWindAngle(device_name, 'tessie:device_id', 'wind_angle'); + */ +function buildFeatureWindAngle(name, externalId, featureName) { + return { + name, + external_id: `${externalId}:${featureName}`, + selector: `${externalId}:${featureName}`, + category: DEVICE_FEATURE_CATEGORIES.ANGLE_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.INTEGER, + unit: DEVICE_FEATURE_UNITS.DEGREE, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 360, + }; +} + +/** + * @description Transforms Tessie feature as Gladys feature.Precipitation per hour and sum of the day. + * @param { string } name - Name device from Tessie. + * @param { string } externalId - Gladys external ID. + * @param { string } featureName - Gladys feature constructor. + * @param { string } unit - Gladys unit. + * @returns { object } Gladys feature or undefined. + * @example + * buildFeatureRain(device_name, 'tessie:device_id', 'rain', 'mm'); + */ +function buildFeatureRain(name, externalId, featureName, unit) { + return { + name, + external_id: `${externalId}:${featureName}`, + selector: `${externalId}:${featureName}`, + category: DEVICE_FEATURE_CATEGORIES.PRECIPITATION_SENSOR, + type: DEVICE_FEATURE_TYPES.SENSOR.DECIMAL, + unit, + read_only: true, + keep_history: true, + has_feedback: false, + min: 0, + max: 100, + }; +} +module.exports = { + buildFeatureCo2, + buildFeatureHumidity, + buildFeatureNoise, + buildFeaturePressure, + buildFeatureWindStrength, + buildFeatureWindAngle, + buildFeatureRain, +}; diff --git a/server/services/tessie/lib/device/netatmo.convertDeviceEnergy.js b/server/services/tessie/lib/device/netatmo.convertDeviceEnergy.js new file mode 100644 index 0000000000..e03c0d45a9 --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.convertDeviceEnergy.js @@ -0,0 +1,114 @@ +const logger = require('../../../../utils/logger'); +const { SUPPORTED_MODULE_TYPE, PARAMS } = require('../utils/tessie.constants'); +const { + buildFeatureBattery, + buildFeatureRfStrength, + buildFeatureWifiStrength, +} = require('./tessie.buildFeaturesCommon'); +const { buildFeatureTemperature } = require('./tessie.buildFeaturesCommonTemp'); +const { + buildFeatureThermSetpointTemperature, + buildFeatureBoilerStatus, + buildFeatureHeatingPowerRequest, + buildFeaturePlugConnectedBoiler, + buildFeatureOpenWindow, +} = require('./tessie.buildFeaturesSpecifEnergy'); + +/** + * @description Transform Tessie Energy device to Gladys device. + * @param {object} netatmoDevice - Tessie device. + * @returns {object} Gladys device. + * @example + * tessie.convertDeviceEnergy({ ... }); + */ +function convertDeviceEnergy(netatmoDevice) { + const { home, name, type: model, room = {}, plug = {} } = netatmoDevice; + const id = netatmoDevice.id || netatmoDevice._id; + const homeId = home || netatmoDevice.home_id; + const nameDevice = name || netatmoDevice.module_name; + const externalId = `tessie:${id}`; + logger.debug(`Tessie convert Energy device "${nameDevice}, ${model}"`); + const features = []; + let params = []; + let roomName = 'undefined'; + const isNotBatteryDevice = model === SUPPORTED_MODULE_TYPE.PLUG; + const isBatteryDevice = model === SUPPORTED_MODULE_TYPE.THERMOSTAT || model === SUPPORTED_MODULE_TYPE.NRV; + + if (isBatteryDevice) { + features.push(buildFeatureBattery(nameDevice, externalId)); + features.push(buildFeatureRfStrength(nameDevice, externalId)); + /* params */ + const plugId = plug.id || plug._id; + const plugName = plug.name || plug.module_name || plug.station_name; + if (plugId) { + params = [ + { name: PARAMS.PLUG_ID, value: plugId }, + { name: PARAMS.PLUG_NAME, value: plugName }, + ]; + } + } + if (isNotBatteryDevice) { + const modulesBridged = netatmoDevice.modules_bridged || []; + features.push(buildFeatureWifiStrength(nameDevice, externalId)); + features.push(buildFeatureRfStrength(nameDevice, externalId)); + /* params */ + params = [{ name: PARAMS.MODULES_BRIDGE_ID, value: JSON.stringify(modulesBridged) }]; + } + /* params common to all devices features */ + params.push({ name: PARAMS.HOME_ID, value: homeId }); + if (room.id) { + roomName = room.name; + params.push({ name: PARAMS.ROOM_ID, value: room.id }, { name: PARAMS.ROOM_NAME, value: roomName }); + } + switch (model) { + case SUPPORTED_MODULE_TYPE.THERMOSTAT: { + /* features common Tessie */ + features.push(buildFeatureTemperature(nameDevice, externalId, 'temperature')); + if (room.id) { + features.push(buildFeatureTemperature(`room ${roomName}`, externalId, 'therm_measured_temperature')); + } + /* features specific Energy */ + features.push(buildFeatureThermSetpointTemperature(nameDevice, externalId)); + features.push(buildFeatureOpenWindow(nameDevice, externalId)); + features.push(buildFeatureBoilerStatus(nameDevice, externalId)); + break; + } + case SUPPORTED_MODULE_TYPE.PLUG: { + /* features specific Plugs */ + features.push(buildFeaturePlugConnectedBoiler(nameDevice, externalId)); + break; + } + case SUPPORTED_MODULE_TYPE.NRV: { + /* features common Tessie */ + if (room.id) { + features.push(buildFeatureTemperature(`room ${roomName}`, externalId, 'therm_measured_temperature')); + } + /* features specific Energy */ + features.push(buildFeatureThermSetpointTemperature(nameDevice, externalId)); + features.push(buildFeatureOpenWindow(nameDevice, externalId)); + features.push(buildFeatureHeatingPowerRequest(nameDevice, externalId)); + break; + } + default: + break; + } + const device = { + name: nameDevice, + external_id: externalId, + selector: externalId, + model, + service_id: this.serviceId, + should_poll: false, + features: features.filter((feature) => feature), + params: params.filter((param) => param), + }; + if (netatmoDevice.not_handled) { + device.not_handled = true; + } + logger.info(`Tessie Energy device "${nameDevice}, ${model}" converted`); + return device; +} + +module.exports = { + convertDeviceEnergy, +}; diff --git a/server/services/tessie/lib/device/netatmo.convertDeviceNotSupported.js b/server/services/tessie/lib/device/netatmo.convertDeviceNotSupported.js new file mode 100644 index 0000000000..d337cba738 --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.convertDeviceNotSupported.js @@ -0,0 +1,40 @@ +const logger = require('../../../../utils/logger'); +const { PARAMS } = require('../utils/tessie.constants'); + +/** + * @description Transform Tessie device not supported to Gladys device. + * @param {object} netatmoDevice - Tessie device. + * @returns {object} Gladys device. + * @example + * tessie.convertDeviceNotSupported({ ... }); + */ +function convertDeviceNotSupported(netatmoDevice) { + const { home, name, type: model, room = {} } = netatmoDevice; + const id = netatmoDevice.id || netatmoDevice._id; + const homeId = home || netatmoDevice.home_id; + const nameDevice = name || netatmoDevice.module_name || netatmoDevice.station_name; + const externalId = `tessie:${id}`; + logger.debug(`Tessie convert device not supported "${nameDevice}, ${model}"`); + /* params common to all devices features */ + const params = [{ name: PARAMS.HOME_ID, value: homeId }]; + if (room.id) { + params.push({ name: PARAMS.ROOM_ID, value: room.id }, { name: PARAMS.ROOM_NAME, value: room.name }); + } + const device = { + name: nameDevice, + external_id: externalId, + selector: externalId, + model, + service_id: this.serviceId, + should_poll: false, + features: [], + params: params.filter((param) => param), + not_handled: true, + }; + logger.info(`Tessie device not supported "${nameDevice}, ${model}" converted`); + return device; +} + +module.exports = { + convertDeviceNotSupported, +}; diff --git a/server/services/tessie/lib/device/netatmo.convertDeviceWeather.js b/server/services/tessie/lib/device/netatmo.convertDeviceWeather.js new file mode 100644 index 0000000000..28f495267c --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.convertDeviceWeather.js @@ -0,0 +1,162 @@ +const logger = require('../../../../utils/logger'); +const { SUPPORTED_MODULE_TYPE, PARAMS } = require('../utils/tessie.constants'); +const { + buildFeatureBattery, + buildFeatureRfStrength, + buildFeatureWifiStrength, +} = require('./tessie.buildFeaturesCommon'); +const { buildFeatureTemperature } = require('./tessie.buildFeaturesCommonTemp'); +const { + buildFeatureCo2, + buildFeatureHumidity, + buildFeatureNoise, + buildFeaturePressure, + buildFeatureWindStrength, + buildFeatureWindAngle, + buildFeatureRain, +} = require('./tessie.buildFeaturesSpecifWeather'); +const { DEVICE_FEATURE_UNITS } = require('../../../../utils/constants'); + +/** + * @description Transform Tessie Weather device to Gladys device. + * @param {object} netatmoDevice - Tessie device. + * @returns {object} Gladys device. + * @example + * tessie.convertDeviceWeather({ ... }); + */ +function convertDeviceWeather(netatmoDevice) { + const { home, name, type: model, room = {}, plug = {} } = netatmoDevice; + const id = netatmoDevice.id || netatmoDevice._id; + const homeId = home || netatmoDevice.home_id; + const nameDevice = name || netatmoDevice.module_name || netatmoDevice.station_name; + const externalId = `tessie:${id}`; + logger.debug(`Tessie convert Weather device "${nameDevice}, ${model}"`); + const features = []; + let params = []; + let roomName = 'undefined'; + const isNotBatteryDevice = model === SUPPORTED_MODULE_TYPE.NAMAIN; + const isBatteryDevice = + model === SUPPORTED_MODULE_TYPE.NAMODULE1 || + model === SUPPORTED_MODULE_TYPE.NAMODULE2 || + model === SUPPORTED_MODULE_TYPE.NAMODULE3 || + model === SUPPORTED_MODULE_TYPE.NAMODULE4; + + if (isBatteryDevice) { + features.push(buildFeatureBattery(nameDevice, externalId)); + features.push(buildFeatureRfStrength(nameDevice, externalId)); + /* params */ + const plugId = plug.id || plug._id; + const plugName = plug.name || plug.module_name || plug.station_name; + if (plugId) { + params = [ + { name: PARAMS.PLUG_ID, value: plugId }, + { name: PARAMS.PLUG_NAME, value: plugName }, + ]; + } + } + if (isNotBatteryDevice) { + const modulesBridged = netatmoDevice.modules_bridged || []; + features.push(buildFeatureWifiStrength(nameDevice, externalId)); + /* params */ + params = [{ name: PARAMS.MODULES_BRIDGE_ID, value: JSON.stringify(modulesBridged) }]; + } + /* params common to all devices features */ + params.push({ name: PARAMS.HOME_ID, value: homeId }); + if (room.id) { + roomName = room.name; + params.push({ name: PARAMS.ROOM_ID, value: room.id }, { name: PARAMS.ROOM_NAME, value: roomName }); + } + + switch (model) { + case SUPPORTED_MODULE_TYPE.NAMAIN: { + /* features common Tessie */ + features.push(buildFeatureTemperature(nameDevice, externalId, 'temperature')); + if (room.id) { + features.push(buildFeatureTemperature(`room ${roomName}`, externalId, 'therm_measured_temperature')); + } + features.push(buildFeatureTemperature(`Minimum in ${roomName}`, externalId, 'min_temp')); + features.push(buildFeatureTemperature(`Maximum in ${roomName}`, externalId, 'max_temp')); + /* features specific Tessie Weather */ + features.push(buildFeatureCo2(nameDevice, externalId)); + features.push(buildFeatureHumidity(nameDevice, externalId)); + features.push(buildFeatureNoise(nameDevice, externalId)); + features.push(buildFeaturePressure(`Pressure - ${nameDevice}`, externalId, 'pressure')); + features.push(buildFeaturePressure(`Absolute pressure - ${nameDevice}`, externalId, 'absolute_pressure')); + break; + } + case SUPPORTED_MODULE_TYPE.NAMODULE1: { + /* features common Tessie */ + features.push(buildFeatureTemperature(nameDevice, externalId, 'temperature')); + features.push(buildFeatureTemperature(`Minimum in ${roomName}`, externalId, 'min_temp')); + features.push(buildFeatureTemperature(`Maximum in ${roomName}`, externalId, 'max_temp')); + /* features specific Tessie Weather */ + features.push(buildFeatureHumidity(nameDevice, externalId)); + break; + } + case SUPPORTED_MODULE_TYPE.NAMODULE2: { + /* features specific Tessie Weather */ + features.push(buildFeatureWindStrength(`Wind strength - ${nameDevice}`, externalId, 'wind_strength')); + features.push(buildFeatureWindAngle(`Wind angle - ${nameDevice}`, externalId, 'wind_angle')); + features.push(buildFeatureWindStrength(`Gust strength - ${nameDevice}`, externalId, 'wind_gust')); + features.push(buildFeatureWindAngle(`Gust angle - ${nameDevice}`, externalId, 'wind_gust_angle')); + features.push(buildFeatureWindStrength(`Maximum wind strength - ${nameDevice}`, externalId, 'max_wind_str')); + features.push(buildFeatureWindAngle(`Maximum wind angle - ${nameDevice}`, externalId, 'max_wind_angle')); + break; + } + case SUPPORTED_MODULE_TYPE.NAMODULE3: { + /* features specific Tessie Weather */ + features.push(buildFeatureRain(`Current rain - ${nameDevice}`, externalId, 'rain', DEVICE_FEATURE_UNITS.MM)); + features.push( + buildFeatureRain( + `Precipitation / 1h - ${nameDevice}`, + externalId, + 'sum_rain_1', + DEVICE_FEATURE_UNITS.MILLIMETER_PER_HOUR, + ), + ); + features.push( + buildFeatureRain( + `Sum rain / 24h - ${nameDevice}`, + externalId, + 'sum_rain_24', + DEVICE_FEATURE_UNITS.MILLIMETER_PER_DAY, + ), + ); + break; + } + case SUPPORTED_MODULE_TYPE.NAMODULE4: { + /* features common Tessie */ + features.push(buildFeatureTemperature(nameDevice, externalId, 'temperature')); + if (room.id) { + features.push(buildFeatureTemperature(`room ${roomName}`, externalId, 'therm_measured_temperature')); + } + features.push(buildFeatureTemperature(`Minimum in ${roomName}`, externalId, 'min_temp')); + features.push(buildFeatureTemperature(`Maximum in ${roomName}`, externalId, 'max_temp')); + /* features specific Tessie Weather */ + features.push(buildFeatureCo2(nameDevice, externalId)); + features.push(buildFeatureHumidity(nameDevice, externalId)); + break; + } + default: + break; + } + const device = { + name: nameDevice, + external_id: externalId, + selector: externalId, + model, + service_id: this.serviceId, + should_poll: false, + features: features.filter((feature) => feature), + params: params.filter((param) => param), + }; + if (netatmoDevice.not_handled) { + device.not_handled = true; + } + logger.info(`Tessie Weather device"${nameDevice}, ${model}" converted`); + return device; +} + +module.exports = { + convertDeviceWeather, +}; diff --git a/server/services/tessie/lib/device/netatmo.deviceMapping.js b/server/services/tessie/lib/device/netatmo.deviceMapping.js new file mode 100644 index 0000000000..e7012659f0 --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.deviceMapping.js @@ -0,0 +1,111 @@ +const { DEVICE_FEATURE_TYPES, DEVICE_FEATURE_CATEGORIES } = require('../../../../utils/constants'); + +const writeValues = { + [DEVICE_FEATURE_CATEGORIES.THERMOSTAT]: { + /* therm_setpoint_temperature: 14 */ + [DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE]: (valueFromDevice) => { + return valueFromDevice; + }, + }, +}; + +const readValues = { + [DEVICE_FEATURE_CATEGORIES.THERMOSTAT]: { + [DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE]: (valueFromDevice) => { + return valueFromDevice; + }, + }, + [DEVICE_FEATURE_CATEGORIES.SWITCH]: { + /* plug_connected_boiler: 1 or boiler_status: true */ + [DEVICE_FEATURE_TYPES.SWITCH.BINARY]: (valueFromDevice) => { + const valueToGladys = valueFromDevice === true || valueFromDevice === 1 ? 1 : 0; + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.BATTERY]: { + /* battery_percent: 76 or battery_state: 'medium' */ + [DEVICE_FEATURE_TYPES.BATTERY.INTEGER]: (valueFromDevice) => { + const batteryLevels = { + max: 100, + full: 90, + high: 75, + medium: 50, + low: 25, + 'very low': 10, + }; + + const valueToGladys = + batteryLevels[valueFromDevice] !== undefined ? batteryLevels[valueFromDevice] : parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR]: { + /* temperature: 20.5 */ + [DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]: (valueFromDevice) => { + return valueFromDevice; + }, + }, + [DEVICE_FEATURE_CATEGORIES.SIGNAL]: { + /* rf_strength: 76 or wifi_strength: 76 */ + [DEVICE_FEATURE_TYPES.SIGNAL.QUALITY]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.OPENING_SENSOR]: { + /* room.open_window: false */ + [DEVICE_FEATURE_TYPES.SENSOR.BINARY]: (valueFromDevice) => { + const valueToGladys = valueFromDevice === true || valueFromDevice === 1 ? 1 : 0; + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.CO2_SENSOR]: { + /* co2: 550 */ + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.HUMIDITY_SENSOR]: { + /* humidity: 26 */ + [DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]: (valueFromDevice) => { + return valueFromDevice; + }, + }, + [DEVICE_FEATURE_CATEGORIES.NOISE_SENSOR]: { + /* noise: 32 */ + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.PRESSURE_SENSOR]: { + /* pressure: 1050 or absolute_pressure: 1018 */ + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.SPEED_SENSOR]: { + /* wind_strength: 5 */ + [DEVICE_FEATURE_TYPES.SPEED_SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.ANGLE_SENSOR]: { + /* wind_angle: 120 */ + [DEVICE_FEATURE_TYPES.SENSOR.INTEGER]: (valueFromDevice) => { + const valueToGladys = parseInt(valueFromDevice, 10); + return valueToGladys; + }, + }, + [DEVICE_FEATURE_CATEGORIES.PRECIPITATION_SENSOR]: { + /* rain: 1.5 or sum_rain_1: 5.1 or sum_rain_24: 10.1 */ + [DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]: (valueFromDevice) => { + return valueFromDevice; + }, + }, +}; + +module.exports = { readValues, writeValues }; diff --git a/server/services/tessie/lib/device/netatmo.updateNRV.js b/server/services/tessie/lib/device/netatmo.updateNRV.js new file mode 100644 index 0000000000..ba2ba96125 --- /dev/null +++ b/server/services/tessie/lib/device/netatmo.updateNRV.js @@ -0,0 +1,71 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('./tessie.deviceMapping'); + +/** + * @description Save values of valves NRV. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNRV(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNRV(deviceGladys, deviceNetatmo, externalId) { + const { room } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:battery_percent`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.battery_state), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_measured_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.therm_measured_temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_setpoint_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.therm_setpoint_temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:open_window`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.open_window), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.rf_strength), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:heating_power_request`) + .forEach((feature) => { + const value = room.heating_power_request > 0; + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](value), + }); + }); + } catch (e) { + logger.error('deviceGladys NRV: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNRV, +}; diff --git a/server/services/tessie/lib/index.js b/server/services/tessie/lib/index.js new file mode 100644 index 0000000000..bf683a91ac --- /dev/null +++ b/server/services/tessie/lib/index.js @@ -0,0 +1,93 @@ +const { init } = require('./tessie.init'); +const { connect } = require('./tessie.connect'); +const { retrieveTokens } = require('./tessie.retrieveTokens'); +const { disconnect } = require('./tessie.disconnect'); +const { setTokens } = require('./tessie.setTokens'); +const { getAccessToken } = require('./tessie.getAccessToken'); +const { getRefreshToken } = require('./tessie.getRefreshToken'); +const { refreshingTokens } = require('./tessie.refreshingTokens'); +const { getConfiguration } = require('./tessie.getConfiguration'); +const { getStatus } = require('./tessie.getStatus'); +const { saveStatus } = require('./tessie.saveStatus'); +const { saveConfiguration } = require('./tessie.saveConfiguration'); +const { convertDeviceEnergy } = require('./device/tessie.convertDeviceEnergy'); +const { convertDeviceWeather } = require('./device/tessie.convertDeviceWeather'); +const { convertDeviceNotSupported } = require('./device/tessie.convertDeviceNotSupported'); +const { discoverDevices } = require('./tessie.discoverDevices'); +const { loadDevices } = require('./tessie.loadDevices'); +const { loadDeviceDetails } = require('./tessie.loadDeviceDetails'); +const { loadThermostatDetails } = require('./tessie.loadThermostatDetails'); +const { loadWeatherStationDetails } = require('./tessie.loadWeatherStationDetails'); +const { pollRefreshingToken } = require('./tessie.pollRefreshingToken'); +const { pollRefreshingValues, refreshNetatmoValues } = require('./tessie.pollRefreshingValues'); +const { setValue } = require('./tessie.setValue'); +const { updateValues } = require('./tessie.updateValues'); +const { updateNAPlug } = require('./update/tessie.updateNAPlug'); +const { updateNATherm1 } = require('./update/tessie.updateNATherm1'); +const { updateNRV } = require('./update/tessie.updateNRV'); +const { updateNAMain } = require('./update/tessie.updateNAMain'); +const { updateNAModule1 } = require('./update/tessie.updateNAModule1'); +const { updateNAModule2 } = require('./update/tessie.updateNAModule2'); +const { updateNAModule3 } = require('./update/tessie.updateNAModule3'); +const { updateNAModule4 } = require('./update/tessie.updateNAModule4'); + +const { STATUS, SCOPES } = require('./utils/tessie.constants'); +const buildScopesConfig = require('./utils/tessie.buildScopesConfig'); + +const NetatmoHandler = function NetatmoHandler(gladys, serviceId) { + this.gladys = gladys; + this.serviceId = serviceId; + this.configuration = { + clientId: null, + clientSecret: null, + energyApi: null, + weatherApi: null, + scopes: buildScopesConfig(SCOPES), + }; + this.configured = false; + this.connected = false; + this.redirectUri = null; + this.accessToken = null; + this.refreshToken = null; + this.expireInToken = null; + this.stateGetAccessToken = null; + this.status = STATUS.NOT_INITIALIZED; + this.pollRefreshToken = undefined; + this.pollRefreshValues = undefined; +}; + +NetatmoHandler.prototype.init = init; +NetatmoHandler.prototype.connect = connect; +NetatmoHandler.prototype.retrieveTokens = retrieveTokens; +NetatmoHandler.prototype.disconnect = disconnect; +NetatmoHandler.prototype.setTokens = setTokens; +NetatmoHandler.prototype.getStatus = getStatus; +NetatmoHandler.prototype.saveStatus = saveStatus; +NetatmoHandler.prototype.getAccessToken = getAccessToken; +NetatmoHandler.prototype.getRefreshToken = getRefreshToken; +NetatmoHandler.prototype.refreshingTokens = refreshingTokens; +NetatmoHandler.prototype.getConfiguration = getConfiguration; +NetatmoHandler.prototype.saveConfiguration = saveConfiguration; +NetatmoHandler.prototype.convertDeviceEnergy = convertDeviceEnergy; +NetatmoHandler.prototype.convertDeviceWeather = convertDeviceWeather; +NetatmoHandler.prototype.convertDeviceNotSupported = convertDeviceNotSupported; +NetatmoHandler.prototype.discoverDevices = discoverDevices; +NetatmoHandler.prototype.loadDevices = loadDevices; +NetatmoHandler.prototype.loadDeviceDetails = loadDeviceDetails; +NetatmoHandler.prototype.loadThermostatDetails = loadThermostatDetails; +NetatmoHandler.prototype.loadWeatherStationDetails = loadWeatherStationDetails; +NetatmoHandler.prototype.pollRefreshingValues = pollRefreshingValues; +NetatmoHandler.prototype.refreshNetatmoValues = refreshNetatmoValues; +NetatmoHandler.prototype.pollRefreshingToken = pollRefreshingToken; +NetatmoHandler.prototype.setValue = setValue; +NetatmoHandler.prototype.updateValues = updateValues; +NetatmoHandler.prototype.updateNAPlug = updateNAPlug; +NetatmoHandler.prototype.updateNATherm1 = updateNATherm1; +NetatmoHandler.prototype.updateNRV = updateNRV; +NetatmoHandler.prototype.updateNAMain = updateNAMain; +NetatmoHandler.prototype.updateNAModule1 = updateNAModule1; +NetatmoHandler.prototype.updateNAModule2 = updateNAModule2; +NetatmoHandler.prototype.updateNAModule3 = updateNAModule3; +NetatmoHandler.prototype.updateNAModule4 = updateNAModule4; + +module.exports = NetatmoHandler; diff --git a/server/services/tessie/lib/netatmo.disconnect.js b/server/services/tessie/lib/netatmo.disconnect.js new file mode 100644 index 0000000000..4b81184896 --- /dev/null +++ b/server/services/tessie/lib/netatmo.disconnect.js @@ -0,0 +1,24 @@ +const logger = require('../../../utils/logger'); +const { STATUS } = require('./utils/tessie.constants'); + +/** + * @description Disconnects service and dependencies. + * @example + * disconnect(); + */ +function disconnect() { + logger.debug('Disonnecting from Tessie...'); + this.saveStatus({ statusType: STATUS.DISCONNECTING, message: null }); + const tokens = { + accessToken: '', + refreshToken: '', + expireIn: 0, + }; + this.setTokens(tokens); + this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + logger.debug('Tessie disconnected'); +} + +module.exports = { + disconnect, +}; diff --git a/server/services/tessie/lib/netatmo.discoverDevices.js b/server/services/tessie/lib/netatmo.discoverDevices.js new file mode 100644 index 0000000000..7e5d7cb4c4 --- /dev/null +++ b/server/services/tessie/lib/netatmo.discoverDevices.js @@ -0,0 +1,64 @@ +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const { STATUS, SUPPORTED_CATEGORY_TYPE } = require('./utils/tessie.constants'); + +/** + * @description Discover Tessie cloud devices. + * @returns {Promise} List of discovered devices;. + * @example + * await discoverDevices(); + */ +async function discoverDevices() { + logger.debug('Looking for Tessie devices...'); + if (this.status !== STATUS.CONNECTED) { + await this.saveStatus({ statusType: this.status, message: null }); + throw new ServiceNotConfiguredError('Unable to discover Tessie devices until service is not well configured'); + } + this.discoveredDevices = []; + await this.saveStatus({ statusType: STATUS.DISCOVERING_DEVICES, message: null }); + + let devicesNetatmo = []; + try { + devicesNetatmo = await this.loadDevices(); + logger.info(`${devicesNetatmo.length} Tessie devices found`); + } catch (e) { + logger.error('Unable to load Tessie devices', e); + } + if (devicesNetatmo.length > 0) { + this.discoveredDevices = devicesNetatmo.map((device) => { + let discoveredDevice; + switch (device.categoryAPI) { + case SUPPORTED_CATEGORY_TYPE.ENERGY: { + discoveredDevice = this.convertDeviceEnergy(device); + break; + } + case SUPPORTED_CATEGORY_TYPE.WEATHER: { + discoveredDevice = this.convertDeviceWeather(device); + break; + } + default: + discoveredDevice = this.convertDeviceNotSupported(device); + break; + } + return { + ...discoveredDevice, + service_id: this.serviceId, + deviceNetatmo: device, + }; + }); + const discoveredDevices = this.discoveredDevices.filter((device) => { + const existInGladys = this.gladys.stateManager.get('deviceByExternalId', device.external_id); + return existInGladys === null; + }); + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + logger.debug(`${discoveredDevices.length} new Tessie devices found`); + return discoveredDevices; + } + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + logger.debug('No devices found'); + return []; +} + +module.exports = { + discoverDevices, +}; diff --git a/server/services/tessie/lib/netatmo.getAccessToken.js b/server/services/tessie/lib/netatmo.getAccessToken.js new file mode 100644 index 0000000000..1555b75497 --- /dev/null +++ b/server/services/tessie/lib/netatmo.getAccessToken.js @@ -0,0 +1,36 @@ +const { GLADYS_VARIABLES, STATUS } = require('./utils/tessie.constants'); +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +/** + * @description Tessie get access token. + * @returns {Promise} Tessie access token. + * @example + * await tessie.getAccessToken(); + */ +async function getAccessToken() { + logger.debug('Loading Tessie access token...'); + const { serviceId } = this; + try { + this.accessToken = await this.gladys.variable.getValue(GLADYS_VARIABLES.ACCESS_TOKEN, serviceId); + if (!this.accessToken || this.accessToken === '') { + const tokens = { + accessToken: '', + refreshToken: '', + expireIn: '', + }; + await this.setTokens(tokens); + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + return undefined; + } + logger.debug(`Tessie access token well loaded`); + return this.accessToken; + } catch (e) { + this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not configured.'); + } +} + +module.exports = { + getAccessToken, +}; diff --git a/server/services/tessie/lib/netatmo.getConfiguration.js b/server/services/tessie/lib/netatmo.getConfiguration.js new file mode 100644 index 0000000000..205b8a3853 --- /dev/null +++ b/server/services/tessie/lib/netatmo.getConfiguration.js @@ -0,0 +1,31 @@ +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); +const logger = require('../../../utils/logger'); +const { GLADYS_VARIABLES, STATUS } = require('./utils/tessie.constants'); + +/** + * @description Loads Tessie stored configuration. + * @returns {Promise} Tessie configuration. + * @example + * await getConfiguration(); + */ +async function getConfiguration() { + logger.debug('Loading Tessie configuration...'); + const { serviceId } = this; + try { + this.configuration.clientId = await this.gladys.variable.getValue(GLADYS_VARIABLES.CLIENT_ID, serviceId); + this.configuration.clientSecret = await this.gladys.variable.getValue(GLADYS_VARIABLES.CLIENT_SECRET, serviceId); + this.configuration.energyApi = + (await this.gladys.variable.getValue(GLADYS_VARIABLES.ENERGY_API, serviceId)) === '1'; + this.configuration.weatherApi = + (await this.gladys.variable.getValue(GLADYS_VARIABLES.WEATHER_API, serviceId)) === '1'; + logger.debug(`Tessie configuration get: clientId='${this.configuration.clientId}'`); + return this.configuration; + } catch (e) { + this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not configured.'); + } +} + +module.exports = { + getConfiguration, +}; diff --git a/server/services/tessie/lib/netatmo.getRefreshToken.js b/server/services/tessie/lib/netatmo.getRefreshToken.js new file mode 100644 index 0000000000..e91aa51b47 --- /dev/null +++ b/server/services/tessie/lib/netatmo.getRefreshToken.js @@ -0,0 +1,37 @@ +const { GLADYS_VARIABLES, STATUS } = require('./utils/tessie.constants'); +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +/** + * @description Tessie get refresh token method. + * @returns {Promise} Tessie refresh token. + * @example + * await tessie.getRefreshToken(); + */ +async function getRefreshToken() { + logger.debug('Loading Tessie refresh token...'); + const { serviceId } = this; + try { + this.refreshToken = await this.gladys.variable.getValue(GLADYS_VARIABLES.REFRESH_TOKEN, serviceId); + this.expireInToken = await this.gladys.variable.getValue(GLADYS_VARIABLES.EXPIRE_IN_TOKEN, serviceId); + if (!this.refreshToken) { + const tokens = { + accessToken: '', + refreshToken: '', + expireIn: '', + }; + await this.setTokens(tokens); + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + return undefined; + } + logger.debug(`Tessie refresh token well loaded`); + return this.refreshToken; + } catch (e) { + this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not configured.'); + } +} + +module.exports = { + getRefreshToken, +}; diff --git a/server/services/tessie/lib/netatmo.getStatus.js b/server/services/tessie/lib/netatmo.getStatus.js new file mode 100644 index 0000000000..055fd94651 --- /dev/null +++ b/server/services/tessie/lib/netatmo.getStatus.js @@ -0,0 +1,18 @@ +/** + * @description Get Tessie status. + * @returns {object} Current Tessie network status. + * @example + * status(); + */ +function getStatus() { + const netatmoStatus = { + configured: this.configured, + connected: this.connected, + status: this.status, + }; + return netatmoStatus; +} + +module.exports = { + getStatus, +}; diff --git a/server/services/tessie/lib/netatmo.init.js b/server/services/tessie/lib/netatmo.init.js new file mode 100644 index 0000000000..de35b8e6fd --- /dev/null +++ b/server/services/tessie/lib/netatmo.init.js @@ -0,0 +1,20 @@ +/** + * @description Initialize service with properties and connect to devices. + * @example tessie.init(); + */ +async function init() { + await this.getConfiguration(); + this.configured = true; + await this.getAccessToken(); + await this.getRefreshToken(); + const response = await this.refreshingTokens(); + if (response.success) { + await this.pollRefreshingToken(); + await this.refreshNetatmoValues(); + await this.pollRefreshingValues(); + } +} + +module.exports = { + init, +}; diff --git a/server/services/tessie/lib/netatmo.loadDeviceDetails.js b/server/services/tessie/lib/netatmo.loadDeviceDetails.js new file mode 100644 index 0000000000..6e6cdd3335 --- /dev/null +++ b/server/services/tessie/lib/netatmo.loadDeviceDetails.js @@ -0,0 +1,118 @@ +const { fetch } = require('undici'); +const logger = require('../../../utils/logger'); +const { API, SUPPORTED_MODULE_TYPE, SUPPORTED_CATEGORY_TYPE } = require('./utils/tessie.constants'); + +/** + * @description Discover Tessie cloud devices. + * @param {object} homeData - House. + * @returns {Promise} List of discovered devices. + * @example + * await loadDevicesDetails(); + */ +async function loadDeviceDetails(homeData) { + const { rooms: roomsHomeData, modules: modulesHomeData, id: homeId } = homeData; + let listDevices = []; + + logger.debug('loading devices details in home id: ', homeId, '...'); + const paramsForm = { + home_id: homeId, + }; + try { + const url = `${API.HOMESTATUS}?${new URLSearchParams(paramsForm).toString()}`; + const responseGetHomestatus = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + const rawBody = await responseGetHomestatus.text(); + if (!responseGetHomestatus.ok) { + logger.error('Tessie error: ', responseGetHomestatus.status, rawBody); + } + + const data = JSON.parse(rawBody); + const { body, status } = data; + const { rooms: roomsHomestatus, modules: modulesHomestatus } = body.home; + + if (status === 'ok') { + if (modulesHomestatus) { + listDevices = modulesHomestatus.map((module) => { + let moduleSupported = false; + let apiNotConfigured = false; + let categoryAPI = SUPPORTED_CATEGORY_TYPE.UNKNOWN; + const { type: model } = module; + if ( + model === SUPPORTED_MODULE_TYPE.THERMOSTAT || + model === SUPPORTED_MODULE_TYPE.PLUG || + model === SUPPORTED_MODULE_TYPE.NRV + ) { + if (!this.configuration.energyApi) { + apiNotConfigured = true; + } else { + apiNotConfigured = false; + } + moduleSupported = true; + categoryAPI = SUPPORTED_CATEGORY_TYPE.ENERGY; + } + if ( + model === SUPPORTED_MODULE_TYPE.NAMAIN || + model === SUPPORTED_MODULE_TYPE.NAMODULE1 || + model === SUPPORTED_MODULE_TYPE.NAMODULE2 || + model === SUPPORTED_MODULE_TYPE.NAMODULE3 || + model === SUPPORTED_MODULE_TYPE.NAMODULE4 + ) { + if (!this.configuration.weatherApi) { + apiNotConfigured = true; + } else { + apiNotConfigured = false; + } + moduleSupported = true; + categoryAPI = SUPPORTED_CATEGORY_TYPE.WEATHER; + } + + const moduleHomeData = modulesHomeData.find((mod) => mod.id === module.id); + const roomDevice = { + ...roomsHomeData.find((roomHomeData) => roomHomeData.id === moduleHomeData.room_id), + ...roomsHomestatus.find((room) => room.id === moduleHomeData.room_id), + }; + const plugDevice = { + ...modulesHomeData.find((mod) => mod.id === module.bridge), + ...modulesHomestatus.find((modulePlug) => modulePlug.id === module.bridge), + }; + const plug = Object.keys(plugDevice).length === 0 ? undefined : plugDevice; + const deviceSupported = { + ...module, + ...moduleHomeData, + home: homeId, + room: roomDevice, + plug, + categoryAPI, + apiNotConfigured, + }; + if (moduleSupported) { + return deviceSupported; + } + return { + ...deviceSupported, + not_handled: true, + }; + }); + } else { + listDevices = undefined; + } + logger.debug('Devices details loaded in home: ', homeId); + } else { + logger.warn('Status load devices not ok: ', status, 'response: ', responseGetHomestatus); + } + return listDevices; + } catch (e) { + logger.error('Error getting devices details - error: ', e, ' - status error: ', e.status); + return undefined; + } +} + +module.exports = { + loadDeviceDetails, +}; diff --git a/server/services/tessie/lib/netatmo.loadDevices.js b/server/services/tessie/lib/netatmo.loadDevices.js new file mode 100644 index 0000000000..9eb9068f0d --- /dev/null +++ b/server/services/tessie/lib/netatmo.loadDevices.js @@ -0,0 +1,161 @@ +const Promise = require('bluebird'); +const { fetch } = require('undici'); +const logger = require('../../../utils/logger'); +const { API, SUPPORTED_MODULE_TYPE } = require('./utils/tessie.constants'); + +/** + * @description Discover Tessie cloud devices. + * @returns {Promise} List of discovered devices. + * @example + * await loadDevices(); + */ +async function loadDevices() { + let listDevices = []; + + if (this.configuration.energyApi || (!this.configuration.energyApi && !this.configuration.weatherApi)) { + try { + const responsePage = await fetch(API.HOMESDATA, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + const rawBody = await responsePage.text(); + if (!responsePage.ok) { + logger.error('Tessie error: ', responsePage.status, rawBody); + } + + const data = JSON.parse(rawBody); + const { body, status } = data; + const { homes } = body; + if (status === 'ok') { + const results = await Promise.map( + homes, + async (home) => { + const { modules } = home; + if (modules && modules.length > 0) { + return this.loadDeviceDetails(home); + } + return undefined; + }, + { concurrency: 2 }, + ); + listDevices = results.filter((device) => device !== undefined).flat(); + } + } catch (e) { + logger.error('Error request ', API.HOMESDATA, ' - e.status: ', e.status, 'e', e); + } + } + + if (this.configuration.energyApi || (!this.configuration.energyApi && !this.configuration.weatherApi)) { + try { + const { plugs, thermostats } = await this.loadThermostatDetails(); + if (listDevices.length > 0) { + /* we add the properties of the "Weather" API request to those of the previous "Energy" API request */ + const listDeviceIds = listDevices.map((device) => device._id || device.id); + listDevices = listDeviceIds.map((id) => { + let deviceList = listDevices.find((device) => device._id === id || device.id === id); + const plugEnergy = plugs.find((plug) => plug._id === id); + const thermostat = thermostats.find((t) => t._id === id); + if (plugEnergy) { + deviceList = { ...deviceList, ...plugEnergy }; + } + if (thermostat) { + const plugThermostat = plugs + .map((plug) => { + const { modules, ...rest } = plug; + return rest; + }) + .find((plug) => plug._id === deviceList.bridge); + + deviceList.plug = { ...deviceList.plug, ...plugThermostat }; + deviceList = { ...deviceList, ...thermostat }; + } + + return deviceList; + }); + /* then we add the plugs and thermostats that would belong to a house + that does not have devices in the "Energy" category */ + listDevices = [ + ...listDevices, + ...plugs.filter((plug) => !listDevices.some((device) => device.id === plug._id)), + ...thermostats.filter((thermostat) => !listDevices.some((device) => device.id === thermostat._id)), + ]; + } else { + /* otherwise we retrieve the plugs and thermostats as the "getThermostats" API request provides them to us */ + listDevices = [...plugs, ...thermostats]; + } + listDevices + .filter((device) => device.type === SUPPORTED_MODULE_TYPE.PLUG) + .forEach((plug) => { + if (!plug.modules_bridged) { + plug.modules_bridged = plug.modules.map((module) => module._id); + } + }); + } catch (e) { + logger.error('Error on loadThermostatDetails - e.status: ', e.status, 'e', e); + } + } + + if (this.configuration.weatherApi || (!this.configuration.energyApi && !this.configuration.weatherApi)) { + try { + const { weatherStations, modulesWeatherStations } = await this.loadWeatherStationDetails(); + if (listDevices.length > 0) { + /* we add the properties of the "Weather" API request to those of the previous "Energy" API request */ + listDevices = listDevices.map((device) => { + const weatherStation = weatherStations.find((station) => station._id === device.id); + if (weatherStation) { + return { ...device, ...weatherStation }; + } + const moduleWeatherStation = modulesWeatherStations.find( + (modWeatherStation) => modWeatherStation._id === device.id, + ); + if (moduleWeatherStation) { + const plugModuleWeatherStation = weatherStations + .map((weatherStationModules) => { + const { modules, ...rest } = weatherStationModules; + return rest; + }) + .find((plug) => plug._id === device.bridge); + device.plug = { + ...device.plug, + ...plugModuleWeatherStation, + }; + return { ...device, ...moduleWeatherStation }; + } + return device; + }); + /* then we add the weather stations that would belong to a house + that does not have devices in the "Energy" category */ + listDevices = [ + ...listDevices, + ...weatherStations.filter((station) => !listDevices.some((device) => device.id === station._id)), + ...modulesWeatherStations.filter( + (moduleStation) => !listDevices.some((device) => device.id === moduleStation._id), + ), + ]; + } else { + /* otherwise we retrieve the plugs and thermostats as the "getThermostats" API request provides them to us */ + listDevices = [...weatherStations, ...modulesWeatherStations]; + } + listDevices + .filter((device) => device.type === SUPPORTED_MODULE_TYPE.NAMAIN) + .forEach((weatherStation) => { + if (!weatherStation.modules_bridged) { + weatherStation.modules_bridged = weatherStation.modules.map((module) => module._id); + } + }); + } catch (e) { + logger.error('Error on loadWeatherStationDetails - e.status: ', e.status, 'e', e); + } + } + logger.debug(`${listDevices.length} Tessie devices loaded`); + logger.info(`Tessie devices not supported : ${listDevices.filter((device) => device.not_handled).length}`); + return listDevices; +} + +module.exports = { + loadDevices, +}; diff --git a/server/services/tessie/lib/netatmo.loadThermostatDetails.js b/server/services/tessie/lib/netatmo.loadThermostatDetails.js new file mode 100644 index 0000000000..89065c9dfd --- /dev/null +++ b/server/services/tessie/lib/netatmo.loadThermostatDetails.js @@ -0,0 +1,62 @@ +const { fetch } = require('undici'); +const logger = require('../../../utils/logger'); +const { API, SUPPORTED_CATEGORY_TYPE } = require('./utils/tessie.constants'); + +/** + * @description Discover Tessie cloud thermostats. + * @returns {Promise} List of discovered thermostats and modules. + * @example + * await loadThermostatDetails(); + */ +async function loadThermostatDetails() { + logger.debug('loading Thermostats details...'); + let plugs; + const thermostats = []; + try { + const responseGetThermostat = await fetch(API.GET_THERMOSTATS, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + const rawBody = await responseGetThermostat.text(); + if (!responseGetThermostat.ok) { + logger.error('Tessie error: ', responseGetThermostat.status, rawBody); + } + const data = JSON.parse(rawBody); + const { body, status } = data; + plugs = body.devices; + if (status === 'ok') { + plugs.forEach((plug) => { + if (!this.configuration.energyApi) { + plug.apiNotConfigured = true; + } else { + plug.apiNotConfigured = false; + } + plug.categoryAPI = SUPPORTED_CATEGORY_TYPE.ENERGY; + plug.modules.forEach((module) => { + const { modules, ...rest } = plug; + module.plug = rest; + if (!this.configuration.energyApi) { + module.apiNotConfigured = true; + } else { + module.apiNotConfigured = false; + } + module.categoryAPI = SUPPORTED_CATEGORY_TYPE.ENERGY; + }); + thermostats.push(...plug.modules); + }); + } + logger.debug('Thermostats details loaded in home'); + return { plugs, thermostats }; + } catch (e) { + logger.error('Error getting thermostats details - error: ', e); + return { plugs: undefined, thermostats: undefined }; + } +} + +module.exports = { + loadThermostatDetails, +}; diff --git a/server/services/tessie/lib/netatmo.loadWeatherStationDetails.js b/server/services/tessie/lib/netatmo.loadWeatherStationDetails.js new file mode 100644 index 0000000000..60b2691eb4 --- /dev/null +++ b/server/services/tessie/lib/netatmo.loadWeatherStationDetails.js @@ -0,0 +1,64 @@ +const { fetch } = require('undici'); +const logger = require('../../../utils/logger'); +const { API, SUPPORTED_CATEGORY_TYPE } = require('./utils/tessie.constants'); + +/** + * @description Discover Tessie cloud weather stations. + * @returns {Promise} List of discovered weather stations and modules. + * @example + * await loadWeatherStationDetails(); + */ +async function loadWeatherStationDetails() { + logger.debug('loading Weather Stations details...'); + let weatherStations; + const modulesWeatherStations = []; + try { + const response = await fetch(API.GET_WEATHER_STATIONS, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + }); + const rawBody = await response.text(); + if (!response.ok) { + logger.error('Tessie error: ', response.status, rawBody); + } + + const data = JSON.parse(rawBody); + const { body, status } = data; + weatherStations = body.devices; + if (status === 'ok') { + weatherStations.forEach((weatherStation) => { + if (!this.configuration.weatherApi) { + weatherStation.apiNotConfigured = true; + } else { + weatherStation.apiNotConfigured = false; + } + weatherStation.categoryAPI = SUPPORTED_CATEGORY_TYPE.WEATHER; + weatherStation.modules.forEach((module) => { + const { modules, ...rest } = weatherStation; + module.plug = rest; + if (!this.configuration.weatherApi) { + module.apiNotConfigured = true; + } else { + module.apiNotConfigured = false; + } + module.home_id = weatherStation.home_id; + module.categoryAPI = SUPPORTED_CATEGORY_TYPE.WEATHER; + }); + modulesWeatherStations.push(...weatherStation.modules); + }); + } + logger.debug('Weather Stations details loaded in home'); + return { weatherStations, modulesWeatherStations }; + } catch (e) { + logger.error('Error getting Weather Stations details - error: ', e); + return { weatherStations: undefined, modulesWeatherStations: undefined }; + } +} + +module.exports = { + loadWeatherStationDetails, +}; diff --git a/server/services/tessie/lib/netatmo.pollRefreshingToken.js b/server/services/tessie/lib/netatmo.pollRefreshingToken.js new file mode 100644 index 0000000000..b7bf78e283 --- /dev/null +++ b/server/services/tessie/lib/netatmo.pollRefreshingToken.js @@ -0,0 +1,29 @@ +const logger = require('../../../utils/logger'); + +/** + * @description Poll refreshing Token values of an Tessie device. + * @example refreshNetatmoTokens(); + */ +async function refreshNetatmoTokens() { + const { expireInToken } = this; + await this.refreshingTokens(); + if (this.expireInToken !== expireInToken) { + logger.warn(`New expiration access_token : ${this.expireInToken}ms `); + clearInterval(this.pollRefreshToken); + await this.pollRefreshingToken(); + } +} + +/** + * @description Poll refreshing Token values of an Tessie device. + * @example pollRefreshingToken(); + */ +function pollRefreshingToken() { + if (this.expireInToken > 0) { + this.pollRefreshToken = setInterval(refreshNetatmoTokens.bind(this), this.expireInToken * 1000); + } +} + +module.exports = { + pollRefreshingToken, +}; diff --git a/server/services/tessie/lib/netatmo.pollRefreshingValues.js b/server/services/tessie/lib/netatmo.pollRefreshingValues.js new file mode 100644 index 0000000000..7694d25980 --- /dev/null +++ b/server/services/tessie/lib/netatmo.pollRefreshingValues.js @@ -0,0 +1,57 @@ +const Promise = require('bluebird'); +const { STATUS } = require('./utils/tessie.constants'); +const logger = require('../../../utils/logger'); + +/** + * @description Poll values of Tessie devices. + * @example refreshNetatmoValues(); + */ +async function refreshNetatmoValues() { + logger.debug('Looking for Tessie devices values...'); + await this.saveStatus({ statusType: STATUS.GET_DEVICES_VALUES, message: null }); + + let devicesNetatmo = []; + try { + devicesNetatmo = await this.loadDevices(); + } catch (e) { + await this.saveStatus({ + statusType: STATUS.ERROR.GET_DEVICES_VALUES, + message: 'get_devices_value_fail', + }); + logger.error('Unable to load Tessie devices', e); + } + await Promise.map( + devicesNetatmo, + async (device) => { + const id = device.id || device._id; + const externalId = `tessie:${id}`; + const deviceExistInGladys = await this.gladys.stateManager.get('deviceByExternalId', externalId); + if (deviceExistInGladys) { + await this.updateValues(deviceExistInGladys, device, externalId); + } else { + logger.info(`device ${externalId} - ${device.type} does not exist in Gladys`); + } + }, + { concurrency: 2 }, + ); + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); +} + +/** + * @description Poll values of Tessie devices. + * @example pollRefreshingValues(); + */ +function pollRefreshingValues() { + this.pollRefreshValues = setInterval(async () => { + try { + await this.refreshNetatmoValues(); + } catch (error) { + logger.error('Error refreshing Tessie values: ', error); + } + }, 120 * 1000); +} + +module.exports = { + pollRefreshingValues, + refreshNetatmoValues, +}; diff --git a/server/services/tessie/lib/netatmo.refreshingTokens.js b/server/services/tessie/lib/netatmo.refreshingTokens.js new file mode 100644 index 0000000000..af86bdc4c9 --- /dev/null +++ b/server/services/tessie/lib/netatmo.refreshingTokens.js @@ -0,0 +1,73 @@ +const { fetch } = require('undici'); + +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +const { STATUS, API } = require('./utils/tessie.constants'); + +/** + * @description Tessie retrieve access and refresh token method. + * @returns {Promise} Tessie access token success. + * @example + * await tessie.refreshingTokens(); + */ +async function refreshingTokens() { + const { clientId, clientSecret } = this.configuration; + if (!clientId || !clientSecret) { + await this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not configured.'); + } + if (!this.accessToken || !this.refreshToken) { + logger.debug('Tessie no access or no refresh token'); + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not connected.'); + } + await this.saveStatus({ statusType: STATUS.PROCESSING_TOKEN, message: null }); + logger.debug('Loading Tessie refreshing tokens...'); + const authentificationForm = { + grant_type: 'refresh_token', + client_id: clientId, + client_secret: clientSecret, + refresh_token: this.refreshToken, + }; + try { + const response = await fetch(API.TOKEN, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Host: API.HEADER.HOST, + }, + body: new URLSearchParams(authentificationForm).toString(), + }); + const rawBody = await response.text(); + if (!response.ok) { + logger.error('Error getting new refresh token: ', response.status, rawBody); + throw new Error(`HTTP error ${response.status} - ${rawBody}`); + } + const data = JSON.parse(rawBody); + const tokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expireIn: data.expire_in, + }; + await this.setTokens(tokens); + await this.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + logger.debug('Tessie new access tokens well loaded with status: ', this.status); + return { success: true }; + } catch (e) { + logger.error('Tessie no successfull refresh token and disconnect: ', e); + const tokens = { + accessToken: '', + refreshToken: '', + expireIn: '', + }; + await this.setTokens(tokens); + await this.saveStatus({ statusType: STATUS.ERROR.PROCESSING_TOKEN, message: 'refresh_token_fail' }); + throw new ServiceNotConfiguredError(`TESSIE: Service is not connected with error ${e}`); + } +} + +module.exports = { + refreshingTokens, +}; diff --git a/server/services/tessie/lib/netatmo.retrieveTokens.js b/server/services/tessie/lib/netatmo.retrieveTokens.js new file mode 100644 index 0000000000..98016413d7 --- /dev/null +++ b/server/services/tessie/lib/netatmo.retrieveTokens.js @@ -0,0 +1,83 @@ +const { fetch } = require('undici'); + +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +const { STATUS, API } = require('./utils/tessie.constants'); + +/** + * @description Tessie retrieve access and refresh token method. + * @param {object} body - Tessie code to retrieve access tokens. + * @returns {Promise} Tessie access token. + * @example + * await tessie.retrieveTokens( + * {codeOAuth, state, redirectUri}, + * ); + */ +async function retrieveTokens(body) { + logger.debug('Getting tokens to Tessie API...'); + const { clientId, clientSecret, scopes } = this.configuration; + const { codeOAuth, state, redirectUri } = body; + if (!clientId || !clientSecret || !codeOAuth) { + await this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not configured.'); + } + if (state !== this.stateGetAccessToken) { + await this.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + throw new ServiceNotConfiguredError( + 'Tessie did not connect correctly. The return does not correspond to the initial request', + ); + } + await this.saveStatus({ statusType: STATUS.PROCESSING_TOKEN, message: null }); + const scopeValues = Object.values(scopes).join(' '); + const authentificationForm = { + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + scope: scopeValues, + code: body.codeOAuth, + }; + try { + const response = await fetch(API.TOKEN, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Host: API.HEADER.HOST, + }, + body: new URLSearchParams(authentificationForm).toString(), + }); + const rawBody = await response.text(); + if (!response.ok) { + logger.error('Error getting new accessToken to Tessie - Details: ', response.status, rawBody); + throw new Error(`HTTP error ${response.status} - ${rawBody}`); + } + const data = JSON.parse(rawBody); + const tokens = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expireIn: data.expire_in, + }; + await this.setTokens(tokens); + this.accessToken = tokens.accessToken; + await this.saveStatus({ statusType: STATUS.CONNECTED }); + logger.debug('Tessie new access tokens well loaded'); + if (this.configuration.energyApi || this.configuration.weatherApi) { + await this.pollRefreshingValues(); + } + await this.pollRefreshingToken(); + return { success: true }; + } catch (e) { + this.saveStatus({ + statusType: STATUS.ERROR.PROCESSING_TOKEN, + message: 'get_access_token_fail', + }); + logger.error('Error getting new accessToken to Tessie - Details: ', e); + throw new ServiceNotConfiguredError(`TESSIE: Service is not connected with error ${e}`); + } +} + +module.exports = { + retrieveTokens, +}; diff --git a/server/services/tessie/lib/netatmo.saveConfiguration.js b/server/services/tessie/lib/netatmo.saveConfiguration.js new file mode 100644 index 0000000000..f6eb891028 --- /dev/null +++ b/server/services/tessie/lib/netatmo.saveConfiguration.js @@ -0,0 +1,34 @@ +const logger = require('../../../utils/logger'); + +const { GLADYS_VARIABLES } = require('./utils/tessie.constants'); + +/** + * @description Save Tessie configuration. + * @param {object} configuration - Configuration to save. + * @returns {Promise} Tessie well save configuration. + * @example + * await saveConfiguration({ endpoint: '...', accessKey: '...', secretKey: '...'}); + */ +async function saveConfiguration(configuration) { + logger.debug('Saving Tessie configuration...'); + const { clientId, clientSecret, energyApi, weatherApi } = configuration; + try { + await this.gladys.variable.setValue(GLADYS_VARIABLES.CLIENT_ID, clientId, this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.CLIENT_SECRET, clientSecret, this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.ENERGY_API, energyApi, this.serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.WEATHER_API, weatherApi, this.serviceId); + this.configuration.clientId = clientId; + this.configuration.clientSecret = clientSecret; + this.configuration.energyApi = energyApi; + this.configuration.weatherApi = weatherApi; + logger.debug('Tessie configuration well stored'); + return true; + } catch (e) { + logger.error('Tessie configuration stored errored', e); + return false; + } +} + +module.exports = { + saveConfiguration, +}; diff --git a/server/services/tessie/lib/netatmo.saveStatus.js b/server/services/tessie/lib/netatmo.saveStatus.js new file mode 100644 index 0000000000..dcccc0180f --- /dev/null +++ b/server/services/tessie/lib/netatmo.saveStatus.js @@ -0,0 +1,113 @@ +const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); +const { STATUS } = require('./utils/tessie.constants'); + +/** + * @description Post Tessie status. + * @param {object} status - Configuration to save. + * @returns {object} Current Tessie network status. + * @example + * status({statusType: 'connecting', message: 'invalid_client'}); + */ +function saveStatus(status) { + logger.debug('Changing status Tessie ...'); + try { + switch (status.statusType) { + case STATUS.ERROR.CONNECTING: + this.status = STATUS.DISCONNECTED; + this.connected = false; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTING, + payload: { statusType: STATUS.CONNECTING, status: status.message }, + }); + break; + case STATUS.ERROR.PROCESSING_TOKEN: + this.status = STATUS.DISCONNECTED; + this.connected = false; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.PROCESSING_TOKEN, + payload: { statusType: STATUS.PROCESSING_TOKEN, status: status.message }, + }); + break; + case STATUS.ERROR.CONNECTED: + this.configured = true; + this.status = STATUS.DISCONNECTED; + this.connected = false; + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTED, + payload: { statusType: STATUS.CONNECTED, status: status.message }, + }); + break; + case STATUS.ERROR.SET_DEVICES_VALUES: + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTED, + payload: { statusType: STATUS.CONNECTED, status: status.message }, + }); + break; + case STATUS.ERROR.GET_DEVICES_VALUES: + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.ERROR.CONNECTED, + payload: { statusType: STATUS.CONNECTED, status: status.message }, + }); + break; + + case STATUS.NOT_INITIALIZED: + this.configured = false; + this.status = STATUS.NOT_INITIALIZED; + this.connected = false; + clearInterval(this.pollRefreshToken); + clearInterval(this.pollRefreshValues); + break; + case STATUS.CONNECTING: + this.configured = true; + this.status = STATUS.CONNECTING; + this.connected = false; + break; + case STATUS.PROCESSING_TOKEN: + this.configured = true; + this.status = STATUS.PROCESSING_TOKEN; + this.connected = false; + break; + case STATUS.CONNECTED: + this.configured = true; + this.status = STATUS.CONNECTED; + this.connected = true; + break; + case STATUS.DISCONNECTING: + this.configured = true; + this.status = STATUS.DISCONNECTING; + break; + case STATUS.DISCONNECTED: + this.configured = true; + this.status = STATUS.DISCONNECTED; + this.connected = false; + clearInterval(this.pollRefreshToken); + clearInterval(this.pollRefreshValues); + break; + case STATUS.DISCOVERING_DEVICES: + this.configured = true; + this.status = STATUS.DISCOVERING_DEVICES; + this.connected = true; + break; + case STATUS.GET_DEVICES_VALUES: + this.configured = true; + this.status = STATUS.GET_DEVICES_VALUES; + this.connected = true; + break; + + default: + break; + } + logger.debug('Status Tessie well changed'); + this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.TESSIE.STATUS, + payload: { status: this.status }, + }); + return true; + } catch (e) { + return false; + } +} +module.exports = { + saveStatus, +}; diff --git a/server/services/tessie/lib/netatmo.setTokens.js b/server/services/tessie/lib/netatmo.setTokens.js new file mode 100644 index 0000000000..adb549295d --- /dev/null +++ b/server/services/tessie/lib/netatmo.setTokens.js @@ -0,0 +1,32 @@ +const logger = require('../../../utils/logger'); +const { GLADYS_VARIABLES } = require('./utils/tessie.constants'); + +/** + * @description Tessie save token method. + * @param {object} tokens - Tessie tokens. + * @returns {Promise} Tessie well set Tokens. + * @example + * await tessie.setTokens({ access_token: '...', refresh_token:'...', expire_time: ...}); + */ +async function setTokens(tokens) { + logger.debug('Storing Tessie tokens...'); + const { serviceId } = this; + const { accessToken, refreshToken, expireIn } = tokens; + try { + await this.gladys.variable.setValue(GLADYS_VARIABLES.ACCESS_TOKEN, accessToken, serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.REFRESH_TOKEN, refreshToken, serviceId); + await this.gladys.variable.setValue(GLADYS_VARIABLES.EXPIRE_IN_TOKEN, expireIn, serviceId); + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expireInToken = expireIn; + logger.debug('Tessie tokens well stored'); + return true; + } catch (e) { + logger.error('Tessie tokens stored errored', e); + return false; + } +} + +module.exports = { + setTokens, +}; diff --git a/server/services/tessie/lib/netatmo.setValue.js b/server/services/tessie/lib/netatmo.setValue.js new file mode 100644 index 0000000000..5dd8af2fb8 --- /dev/null +++ b/server/services/tessie/lib/netatmo.setValue.js @@ -0,0 +1,84 @@ +const { fetch } = require('undici'); +const logger = require('../../../utils/logger'); +const { API, STATUS, PARAMS } = require('./utils/tessie.constants'); +const { BadParameters } = require('../../../utils/coreErrors'); +const { writeValues } = require('./device/tessie.deviceMapping'); + +/** + * @description Send the new device value over device protocol. + * @param {object} device - Updated Gladys device. + * @param {object} deviceFeature - Updated Gladys device feature. + * @param {string|number} value - The new device feature value. + * @example + * setValue(device, deviceFeature, 0); + */ +async function setValue(device, deviceFeature, value) { + const externalId = deviceFeature.external_id; + const homeId = device.params.find((oneParam) => oneParam.name === PARAMS.HOME_ID); + const roomId = device.params.find((oneParam) => oneParam.name === PARAMS.ROOM_ID); + if (!homeId || !roomId) { + throw new BadParameters( + `Tessie device external_id: "${externalId}" should contains parameters "HOME_ID" and "ROOM_ID"`, + ); + } + const [prefix, ...topic] = externalId.split(':'); + if (prefix !== 'tessie') { + throw new BadParameters(`Tessie device external_id is invalid: "${externalId}" should starts with "tessie:"`); + } + if (!topic || topic.length === 0) { + throw new BadParameters(`Tessie device external_id is invalid: "${externalId}" have no id and category indicator`); + } + const featureName = topic[topic.length - 1]; + + const transformedValue = writeValues[deviceFeature.category][deviceFeature.type](value); + logger.debug(`Change value for device ${device.name} / ${featureName} to value ${transformedValue}...`); + + const paramsForm = { + home_id: homeId.value, + room_id: roomId.value, + mode: 'manual', + temp: transformedValue, + }; + try { + const responseSetRoomThermpoint = await fetch(API.SET_ROOM_THERMPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.accessToken}`, + 'Content-Type': API.HEADER.CONTENT_TYPE, + Accept: API.HEADER.ACCEPT, + }, + body: new URLSearchParams(paramsForm).toString(), + }); + const rawBody = await responseSetRoomThermpoint.text(); + if (!responseSetRoomThermpoint.ok) { + logger.error('Tessie error: ', responseSetRoomThermpoint.status, rawBody); + if (responseSetRoomThermpoint.status === 403 && JSON.parse(rawBody).error.code === 13) { + await this.saveStatus({ + statusType: STATUS.ERROR.SET_DEVICES_VALUES, + message: 'set_devices_value_fail_scope_rights', + }); + } else { + await this.saveStatus({ + statusType: STATUS.ERROR.SET_DEVICES_VALUES, + message: 'set_devices_value_error_unknown', + }); + } + } else { + logger.debug(`Value has been changed on the device ${device.name} / ${featureName}: ${transformedValue}`); + } + } catch (e) { + logger.error( + 'setValue error with status code: ', + e.code, + ' - ', + e.response.status, + 'and with status message: ', + e.response.statusText, + ); + logger.error('error details: ', e.response.data.error.code, ' - ', e.response.data.error.message); + } +} + +module.exports = { + setValue, +}; diff --git a/server/services/tessie/lib/netatmo.updateValues.js b/server/services/tessie/lib/netatmo.updateValues.js new file mode 100644 index 0000000000..2c8fafd55d --- /dev/null +++ b/server/services/tessie/lib/netatmo.updateValues.js @@ -0,0 +1,65 @@ +const { BadParameters } = require('../../../utils/coreErrors'); +const logger = require('../../../utils/logger'); +const { SUPPORTED_MODULE_TYPE } = require('./utils/tessie.constants'); + +/** + * @description Save values of an Tessie device. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateValues(deviceGladys, deviceNetatmo, externalId); + */ +async function updateValues(deviceGladys, deviceNetatmo, externalId) { + const [prefix, topic] = externalId.split(':'); + const { reachable } = deviceNetatmo; + if (prefix !== 'tessie') { + throw new BadParameters(`Tessie device external_id is invalid: "${externalId}" should starts with "tessie:"`); + } + if (!topic || topic.length === 0) { + throw new BadParameters(`Tessie device external_id is invalid: "${externalId}" have no id and category indicator`); + } + if (!reachable && typeof reachable !== 'undefined') { + logger.info(`Tessie device "${deviceGladys.name}" is not reachable`); + } + + switch (deviceNetatmo.type) { + case SUPPORTED_MODULE_TYPE.PLUG: { + await this.updateNAPlug(deviceGladys, deviceNetatmo, externalId); + break; + } + case SUPPORTED_MODULE_TYPE.THERMOSTAT: { + await this.updateNATherm1(deviceGladys, deviceNetatmo, externalId); + break; + } + case SUPPORTED_MODULE_TYPE.NRV: { + await this.updateNRV(deviceGladys, deviceNetatmo, externalId); + break; + } + case SUPPORTED_MODULE_TYPE.NAMAIN: { + await this.updateNAMain(deviceGladys, deviceNetatmo, externalId); + break; + } + case SUPPORTED_MODULE_TYPE.NAMODULE1: { + await this.updateNAModule1(deviceGladys, deviceNetatmo, externalId); + break; + } + case SUPPORTED_MODULE_TYPE.NAMODULE2: { + await this.updateNAModule2(deviceGladys, deviceNetatmo, externalId); + break; + } + case SUPPORTED_MODULE_TYPE.NAMODULE3: { + await this.updateNAModule3(deviceGladys, deviceNetatmo, externalId); + break; + } + case SUPPORTED_MODULE_TYPE.NAMODULE4: { + await this.updateNAModule4(deviceGladys, deviceNetatmo, externalId); + break; + } + default: + break; + } +} + +module.exports = { + updateValues, +}; diff --git a/server/services/tessie/lib/tessie.connect.js b/server/services/tessie/lib/tessie.connect.js new file mode 100644 index 0000000000..424a80fd54 --- /dev/null +++ b/server/services/tessie/lib/tessie.connect.js @@ -0,0 +1,33 @@ +const crypto = require('crypto'); + +const logger = require('../../../utils/logger'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); + +const { STATUS, API } = require('./utils/tessie.constants'); + +/** + * @description Connect to Tessie and getting code to get access tokens. + * @returns {Promise} Tessie access token. + * @example + * connect(); + */ +async function connect() { + const { clientId, clientSecret, scopes } = this.configuration; + if (!clientId || !clientSecret) { + await this.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + throw new ServiceNotConfiguredError('Tessie is not configured.'); + } + await this.saveStatus({ statusType: STATUS.CONNECTING, message: null }); + logger.debug('Connecting to Tessie...'); + + this.stateGetAccessToken = crypto.randomBytes(16).toString('hex'); + const scopeValues = Object.values(scopes).join(' '); + this.redirectUri = `${API.OAUTH2}?client_id=${clientId}&scope=${encodeURIComponent(scopeValues)}&state=${this.stateGetAccessToken + }`; + this.configured = true; + return { authUrl: this.redirectUri, state: this.stateGetAccessToken }; +} + +module.exports = { + connect, +}; diff --git a/server/services/tessie/lib/update/netatmo.updateNAMain.js b/server/services/tessie/lib/update/netatmo.updateNAMain.js new file mode 100644 index 0000000000..b3087aa929 --- /dev/null +++ b/server/services/tessie/lib/update/netatmo.updateNAMain.js @@ -0,0 +1,113 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('../device/tessie.deviceMapping'); + +/** + * @description Save values of Smart Home Weather Station NAMain Indoor. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNAMain(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNAMain(deviceGladys, deviceNetatmo, externalId) { + const { room, dashboard_data: dashboardData } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:temperature`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.temperature || (dashboardData && dashboardData.Temperature); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + if (room) { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_measured_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.therm_measured_temperature), + }); + }); + } + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:co2`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.co2 || (dashboardData && dashboardData.CO2); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:humidity`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.humidity || (dashboardData && dashboardData.Humidity); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:noise`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.noise || (dashboardData && dashboardData.Noise); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:pressure`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.pressure || (dashboardData && dashboardData.Pressure); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:absolute_pressure`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.absolute_pressure || (dashboardData && dashboardData.AbsolutePressure); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + if (dashboardData) { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:min_temp`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](dashboardData.min_temp), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:max_temp`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](dashboardData.max_temp), + }); + }); + } + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:wifi_strength`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.wifi_strength || deviceNetatmo.wifi_status; + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + } catch (e) { + logger.error('deviceGladys NAMain: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNAMain, +}; diff --git a/server/services/tessie/lib/update/netatmo.updateNAModule1.js b/server/services/tessie/lib/update/netatmo.updateNAModule1.js new file mode 100644 index 0000000000..d5388987e7 --- /dev/null +++ b/server/services/tessie/lib/update/netatmo.updateNAModule1.js @@ -0,0 +1,75 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('../device/tessie.deviceMapping'); + +/** + * @description Save values of Smart Outdoor Module NAModule1 - Weather Station . + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNAModule1(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNAModule1(deviceGladys, deviceNetatmo, externalId) { + const { dashboard_data: dashboardData } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:battery_percent`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.battery_percent), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:temperature`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.temperature || (dashboardData && dashboardData.Temperature); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:humidity`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.humidity || (dashboardData && dashboardData.Humidity); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + if (dashboardData) { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:min_temp`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](dashboardData.min_temp), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:max_temp`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](dashboardData.max_temp), + }); + }); + } + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.rf_strength || (dashboardData && dashboardData.rf_status); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + } catch (e) { + logger.error('deviceGladys NAModule1: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNAModule1, +}; diff --git a/server/services/tessie/lib/update/netatmo.updateNAModule2.js b/server/services/tessie/lib/update/netatmo.updateNAModule2.js new file mode 100644 index 0000000000..7178e3bc6e --- /dev/null +++ b/server/services/tessie/lib/update/netatmo.updateNAModule2.js @@ -0,0 +1,93 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('../device/tessie.deviceMapping'); + +/** + * @description Save values of Smart Anemometer Module NAModule2 - Weather Station . + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNAModule2(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNAModule2(deviceGladys, deviceNetatmo, externalId) { + const { dashboard_data: dashboardData } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:battery_percent`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:battery_percent`, + state: readValues[feature.category][feature.type](deviceNetatmo.battery_percent), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:wind_strength`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.wind_strength || (dashboardData && dashboardData.WindStrength); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:wind_angle`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.wind_angle || (dashboardData && dashboardData.WindAngle); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:wind_gust`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.wind_gust || (dashboardData && dashboardData.GustStrength); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:wind_gust_angle`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.wind_gust_angle || (dashboardData && dashboardData.GustAngle); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + if (dashboardData) { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:max_wind_str`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](dashboardData.max_wind_str), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:max_wind_angle`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](dashboardData.max_wind_angle), + }); + }); + } + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.rf_strength || (dashboardData && dashboardData.rf_status); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + } catch (e) { + logger.error('deviceGladys NAModule2: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNAModule2, +}; diff --git a/server/services/tessie/lib/update/netatmo.updateNAModule3.js b/server/services/tessie/lib/update/netatmo.updateNAModule3.js new file mode 100644 index 0000000000..5e494878f3 --- /dev/null +++ b/server/services/tessie/lib/update/netatmo.updateNAModule3.js @@ -0,0 +1,66 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('../device/tessie.deviceMapping'); + +/** + * @description Save values of Smart Rain Gauge Module NAModule3. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNAModule3(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNAModule3(deviceGladys, deviceNetatmo, externalId) { + const { dashboard_data: dashboardData } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:battery_percent`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `${externalId}:battery_percent`, + state: readValues[feature.category][feature.type](deviceNetatmo.battery_percent), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rain`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.rain || (dashboardData && dashboardData.Rain); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:sum_rain_1`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.sum_rain_1 || (dashboardData && dashboardData.sum_rain_1); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:sum_rain_24`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.sum_rain_24 || (dashboardData && dashboardData.sum_rain_24); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.rf_strength || (dashboardData && dashboardData.rf_status); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + } catch (e) { + logger.error('deviceGladys NAModule3: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNAModule3, +}; diff --git a/server/services/tessie/lib/update/netatmo.updateNAModule4.js b/server/services/tessie/lib/update/netatmo.updateNAModule4.js new file mode 100644 index 0000000000..ab02647797 --- /dev/null +++ b/server/services/tessie/lib/update/netatmo.updateNAModule4.js @@ -0,0 +1,94 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('../device/tessie.deviceMapping'); + +/** + * @description Save values of Smart Indoor Module NAModule4 - Weather Station . + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNAModule4(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNAModule4(deviceGladys, deviceNetatmo, externalId) { + const { room, dashboard_data: dashboardData } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:battery_percent`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.battery_percent), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:temperature`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.temperature || (dashboardData && dashboardData.Temperature); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + if (room) { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_measured_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.therm_measured_temperature), + }); + }); + } + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:co2`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.co2 || (dashboardData && dashboardData.CO2); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:humidity`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.humidity || (dashboardData && dashboardData.Humidity); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + if (dashboardData) { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:min_temp`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](dashboardData.min_temp), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:max_temp`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](dashboardData.max_temp), + }); + }); + } + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + const valueDeviceNetatmo = deviceNetatmo.rf_strength || (dashboardData && dashboardData.rf_status); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](valueDeviceNetatmo), + }); + }); + } catch (e) { + logger.error('deviceGladys NAModule4: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNAModule4, +}; diff --git a/server/services/tessie/lib/update/netatmo.updateNAPlug.js b/server/services/tessie/lib/update/netatmo.updateNAPlug.js new file mode 100644 index 0000000000..8da0f97a98 --- /dev/null +++ b/server/services/tessie/lib/update/netatmo.updateNAPlug.js @@ -0,0 +1,53 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('../device/tessie.deviceMapping'); + +/** + * @description Save values of NAPlug. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNAPlug(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNAPlug(deviceGladys, deviceNetatmo, externalId) { + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.rf_strength), + }); + }); + + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:wifi_strength`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.wifi_strength), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:plug_connected_boiler`) + .forEach((feature) => { + if (typeof deviceNetatmo.plug_connected_boiler !== 'undefined') { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.plug_connected_boiler), + }); + } else { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](false), + }); + } + }); + } catch (e) { + logger.error('deviceGladys NAPlug: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNAPlug, +}; diff --git a/server/services/tessie/lib/update/netatmo.updateNATherm1.js b/server/services/tessie/lib/update/netatmo.updateNATherm1.js new file mode 100644 index 0000000000..981eda6d99 --- /dev/null +++ b/server/services/tessie/lib/update/netatmo.updateNATherm1.js @@ -0,0 +1,78 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('../device/tessie.deviceMapping'); + +/** + * @description Save values of Thermostats NATherm1. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNATherm1(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNATherm1(deviceGladys, deviceNetatmo, externalId) { + const { measured, room } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:battery_percent`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.battery_percent), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](measured.temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_measured_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.therm_measured_temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_setpoint_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.therm_setpoint_temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:open_window`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.open_window), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.rf_strength), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:boiler_status`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.boiler_status), + }); + }); + } catch (e) { + logger.error('deviceGladys NATherm1: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNATherm1, +}; diff --git a/server/services/tessie/lib/update/netatmo.updateNRV.js b/server/services/tessie/lib/update/netatmo.updateNRV.js new file mode 100644 index 0000000000..60a551a905 --- /dev/null +++ b/server/services/tessie/lib/update/netatmo.updateNRV.js @@ -0,0 +1,71 @@ +const { EVENTS } = require('../../../../utils/constants'); +const logger = require('../../../../utils/logger'); +const { readValues } = require('../device/tessie.deviceMapping'); + +/** + * @description Save values of valves NRV. + * @param {object} deviceGladys - Device object in Gladys. + * @param {object} deviceNetatmo - Device object coming from the Tessie API. + * @param {string} externalId - Device identifier in gladys. + * @example updateNRV(deviceGladys, deviceNetatmo, externalId); + */ +async function updateNRV(deviceGladys, deviceNetatmo, externalId) { + const { room } = deviceNetatmo; + try { + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:battery_percent`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.battery_state), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_measured_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.therm_measured_temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:therm_setpoint_temperature`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.therm_setpoint_temperature), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:open_window`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](room.open_window), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:rf_strength`) + .forEach((feature) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](deviceNetatmo.rf_strength), + }); + }); + deviceGladys.features + .filter((feature) => feature.external_id === `${externalId}:heating_power_request`) + .forEach((feature) => { + const value = room.heating_power_request > 0; + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: readValues[feature.category][feature.type](value), + }); + }); + } catch (e) { + logger.error('deviceGladys NRV: ', deviceGladys.name, 'error: ', e); + } +} + +module.exports = { + updateNRV, +}; diff --git a/server/services/tessie/lib/utils/tessie.buildScopesConfig.js b/server/services/tessie/lib/utils/tessie.buildScopesConfig.js new file mode 100644 index 0000000000..77d78f3dab --- /dev/null +++ b/server/services/tessie/lib/utils/tessie.buildScopesConfig.js @@ -0,0 +1,26 @@ +/** + * @description Poll refreshing Token values of an Tessie device. + * @param {object} scopes - Scopes format. + * @returns {object} Object scopes string - Returns all scopes as a string object separating scopes by API. + * @example + * buildScopesConfig({ + * ENERGY: { + * read: 'read_thermostat', + * write: 'write_thermostat', + * }}); + */ +function buildScopesConfig(scopes) { + const scopesConfig = {}; + Object.keys(scopes).forEach((key) => { + const words = key.toLowerCase().split('_'); + const camelCaseKey = words + .map((word, index) => (index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1))) + .join(''); + const scopeKey = `scope${camelCaseKey.charAt(0).toUpperCase() + camelCaseKey.slice(1)}`; + scopesConfig[scopeKey] = Object.values(scopes[key]).join(' '); + }); + + return scopesConfig; +} + +module.exports = buildScopesConfig; diff --git a/server/services/tessie/lib/utils/tessie.constants.js b/server/services/tessie/lib/utils/tessie.constants.js new file mode 100644 index 0000000000..5ca374baee --- /dev/null +++ b/server/services/tessie/lib/utils/tessie.constants.js @@ -0,0 +1,107 @@ +const GLADYS_VARIABLES = { + CLIENT_ID: 'TESSIE_CLIENT_ID', + CLIENT_SECRET: 'TESSIE_CLIENT_SECRET', + + ENERGY_API: 'TESSIE_ENERGY_API', + WEATHER_API: 'TESSIE_WEATHER_API', + + ACCESS_TOKEN: 'TESSIE_ACCESS_TOKEN', + REFRESH_TOKEN: 'TESSIE_REFRESH_TOKEN', + EXPIRE_IN_TOKEN: 'TESSIE_EXPIRE_IN_TOKEN', +}; + +const SCOPES = { + ENERGY: { + read: 'read_thermostat', + write: 'write_thermostat', + }, + HOME_SECURITY: { + read_camera: 'read_camera', + read_presence: 'read_presence', + read_carbonmonoxidedetector: 'read_carbonmonoxidedetector', + read_smokedetector: 'read_smokedetector', + }, + WEATHER: { + read_station: 'read_station', + }, + AIRCARE: { + read_homecoach: 'read_homecoach', + }, +}; + +const STATUS = { + NOT_INITIALIZED: 'not_initialized', + CONNECTING: 'connecting', + DISCONNECTING: 'disconnecting', + PROCESSING_TOKEN: 'processing token', + CONNECTED: 'connected', + DISCONNECTED: 'disconnected', + ERROR: { + CONNECTING: 'error connecting', + PROCESSING_TOKEN: 'error processing token', + DISCONNECTING: 'error disconnecting', + CONNECTED: 'error connected', + SET_DEVICES_VALUES: 'error set devices values', + GET_DEVICES_VALUES: 'error get devices values', + }, + GET_DEVICES_VALUES: 'get devices values', + DISCOVERING_DEVICES: 'discovering', +}; + +const GITHUB_BASE_URL = 'https://github.com/GladysAssistant/Gladys/issues/new'; +const BASE_API = 'https://api.tessie.com'; +const API = { + HEADER: { + ACCEPT: 'application/json', + HOST: 'api.tessie.com', + CONTENT_TYPE: 'application/x-www-form-urlencoded;charset=UTF-8', + }, + OAUTH2: `${BASE_API}/oauth2/authorize`, + TOKEN: `${BASE_API}/oauth2/token`, + GET_THERMOSTATS: `${BASE_API}/api/getthermostatsdata`, + GET_WEATHER_STATIONS: `${BASE_API}/api/getstationsdata?get_favorites=false`, + POST_THERMPOINT: `${BASE_API}/api/setroomthermpoint`, + HOMESDATA: `${BASE_API}/api/homesdata`, + HOMESTATUS: `${BASE_API}/api/homestatus`, + GET_ROOM_MEASURE: `${BASE_API}/api/getroommeasure`, + SET_ROOM_THERMPOINT: `${BASE_API}/api/setroomthermpoint`, + SET_THERM_MODE: `${BASE_API}/api/setthermmode`, + GET_MEASURE: `${BASE_API}/api/getmeasure`, +}; + +const SUPPORTED_CATEGORY_TYPE = { + ENERGY: 'Energy', + WEATHER: 'Weather', + UNKNOWN: 'unknown', +}; + +const SUPPORTED_MODULE_TYPE = { + THERMOSTAT: 'NATherm1', + PLUG: 'NAPlug', + NRV: 'NRV', + NAMAIN: 'NAMain', + NAMODULE1: 'NAModule1', + NAMODULE2: 'NAModule2', + NAMODULE3: 'NAModule3', + NAMODULE4: 'NAModule4', +}; + +const PARAMS = { + HOME_ID: 'home_id', + ROOM_ID: 'room_id', + ROOM_NAME: 'room_name', + PLUG_ID: 'plug_id', + PLUG_NAME: 'plug_name', + MODULES_BRIDGE_ID: 'modules_bridge_id', +}; + +module.exports = { + GLADYS_VARIABLES, + SCOPES, + STATUS, + GITHUB_BASE_URL, + API, + SUPPORTED_CATEGORY_TYPE, + SUPPORTED_MODULE_TYPE, + PARAMS, +}; diff --git a/server/services/tessie/package-lock.json b/server/services/tessie/package-lock.json new file mode 100644 index 0000000000..8181240a84 --- /dev/null +++ b/server/services/tessie/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "gladys-tessie", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gladys-tessie", + "version": "1.0.0", + "cpu": [ + "x64", + "arm", + "arm64" + ], + "os": [ + "darwin", + "linux", + "win32" + ], + "dependencies": { + "bluebird": "^3.7.2", + "undici": "^7.5.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, + "node_modules/undici": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.5.0.tgz", + "integrity": "sha512-NFQG741e8mJ0fLQk90xKxFdaSM7z4+IQpAgsFI36bCDY9Z2+aXXZjVy2uUksMouWfMI9+w5ejOq5zYYTBCQJDQ==", + "engines": { + "node": ">=20.18.1" + } + } + } +} \ No newline at end of file diff --git a/server/services/tessie/package.json b/server/services/tessie/package.json new file mode 100644 index 0000000000..6c9a03c657 --- /dev/null +++ b/server/services/tessie/package.json @@ -0,0 +1,19 @@ +{ + "name": "gladys-tessie", + "version": "1.0.0", + "main": "index.js", + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm", + "arm64" + ], + "dependencies": { + "bluebird": "^3.7.2", + "undici": "^7.5.0" + } +} \ No newline at end of file diff --git a/server/test/services/tessie/.eslintrc.json b/server/test/services/tessie/.eslintrc.json new file mode 100644 index 0000000000..598403730f --- /dev/null +++ b/server/test/services/tessie/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "rules": { + "no-underscore-dangle": "off", + "no-promise-executor-return": "off" + } +} diff --git a/server/test/services/tessie/controllers/netatmo.controller.test.js b/server/test/services/tessie/controllers/netatmo.controller.test.js new file mode 100644 index 0000000000..08403fe3a7 --- /dev/null +++ b/server/test/services/tessie/controllers/netatmo.controller.test.js @@ -0,0 +1,132 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const NetatmoController = require('../../../../services/netatmo/api/netatmo.controller'); +const { NetatmoHandlerMock } = require('../netatmo.mock.test'); + +const netatmoController = NetatmoController(NetatmoHandlerMock); + +describe('Netatmo Controller', () => { + let req; + let res; + + beforeEach(() => { + sinon.reset(); + + req = { + body: {}, + params: {}, + query: {}, + }; + + res = { + json: sinon.spy(), + status: sinon.stub().returnsThis(), + }; + }); + + describe('getConfiguration', () => { + it('should get the netatmo configuration', async () => { + const configuration = { clientId: 'test', clientSecret: 'test', redirectUri: 'test' }; + NetatmoHandlerMock.getConfiguration.resolves(configuration); + + await netatmoController['get /api/v1/service/netatmo/configuration'].controller(req, res); + expect(res.json.calledWith(configuration)).to.equal(true); + }); + }); + + describe('getStatus', () => { + it('should get the netatmo status', async () => { + const status = { connected: true }; + NetatmoHandlerMock.getStatus.resolves(status); + + await netatmoController['get /api/v1/service/netatmo/status'].controller(req, res); + expect(res.json.calledWith(status)).to.equal(true); + }); + }); + + describe('saveConfiguration', () => { + it('should save the netatmo configuration', async () => { + const configuration = { clientId: 'test', clientSecret: 'test', redirectUri: 'test' }; + req.body = configuration; + NetatmoHandlerMock.saveConfiguration.resolves(true); + + await netatmoController['post /api/v1/service/netatmo/configuration'].controller(req, res); + expect(res.json.calledWith({ success: true })).to.equal(true); + }); + }); + + describe('saveStatus', () => { + it('should save the netatmo status', async () => { + const status = { connected: true }; + req.body = status; + NetatmoHandlerMock.saveStatus.resolves(true); + + await netatmoController['post /api/v1/service/netatmo/status'].controller(req, res); + expect(res.json.calledWith({ success: true })).to.equal(true); + }); + }); + + describe('connect', () => { + it('should connect netatmo', async () => { + NetatmoHandlerMock.getConfiguration.resolves(true); + NetatmoHandlerMock.connect.resolves(true); + + await netatmoController['post /api/v1/service/netatmo/connect'].controller(req, res); + expect(res.json.calledWith(true)).to.equal(true); + }); + }); + + describe('retrieveTokens', () => { + it('should retrieve netatmo tokens', async () => { + req.body = { code: 'test-code' }; + NetatmoHandlerMock.retrieveTokens.resolves({ accessToken: 'test-token', refreshToken: 'test-refresh-token' }); + + await netatmoController['post /api/v1/service/netatmo/token'].controller(req, res); + expect( + res.json.calledWith({ + accessToken: 'test-token', + refreshToken: 'test-refresh-token', + }), + ).to.equal(true); + }); + }); + + describe('disconnect', () => { + it('should disconnect netatmo', async () => { + NetatmoHandlerMock.disconnect.resolves(); + + await netatmoController['post /api/v1/service/netatmo/disconnect'].controller(req, res); + expect(res.json.calledWith({ success: true })).to.equal(true); + }); + }); + + describe('discover', () => { + it('should discover netatmo devices', async () => { + const devices = [{ id: 'device1' }, { id: 'device2' }]; + NetatmoHandlerMock.discoverDevices.resolves(devices); + + await netatmoController['get /api/v1/service/netatmo/discover'].controller(req, res); + expect(res.json.calledWith(devices)).to.equal(true); + }); + it('should return already discovered devices that are not in Gladys', async () => { + const discoveredDevices = [ + { external_id: 'netatmo:70:ee:50:xx:xx:e0', notInGladys: true }, + { external_id: 'netatmo:70:ee:50:xx:xx:e1', notInGladys: false }, + ]; + NetatmoHandlerMock.discoveredDevices = discoveredDevices; + NetatmoHandlerMock.gladys = { + stateManager: { + get: sinon.stub().callsFake((type, externalId) => { + const device = discoveredDevices.find((d) => d.external_id === externalId); + return device && !device.notInGladys ? {} : null; + }), + }, + }; + await netatmoController['get /api/v1/service/netatmo/discover'].controller(req, res); + + const expectedDevices = discoveredDevices.filter((d) => d.notInGladys); + expect(res.json.calledWith(expectedDevices)).to.equal(true); + }); + }); +}); diff --git a/server/test/services/tessie/index.test.js b/server/test/services/tessie/index.test.js new file mode 100644 index 0000000000..19964cf0eb --- /dev/null +++ b/server/test/services/tessie/index.test.js @@ -0,0 +1,58 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +const { NetatmoHandlerMock } = require('./netatmo.mock.test'); +const { STATUS } = require('../../../services/netatmo/lib/utils/netatmo.constants'); + +describe('Netatmo Service', () => { + let NetatmoService; + let netatmoService; + let gladys; + let serviceId; + + beforeEach(() => { + gladys = { service: { getService: sinon.stub() } }; + serviceId = 'some-service-id'; + + NetatmoService = proxyquire('../../../services/netatmo/index', { + './lib': function mockFunction() { + return NetatmoHandlerMock; + }, + }); + + netatmoService = NetatmoService(gladys, serviceId); + }); + + afterEach(() => { + sinon.reset(); + }); + + describe('start', () => { + it('should start the service correctly', async () => { + await netatmoService.start(); + expect(NetatmoHandlerMock.init.calledOnce).to.equal(true); + }); + }); + + describe('stop', () => { + it('should stop the service correctly', async () => { + await netatmoService.stop(); + expect(NetatmoHandlerMock.disconnect.calledOnce).to.equal(true); + }); + }); + + describe('isUsed', () => { + it('should return true when Netatmo is connected', async () => { + NetatmoHandlerMock.status = STATUS.CONNECTED; + const result = await netatmoService.isUsed(); + expect(result).to.equal(true); + }); + + it('should return false when Netatmo is not connected', async () => { + NetatmoHandlerMock.status = STATUS.NOT_INITIALIZED; + const result = await netatmoService.isUsed(); + expect(result).to.equal(false); + }); + }); +}); diff --git a/server/test/services/tessie/lib/device/netatmo.convertDeviceEnergy.test.js b/server/test/services/tessie/lib/device/netatmo.convertDeviceEnergy.test.js new file mode 100644 index 0000000000..a816e924ad --- /dev/null +++ b/server/test/services/tessie/lib/device/netatmo.convertDeviceEnergy.test.js @@ -0,0 +1,263 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { SUPPORTED_MODULE_TYPE } = require('../../../../../services/netatmo/lib/utils/netatmo.constants'); +const devicesNetatmoMock = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const devicesGladysMock = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Convert Energy Device', () => { + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should correctly convert a Netatmo Plug device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAPlug')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAPlug')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.PLUG); + + const featureMock = gladysDevice.features.filter((feature) => feature.name.includes('connected boiler'))[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:plug_connected_boiler`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).to.have.property('value', JSON.stringify(deviceNetatmoMock.modules_bridged)); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Thermostat device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NATherm1')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NATherm1')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.THERMOSTAT); + + const featureMock = gladysDevice.features.filter((feature) => feature.type === 'target-temperature')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:therm_setpoint_temperature`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Thermostat device without room and without plug', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NATherm1')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NATherm1')[0] }; + const roomNameParam = deviceGladysMock.params.find((param) => param.name === 'room_name'); + if (roomNameParam) { + deviceGladysMock.features.forEach((feature) => { + if (feature.category === 'temperature-sensor') { + feature.name = feature.name.replace(roomNameParam.value, 'undefined'); + } + }); + } + deviceGladysMock.features = deviceGladysMock.features.filter( + (feature) => feature.external_id !== 'netatmo:04:00:00:xx:xx:xx:therm_measured_temperature', + ); + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceGladysMock.params = [ + { + name: 'home_id', + value: '5e1xxxxxxxxxxxxxxxxx', + }, + ]; + deviceNetatmoMock.room = undefined; + deviceNetatmoMock.plug = undefined; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.THERMOSTAT); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor'); + expect(featureMock[0]).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:temperature`); + expect(featureMock.length).to.deep.equal(1); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.deep.equal(undefined); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Valve device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NRV')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NRV')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NRV); + + const featureMock = gladysDevice.features.filter((feature) => feature.type === 'target-temperature')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:therm_setpoint_temperature`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Valve device with secondaries names and ids', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NRV')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NRV')[0] }; + deviceNetatmoMock._id = deviceNetatmoMock.id; + deviceNetatmoMock.id = undefined; + deviceNetatmoMock.home_id = deviceNetatmoMock.home; + deviceNetatmoMock.home = undefined; + deviceNetatmoMock.module_name = deviceNetatmoMock.name; + deviceNetatmoMock.name = undefined; + deviceNetatmoMock.plug._id = deviceNetatmoMock.plug.id; + deviceNetatmoMock.plug.id = undefined; + deviceNetatmoMock.plug.module_name = deviceNetatmoMock.plug.name; + deviceNetatmoMock.plug.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock._id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NRV); + + const featureMock = gladysDevice.features.filter((feature) => feature.type === 'target-temperature')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock._id}:therm_setpoint_temperature`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.module_name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Valve device without room and without plug', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NRV')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NRV')[0] }; + const roomNameParam = deviceGladysMock.params.find((param) => param.name === 'room_name'); + if (roomNameParam) { + deviceGladysMock.features.forEach((feature) => { + if (feature.category === 'temperature-sensor') { + feature.name = feature.name.replace(roomNameParam.value, 'undefined'); + } + }); + } + deviceGladysMock.features = deviceGladysMock.features.filter( + (feature) => feature.external_id !== 'netatmo:09:00:00:xx:xx:xx:therm_measured_temperature', + ); + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceGladysMock.params = [ + { + name: 'home_id', + value: '5e1xxxxxxxxxxxxxxxxx', + }, + ]; + deviceNetatmoMock.room = undefined; + deviceNetatmoMock.plug = undefined; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NRV); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor'); + expect(featureMock.length).to.deep.equal(0); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.deep.equal(undefined); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo device without room and without modules_bridged', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAPlug')[1] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAPlug')[1] }; + deviceNetatmoMock.modules_bridged = undefined; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.PLUG); + + const featureMock = gladysDevice.features.filter((feature) => feature.name.includes('connected boiler'))[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:plug_connected_boiler`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).deep.equal({ name: 'modules_bridge_id', value: '[]' }); + + const paramRoomMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramRoomMock).to.equal(undefined); + }); + + it('should correctly convert a Netatmo device not supported', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.not_handled)[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceEnergy(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal([]); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', 'NOC'); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.room.name); + }); +}); diff --git a/server/test/services/tessie/lib/device/netatmo.convertDeviceNotSupported.test.js b/server/test/services/tessie/lib/device/netatmo.convertDeviceNotSupported.test.js new file mode 100644 index 0000000000..75375be421 --- /dev/null +++ b/server/test/services/tessie/lib/device/netatmo.convertDeviceNotSupported.test.js @@ -0,0 +1,93 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const devicesNetatmoMock = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const devicesGladysMock = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Convert Device not supported', () => { + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should correctly convert a Netatmo device not supported', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.not_handled)[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceNotSupported(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal([]); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', 'NOC'); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.room.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo device without room and with secondary datas', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NOC')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceNetatmoMock._id = deviceNetatmoMock.id; + deviceNetatmoMock.id = undefined; + deviceNetatmoMock.home_id = deviceNetatmoMock.home; + deviceNetatmoMock.home = undefined; + deviceNetatmoMock.module_name = deviceNetatmoMock.name; + deviceNetatmoMock.name = undefined; + deviceNetatmoMock.room = undefined; + + const gladysDevice = netatmoHandler.convertDeviceNotSupported(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal([]); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock._id}`); + expect(gladysDevice).to.have.property('model', 'NOC'); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramMock).to.equal(undefined); + }); + + it('should correctly convert a Netatmo device not supported with station_name', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.not_handled)[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + deviceNetatmoMock.station_name = deviceNetatmoMock.name; + deviceNetatmoMock.module_name = undefined; + deviceNetatmoMock.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceNotSupported(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal([]); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', 'NOC'); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.room.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); +}); diff --git a/server/test/services/tessie/lib/device/netatmo.convertDeviceWeather.test.js b/server/test/services/tessie/lib/device/netatmo.convertDeviceWeather.test.js new file mode 100644 index 0000000000..5ba4c21b57 --- /dev/null +++ b/server/test/services/tessie/lib/device/netatmo.convertDeviceWeather.test.js @@ -0,0 +1,262 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { SUPPORTED_MODULE_TYPE } = require('../../../../../services/netatmo/lib/utils/netatmo.constants'); +const devicesNetatmoMock = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const devicesGladysMock = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Convert Weather Device', () => { + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should correctly convert a Netatmo Weather Station NAMain device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAMain')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAMain')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMAIN); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor')[2]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:min_temp`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).to.have.property('value', JSON.stringify(deviceNetatmoMock.modules_bridged)); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Outdoor module NAModule1 device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule1')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule1')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE1); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'humidity-sensor')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:humidity`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Anemometer NAModule2 device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule2')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule2')[0] }; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE2); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'speed-sensor')[0]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:wind_strength`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Rain gauge Weather Station NAModule3 device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule3')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule3')[0] }; + deviceNetatmoMock.station_name = deviceNetatmoMock.name; + deviceNetatmoMock.module_name = undefined; + deviceNetatmoMock.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE3); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'precipitation-sensor')[2]; + expect(featureMock).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:sum_rain_24`); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Indoor module Weather Station NAModule4 device', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule4')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule4')[0] }; + deviceNetatmoMock.module_name = deviceNetatmoMock.name; + deviceNetatmoMock.name = undefined; + deviceNetatmoMock.plug._id = deviceNetatmoMock.plug.id; + deviceNetatmoMock.plug.id = undefined; + deviceNetatmoMock.plug.module_name = deviceNetatmoMock.plug.name; + deviceNetatmoMock.plug.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE4); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor'); + expect(featureMock[2]).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:min_temp`); + expect(featureMock.length).to.deep.equal(4); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.have.property('value', deviceNetatmoMock.plug.module_name); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo Weather Station device without modules_bridged and without room', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAMain')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAMain')[0] }; + const roomNameParam = deviceGladysMock.params.find((param) => param.name === 'room_name'); + if (roomNameParam) { + deviceGladysMock.features.forEach((feature) => { + if (feature.category === 'temperature-sensor') { + feature.name = feature.name.replace(roomNameParam.value, 'undefined'); + } + }); + } + deviceGladysMock.features = deviceGladysMock.features.filter( + (feature) => feature.external_id !== 'netatmo:70:ee:50:jj:jj:jj:therm_measured_temperature', + ); + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceGladysMock.params + .filter((param) => param.name === 'modules_bridge_id') + .forEach((param) => { + param.value = '[]'; + }); + deviceNetatmoMock.room = undefined; + deviceNetatmoMock.modules_bridged = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMAIN); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'modules_bridge_id')[0]; + expect(paramMock).deep.equal({ name: 'modules_bridge_id', value: '[]' }); + + const paramRoomMock = gladysDevice.params.filter((param) => param.name === 'room_name')[0]; + expect(paramRoomMock).to.equal(undefined); + }); + + it('should correctly convert a Netatmo Weather Station NAModule4 device without room and without plug', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.model === 'NAModule4')[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NAModule4')[0] }; + const roomNameParam = deviceGladysMock.params.find((param) => param.name === 'room_name'); + if (roomNameParam) { + deviceGladysMock.features.forEach((feature) => { + if (feature.category === 'temperature-sensor') { + feature.name = feature.name.replace(roomNameParam.value, 'undefined'); + } + }); + } + deviceGladysMock.features = deviceGladysMock.features.filter( + (feature) => feature.external_id !== 'netatmo:03:00:00:yy:yy:yy:therm_measured_temperature', + ); + deviceGladysMock.params = deviceGladysMock.params.filter( + (param) => param.name !== 'room_name' && param.name !== 'room_id', + ); + deviceGladysMock.params = [ + { + name: 'home_id', + value: '5e1xxxxxxxxxxxxxxxxx', + }, + ]; + deviceNetatmoMock.room = undefined; + deviceNetatmoMock.plug = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal(deviceGladysMock.features); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}`); + expect(gladysDevice).to.have.property('model', SUPPORTED_MODULE_TYPE.NAMODULE4); + + const featureMock = gladysDevice.features.filter((feature) => feature.category === 'temperature-sensor'); + expect(featureMock[1]).to.have.property('external_id', `netatmo:${deviceNetatmoMock.id}:min_temp`); + expect(featureMock.length).to.deep.equal(3); + + const paramMock = gladysDevice.params.filter((param) => param.name === 'plug_name')[0]; + expect(paramMock).to.deep.equal(undefined); + + expect(gladysDevice.features).to.be.an('array'); + expect(gladysDevice.params).to.be.an('array'); + }); + + it('should correctly convert a Netatmo device not supported', () => { + const deviceGladysMock = { ...devicesGladysMock.filter((device) => device.not_handled)[0] }; + const deviceNetatmoMock = { ...devicesNetatmoMock.filter((device) => device.type === 'NOC')[0] }; + deviceNetatmoMock._id = deviceNetatmoMock.id; + deviceNetatmoMock.id = undefined; + deviceNetatmoMock.home_id = deviceNetatmoMock.home; + deviceNetatmoMock.home = undefined; + deviceNetatmoMock.module_name = deviceNetatmoMock.name; + deviceNetatmoMock.name = undefined; + + const gladysDevice = netatmoHandler.convertDeviceWeather(deviceNetatmoMock); + + expect(gladysDevice).deep.equal(deviceGladysMock); + expect(gladysDevice.features).deep.equal([]); + expect(gladysDevice.params).deep.equal(deviceGladysMock.params); + + expect(gladysDevice).to.have.property('name', deviceGladysMock.name); + expect(gladysDevice).to.have.property('external_id', `netatmo:${deviceNetatmoMock._id}`); + expect(gladysDevice).to.have.property('model', 'NOC'); + expect(gladysDevice).to.have.property('not_handled', true); + }); +}); diff --git a/server/test/services/tessie/lib/device/netatmo.deviceMapping.test.js b/server/test/services/tessie/lib/device/netatmo.deviceMapping.test.js new file mode 100644 index 0000000000..71186f48c5 --- /dev/null +++ b/server/test/services/tessie/lib/device/netatmo.deviceMapping.test.js @@ -0,0 +1,104 @@ +const { expect } = require('chai'); +const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../../utils/constants'); +const { writeValues, readValues } = require('../../../../../services/netatmo/lib/device/netatmo.deviceMapping'); + +describe('Netatmo device mapping', () => { + describe('writeValues', () => { + it('should correctly transform THERMOSTAT.TARGET_TEMPERATURE value from Gladys to Netatmo', () => { + const thermostatValue = 21; + const mappingFunction = + writeValues[DEVICE_FEATURE_CATEGORIES.THERMOSTAT][DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE]; + + expect(mappingFunction(thermostatValue)).to.equal(21); + }); + }); + + describe('readValues', () => { + it('should correctly transform THERMOSTAT.TARGET_TEMPERATURE value from Netatmo to Gladys', () => { + const thermostatValue = 21.5; + const mappingFunction = + readValues[DEVICE_FEATURE_CATEGORIES.THERMOSTAT][DEVICE_FEATURE_TYPES.THERMOSTAT.TARGET_TEMPERATURE]; + + expect(mappingFunction(thermostatValue)).to.equal(21.5); + }); + + it('should correctly transform SWITCH.BINARY value from Netatmo to Gladys', () => { + const binarySwitchValueTrue = true; + const binarySwitchValueFalse = false; + const numberSwitchValueTrue = 1; + const numberSwitchValueFalse = 0; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY]; + + expect(mappingFunction(binarySwitchValueTrue)).to.eq(1); + expect(mappingFunction(binarySwitchValueFalse)).to.eq(0); + expect(mappingFunction(numberSwitchValueTrue)).to.eq(1); + expect(mappingFunction(numberSwitchValueFalse)).to.eq(0); + }); + + it('should correctly transform BATTERY.INTEGER value from Netatmo to Gladys', () => { + const valueFromDevice = 60.5; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.BATTERY][DEVICE_FEATURE_TYPES.BATTERY.INTEGER]; + + expect(mappingFunction(valueFromDevice)).to.equal(60); + }); + + it('should correctly transform TEMPERATURE_SENSOR.DECIMAL value from Netatmo to Gladys', () => { + const valueFromDevice = 20.5; + const mappingFunction = + readValues[DEVICE_FEATURE_CATEGORIES.TEMPERATURE_SENSOR][DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]; + + expect(mappingFunction(valueFromDevice)).to.equal(20.5); + }); + + it('should correctly transform SIGNAL.QUALITY value from Netatmo to Gladys', () => { + const valueFromDevice = 76; + const valueFromDeviceFloat = 76.5; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.SIGNAL][DEVICE_FEATURE_TYPES.SIGNAL.QUALITY]; + + expect(mappingFunction(valueFromDevice)).to.equal(76); + expect(mappingFunction(valueFromDeviceFloat)).to.equal(76); + }); + + it('should correctly transform OPENING_SENSOR.BINARY value from Netatmo to Gladys', () => { + const binarySwitchValueTrue = true; + const binarySwitchValueFalse = false; + const numberSwitchValueTrue = 1; + const numberSwitchValueFalse = 0; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.OPENING_SENSOR][DEVICE_FEATURE_TYPES.SENSOR.BINARY]; + + expect(mappingFunction(binarySwitchValueTrue)).to.eq(1); + expect(mappingFunction(binarySwitchValueFalse)).to.eq(0); + expect(mappingFunction(numberSwitchValueTrue)).to.eq(1); + expect(mappingFunction(numberSwitchValueFalse)).to.eq(0); + }); + + it('should correctly transform SPEED_SENSOR.INTEGER value from Netatmo to Gladys', () => { + const valueFromDevice = 10; + const valueFromDeviceFloat = 10.5; + const mappingFunction = + readValues[DEVICE_FEATURE_CATEGORIES.SPEED_SENSOR][DEVICE_FEATURE_TYPES.SPEED_SENSOR.INTEGER]; + + expect(mappingFunction(valueFromDevice)).to.equal(10); + expect(mappingFunction(valueFromDeviceFloat)).to.equal(10); + }); + + it('should correctly transform ANGLE_SENSOR.INTEGER value from Netatmo to Gladys', () => { + const valueFromDevice = 10; + const valueFromDeviceFloat = 10.5; + const mappingFunction = readValues[DEVICE_FEATURE_CATEGORIES.ANGLE_SENSOR][DEVICE_FEATURE_TYPES.SENSOR.INTEGER]; + + expect(mappingFunction(valueFromDevice)).to.equal(10); + expect(mappingFunction(valueFromDeviceFloat)).to.equal(10); + }); + + it('should correctly transform PRECIPITATION_SENSOR.DECIMAL value from Netatmo to Gladys', () => { + const valueFromDevice = 1; + const valueFromDeviceFloat = 1.5; + const mappingFunction = + readValues[DEVICE_FEATURE_CATEGORIES.PRECIPITATION_SENSOR][DEVICE_FEATURE_TYPES.SENSOR.DECIMAL]; + + expect(mappingFunction(valueFromDevice)).to.equal(1); + expect(mappingFunction(valueFromDeviceFloat)).to.equal(1.5); + }); + }); +}); diff --git a/server/test/services/tessie/lib/index.test.js b/server/test/services/tessie/lib/index.test.js new file mode 100644 index 0000000000..28b8b4755c --- /dev/null +++ b/server/test/services/tessie/lib/index.test.js @@ -0,0 +1,25 @@ +const { expect } = require('chai'); +const NetatmoHandler = require('../../../../services/netatmo/lib'); + +const gladys = {}; +const serviceId = '123'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('NetatmoHandler Constructor', () => { + it('should properly initialize properties', () => { + expect(netatmoHandler.gladys).to.equal(gladys); + expect(netatmoHandler.serviceId).to.equal(serviceId); + expect(netatmoHandler.configuration).to.deep.equal({ + clientId: null, + clientSecret: null, + energyApi: null, + weatherApi: null, + scopes: { + scopeAircare: 'read_homecoach', + scopeEnergy: 'read_thermostat write_thermostat', + scopeHomeSecurity: 'read_camera read_presence read_carbonmonoxidedetector read_smokedetector', + scopeWeather: 'read_station', + }, + }); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.connect.test.js b/server/test/services/tessie/lib/netatmo.connect.test.js new file mode 100644 index 0000000000..9f54d1297c --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.connect.test.js @@ -0,0 +1,49 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo connect', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should throw an error if netatmo is not configured', async () => { + try { + await netatmoHandler.connect(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.equal('Netatmo is not configured.'); + } + }); + + it('should return auth url and state if netatmo is configured', async () => { + netatmoHandler.configuration.clientId = 'test-client-id'; + netatmoHandler.configuration.clientSecret = 'test-client-secret'; + netatmoHandler.configuration.scopes = { scopeEnergy: 'scope' }; + + const result = await netatmoHandler.connect(); + expect(result).to.have.property('authUrl'); + expect(result).to.have.property('state'); + expect(netatmoHandler.configured).to.equal(true); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.disconnect.test.js b/server/test/services/tessie/lib/netatmo.disconnect.test.js new file mode 100644 index 0000000000..47bb468e1b --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.disconnect.test.js @@ -0,0 +1,59 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Disconnect', () => { + let clock; + + beforeEach(() => { + sinon.reset(); + clock = sinon.useFakeTimers(); + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + }); + + it('should properly disconnect from Netatmo', () => { + sinon.spy(clock, 'clearInterval'); + const intervalPollRefreshTokenSpy = sinon.spy(); + const intervalPollRefreshValuesSpy = sinon.spy(); + netatmoHandler.pollRefreshToken = setInterval(intervalPollRefreshTokenSpy, 3600 * 1000); + netatmoHandler.pollRefreshValues = setInterval(intervalPollRefreshValuesSpy, 120 * 1000); + + netatmoHandler.disconnect(); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnecting' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + + clock.tick(3600 * 1000 * 2); + assert.calledTwice(clock.clearInterval); + expect(intervalPollRefreshTokenSpy.notCalled).to.equal(true); + expect(intervalPollRefreshValuesSpy.notCalled).to.equal(true); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.discoverDevices.test.js b/server/test/services/tessie/lib/netatmo.discoverDevices.test.js new file mode 100644 index 0000000000..8d8451094e --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.discoverDevices.test.js @@ -0,0 +1,103 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const nock = require('nock'); + +const { fake } = sinon; + +const devicesMock = require('../netatmo.loadDevices.mock.test.json'); +const discoverDevicesMock = require('../netatmo.discoverDevices.mock.test.json'); +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Discover devices', () => { + beforeEach(() => { + sinon.reset(); + nock.cleanAll(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + nock.cleanAll(); + }); + + it('should discover devices successfully', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.gladys.stateManager.get = sinon.stub().returns(null); + netatmoHandler.loadDevices = sinon.stub().returns(devicesMock); + + const discoveredDevices = await netatmoHandler.discoverDevices(); + + expect(discoveredDevices.length).to.equal(devicesMock.length); + expect(discoveredDevices[0].external_id).to.equal(`netatmo:${devicesMock[0].id}`); + expect(discoveredDevices[1].external_id).to.equal(`netatmo:${devicesMock[1].id}`); + expect(discoverDevicesMock[0]).to.deep.equal(discoveredDevices[0]); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'discovering' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + }); + + it('should throw an error if not connected', async () => { + try { + await netatmoHandler.discoverDevices(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Unable to discover Netatmo devices until service is not well configured'); + expect(netatmoHandler.status).to.equal('not_initialized'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + } + }); + + it('should handle an error during device loading', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().rejects(new Error('Failed to load')); + + const discoveredDevices = await netatmoHandler.discoverDevices(); + + expect(discoveredDevices).to.deep.equal([]); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + }); + + it('should handle no devices found', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves([]); + + const discoveredDevices = await netatmoHandler.discoverDevices(); + + expect(discoveredDevices).to.deep.equal([]); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.getAccessToken.test.js b/server/test/services/tessie/lib/netatmo.getAccessToken.test.js new file mode 100644 index 0000000000..b35b7af667 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.getAccessToken.test.js @@ -0,0 +1,71 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: fake.returns('valid_access_token'), + setValue: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('getAccessToken', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load the access token if available', async () => { + const accessToken = await netatmoHandler.getAccessToken(); + expect(accessToken).to.equal('valid_access_token'); + }); + + it('should return undefined and disconnect if no access token is available', async () => { + netatmoHandler.gladys.variable.getValue = fake.returns(null); + + const accessToken = await netatmoHandler.getAccessToken(); + expect(accessToken).to.equal(undefined); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + }); + + it('should throw an error if not configured', async () => { + netatmoHandler.gladys.variable.getValue = fake.rejects(new Error('Test error')); + + try { + await netatmoHandler.getAccessToken(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not configured.'); + } + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.getConfiguration.test.js b/server/test/services/tessie/lib/netatmo.getConfiguration.test.js new file mode 100644 index 0000000000..5e29b3988f --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.getConfiguration.test.js @@ -0,0 +1,73 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_CLIENT_ID') { + return Promise.resolve('valid_client_id'); + } + if (variableName === 'NETATMO_CLIENT_SECRET') { + return Promise.resolve('valid_client_secret'); + } + if (variableName === 'NETATMO_ENERGY_API') { + return Promise.resolve('1'); + } + if (variableName === 'NETATMO_WEATHER_API') { + return Promise.resolve('0'); + } + return Promise.reject(new Error('Unknown variable')); + }), + setValue: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo getConfiguration', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load the configuration if available', async () => { + const configuration = await netatmoHandler.getConfiguration(); + expect(configuration.clientId).to.equal('valid_client_id'); + expect(configuration.clientSecret).to.equal('valid_client_secret'); + expect(configuration.energyApi).to.equal(true); + expect(configuration.weatherApi).to.equal(false); + }); + + it('should throw an error if not configured', async () => { + netatmoHandler.gladys.variable.getValue = fake.rejects(new Error('Test error')); + try { + await netatmoHandler.getConfiguration(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(netatmoHandler.configuration.clientId).to.equal('valid_client_id'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not configured.'); + } + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.getRefreshToken.test.js b/server/test/services/tessie/lib/netatmo.getRefreshToken.test.js new file mode 100644 index 0000000000..bfc93fb442 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.getRefreshToken.test.js @@ -0,0 +1,89 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_REFRESH_TOKEN') { + return Promise.resolve('valid_refresh_token'); + } + if (variableName === 'NETATMO_EXPIRE_IN_TOKEN') { + return Promise.resolve(10800); + } + return Promise.reject(new Error('Unknown variable')); + }), + setValue: sinon.stub().resolves(), + }, +}; +const serviceIdFake = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceIdFake); + +describe('getRefreshToken', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should load the refresh token if available', async () => { + const refreshToken = await netatmoHandler.getRefreshToken(); + expect(refreshToken).to.equal('valid_refresh_token'); + expect(netatmoHandler.refreshToken).to.equal('valid_refresh_token'); + expect(netatmoHandler.expireInToken).to.equal(10800); + }); + + it('should return undefined and disconnect if no refresh token is available', async () => { + netatmoHandler.gladys.variable.getValue = fake.returns(null); + + const refreshToken = await netatmoHandler.getRefreshToken(); + expect(refreshToken).to.equal(undefined); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + }); + + it('should throw an error if not configured', async () => { + netatmoHandler.gladys.variable.getValue = sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_REFRESH_TOKEN') { + return Promise.resolve('valid_refresh_token'); + } + if (variableName === 'NETATMO_EXPIRE_IN_TOKEN') { + return Promise.reject(new Error('Test error')); + } + return Promise.reject(new Error('Unknown variable')); + }); + + try { + await netatmoHandler.getRefreshToken(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(netatmoHandler.refreshToken).to.equal('valid_refresh_token'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not configured.'); + } + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.getStatus.test.js b/server/test/services/tessie/lib/netatmo.getStatus.test.js new file mode 100644 index 0000000000..abc9d24827 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.getStatus.test.js @@ -0,0 +1,34 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const { STATUS } = require('../../../../services/netatmo/lib/utils/netatmo.constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo getStatus', () => { + it('should return the current status of Netatmo handler', () => { + netatmoHandler.configured = true; + netatmoHandler.connected = false; + netatmoHandler.status = STATUS.CONNECTED; + + const status = netatmoHandler.getStatus(); + + expect(status).to.deep.equal({ + configured: true, + connected: false, + status: 'connected', + }); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.init.test.js b/server/test/services/tessie/lib/netatmo.init.test.js new file mode 100644 index 0000000000..4c03e633b6 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.init.test.js @@ -0,0 +1,98 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_CLIENT_ID') { + return Promise.resolve('valid_client_id'); + } + if (variableName === 'NETATMO_CLIENT_SECRET') { + return Promise.resolve('valid_client_secret'); + } + if (variableName === 'NETATMO_ACCESS_TOKEN') { + return Promise.resolve('valid_access_token'); + } + if (variableName === 'NETATMO_REFRESH_TOKEN') { + return Promise.resolve('valid_refresh_token'); + } + if (variableName === 'NETATMO_EXPIRE_IN_TOKEN') { + return Promise.resolve(10800); + } + return Promise.reject(new Error('Unknown variable')); + }), + setValue: sinon.stub().resolves(), + }, +}; +const serviceIdFake = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceIdFake); +netatmoHandler.pollRefreshingToken = fake.resolves(null); +netatmoHandler.pollRefreshingValues = fake.resolves(null); + +describe('Netatmo Init', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.gladys.variable.getValue = sinon.fake((variableName, serviceId) => { + if (variableName === 'NETATMO_CLIENT_ID') { + return Promise.resolve('valid_client_id'); + } + if (variableName === 'NETATMO_CLIENT_SECRET') { + return Promise.resolve('valid_client_secret'); + } + if (variableName === 'NETATMO_ENERGY_API') { + return Promise.resolve('1'); + } + if (variableName === 'NETATMO_WEATHER_API') { + return Promise.resolve('0'); + } + if (variableName === 'NETATMO_ACCESS_TOKEN') { + return Promise.resolve('valid_access_token'); + } + if (variableName === 'NETATMO_REFRESH_TOKEN') { + return Promise.resolve('valid_refresh_token'); + } + if (variableName === 'NETATMO_EXPIRE_IN_TOKEN') { + return Promise.resolve(10800); + } + return Promise.reject(new Error('Unknown variable')); + }); + netatmoHandler.refreshingTokens = fake.resolves({ success: true }); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should handle valid access and refresh tokens', async () => { + netatmoHandler.configuration.clientId = 'valid_client_id'; + netatmoHandler.configuration.clientSecret = 'valid_client_secret'; + netatmoHandler.configuration.energyApi = true; + netatmoHandler.configuration.weatherApi = false; + netatmoHandler.accessToken = 'valid_access_token'; + netatmoHandler.refreshToken = 'valid_refresh_token'; + + await netatmoHandler.init(); + + expect(netatmoHandler.refreshingTokens.called).to.equal(true); + expect(netatmoHandler.pollRefreshingToken.called).to.equal(true); + expect(netatmoHandler.pollRefreshingValues.called).to.equal(true); + }); + + it('should handle failed token refresh', async () => { + netatmoHandler.refreshingTokens = fake.resolves({ success: false }); + + await netatmoHandler.init(); + + expect(netatmoHandler.refreshingTokens.called).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(0); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.loadDeviceDetails.test.js b/server/test/services/tessie/lib/netatmo.loadDeviceDetails.test.js new file mode 100644 index 0000000000..5d5341a1da --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.loadDeviceDetails.test.js @@ -0,0 +1,321 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const bodyHomesDataMock = JSON.parse(JSON.stringify(require('../netatmo.homesdata.mock.test.json'))); +const bodyHomeStatusMock = JSON.parse(JSON.stringify(require('../netatmo.homestatus.mock.test.json'))); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; +const homesMock = bodyHomesDataMock.homes[0]; + +describe('Netatmo Load Device Details', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should load device details successfully with API not configured', async () => { + netatmoHandler.configuration.energyApi = false; + netatmoHandler.configuration.weatherApi = false; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(200, { + body: bodyHomeStatusMock, + status: 'ok', + }); + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + expect(devices).to.have.lengthOf(10); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + const natPlugDevices = devices.filter((device) => device.type === 'NAPlug'); + const natValveDevices = devices.filter((device) => device.type === 'NRV'); + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + const natNAModule1Devices = devices.filter((device) => device.type === 'NAModule1'); + const natNAModule2Devices = devices.filter((device) => device.type === 'NAModule2'); + const natNAModule3Devices = devices.filter((device) => device.type === 'NAModule3'); + const natNAModule4Devices = devices.filter((device) => device.type === 'NAModule4'); + const natNotHandledDevices = devices.filter((device) => device.not_handled); + + expect(natThermDevices).to.have.lengthOf(1); + expect(natPlugDevices).to.have.lengthOf(2); + expect(natValveDevices).to.have.lengthOf(1); + expect(natWeatherStationDevices).to.have.lengthOf(1); + expect(natNAModule1Devices).to.have.lengthOf(1); + expect(natNAModule2Devices).to.have.lengthOf(1); + expect(natNAModule3Devices).to.have.lengthOf(1); + expect(natNAModule4Devices).to.have.lengthOf(1); + expect(natNotHandledDevices).to.have.lengthOf(1); + natThermDevices.forEach((device) => { + expect(device) + .to.haveOwnProperty('apiNotConfigured') + .to.be.eq(true); + expect(device.room).to.be.an('object'); + expect(device.room).to.not.deep.equal({}); + expect(device.plug).to.be.an('object'); + expect(device.plug).to.not.deep.equal({}); + expect(device.categoryAPI).to.be.eq('Energy'); + }); + natWeatherStationDevices.forEach((device) => { + expect(device) + .to.haveOwnProperty('apiNotConfigured') + .to.be.eq(true); + expect(device.categoryAPI).to.be.eq('Weather'); + expect(device) + .to.have.property('modules_bridged') + .that.is.an('array'); + }); + }); + + it('should no load device details without modules with API configured', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.configuration.weatherApi = true; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(200, { + body: bodyHomeStatusMock, + status: 'ok', + }); + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + expect(devices).to.have.lengthOf(10); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + natThermDevices.forEach((device) => { + expect(device) + .to.haveOwnProperty('apiNotConfigured') + .to.be.eq(false); + }); + natWeatherStationDevices.forEach((device) => { + expect(device) + .to.haveOwnProperty('apiNotConfigured') + .to.be.eq(false); + }); + }); + + it('should load device details successfully without thermostat', async () => { + const homesMockFake = { + ...JSON.parse(JSON.stringify(homesMock)), + }; + homesMockFake.modules = homesMockFake.modules.filter((module) => module.type !== 'NATherm1'); + const bodyHomeStatusMockFake = { + ...JSON.parse(JSON.stringify(bodyHomeStatusMock)), + }; + bodyHomeStatusMockFake.home.modules = bodyHomeStatusMock.home.modules.filter( + (module) => module.type !== 'NATherm1', + ); + + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMockFake.id}`, + }) + .reply(200, { + body: bodyHomeStatusMockFake, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMockFake); + + expect(devices).to.have.lengthOf(9); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + const natPlugDevices = devices.filter((device) => device.type === 'NAPlug'); + const natValveDevices = devices.filter((device) => device.type === 'NRV'); + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + const natNAModule1Devices = devices.filter((device) => device.type === 'NAModule1'); + const natNAModule2Devices = devices.filter((device) => device.type === 'NAModule2'); + const natNAModule3Devices = devices.filter((device) => device.type === 'NAModule3'); + const natNAModule4Devices = devices.filter((device) => device.type === 'NAModule4'); + const natNotHandledDevices = devices.filter((device) => device.not_handled); + expect(natThermDevices).to.have.lengthOf(0); + expect(natPlugDevices).to.have.lengthOf(2); + expect(natValveDevices).to.have.lengthOf(1); + expect(natWeatherStationDevices).to.have.lengthOf(1); + expect(natNAModule1Devices).to.have.lengthOf(1); + expect(natNAModule2Devices).to.have.lengthOf(1); + expect(natNAModule3Devices).to.have.lengthOf(1); + expect(natNAModule4Devices).to.have.lengthOf(1); + expect(natNotHandledDevices).to.have.lengthOf(1); + expect(devices).to.be.an('array'); + }); + + it('should load device details successfully without weatherStation', async () => { + const homesMockFake = { ...JSON.parse(JSON.stringify(homesMock)) }; + homesMockFake.modules = homesMockFake.modules.filter((module) => module.type !== 'NAMain'); + const bodyHomeStatusMockFake = { ...JSON.parse(JSON.stringify(bodyHomeStatusMock)) }; + bodyHomeStatusMockFake.home.modules = bodyHomeStatusMock.home.modules.filter((module) => module.type !== 'NAMain'); + netatmoHandler.loadWeatherStationDetails = sinon.stub().resolves([]); + + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMockFake.id}`, + }) + .reply(200, { + body: bodyHomeStatusMockFake, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMockFake); + + expect(devices).to.have.lengthOf(9); + const natThermDevices = devices.filter((device) => device.type === 'NATherm1'); + const natPlugDevices = devices.filter((device) => device.type === 'NAPlug'); + const natValveDevices = devices.filter((device) => device.type === 'NRV'); + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + const natNAModule1Devices = devices.filter((device) => device.type === 'NAModule1'); + const natNAModule2Devices = devices.filter((device) => device.type === 'NAModule2'); + const natNAModule3Devices = devices.filter((device) => device.type === 'NAModule3'); + const natNAModule4Devices = devices.filter((device) => device.type === 'NAModule4'); + const natNotHandledDevices = devices.filter((device) => device.not_handled); + expect(natThermDevices).to.have.lengthOf(1); + expect(natPlugDevices).to.have.lengthOf(2); + expect(natValveDevices).to.have.lengthOf(1); + expect(natWeatherStationDevices).to.have.lengthOf(0); + expect(natNAModule1Devices).to.have.lengthOf(1); + expect(natNAModule2Devices).to.have.lengthOf(1); + expect(natNAModule3Devices).to.have.lengthOf(1); + expect(natNAModule4Devices).to.have.lengthOf(1); + expect(natNotHandledDevices).to.have.lengthOf(1); + }); + + it('should load device details successfully but without weather station details', async () => { + netatmoHandler.loadWeatherStationDetails = sinon.stub().resolves([]); + + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(200, { + body: bodyHomeStatusMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + const natWeatherStationDevices = devices.filter((device) => device.type === 'NAMain'); + const natNAModule1Devices = devices.filter((device) => device.type === 'NAModule1'); + expect(natWeatherStationDevices).to.have.lengthOf.at.least(1); + expect(natNAModule1Devices).to.have.lengthOf.at.least(1); + natWeatherStationDevices.forEach((device) => { + expect(device) + .to.have.property('room') + .that.is.an('object'); + expect(device) + .to.have.property('modules_bridged') + .that.is.an('array'); + expect(device).to.not.have.property('dashboard_data'); + }); + expect(natNAModule1Devices).to.have.lengthOf.at.least(1); + natNAModule1Devices.forEach((device) => { + expect(device) + .to.have.property('plug') + .that.is.an('object'); + expect(device.plug).to.not.have.property('dashboard_data'); + }); + }); + + it('should no load device details without modules', async () => { + const homesMockFake = { ...JSON.parse(JSON.stringify(homesMock)) }; + homesMockFake.modules = homesMockFake.modules.filter((module) => module.type !== 'NATherm1'); + homesMockFake.modules = homesMockFake.modules.filter((module) => module.type !== 'NAMain'); + const bodyHomeStatusMockFake = { ...bodyHomeStatusMock }; + bodyHomeStatusMockFake.home.modules = undefined; + + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMockFake.id}`, + }) + .reply(200, { + body: bodyHomeStatusMockFake, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMockFake); + + expect(devices).to.be.eq(undefined); + }); + + it('should handle API errors gracefully', async () => { + // Intercept specific to this test + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(400, { + status: 'error', + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + expect(devices).to.be.eq(undefined); + }); + + it('should handle unexpected API responses', async () => { + netatmoMock + .intercept({ + method: 'GET', + path: `/api/homestatus?home_id=${homesMock.id}`, + }) + .reply(200, { + body: bodyHomeStatusMock, + status: 'error', + }); + + const devices = await netatmoHandler.loadDeviceDetails(homesMock); + + expect(devices).to.be.an('array'); + expect(devices).to.have.lengthOf(0); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.loadDevices.test.js b/server/test/services/tessie/lib/netatmo.loadDevices.test.js new file mode 100644 index 0000000000..7841397109 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.loadDevices.test.js @@ -0,0 +1,309 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const devicesMock = JSON.parse(JSON.stringify(require('../netatmo.loadDevicesComplete.mock.test.json'))); +const deviceDetailsMock = JSON.parse(JSON.stringify(require('../netatmo.loadDevicesDetails.mock.test.json'))); +const thermostatsDetailsMock = JSON.parse(JSON.stringify(require('../netatmo.loadThermostatDetails.mock.test.json'))); +const weatherStationsDetailsMock = JSON.parse( + JSON.stringify(require('../netatmo.loadWeatherStationDetails.mock.test.json')), +); +const bodyHomesDataMock = JSON.parse(JSON.stringify(require('../netatmo.homesdata.mock.test.json'))); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); +const logger = require('../../../../utils/logger'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; + +describe('Netatmo Load Devices', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.configuration.energyApi = false; + netatmoHandler.configuration.weatherApi = false; + netatmoHandler.accessToken = accessToken; + netatmoHandler.loadDeviceDetails = sinon.stub().resolves(deviceDetailsMock); + netatmoHandler.loadThermostatDetails = sinon.stub().resolves(thermostatsDetailsMock); + netatmoHandler.loadWeatherStationDetails = sinon.stub().resolves(weatherStationsDetailsMock); + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should load all devices successfully if all API not configured', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.not.deep.eq([]); + }); + + it('should load energy devices successfully', async () => { + netatmoHandler.configuration.energyApi = true; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.not.deep.eq([]); + }); + + it('should load thermostat devices successfully if no devices in loadDeviceDetails', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadDeviceDetails = sinon.stub().resolves([]); + const plugsMock = [...JSON.parse(JSON.stringify(thermostatsDetailsMock.plugs))]; + const thermostatsMock = [...JSON.parse(JSON.stringify(thermostatsDetailsMock.thermostats))]; + + thermostatsMock.forEach((thermostat) => { + plugsMock.push(thermostat); + }); + plugsMock + .filter((device) => device.type === 'NAPlug') + .forEach((plug) => { + if (!plug.modules_bridged) { + plug.modules_bridged = plug.modules.map((module) => module._id); + } + }); + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + expect(devices).to.be.an('array'); + expect(devices).to.deep.eq(plugsMock); + }); + + it('should load weather devices successfully if no devices in loadDeviceDetails', async () => { + netatmoHandler.configuration.weatherApi = true; + netatmoHandler.loadDeviceDetails = sinon.stub().resolves([]); + const weatherStationsMock = [...JSON.parse(JSON.stringify(weatherStationsDetailsMock.weatherStations))]; + const modulesWeatherStationsMock = [ + ...JSON.parse(JSON.stringify(weatherStationsDetailsMock.modulesWeatherStations)), + ]; + modulesWeatherStationsMock.forEach((moduleWeatherStation) => { + weatherStationsMock.push(moduleWeatherStation); + }); + weatherStationsMock + .filter((device) => device.type === 'NAMain') + .forEach((plug) => { + if (!plug.modules_bridged) { + plug.modules_bridged = plug.modules.map((module) => module._id); + } + }); + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.deep.eq(weatherStationsMock); + }); + + it('should load energy and weather devices successfully', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.configuration.weatherApi = true; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.deep.eq(devicesMock); + }); + + it('should handle API errors gracefully', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); + + it('should handle unexpected API responses', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'error', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.have.lengthOf(0); + }); + + it('should handle API errors gracefully', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + const badBodyHomesData = { ...JSON.parse(JSON.stringify(bodyHomesDataMock)) }; + badBodyHomesData.homes[0].modules = undefined; + badBodyHomesData.homes[1].modules = undefined; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: badBodyHomesData, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); + + it('should return an empty array if no homes are returned from the API', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + const bodyHomesDataEmpty = { ...JSON.parse(JSON.stringify(bodyHomesDataMock)) }; + bodyHomesDataEmpty.homes = []; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataEmpty, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); + + it('should return an empty array if homes are returned without modules', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().resolves({ plugs: [], thermostats: [] }); + const bodyHomesDataNoModules = { ...JSON.parse(JSON.stringify(bodyHomesDataMock)) }; + bodyHomesDataNoModules.homes.forEach((home) => { + home.modules = undefined; + }); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataNoModules, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.deep.eq([]); + }); + + it('should handle API errors on loadThermostatDetails and loadWeatherStationDetails', async () => { + netatmoHandler.configuration.energyApi = true; + netatmoHandler.configuration.weatherApi = true; + netatmoHandler.loadThermostatDetails = sinon.stub().rejects(new Error('Failed to load thermostatsDetails')); + netatmoHandler.loadWeatherStationDetails = sinon.stub().rejects(new Error('Failed to load weatherStationsDetails')); + sinon.stub(logger, 'error'); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/homesdata', + }) + .reply(200, { + body: bodyHomesDataMock, + status: 'ok', + }); + + const devices = await netatmoHandler.loadDevices(); + + expect(devices).to.be.an('array'); + expect(devices).to.not.deep.eq([]); + sinon.assert.calledTwice(logger.error); + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.loadThermostatDetails.test.js b/server/test/services/tessie/lib/netatmo.loadThermostatDetails.test.js new file mode 100644 index 0000000000..d76ce48644 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.loadThermostatDetails.test.js @@ -0,0 +1,143 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const bodyGetThermostatMock = JSON.parse(JSON.stringify(require('../netatmo.getThermostat.mock.test.json'))); +const thermostatsDetailsMock = JSON.parse(JSON.stringify(require('../netatmo.loadThermostatDetails.mock.test.json'))); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; + +describe('Netatmo Load Thermostat Details', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should load thermostat details successfully with API not configured', async () => { + netatmoHandler.configuration.energyApi = false; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getthermostatsdata', + }) + .reply(200, { + body: bodyGetThermostatMock, + status: 'ok', + }); + + const { plugs, thermostats } = await netatmoHandler.loadThermostatDetails(); + + expect(plugs).to.deep.eq(thermostatsDetailsMock.plugs); + expect(thermostats).to.deep.eq(thermostatsDetailsMock.thermostats); + expect(plugs).to.be.an('array'); + expect(thermostats).to.be.an('array'); + }); + + it('should load thermostat details successfully with API configured', async () => { + netatmoHandler.configuration.energyApi = true; + thermostatsDetailsMock.plugs.forEach((plug) => { + plug.apiNotConfigured = false; + plug.modules.forEach((module) => { + module.apiNotConfigured = false; + module.plug.apiNotConfigured = false; + }); + }); + thermostatsDetailsMock.thermostats.forEach((thermostat) => { + thermostat.apiNotConfigured = false; + thermostat.plug.apiNotConfigured = false; + }); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getthermostatsdata', + }) + .reply(200, { + body: bodyGetThermostatMock, + status: 'ok', + }); + + const { plugs, thermostats } = await netatmoHandler.loadThermostatDetails(); + expect(plugs).to.deep.eq(thermostatsDetailsMock.plugs); + expect(thermostats).to.deep.eq(thermostatsDetailsMock.thermostats); + expect(plugs).to.be.an('array'); + expect(thermostats).to.be.an('array'); + }); + + it('should handle API errors gracefully', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getthermostatsdata', + }) + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const { plugs, thermostats } = await netatmoHandler.loadThermostatDetails(); + + expect(plugs).to.be.eq(undefined); + expect(thermostats).to.be.eq(undefined); + }); + + it('should handle unexpected API responses', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getthermostatsdata', + }) + .reply(200, { + body: bodyGetThermostatMock, + status: 'error', + }); + + const { plugs, thermostats } = await netatmoHandler.loadThermostatDetails(); + expect(plugs).to.deep.eq(bodyGetThermostatMock.devices); + expect(thermostats).to.deep.eq([]); + expect(plugs).to.be.an('array'); + expect(thermostats).to.be.an('array'); + expect(thermostats).to.have.lengthOf(0); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.loadWeatherStationDetails.test.js b/server/test/services/tessie/lib/netatmo.loadWeatherStationDetails.test.js new file mode 100644 index 0000000000..45d689a165 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.loadWeatherStationDetails.test.js @@ -0,0 +1,144 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const bodyGetWeatherStationMock = JSON.parse(JSON.stringify(require('../netatmo.getWeatherStation.mock.test.json'))); +const weatherStationsDetailsMock = JSON.parse( + JSON.stringify(require('../netatmo.loadWeatherStationDetails.mock.test.json')), +); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = {}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const accessToken = 'testAccessToken'; + +describe('Netatmo Load Weather Station Details', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'not_initialized'; + netatmoHandler.accessToken = accessToken; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should load weather station details successfully with API not configured', async () => { + netatmoHandler.configuration.weatherApi = false; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getstationsdata?get_favorites=false', + }) + .reply(200, { + body: bodyGetWeatherStationMock, + status: 'ok', + }); + + const { weatherStations, modulesWeatherStations } = await netatmoHandler.loadWeatherStationDetails(); + expect(weatherStations).to.deep.eq(weatherStationsDetailsMock.weatherStations); + expect(modulesWeatherStations).to.deep.eq(weatherStationsDetailsMock.modulesWeatherStations); + expect(weatherStations).to.be.an('array'); + expect(modulesWeatherStations).to.be.an('array'); + }); + + it('should load weather station details successfully with API configured', async () => { + netatmoHandler.configuration.weatherApi = true; + weatherStationsDetailsMock.weatherStations.forEach((weatherStation) => { + weatherStation.apiNotConfigured = false; + weatherStation.modules.forEach((module) => { + module.apiNotConfigured = false; + module.plug.apiNotConfigured = false; + }); + }); + weatherStationsDetailsMock.modulesWeatherStations.forEach((moduleWeatherStations) => { + moduleWeatherStations.apiNotConfigured = false; + moduleWeatherStations.plug.apiNotConfigured = false; + }); + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getstationsdata?get_favorites=false', + }) + .reply(200, { + body: bodyGetWeatherStationMock, + status: 'ok', + }); + + const { weatherStations, modulesWeatherStations } = await netatmoHandler.loadWeatherStationDetails(); + expect(weatherStations).to.deep.eq(weatherStationsDetailsMock.weatherStations); + expect(modulesWeatherStations).to.deep.eq(weatherStationsDetailsMock.modulesWeatherStations); + expect(weatherStations).to.be.an('array'); + expect(modulesWeatherStations).to.be.an('array'); + }); + + it('should handle API errors gracefully', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getstationsdata?get_favorites=false', + }) + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + const { weatherStations, modulesWeatherStations } = await netatmoHandler.loadWeatherStationDetails(); + + expect(weatherStations).to.be.eq(undefined); + expect(modulesWeatherStations).to.be.eq(undefined); + }); + + it('should handle unexpected API responses', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'GET', + path: '/api/getstationsdata?get_favorites=false', + }) + .reply(200, { + body: bodyGetWeatherStationMock, + status: 'error', + }); + + const { weatherStations, modulesWeatherStations } = await netatmoHandler.loadWeatherStationDetails(); + expect(weatherStations).to.deep.eq(bodyGetWeatherStationMock.devices); + expect(modulesWeatherStations).to.deep.eq([]); + expect(weatherStations).to.be.an('array'); + expect(modulesWeatherStations).to.be.an('array'); + expect(modulesWeatherStations).to.have.lengthOf(0); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.pollRefreshingTokens.test.js b/server/test/services/tessie/lib/netatmo.pollRefreshingTokens.test.js new file mode 100644 index 0000000000..ab24b9f050 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.pollRefreshingTokens.test.js @@ -0,0 +1,155 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const { refreshingTokens } = netatmoHandler; + +describe('Netatmo pollRefreshingToken', () => { + let clock; + let tokens; + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + clock = sinon.useFakeTimers(); + netatmoHandler.status = 'not_initialized'; + netatmoHandler.configuration = { + clientId: 'valid_client_id', + clientSecret: 'valid_client_secret', + scopes: { scopeEnergy: 'scope' }, + }; + netatmoHandler.accessToken = 'valid_access_token'; + netatmoHandler.refreshToken = 'valid_refresh_token'; + netatmoHandler.expireInToken = 3600; + tokens = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expire_in: 3600, + }; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should refresh tokens periodically', async () => { + netatmoHandler.refreshingTokens = sinon.stub().resolves({ success: true }); + + await netatmoHandler.pollRefreshingToken(); + + clock.tick(3600 * 1000); + sinon.assert.calledOnce(netatmoHandler.refreshingTokens); + + netatmoHandler.refreshingTokens = refreshingTokens; + tokens.refresh_token = 'new-refresh-token2'; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .reply(200, tokens); + + clock.tick(3600 * 1000); + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(netatmoHandler.accessToken).to.equal('new-access-token'); + expect(netatmoHandler.refreshToken).to.equal('new-refresh-token2'); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_ACCESS_TOKEN', + 'new-access-token', + serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_REFRESH_TOKEN', + 'new-refresh-token2', + serviceId, + ); + sinon.assert.calledWith(netatmoHandler.gladys.variable.setValue, 'NETATMO_EXPIRE_IN_TOKEN', 3600, serviceId); + }); + + it('should handle token expiration change correctly', async () => { + netatmoHandler.refreshingTokens = sinon.stub().callsFake(async () => { + netatmoHandler.expireInToken = 7200; + return { success: true }; + }); + const initialExpireInToken = netatmoHandler.expireInToken; + const pollRefreshingTokenSpy = sinon.spy(netatmoHandler, 'pollRefreshingToken'); + const pollRefreshTokenId = netatmoHandler.pollRefreshToken.id; + + await netatmoHandler.pollRefreshingToken(); + + clock.tick(initialExpireInToken * 1000); + expect(netatmoHandler.pollRefreshToken.id).to.equal(pollRefreshTokenId + 1); + expect(netatmoHandler.pollRefreshingToken.callCount).to.equal(1); + sinon.assert.calledOnce(pollRefreshingTokenSpy); + + pollRefreshingTokenSpy.restore(); + netatmoHandler.refreshingTokens = refreshingTokens; + }); + + it('should not set interval if expireInToken is null', async () => { + netatmoHandler.expireInToken = null; + const refreshingTokensSpy = sinon.spy(netatmoHandler, 'refreshingTokens'); + + await netatmoHandler.pollRefreshingToken(); + + sinon.assert.notCalled(refreshingTokensSpy); + + refreshingTokensSpy.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.pollRefreshingValues.test.js b/server/test/services/tessie/lib/netatmo.pollRefreshingValues.test.js new file mode 100644 index 0000000000..53f9af5891 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.pollRefreshingValues.test.js @@ -0,0 +1,129 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesMock = JSON.parse(JSON.stringify(require('../netatmo.loadDevices.mock.test.json'))); +const devicesExistsMock = JSON.parse(JSON.stringify(require('../netatmo.discoverDevices.mock.test.json'))); +const { EVENTS } = require('../../../../utils/constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); +const logger = require('../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + stateManager: { + get: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; + +describe('Netatmo pollRefreshingValues', () => { + let clock; + let netatmoHandler; + + beforeEach(() => { + sinon.reset(); + + clock = sinon.useFakeTimers(); + netatmoHandler = new NetatmoHandler(gladys, serviceId); + netatmoHandler.status = 'not_initialized'; + netatmoHandler.configured = true; + netatmoHandler.connected = true; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + }); + + it('should refresh device values periodically', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.refreshNetatmoValues = sinon.stub().resolves(); + netatmoHandler.gladys.stateManager.get = sinon.stub().returns(devicesExistsMock); + + netatmoHandler.pollRefreshingValues(); + + clock.tick(120 * 1000); + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.calledOnce(netatmoHandler.refreshNetatmoValues); + }); + + it('should handle an error during device loading', async () => { + netatmoHandler.refreshNetatmoValues = sinon.stub().rejects(new Error('Failed to load devices')); + sinon.stub(logger, 'error'); + + netatmoHandler.pollRefreshingValues(); + + clock.tick(120 * 1000); + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.called(netatmoHandler.refreshNetatmoValues); + sinon.assert.calledOnce(logger.error); + + logger.error.restore(); + }); + + it('should refresh device values', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves(devicesMock); + netatmoHandler.updateValues = sinon.stub().resolves(); + netatmoHandler.gladys.stateManager.get = sinon.stub().returns(devicesExistsMock); + + netatmoHandler.refreshNetatmoValues(); + + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.calledOnce(netatmoHandler.loadDevices); + sinon.assert.called(netatmoHandler.updateValues); + }); + + it('should refresh device values without device existing in Gladys', async () => { + netatmoHandler.status = 'connected'; + netatmoHandler.loadDevices = sinon.stub().resolves(devicesMock); + + await netatmoHandler.refreshNetatmoValues(); + + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.called(netatmoHandler.loadDevices); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + }); + + it('should handle an error during device loading', async () => { + netatmoHandler.loadDevices = sinon.stub().rejects(new Error('Failed to load devices')); + + await netatmoHandler.refreshNetatmoValues(); + + clock.restore(); + await new Promise((resolve) => setTimeout(resolve, 50)); + sinon.assert.called(netatmoHandler.loadDevices); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(4); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { status: 'get_devices_value_fail', statusType: 'connected' }, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.refreshingTokens.test.js b/server/test/services/tessie/lib/netatmo.refreshingTokens.test.js new file mode 100644 index 0000000000..3216e3e1a6 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.refreshingTokens.test.js @@ -0,0 +1,211 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Refreshing Tokens', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.configuration = { + clientId: 'valid_client_id', + clientSecret: 'valid_client_secret', + scopes: { scopeEnergy: 'scope' }, + }; + netatmoHandler.accessToken = 'valid_access_token'; + netatmoHandler.refreshToken = 'valid_refresh_token'; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should throw an error if configuration are missing', async () => { + netatmoHandler.configuration.clientId = null; + netatmoHandler.configuration.clientSecret = null; + + try { + await netatmoHandler.refreshingTokens(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not configured.'); + expect(netatmoHandler.status).to.equal('not_initialized'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + } + }); + + it('should throw an error if tokens are missing', async () => { + netatmoHandler.accessToken = null; + netatmoHandler.refreshToken = null; + netatmoHandler.configuration.clientId = 'valid_client_id'; + netatmoHandler.configuration.clientSecret = 'valid_client_secret'; + + try { + await netatmoHandler.refreshingTokens(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.equal('Netatmo is not connected.'); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + } + }); + + it('should successfully refresh tokens', async () => { + const tokens = { + access_token: 'new-access-token', + refresh_token: 'new-refresh-token', + expire_in: 3600, + }; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .reply(200, tokens); + + const result = await netatmoHandler.refreshingTokens(); + expect(result).to.deep.equal({ success: true }); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_ACCESS_TOKEN', + 'new-access-token', + serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_REFRESH_TOKEN', + 'new-refresh-token', + serviceId, + ); + sinon.assert.calledWith(netatmoHandler.gladys.variable.setValue, 'NETATMO_EXPIRE_IN_TOKEN', 3600, serviceId); + }); + + it('should handle an error during token refresh', async () => { + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .reply(400, { error: 'invalid_request' }); + + try { + await netatmoHandler.refreshingTokens(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + expect(e.message).to.include('HTTP error 400 - {"error":"invalid_request"}'); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(3); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-processing-token', + payload: { statusType: 'processing token', status: 'refresh_token_fail' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + } + }); + + it('should handle errors without a response object', async () => { + netatmoHandler.configuration.clientId = 'test-client-id'; + netatmoHandler.configuration.clientSecret = 'test-client-secret'; + netatmoHandler.configuration.scopes = { scopeEnergy: 'scope' }; + netatmoHandler.refreshToken = 'refresh-token'; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .replyWithError('Network error'); + try { + await netatmoHandler.refreshingTokens(); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(ServiceNotConfiguredError); + expect(e.message).to.include('NETATMO: Service is not connected with error'); + expect(e.response).to.equal(undefined); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(3); + } + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.retrieveToken.test.js b/server/test/services/tessie/lib/netatmo.retrieveToken.test.js new file mode 100644 index 0000000000..d848fdc862 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.retrieveToken.test.js @@ -0,0 +1,236 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const { fake } = sinon; + +const { EVENTS } = require('../../../../utils/constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +netatmoHandler.pollRefreshingToken = fake.resolves(null); +netatmoHandler.pollRefreshingValues = fake.resolves(null); + +describe('Netatmo retrieveTokens', () => { + let body; + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + body = { codeOAuth: 'test-code', state: 'valid-state', redirectUri: 'test-redirect-uri' }; + netatmoHandler.configuration.clientId = 'clientId-fake'; + netatmoHandler.configuration.clientSecret = 'clientSecret-fake'; + netatmoHandler.stateGetAccessToken = 'valid-state'; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should throw an error if configuration is not complete', async () => { + netatmoHandler.configuration.clientId = null; + + try { + await netatmoHandler.retrieveTokens(body); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.equal('Netatmo is not configured.'); + expect(netatmoHandler.status).to.equal('not_initialized'); + expect(netatmoHandler.configured).to.equal(false); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }), + ).to.equal(true); + } + }); + + it('should throw an error if state does not match', async () => { + body = { codeOAuth: 'test-code', state: 'invalid-state', redirectUri: 'test-redirect-uri' }; + + try { + await netatmoHandler.retrieveTokens(body); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.include( + 'Netatmo did not connect correctly. The return does not correspond to the initial request', + ); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + } + }); + + it('should retrieve tokens and update netatmo status if state matches', async () => { + const tokens = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expire_in: 3600, + }; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .reply(200, tokens); + + const result = await netatmoHandler.retrieveTokens(body); + + expect(result).to.deep.equal({ success: true }); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + sinon.assert.notCalled(netatmoHandler.pollRefreshingValues); + sinon.assert.calledOnce(netatmoHandler.pollRefreshingToken); + }); + + it('should retrieve tokens and launch pollRefreshingValues', async () => { + const tokens = { + access_token: 'access-token', + refresh_token: 'refresh-token', + expire_in: 3600, + }; + netatmoHandler.configuration.energyApi = true; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .reply(200, tokens); + + const result = await netatmoHandler.retrieveTokens(body); + + expect(result).to.deep.equal({ success: true }); + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + sinon.assert.calledOnce(netatmoHandler.pollRefreshingValues); + sinon.assert.calledOnce(netatmoHandler.pollRefreshingToken); + }); + + it('should throw an error when axios request fails', async () => { + const bodyFake = { codeOAuth: 'test-code', state: 'valid-state', redirectUri: 'test-redirect-uri' }; + netatmoHandler.configuration.clientId = 'test-client-id'; + netatmoHandler.configuration.clientSecret = 'test-client-secret'; + netatmoHandler.configuration.scopes = { scopeEnergy: 'scope' }; + netatmoHandler.stateGetAccessToken = 'valid-state'; + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .reply(400, { error: 'invalid_request' }); + + try { + await netatmoHandler.retrieveTokens(bodyFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + expect(e.message).to.include('HTTP error 400 - {"error":"invalid_request"}'); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(3); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-processing-token', + payload: { statusType: 'processing token', status: 'get_access_token_fail' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + } + }); + + it('should handle errors without a response object', async () => { + const bodyFake = { codeOAuth: 'test-code', state: 'valid-state', redirectUri: 'test-redirect-uri' }; + netatmoHandler.configuration.clientId = 'test-client-id'; + netatmoHandler.configuration.clientSecret = 'test-client-secret'; + netatmoHandler.configuration.scopes = { scopeEnergy: 'scope' }; + netatmoHandler.stateGetAccessToken = 'valid-state'; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/oauth2/token', + }) + .replyWithError('Network error'); + + try { + await netatmoHandler.retrieveTokens(bodyFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e.message).to.include('NETATMO: Service is not connected with error'); + expect(e.response).to.equal(undefined); + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + } + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.saveConfiguration.test.js b/server/test/services/tessie/lib/netatmo.saveConfiguration.test.js new file mode 100644 index 0000000000..a06bd34836 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.saveConfiguration.test.js @@ -0,0 +1,73 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + variable: { + setValue: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Save configuration', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should successfully save the configuration', async () => { + const testConfig = { + clientId: 'new-client-id', + clientSecret: 'new-client-secret', + }; + const result = await netatmoHandler.saveConfiguration(testConfig); + + expect(result).to.equal(true); + expect(netatmoHandler.configuration.clientId).to.equal('new-client-id'); + expect(netatmoHandler.configuration.clientSecret).to.equal('new-client-secret'); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_CLIENT_ID', + 'new-client-id', + netatmoHandler.serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_CLIENT_SECRET', + 'new-client-secret', + netatmoHandler.serviceId, + ); + }); + + it('should handle an error during configuration save', async () => { + const testConfig = { + clientId: 'new-client-id', + clientSecret: 'new-client-secret', + }; + netatmoHandler.gladys.variable.setValue + .withArgs('NETATMO_CLIENT_ID', sinon.match.any) + .throws(new Error('Failed to save')); + const result = await netatmoHandler.saveConfiguration(testConfig); + + expect(result).to.equal(false); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_CLIENT_ID', + 'new-client-id', + netatmoHandler.serviceId, + ); + sinon.assert.neverCalledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_CLIENT_SECRET', + 'new-client-secret', + netatmoHandler.serviceId, + ); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.saveStatus.test.js b/server/test/services/tessie/lib/netatmo.saveStatus.test.js new file mode 100644 index 0000000000..174e0eaef5 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.saveStatus.test.js @@ -0,0 +1,298 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { assert, fake } = sinon; + +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); +const { STATUS } = require('../../../../services/netatmo/lib/utils/netatmo.constants'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo saveStatus', () => { + let clock; + + beforeEach(() => { + sinon.reset(); + clock = sinon.useFakeTimers(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + clock.restore(); + sinon.reset(); + }); + + it('should update the status to NOT_INITIALIZED and emit the event', () => { + sinon.spy(clock, 'clearInterval'); + const intervalPollRefreshTokenSpy = sinon.spy(); + const intervalPollRefreshValuesSpy = sinon.spy(); + netatmoHandler.pollRefreshToken = setInterval(intervalPollRefreshTokenSpy, 3600); + netatmoHandler.pollRefreshValues = setInterval(intervalPollRefreshValuesSpy, 120); + + netatmoHandler.saveStatus({ statusType: STATUS.NOT_INITIALIZED, message: null }); + + expect(netatmoHandler.status).to.equal('not_initialized'); + expect(netatmoHandler.configured).to.equal(false); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'not_initialized' }, + }); + clock.tick(3600 * 1000 * 2); + assert.calledTwice(clock.clearInterval); + expect(intervalPollRefreshTokenSpy.notCalled).to.equal(true); + expect(intervalPollRefreshValuesSpy.notCalled).to.equal(true); + }); + it('should update the status to CONNECTING and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.CONNECTING, message: null }); + + expect(netatmoHandler.status).to.equal('connecting'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connecting' }, + }); + }); + it('should update the status to PROCESSING_TOKEN and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.PROCESSING_TOKEN, message: null }); + + expect(netatmoHandler.status).to.equal('processing token'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'processing token' }, + }); + }); + it('should update the status to CONNECTED and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }); + }); + it('should update the status to DISCONNECTING and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.DISCONNECTING, message: null }); + + expect(netatmoHandler.status).to.equal('disconnecting'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnecting' }, + }); + }); + it('should update the status to DISCONNECTED and emit the event', () => { + sinon.spy(clock, 'clearInterval'); + const intervalPollRefreshTokenSpy = sinon.spy(); + const intervalPollRefreshValuesSpy = sinon.spy(); + netatmoHandler.pollRefreshToken = setInterval(intervalPollRefreshTokenSpy, 3600); + netatmoHandler.pollRefreshValues = setInterval(intervalPollRefreshValuesSpy, 120); + + netatmoHandler.saveStatus({ statusType: STATUS.DISCONNECTED, message: null }); + + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }); + clock.tick(3600 * 1000 * 2); + assert.calledTwice(clock.clearInterval); + expect(intervalPollRefreshTokenSpy.notCalled).to.equal(true); + expect(intervalPollRefreshValuesSpy.notCalled).to.equal(true); + }); + it('should update the status to DISCOVERING_DEVICES and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.DISCOVERING_DEVICES, message: null }); + + expect(netatmoHandler.status).to.equal('discovering'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'discovering' }, + }); + }); + it('should update the status to GET_DEVICES_VALUES and emit the event', () => { + netatmoHandler.saveStatus({ statusType: STATUS.GET_DEVICES_VALUES, message: null }); + + expect(netatmoHandler.status).to.equal('get devices values'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(true); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(1); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'get devices values' }, + }); + }); + + it('should update the status to ERROR CONNECTING and emit the event', () => { + netatmoHandler.saveStatus({ + statusType: 'error connecting', + message: 'error_connecting', + }); + + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTING, + payload: { statusType: 'connecting', status: 'error_connecting' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }); + }); + + it('should update the status to ERROR PROCESSING_TOKEN and emit the event', () => { + netatmoHandler.saveStatus({ + statusType: 'error processing token', + message: 'get_access_token_fail', + }); + + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.PROCESSING_TOKEN, + payload: { statusType: 'processing token', status: 'get_access_token_fail' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.STATUS, + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }); + }); + + it('should update the status to ERROR CONNECTED and emit the event', () => { + netatmoHandler.saveStatus({ + statusType: 'error connected', + message: 'error_connected', + }); + + expect(netatmoHandler.status).to.equal('disconnected'); + expect(netatmoHandler.configured).to.equal(true); + expect(netatmoHandler.connected).to.equal(false); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.NETATMO.ERROR.CONNECTED, + payload: { statusType: 'connected', status: 'error_connected' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'disconnected' }, + }); + }); + + it('should update the status to ERROR SET_DEVICES_VALUES and emit the event', () => { + netatmoHandler.status = 'connected'; + netatmoHandler.saveStatus({ + statusType: 'error set devices values', + message: 'error_set_devices_values', + }); + + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { statusType: 'connected', status: 'error_set_devices_values' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }); + }); + + it('should update the status to ERROR GET_DEVICES_VALUES and emit the event', () => { + netatmoHandler.status = 'connected'; + netatmoHandler.saveStatus({ + statusType: 'error get devices values', + message: 'error_get_devices_values', + }); + + expect(netatmoHandler.status).to.equal('connected'); + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { statusType: 'connected', status: 'error_get_devices_values' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }); + }); + + it('should handle unknown status types gracefully', () => { + const result = netatmoHandler.saveStatus({ statusType: 'unknown_status', message: null }); + + expect(result).to.equal(true); + }); + + it('should return false on error', () => { + netatmoHandler.gladys.event.emit = sinon.stub().throws(new Error('Emit failed')); + + const result = netatmoHandler.saveStatus({ statusType: STATUS.CONNECTED, message: null }); + expect(result).to.equal(false); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.setTokens.test.js b/server/test/services/tessie/lib/netatmo.setTokens.test.js new file mode 100644 index 0000000000..3cd2067d7c --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.setTokens.test.js @@ -0,0 +1,89 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + variable: { + setValue: sinon.stub().resolves(), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo Set Token', () => { + beforeEach(() => { + sinon.reset(); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should successfully save tokens', async () => { + const testTokens = { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expireIn: 3600, + }; + + const result = await netatmoHandler.setTokens(testTokens); + + expect(result).to.equal(true); + expect(netatmoHandler.accessToken).to.equal('new-access-token'); + expect(netatmoHandler.refreshToken).to.equal('new-refresh-token'); + expect(netatmoHandler.expireInToken).to.equal(3600); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_ACCESS_TOKEN', + 'new-access-token', + netatmoHandler.serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_REFRESH_TOKEN', + 'new-refresh-token', + netatmoHandler.serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_EXPIRE_IN_TOKEN', + 3600, + netatmoHandler.serviceId, + ); + }); + + it('should handle an error during token save', async () => { + const testTokens = { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + expireIn: 3600, + }; + + netatmoHandler.gladys.variable.setValue + .withArgs('NETATMO_REFRESH_TOKEN', sinon.match.any) + .throws(new Error('Failed to save')); + + const result = await netatmoHandler.setTokens(testTokens); + + expect(result).to.equal(false); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_ACCESS_TOKEN', + 'new-access-token', + netatmoHandler.serviceId, + ); + sinon.assert.calledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_REFRESH_TOKEN', + 'new-refresh-token', + netatmoHandler.serviceId, + ); + sinon.assert.neverCalledWith( + netatmoHandler.gladys.variable.setValue, + 'NETATMO_EXPIRE_IN_TOKEN', + 3600, + netatmoHandler.serviceId, + ); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.setValue.test.js b/server/test/services/tessie/lib/netatmo.setValue.test.js new file mode 100644 index 0000000000..82193461d9 --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.setValue.test.js @@ -0,0 +1,207 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MockAgent, setGlobalDispatcher, getGlobalDispatcher } = require('undici'); + +const { fake } = sinon; + +const devicesMock = require('../netatmo.convertDevices.mock.test.json'); +const { EVENTS } = require('../../../../utils/constants'); +const { BadParameters } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; +const netatmoHandler = new NetatmoHandler(gladys, serviceId); +const [deviceMock] = devicesMock.filter((device) => device.model === 'NATherm1'); + +describe('Netatmo Set Value', () => { + let mockAgent; + let netatmoMock; + let originalDispatcher; + + beforeEach(() => { + sinon.reset(); + + // Store the original dispatcher + originalDispatcher = getGlobalDispatcher(); + + // MockAgent setup + mockAgent = new MockAgent(); + setGlobalDispatcher(mockAgent); + mockAgent.disableNetConnect(); + netatmoMock = mockAgent.get('https://api.netatmo.com'); + + netatmoHandler.status = 'connected'; + netatmoHandler.accessToken = 'valid_access_token'; + }); + + afterEach(() => { + sinon.reset(); + // Clean up the mock agent + mockAgent.close(); + // Restore the original dispatcher + setGlobalDispatcher(originalDispatcher); + }); + + it('should set device value successfully', async () => { + const deviceFeatureMock = deviceMock.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const newValue = 20; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/api/setroomthermpoint', + }) + .reply(200, {}); + + await netatmoHandler.setValue(deviceMock, deviceFeatureMock, newValue); + }); + + it('should throw an error if not home ID parameter', async () => { + const deviceMockFake = { ...JSON.parse(JSON.stringify(deviceMock)) }; + const deviceFeatureMock = deviceMockFake.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const externalId = deviceFeatureMock.external_id; + deviceMockFake.params = deviceMockFake.params.filter((oneParam) => oneParam.name === 'home_id'); + const newValue = 20; + + try { + await netatmoHandler.setValue(deviceMockFake, deviceFeatureMock, newValue); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id: "${externalId}" should contains parameters "HOME_ID" and "ROOM_ID"`, + ); + } + }); + + it('should throw an error if bad externalId prefix', async () => { + const deviceMockFake = { ...JSON.parse(JSON.stringify(deviceMock)) }; + const deviceFeatureMock = deviceMockFake.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const externalIdFake = deviceFeatureMock.external_id.replace('netatmo:', ''); + deviceFeatureMock.external_id = externalIdFake; + const newValue = 20; + + try { + await netatmoHandler.setValue(deviceMockFake, deviceFeatureMock, newValue); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id is invalid: "${externalIdFake}" should starts with "netatmo:"`, + ); + } + }); + + it('should throw an error if no externalId topic', async () => { + const deviceMockFake = { ...JSON.parse(JSON.stringify(deviceMock)) }; + const deviceFeatureMock = deviceMockFake.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const externalIdFake = 'netatmo'; + deviceFeatureMock.external_id = externalIdFake; + const newValue = 20; + + try { + await netatmoHandler.setValue(deviceMockFake, deviceFeatureMock, newValue); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id is invalid: "${externalIdFake}" have no id and category indicator`, + ); + } + }); + + it('should handle API errors gracefully with error 400', async () => { + const deviceFeatureMock = deviceMock.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const newValue = 20; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/api/setroomthermpoint', + }) + .reply(400, { + error: { + code: { + type: 'number', + example: 21, + }, + message: { + type: 'string', + example: 'invalid [parameter]', + }, + }, + }); + + await netatmoHandler.setValue(deviceMock, deviceFeatureMock, newValue); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { statusType: 'connected', status: 'set_devices_value_error_unknown' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + }); + + it('should handle API errors gracefully with error 403 and code 13', async () => { + const deviceFeatureMock = deviceMock.features.filter((feature) => + feature.external_id.includes('therm_setpoint_temperature'), + )[0]; + const newValue = 20; + + // Intercept the HTTP/2 call via undici + netatmoMock + .intercept({ + method: 'POST', + path: '/api/setroomthermpoint', + }) + .reply(403, { + error: { + code: 13, + message: 'invalid [parameter]', + }, + }); + + await netatmoHandler.setValue(deviceMock, deviceFeatureMock, newValue); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(2); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.error-connected', + payload: { statusType: 'connected', status: 'set_devices_value_fail_scope_rights' }, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.WEBSOCKET.SEND_ALL, { + type: 'netatmo.status', + payload: { status: 'connected' }, + }), + ).to.equal(true); + }); +}); diff --git a/server/test/services/tessie/lib/netatmo.updateValues.test.js b/server/test/services/tessie/lib/netatmo.updateValues.test.js new file mode 100644 index 0000000000..74e0114d1d --- /dev/null +++ b/server/test/services/tessie/lib/netatmo.updateValues.test.js @@ -0,0 +1,166 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = require('../netatmo.convertDevices.mock.test.json'); +const devicesNetatmo = require('../netatmo.loadDevices.mock.test.json'); +const { BadParameters } = require('../../../../utils/coreErrors'); +const NetatmoHandler = require('../../../../services/netatmo/lib/index'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update features type', () => { + beforeEach(() => { + sinon.reset(); + + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should not update values if type not supported', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[devicesGladys.length - 1])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[devicesNetatmo.length - 1])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + sinon.assert.notCalled(netatmoHandler.gladys.event.emit); + }); + + it('should not update values if feature not found', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + deviceGladys.features = []; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + sinon.assert.notCalled(netatmoHandler.gladys.event.emit); + }); + + it('should handle invalid external_id format on prefix', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + const externalIdFake = deviceGladys.external_id.replace('netatmo:', ''); + + try { + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalIdFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id is invalid: "${externalIdFake}" should starts with "netatmo:"`, + ); + } + }); + + it('should handle invalid external_id format on topic', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + const externalIdFake = 'netatmo:'; + + try { + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalIdFake); + expect.fail('should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(BadParameters); + expect(e.message).to.equal( + `Netatmo device external_id is invalid: "${externalIdFake}" have no id and category indicator`, + ); + } + }); + + it('should update device values for a plug device', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladys.features.length); + }); + + it('should save all values thermostat according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[1])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[1])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(7); + }); + + it('should save all values valves according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[3])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[3])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + }); + + it('should save all values weather station according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[4])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[4])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(10); + }); + + it('should save all values module outdoor according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[5])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[5])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + }); + + it('should save all values module anemometer according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[6])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[6])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(8); + }); + + it('should save all values module rain gauge according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[7])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[7])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(5); + }); + + it('should save all values module indoor according to all cases', async () => { + const deviceGladys = { ...JSON.parse(JSON.stringify(devicesGladys[8])) }; + const deviceNetatmo = { ...JSON.parse(JSON.stringify(devicesNetatmo[8])) }; + const externalId = `netatmo:${deviceNetatmo.id}`; + + await netatmoHandler.updateValues(deviceGladys, deviceNetatmo, externalId); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(8); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAMain.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAMain.test.js new file mode 100644 index 0000000000..b8fc239a8a --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAMain.test.js @@ -0,0 +1,142 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update NAMain features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[4])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[4])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.temperature = undefined; + deviceNetatmoMock.co2 = undefined; + deviceNetatmoMock.humidity = undefined; + deviceNetatmoMock.noise = undefined; + deviceNetatmoMock.pressure = undefined; + deviceNetatmoMock.absolute_pressure = undefined; + deviceNetatmoMock.wifi_strength = undefined; + await netatmoHandler.updateNAMain(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:temperature`, + state: 20.1, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:therm_measured_temperature`, + state: 20.5, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(7), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:min_temp`, + state: 18.5, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(8), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_temp`, + state: 20.7, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + delete deviceNetatmoMock.room; + + await netatmoHandler.updateNAMain(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(7); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:temperature`, + state: 20.2, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:temperature', + state: 20.2, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:co2', + state: 500, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:humidity', + state: 50, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:noise', + state: 32, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:pressure', + state: 1022, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(5).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:absolute_pressure', + state: 1007, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(6).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:jj:jj:jj:wifi_strength', + state: 38, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAMain(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAModule1.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAModule1.test.js new file mode 100644 index 0000000000..2a499e3345 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAModule1.test.js @@ -0,0 +1,117 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update Smart Outdoor module NAModule1 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[5])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[5])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.temperature = undefined; + deviceNetatmoMock.humidity = undefined; + deviceNetatmoMock.rf_strength = undefined; + await netatmoHandler.updateNAModule1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:temperature`, + state: 14.2, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(3), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:min_temp`, + state: 5.1, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(4), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_temp`, + state: 14.8, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + + await netatmoHandler.updateNAModule1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(4); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 32, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:02:00:00:yy:yy:yy:battery_percent', + state: 32, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:02:00:00:yy:yy:yy:temperature', + state: 14.2, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:02:00:00:yy:yy:yy:humidity', + state: 78, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:02:00:00:yy:yy:yy:rf_strength', + state: 59, + }), + ).to.equal(true); + }); + + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAModule1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAModule2.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAModule2.test.js new file mode 100644 index 0000000000..9ae7a084d1 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAModule2.test.js @@ -0,0 +1,130 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update Smart Outdoor module NAModule2 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[6])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[6])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.wind_strength = undefined; + deviceNetatmoMock.wind_angle = undefined; + deviceNetatmoMock.wind_gust = undefined; + deviceNetatmoMock.wind_gust_angle = undefined; + deviceNetatmoMock.rf_strength = undefined; + await netatmoHandler.updateNAModule2(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:wind_strength`, + state: 1, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(5), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_wind_str`, + state: 11, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(6), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_wind_angle`, + state: 137, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + + await netatmoHandler.updateNAModule2(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 51, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:battery_percent', + state: 51, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:wind_strength', + state: 1, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:wind_angle', + state: 276, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:wind_gust', + state: 6, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:wind_gust_angle', + state: 303, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(5).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:06:00:00:yy:yy:yy:rf_strength', + state: 120, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAModule2(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAModule3.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAModule3.test.js new file mode 100644 index 0000000000..b32059ebfc --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAModule3.test.js @@ -0,0 +1,113 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update NAModule3 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[7])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[7])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.rain = undefined; + deviceNetatmoMock.sum_rain_1 = undefined; + deviceNetatmoMock.sum_rain_24 = undefined; + deviceNetatmoMock.rf_strength = undefined; + await netatmoHandler.updateNAModule3(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:rain`, + state: 0.101, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(3), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:sum_rain_24`, + state: 0.1, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + + await netatmoHandler.updateNAModule3(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(5); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 52, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:05:00:00:yy:yy:yy:battery_percent', + state: 52, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:05:00:00:yy:yy:yy:rain', + state: 0.101, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:05:00:00:yy:yy:yy:sum_rain_1', + state: 0.505, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:05:00:00:yy:yy:yy:rf_strength', + state: 96, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAModule3(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAModule4.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAModule4.test.js new file mode 100644 index 0000000000..c1bfa47867 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAModule4.test.js @@ -0,0 +1,129 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update Smart Indoor module NAModule4 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[8])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[8])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with secondaries datas', async () => { + deviceNetatmoMock.temperature = undefined; + deviceNetatmoMock.co2 = undefined; + deviceNetatmoMock.humidity = undefined; + deviceNetatmoMock.rf_strength = undefined; + + await netatmoHandler.updateNAModule4(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(1), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:temperature`, + state: 18.1, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(2), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:therm_measured_temperature`, + state: 19, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(5), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:min_temp`, + state: 15.7, + }); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit.getCall(6), 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:max_temp`, + state: 18.2, + }); + }); + + it('should save all values according to all cases', async () => { + delete deviceNetatmoMock.dashboard_data; + delete deviceNetatmoMock.room; + + await netatmoHandler.updateNAModule4(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(5); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 41, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:battery_percent', + state: 41, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:temperature', + state: 18.2, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:co2', + state: 922, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:humidity', + state: 60, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:03:00:00:yy:yy:yy:rf_strength', + state: 72, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAModule4(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNAPlug.test.js b/server/test/services/tessie/lib/update/netatmo.updateNAPlug.test.js new file mode 100644 index 0000000000..d640237b59 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNAPlug.test.js @@ -0,0 +1,117 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update NAPlug features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[0])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[0])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should update device values correctly for a plug device connected on boiler', async () => { + await netatmoHandler.updateNAPlug(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:rf_strength', + state: 70, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:rf_strength', + state: 70, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:wifi_strength', + state: 45, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler', + state: 0, + }), + ).to.equal(true); + }); + + it('should update device values correctly for a plug device no connected boiler', async () => { + deviceNetatmoMock.plug_connected_boiler = undefined; + + await netatmoHandler.updateNAPlug(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(deviceGladysMock.features.length); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:rf_strength', + state: 70, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:rf_strength', + state: 70, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:wifi_strength', + state: 45, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler', + state: 0, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNAPlug(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNATherm1.test.js b/server/test/services/tessie/lib/update/netatmo.updateNATherm1.test.js new file mode 100644 index 0000000000..e51ec51b49 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNATherm1.test.js @@ -0,0 +1,111 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update NATherm1 features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[1])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[1])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases', async () => { + await netatmoHandler.updateNATherm1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(7); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 60, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:battery_percent', + state: 60, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:temperature', + state: 19.6, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:therm_measured_temperature', + state: 19.4, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:therm_setpoint_temperature', + state: 19.5, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:open_window', + state: 0, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(5).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:rf_strength', + state: 60, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(6).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:04:00:00:xx:xx:xx:boiler_status', + state: 1, + }), + ).to.equal(true); + }); + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNATherm1(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/lib/update/netatmo.updateNRV.test.js b/server/test/services/tessie/lib/update/netatmo.updateNRV.test.js new file mode 100644 index 0000000000..003a33b648 --- /dev/null +++ b/server/test/services/tessie/lib/update/netatmo.updateNRV.test.js @@ -0,0 +1,118 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake } = sinon; + +const devicesGladys = JSON.parse(JSON.stringify(require('../../netatmo.convertDevices.mock.test.json'))); +const devicesNetatmo = JSON.parse(JSON.stringify(require('../../netatmo.loadDevices.mock.test.json'))); +const { EVENTS } = require('../../../../../utils/constants'); +const NetatmoHandler = require('../../../../../services/netatmo/lib/index'); +const logger = require('../../../../../utils/logger'); + +const gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + setValue: fake.resolves(null), + }, +}; +const serviceId = 'serviceId'; + +const netatmoHandler = new NetatmoHandler(gladys, serviceId); + +describe('Netatmo update NRV features', () => { + let deviceGladysMock; + let deviceNetatmoMock; + let externalIdMock; + beforeEach(() => { + sinon.reset(); + + deviceGladysMock = { ...JSON.parse(JSON.stringify(devicesGladys[3])) }; + deviceNetatmoMock = { ...JSON.parse(JSON.stringify(devicesNetatmo[3])) }; + externalIdMock = `netatmo:${deviceNetatmoMock.id}`; + netatmoHandler.status = 'not_initialized'; + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should save all values according to all cases with heating power request', async () => { + await netatmoHandler.updateNRV(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:battery_percent`, + state: 90, + }); + expect( + netatmoHandler.gladys.event.emit.getCall(0).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:battery_percent', + state: 90, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(1).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:therm_measured_temperature', + state: 18.5, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(2).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:therm_setpoint_temperature', + state: 19, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(3).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:open_window', + state: 0, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(4).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:rf_strength', + state: 80, + }), + ).to.equal(true); + expect( + netatmoHandler.gladys.event.emit.getCall(5).calledWith(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'netatmo:09:00:00:xx:xx:xx:heating_power_request', + state: 1, + }), + ).to.equal(true); + }); + + it('should save all values according to all cases without heating power request', async () => { + deviceNetatmoMock.room.heating_power_request = 0; + + await netatmoHandler.updateNRV(deviceGladysMock, deviceNetatmoMock, externalIdMock); + + expect(netatmoHandler.gladys.event.emit.callCount).to.equal(6); + sinon.assert.calledWith(netatmoHandler.gladys.event.emit, 'device.new-state', { + device_feature_external_id: `${deviceGladysMock.external_id}:heating_power_request`, + state: 0, + }); + }); + + it('should handle errors correctly', async () => { + deviceNetatmoMock.battery_percent = undefined; + const error = new Error('Test error'); + netatmoHandler.gladys = { + event: { + emit: sinon.stub().throws(error), + }, + }; + sinon.stub(logger, 'error'); + + try { + await netatmoHandler.updateNRV(deviceGladysMock, deviceNetatmoMock, externalIdMock); + } catch (e) { + expect(e).to.equal(error); + sinon.assert.calledOnce(logger.error); + } + + logger.error.restore(); + }); +}); diff --git a/server/test/services/tessie/netatmo.convertDevices.mock.test.json b/server/test/services/tessie/netatmo.convertDevices.mock.test.json new file mode 100644 index 0000000000..b0ff9b1265 --- /dev/null +++ b/server/test/services/tessie/netatmo.convertDevices.mock.test.json @@ -0,0 +1,996 @@ +[ + { + "name": "Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx", + "selector": "netatmo:70:ee:50:xx:xx:xx", + "model": "NAPlug", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Link Wifi quality - Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx:wifi_strength", + "selector": "netatmo:70:ee:50:xx:xx:xx:wifi_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx:rf_strength", + "selector": "netatmo:70:ee:50:xx:xx:xx:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Relais Test connected boiler", + "external_id": "netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler", + "selector": "netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "modules_bridge_id", + "value": "[\"01:00:00:xx:xx:xx\",\"02:00:00:xx:xx:xx\"]" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "1234567890" + }, + { + "name": "room_name", + "value": "Garage Test" + } + ] + }, + { + "name": "Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx", + "selector": "netatmo:04:00:00:xx:xx:xx", + "model": "NATherm1", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:battery_percent", + "selector": "netatmo:04:00:00:xx:xx:xx:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:rf_strength", + "selector": "netatmo:04:00:00:xx:xx:xx:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:temperature", + "selector": "netatmo:04:00:00:xx:xx:xx:temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - room Maison Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:therm_measured_temperature", + "selector": "netatmo:04:00:00:xx:xx:xx:therm_measured_temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Setpoint temperature - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:therm_setpoint_temperature", + "selector": "netatmo:04:00:00:xx:xx:xx:therm_setpoint_temperature", + "category": "thermostat", + "type": "target-temperature", + "unit": "celsius", + "read_only": false, + "keep_history": true, + "has_feedback": false, + "min": 5, + "max": 30 + }, + { + "name": "Detecting open window - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:open_window", + "selector": "netatmo:04:00:00:xx:xx:xx:open_window", + "category": "opening-sensor", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + }, + { + "name": "Boiler status - Thermostat Test", + "external_id": "netatmo:04:00:00:xx:xx:xx:boiler_status", + "selector": "netatmo:04:00:00:xx:xx:xx:boiler_status", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:xx:xx:xx" + }, + { + "name": "plug_name", + "value": "Relais Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "0987654321" + }, + { + "name": "room_name", + "value": "Maison Test" + } + ] + }, + { + "name": "Relais Salon Test", + "external_id": "netatmo:70:ee:50:yy:yy:yy", + "selector": "netatmo:70:ee:50:yy:yy:yy", + "model": "NAPlug", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Link Wifi quality - Relais Salon Test", + "external_id": "netatmo:70:ee:50:yy:yy:yy:wifi_strength", + "selector": "netatmo:70:ee:50:yy:yy:yy:wifi_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Relais Salon Test", + "external_id": "netatmo:70:ee:50:yy:yy:yy:rf_strength", + "selector": "netatmo:70:ee:50:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Relais Salon Test connected boiler", + "external_id": "netatmo:70:ee:50:yy:yy:yy:plug_connected_boiler", + "selector": "netatmo:70:ee:50:yy:yy:yy:plug_connected_boiler", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "modules_bridge_id", + "value": "[]" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + } + ] + }, + { + "name": "Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx", + "selector": "netatmo:09:00:00:xx:xx:xx", + "model": "NRV", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:battery_percent", + "selector": "netatmo:09:00:00:xx:xx:xx:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:rf_strength", + "selector": "netatmo:09:00:00:xx:xx:xx:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - room Office", + "external_id": "netatmo:09:00:00:xx:xx:xx:therm_measured_temperature", + "selector": "netatmo:09:00:00:xx:xx:xx:therm_measured_temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Setpoint temperature - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:therm_setpoint_temperature", + "selector": "netatmo:09:00:00:xx:xx:xx:therm_setpoint_temperature", + "category": "thermostat", + "type": "target-temperature", + "unit": "celsius", + "read_only": false, + "keep_history": true, + "has_feedback": false, + "min": 5, + "max": 30 + }, + { + "name": "Detecting open window - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:open_window", + "selector": "netatmo:09:00:00:xx:xx:xx:open_window", + "category": "opening-sensor", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + }, + { + "name": "Heating power request - Valve Test", + "external_id": "netatmo:09:00:00:xx:xx:xx:heating_power_request", + "selector": "netatmo:09:00:00:xx:xx:xx:heating_power_request", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:yy:yy:yy" + }, + { + "name": "plug_name", + "value": "Relais Salon Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Office" + } + ] + }, + { + "name": "Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj", + "selector": "netatmo:70:ee:50:jj:jj:jj", + "model": "NAMain", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Link Wifi quality - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:wifi_strength", + "selector": "netatmo:70:ee:50:jj:jj:jj:wifi_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:temperature", + "selector": "netatmo:70:ee:50:jj:jj:jj:temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - room Office", + "external_id": "netatmo:70:ee:50:jj:jj:jj:therm_measured_temperature", + "selector": "netatmo:70:ee:50:jj:jj:jj:therm_measured_temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Minimum in Office", + "external_id": "netatmo:70:ee:50:jj:jj:jj:min_temp", + "selector": "netatmo:70:ee:50:jj:jj:jj:min_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Maximum in Office", + "external_id": "netatmo:70:ee:50:jj:jj:jj:max_temp", + "selector": "netatmo:70:ee:50:jj:jj:jj:max_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "CO2 - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:co2", + "selector": "netatmo:70:ee:50:jj:jj:jj:co2", + "category": "co2-sensor", + "type": "integer", + "unit": "ppm", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 5000 + }, + { + "name": "Humidity - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:humidity", + "selector": "netatmo:70:ee:50:jj:jj:jj:humidity", + "category": "humidity-sensor", + "type": "decimal", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Noise - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:noise", + "selector": "netatmo:70:ee:50:jj:jj:jj:noise", + "category": "noise-sensor", + "type": "integer", + "unit": "decibel", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 250 + }, + { + "name": "Pressure - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:pressure", + "selector": "netatmo:70:ee:50:jj:jj:jj:pressure", + "category": "pressure-sensor", + "type": "integer", + "unit": "milli-bar", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -1000, + "max": 2000 + }, + { + "name": "Absolute pressure - Weather Station Test", + "external_id": "netatmo:70:ee:50:jj:jj:jj:absolute_pressure", + "selector": "netatmo:70:ee:50:jj:jj:jj:absolute_pressure", + "category": "pressure-sensor", + "type": "integer", + "unit": "milli-bar", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -1000, + "max": 2000 + } + ], + "params": [ + { + "name": "modules_bridge_id", + "value": "[\"02:00:00:yy:yy:yy\",\"05:00:00:yy:yy:yy\",\"06:00:00:yy:yy:yy\",\"03:00:00:yy:yy:yy\"]" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Office" + } + ] + }, + { + "name": "Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy", + "selector": "netatmo:02:00:00:yy:yy:yy", + "model": "NAModule1", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy:battery_percent", + "selector": "netatmo:02:00:00:yy:yy:yy:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy:rf_strength", + "selector": "netatmo:02:00:00:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy:temperature", + "selector": "netatmo:02:00:00:yy:yy:yy:temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Minimum in Outdoor", + "external_id": "netatmo:02:00:00:yy:yy:yy:min_temp", + "selector": "netatmo:02:00:00:yy:yy:yy:min_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Maximum in Outdoor", + "external_id": "netatmo:02:00:00:yy:yy:yy:max_temp", + "selector": "netatmo:02:00:00:yy:yy:yy:max_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Humidity - Outdoor Hygrometer", + "external_id": "netatmo:02:00:00:yy:yy:yy:humidity", + "selector": "netatmo:02:00:00:yy:yy:yy:humidity", + "category": "humidity-sensor", + "type": "decimal", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:jj:jj:jj" + }, + { + "name": "plug_name", + "value": "Weather Station Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Outdoor" + } + ] + }, + { + "name": "Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy", + "selector": "netatmo:06:00:00:yy:yy:yy", + "model": "NAModule2", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:battery_percent", + "selector": "netatmo:06:00:00:yy:yy:yy:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:rf_strength", + "selector": "netatmo:06:00:00:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Wind strength - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:wind_strength", + "selector": "netatmo:06:00:00:yy:yy:yy:wind_strength", + "category": "speed-sensor", + "type": "integer", + "unit": "kilometer-per-hour", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 300 + }, + { + "name": "Wind angle - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:wind_angle", + "selector": "netatmo:06:00:00:yy:yy:yy:wind_angle", + "category": "angle-sensor", + "type": "integer", + "unit": "degree", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 360 + }, + { + "name": "Gust strength - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:wind_gust", + "selector": "netatmo:06:00:00:yy:yy:yy:wind_gust", + "category": "speed-sensor", + "type": "integer", + "unit": "kilometer-per-hour", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 300 + }, + { + "name": "Gust angle - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:wind_gust_angle", + "selector": "netatmo:06:00:00:yy:yy:yy:wind_gust_angle", + "category": "angle-sensor", + "type": "integer", + "unit": "degree", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 360 + }, + { + "name": "Maximum wind strength - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:max_wind_str", + "selector": "netatmo:06:00:00:yy:yy:yy:max_wind_str", + "category": "speed-sensor", + "type": "integer", + "unit": "kilometer-per-hour", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 300 + }, + { + "name": "Maximum wind angle - Anemometer STARK Home", + "external_id": "netatmo:06:00:00:yy:yy:yy:max_wind_angle", + "selector": "netatmo:06:00:00:yy:yy:yy:max_wind_angle", + "category": "angle-sensor", + "type": "integer", + "unit": "degree", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 360 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:jj:jj:jj" + }, + { + "name": "plug_name", + "value": "Weather Station Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Outdoor" + } + ] + }, + { + "name": "STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy", + "selector": "netatmo:05:00:00:yy:yy:yy", + "model": "NAModule3", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:battery_percent", + "selector": "netatmo:05:00:00:yy:yy:yy:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:rf_strength", + "selector": "netatmo:05:00:00:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Current rain - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:rain", + "selector": "netatmo:05:00:00:yy:yy:yy:rain", + "category": "precipitation-sensor", + "type": "decimal", + "unit": "mm", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Precipitation / 1h - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:sum_rain_1", + "selector": "netatmo:05:00:00:yy:yy:yy:sum_rain_1", + "category": "precipitation-sensor", + "type": "decimal", + "unit": "millimeter-per-hour", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Sum rain / 24h - STARK House Rain Gauge", + "external_id": "netatmo:05:00:00:yy:yy:yy:sum_rain_24", + "selector": "netatmo:05:00:00:yy:yy:yy:sum_rain_24", + "category": "precipitation-sensor", + "type": "decimal", + "unit": "millimeter-per-day", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:jj:jj:jj" + }, + { + "name": "plug_name", + "value": "Weather Station Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Outdoor" + } + ] + }, + { + "name": "STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy", + "selector": "netatmo:03:00:00:yy:yy:yy", + "model": "NAModule4", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Battery - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:battery_percent", + "selector": "netatmo:03:00:00:yy:yy:yy:battery_percent", + "category": "battery", + "type": "integer", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:rf_strength", + "selector": "netatmo:03:00:00:yy:yy:yy:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Temperature - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:temperature", + "selector": "netatmo:03:00:00:yy:yy:yy:temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - room Kitchen", + "external_id": "netatmo:03:00:00:yy:yy:yy:therm_measured_temperature", + "selector": "netatmo:03:00:00:yy:yy:yy:therm_measured_temperature", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Minimum in Kitchen", + "external_id": "netatmo:03:00:00:yy:yy:yy:min_temp", + "selector": "netatmo:03:00:00:yy:yy:yy:min_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "Temperature - Maximum in Kitchen", + "external_id": "netatmo:03:00:00:yy:yy:yy:max_temp", + "selector": "netatmo:03:00:00:yy:yy:yy:max_temp", + "category": "temperature-sensor", + "type": "decimal", + "unit": "celsius", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": -10, + "max": 50 + }, + { + "name": "CO2 - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:co2", + "selector": "netatmo:03:00:00:yy:yy:yy:co2", + "category": "co2-sensor", + "type": "integer", + "unit": "ppm", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 5000 + }, + { + "name": "Humidity - STARK House kitchen hygrometer", + "external_id": "netatmo:03:00:00:yy:yy:yy:humidity", + "selector": "netatmo:03:00:00:yy:yy:yy:humidity", + "category": "humidity-sensor", + "type": "decimal", + "unit": "percent", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + } + ], + "params": [ + { + "name": "plug_id", + "value": "70:ee:50:jj:jj:jj" + }, + { + "name": "plug_name", + "value": "Weather Station Test" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "8765432109" + }, + { + "name": "room_name", + "value": "Kitchen" + } + ] + }, + { + "name": "Outdoor Parking Camera", + "external_id": "netatmo:70:ee:00:xx:xx:xx", + "selector": "netatmo:70:ee:00:xx:xx:xx", + "model": "NOC", + "service_id": "serviceId", + "should_poll": false, + "features": [], + "params": [ + { "name": "home_id", "value": "5e1xxxxxxxxxxxxxxxxx" }, + { "name": "room_id", "value": "8765432109" }, + { "name": "room_name", "value": "Extérieur" } + ], + "not_handled": true + } +] diff --git a/server/test/services/tessie/netatmo.discoverDevices.mock.test.json b/server/test/services/tessie/netatmo.discoverDevices.mock.test.json new file mode 100644 index 0000000000..8acf4b751e --- /dev/null +++ b/server/test/services/tessie/netatmo.discoverDevices.mock.test.json @@ -0,0 +1,108 @@ +[ + { + "name": "Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx", + "selector": "netatmo:70:ee:50:xx:xx:xx", + "model": "NAPlug", + "service_id": "serviceId", + "should_poll": false, + "features": [ + { + "name": "Link Wifi quality - Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx:wifi_strength", + "selector": "netatmo:70:ee:50:xx:xx:xx:wifi_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Link RF quality - Relais Test", + "external_id": "netatmo:70:ee:50:xx:xx:xx:rf_strength", + "selector": "netatmo:70:ee:50:xx:xx:xx:rf_strength", + "category": "signal", + "type": "integer", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 100 + }, + { + "name": "Relais Test connected boiler", + "external_id": "netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler", + "selector": "netatmo:70:ee:50:xx:xx:xx:plug_connected_boiler", + "category": "switch", + "type": "binary", + "read_only": true, + "keep_history": true, + "has_feedback": false, + "min": 0, + "max": 1 + } + ], + "params": [ + { + "name": "modules_bridge_id", + "value": "[\"01:00:00:xx:xx:xx\",\"02:00:00:xx:xx:xx\"]" + }, + { + "name": "home_id", + "value": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "name": "room_id", + "value": "1234567890" + }, + { + "name": "room_name", + "value": "Garage Test" + } + ], + "deviceNetatmo": { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx", "02:00:00:xx:xx:xx"], + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true + } + } +] diff --git a/server/test/services/tessie/netatmo.getThermostat.mock.test.json b/server/test/services/tessie/netatmo.getThermostat.mock.test.json new file mode 100644 index 0000000000..17a830f108 --- /dev/null +++ b/server/test/services/tessie/netatmo.getThermostat.mock.test.json @@ -0,0 +1,63 @@ +{ + "devices": [ + { + "_id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "last_setup": 1580000589, + "firmware": 200, + "last_status_store": 1602798842, + "plug_connected_boiler": true, + "wifi_status": 45, + "modules": [ + { + "_id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "firmware": 60, + "last_message": 1602798840, + "rf_status": 60, + "battery_vp": 3000, + "therm_orientation": 1, + "therm_relay_cmd": 100, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 50, + "event_history": {}, + "setpoint_history": [], + "last_therm_seen": 1602798840, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [], + "measured": { + "time": 1602798668, + "temperature": 19, + "setpoint_temp": 19 + } + } + ], + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "udp_conn": true, + "last_plug_seen": 1602798842 + } + ], + "user": { + "mail": "tony.stark@gmail.com", + "administrative": { + "lang": "fr", + "reg_locale": "fr-FR", + "country": "FR", + "unit": 0, + "windunit": 0, + "pressureunit": 0, + "feel_like_algo": 0 + } + } +} diff --git a/server/test/services/tessie/netatmo.getWeatherStation.mock.test.json b/server/test/services/tessie/netatmo.getWeatherStation.mock.test.json new file mode 100644 index 0000000000..c0687bae9a --- /dev/null +++ b/server/test/services/tessie/netatmo.getWeatherStation.mock.test.json @@ -0,0 +1,149 @@ +{ + "devices": [ + { + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "type": "NAMain", + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "modules": [ + { + "_id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "reachable": true, + "firmware": 53, + "last_message": 1706525865, + "last_seen": 1706517393, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + } + }, + { + "_id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "reachable": true, + "firmware": 27, + "last_message": 1706723822, + "last_seen": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + } + }, + { + "_id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "battery_percent": 52, + "reachable": false, + "firmware": 14, + "last_message": 1706021182, + "last_seen": 1705945317, + "rf_status": 96, + "battery_vp": 4944, + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + } + }, + { + "_id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "reachable": true, + "firmware": 53, + "last_message": 1706517399, + "last_seen": 1706517393, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + } + } + ] + } + ], + "user": { + "mail": "tony.stark@gmail.com", + "administrative": { + "lang": "fr", + "reg_locale": "fr-FR", + "country": "FR", + "unit": 0, + "windunit": 0, + "pressureunit": 0, + "feel_like_algo": 0 + } + } +} diff --git a/server/test/services/tessie/netatmo.homesdata.mock.test.json b/server/test/services/tessie/netatmo.homesdata.mock.test.json new file mode 100644 index 0000000000..ccf48b338b --- /dev/null +++ b/server/test/services/tessie/netatmo.homesdata.mock.test.json @@ -0,0 +1,181 @@ +{ + "homes": [ + { + "id": "5e1xxxxxxxxxxxxxxxxx", + "name": "Maison Stark", + "altitude": 122, + "coordinates": [0.3289786995650213, 49.60009176402246], + "country": "FR", + "timezone": "Europe/Paris", + "rooms": [ + { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + { + "id": "0987654321", + "name": "Maison Test", + "type": "livingroom", + "module_ids": ["04:00:00:xx:xx:xx"] + }, + { + "id": "8765432109", + "name": "Extérieur", + "type": "outdoor", + "module_ids": [ + "70:ee:00:xx:xx:xx", + "02:00:00:xx:xx:xx", + "05:00:00:xx:xx:xx", + "06:00:00:xx:xx:xx", + "09:00:00:xx:xx:xx" + ] + } + ], + "modules": [ + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "name": "Outdoor Parking Camera", + "setup_date": 1608493953, + "room_id": "8765432109" + }, + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["04:00:00:xx:xx:xx", "02:00:00:xx:xx:xx"] + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "name": "Thermostat Test", + "setup_date": 1580500000, + "room_id": "0987654321", + "bridge": "70:ee:50:xx:xx:xx" + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "name": "Relais Salon Test", + "setup_date": 1578496638 + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "bridge": "70:ee:50:yy:yy:yy" + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"] + }, + { + "id": "02:00:00:xx:xx:xx", + "type": "NAModule1", + "name": "Outdoor Hygrometer", + "setup_date": 1605564992, + "room_id": "8765432109", + "bridge": "70:ee:50:jj:jj:jj" + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "name": "Anemometer STARK Home", + "setup_date": 1605604559, + "room_id": "8765432109", + "bridge": "70:ee:50:jj:jj:jj" + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "name": "STARK House Rain Gauge", + "setup_date": 1605604312, + "room_id": "8765432109", + "bridge": "70:ee:50:jj:jj:jj" + }, + { + "id": "03:00:00:xx:xx:xx", + "type": "NAModule4", + "name": "STARK House kitchen hygrometer", + "setup_date": 1605564990, + "room_id": "8765432109", + "bridge": "70:ee:50:jj:jj:jj" + } + ], + "temperature_control_mode": "heating", + "therm_mode": "schedule", + "therm_setpoint_default_duration": 180, + "persons": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "pseudo": "Tony", + "url": "https://netatmocameraimage.blob.core.windows.net/production/xxx?sv=2010-01-01&sr=b&se=2023-12-31T00:01:00Z&sp=r&spr=https&sig=xxxxxx" + } + ], + "schedules": [ + { + "timetable": [ + { + "zone_id": 1, + "m_offset": 0 + }, + { + "zone_id": 6, + "m_offset": 345 + } + ], + "zones": [ + { + "name": "Confort", + "id": 0, + "type": 0, + "rooms_temp": [ + { + "room_id": "1234567890", + "temp": 17 + }, + { + "room_id": "0987654321", + "temp": 20 + } + ], + "rooms": [ + { + "id": "1456430165", + "therm_setpoint_temperature": 17 + }, + { + "id": "3416175565", + "therm_setpoint_temperature": 17 + } + ] + } + ], + "name": "Standard", + "default": false, + "away_temp": 17, + "hg_temp": 8, + "id": "5e147b4da11ec5d9f86b25a3", + "type": "therm" + } + ] + }, + { + "id": "7e2xxxxxxxxxxxxxxxxx", + "name": "Secondary House Stark", + "altitude": 1220, + "country": "US", + "modules": [] + } + ] +} diff --git a/server/test/services/tessie/netatmo.homestatus.mock.test.json b/server/test/services/tessie/netatmo.homestatus.mock.test.json new file mode 100644 index 0000000000..5e1eb68fc7 --- /dev/null +++ b/server/test/services/tessie/netatmo.homestatus.mock.test.json @@ -0,0 +1,176 @@ +{ + "home": { + "id": "5e1xxxxxxxxxxxxxxxxx", + "rooms": [ + { + "id": "1234567890", + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 12, + "therm_setpoint_temperature": 10, + "therm_setpoint_start_time": 1702657812, + "therm_setpoint_mode": "schedule" + }, + { + "id": "0987654321", + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 14.4, + "therm_setpoint_temperature": 14.5, + "therm_setpoint_start_time": 1703023788, + "therm_setpoint_end_time": 1703034588, + "therm_setpoint_mode": "manual" + }, + { + "id": "8765432109", + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 12, + "therm_setpoint_temperature": 10, + "therm_setpoint_start_time": 1702657812, + "therm_setpoint_mode": "schedule" + } + ], + "modules": [ + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "firmware_revision": 3018000, + "wifi_state": "high", + "wifi_strength": 55, + "sd_status": 4, + "alim_status": 2, + "siren_status": "no_sound", + "vpn_url": "https://prodvpn-eu-14.netatmo.net/restricted/xx.xxx.xxx.xxx/xxxxxxxxxxxxxxxxxxxxxxxxxxxxx/yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy,,", + "is_local": true, + "floodlight": "off", + "monitoring": "on" + }, + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 236, + "rf_strength": 108, + "wifi_strength": 35 + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "battery_state": "high", + "battery_level": 4003, + "firmware_revision": 76, + "rf_strength": 68, + "reachable": true, + "boiler_valve_comfort_boost": false, + "bridge": "70:ee:50:xx:xx:xx", + "boiler_status": false + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 236, + "rf_strength": 106, + "wifi_strength": 66 + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy" + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 38, + "ts": 1706464169, + "temperature": 20.2, + "co2": 500, + "humidity": 50, + "noise": 32, + "pressure": 1022.5, + "absolute_pressure": 1007.8 + }, + { + "id": "02:00:00:xx:xx:xx", + "type": "NAModule1", + "battery_state": "very_low", + "battery_level": 4788, + "firmware_revision": 53, + "rf_state": "high", + "rf_strength": 59, + "last_seen": 1706525865, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706525814, + "temperature": 14.2, + "humidity": 78 + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "battery_state": "medium", + "battery_level": 4993, + "firmware_revision": 27, + "rf_state": "very_low", + "rf_strength": 120, + "last_seen": 1706723822, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706723822, + "wind_strength": 1, + "wind_angle": 276, + "wind_gust": 6, + "wind_gust_angle": 303 + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "battery_state": "medium", + "battery_level": 4944, + "firmware_revision": 14, + "rf_state": "very_low", + "rf_strength": 96, + "last_seen": 1705945317, + "reachable": false, + "bridge": "70:ee:50:jj:jj:jj", + "rain": 0, + "sum_rain_1": 0 + }, + { + "id": "03:00:00:xx:xx:xx", + "type": "NAModule4", + "battery_state": "medium", + "battery_level": 4932, + "firmware_revision": 53, + "rf_state": "medium", + "rf_strength": 70, + "last_seen": 1706464227, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706464124, + "temperature": 18.1, + "co2": 854, + "humidity": 60 + } + ], + "persons": [ + { + "id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + "last_seen": 1702874136, + "out_of_sight": true + } + ] + } +} diff --git a/server/test/services/tessie/netatmo.loadDevices.mock.test.json b/server/test/services/tessie/netatmo.loadDevices.mock.test.json new file mode 100644 index 0000000000..ed3816f982 --- /dev/null +++ b/server/test/services/tessie/netatmo.loadDevices.mock.test.json @@ -0,0 +1,805 @@ +[ + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx", "02:00:00:xx:xx:xx"], + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "battery_state": "medium", + "battery_level": 3020, + "firmware_revision": 65, + "rf_strength": 60, + "reachable": true, + "boiler_valve_comfort_boost": false, + "bridge": "70:ee:50:xx:xx:xx", + "boiler_status": true, + "name": "Thermostat Test", + "setup_date": 1580500000, + "room_id": "0987654321", + "_id": "04:00:00:xx:xx:xx", + "firmware": 65, + "last_message": 1600500000, + "rf_status": 60, + "battery_vp": 3020, + "therm_orientation": 2, + "therm_relay_cmd": 80, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 60, + "event_history": { + "boiler_not_responding_events": ["event1", "event2"], + "boiler_responding_events": ["event3", "event4"] + }, + "setpoint_history": [ + { "time": 1600500000, "setpoint_temp": 19 }, + { "time": 1600600000, "setpoint_temp": 20 } + ], + "last_therm_seen": 1600500000, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [ + { "monday": [{ "start": "06:00", "end": "09:00", "temp": 19 }] }, + { "tuesday": [{ "start": "06:00", "end": "09:00", "temp": 19.5 }] } + ], + "measured": { + "time": 1600498668, + "temperature": 19.6, + "setpoint_temp": 19 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "0987654321", + "name": "Maison Test", + "type": "livingroom", + "module_ids": ["04:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 50, + "open_window": false, + "therm_measured_temperature": 19.4, + "therm_setpoint_temperature": 19.5, + "therm_setpoint_start_time": 1600500000, + "therm_setpoint_end_time": 1600800000, + "therm_setpoint_mode": "manual" + }, + "plug": { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx"], + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 100, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "_id": "70:ee:50:yy:yy:yy", + "firmware": 124, + "last_status_store": 1601000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"], + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["09:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 100, + "open_window": false, + "therm_measured_temperature": 18.5, + "therm_setpoint_temperature": 19, + "therm_setpoint_start_time": 1706421605, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "room_id": "2812958323", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"] + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 38, + "ts": 1706464169, + "temperature": 20.2, + "co2": 500, + "humidity": 50, + "noise": 32, + "pressure": 1022.5, + "absolute_pressure": 1007.8, + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20.5, + "therm_setpoint_start_time": 1706461202, + "therm_setpoint_mode": "schedule" + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "battery_state": "very_low", + "battery_level": 4788, + "firmware_revision": 53, + "rf_state": "high", + "rf_strength": 59, + "last_seen": 1706525865, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706525814, + "temperature": 14.2, + "humidity": 78, + "name": "Outdoor Hygrometer", + "setup_date": 1605564992, + "room_id": "8765432109", + "_id": "02:00:00:yy:yy:yy", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "firmware": 53, + "last_message": 1706525865, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "battery_state": "medium", + "battery_level": 4993, + "firmware_revision": 27, + "rf_state": "very_low", + "rf_strength": 120, + "last_seen": 1706723822, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706723822, + "wind_strength": 1, + "wind_angle": 276, + "wind_gust": 6, + "wind_gust_angle": 303, + "name": "Anemometer STARK Home", + "setup_date": 1605604559, + "room_id": "8765432109", + "_id": "06:00:00:yy:yy:yy", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "firmware": 27, + "last_message": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "battery_state": "medium", + "battery_level": 4944, + "firmware_revision": 14, + "rf_state": "very_low", + "rf_strength": 96, + "last_seen": 1705945317, + "reachable": false, + "bridge": "70:ee:50:jj:jj:jj", + "rain": 0.101, + "sum_rain_1": 0.505, + "name": "STARK House Rain Gauge", + "setup_date": 1605604312, + "room_id": "8765432109", + "_id": "05:00:00:yy:yy:yy", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "battery_percent": 52, + "firmware": 14, + "last_message": 1706021182, + "rf_status": 96, + "battery_vp": 4944, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "battery_state": "medium", + "battery_level": 4933, + "firmware_revision": 53, + "rf_state": "medium", + "rf_strength": 72, + "last_seen": 1706517393, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706517393, + "temperature": 18.2, + "co2": 922, + "humidity": 60, + "name": "STARK House kitchen hygrometer", + "setup_date": 1608046641, + "room_id": "8765432109", + "_id": "03:00:00:yy:yy:yy", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "firmware": 53, + "last_message": 1706517399, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Kitchen", + "type": "home_kitchen", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["09:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 100, + "open_window": false, + "therm_measured_temperature": 18.5, + "therm_setpoint_temperature": 19, + "therm_setpoint_start_time": 1706421605, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "room_id": "2812958323", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"] + } + }, + { + "_id": "70:ee:50:mm:mm:mm", + "station_name": "Maison STARK Secondaire (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "type": "NAMain", + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK Secondaire", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "modules": [], + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "firmware_revision": 3018000, + "wifi_state": "high", + "wifi_strength": 59, + "sd_status": 4, + "alim_status": 2, + "siren_status": "no_sound", + "monitoring": "on", + "name": "Outdoor Parking Camera", + "setup_date": 1608493953, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Extérieur", + "type": "outdoor", + "module_ids": ["70:ee:00:xx:xx:xx", "02:00:00:xx:xx:xx", "05:00:00:xx:xx:xx", "06:00:00:xx:xx:xx"] + }, + "not_handled": true + } +] diff --git a/server/test/services/tessie/netatmo.loadDevicesComplete.mock.test.json b/server/test/services/tessie/netatmo.loadDevicesComplete.mock.test.json new file mode 100644 index 0000000000..9aed5c08d4 --- /dev/null +++ b/server/test/services/tessie/netatmo.loadDevicesComplete.mock.test.json @@ -0,0 +1,979 @@ +[ + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["04:00:00:xx:xx:xx"], + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000589, + "firmware": 200, + "last_status_store": 1602798842, + "plug_connected_boiler": true, + "wifi_status": 45, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "udp_conn": true, + "last_plug_seen": 1602798842, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true, + "modules": [ + { + "_id": "04:00:00:xx:xx:xx", + "anticipating": false, + "battery_percent": 50, + "battery_vp": 3000, + "categoryAPI": "Energy", + "apiNotConfigured": true, + "event_history": {}, + "firmware": 60, + "last_message": 1602798840, + "last_therm_seen": 1602798840, + "measured": { + "setpoint_temp": 19, + "temperature": 19, + "time": 1602798668 + }, + "module_name": "Thermostat Test", + "plug": { + "_id": "70:ee:50:xx:xx:xx", + "categoryAPI": "Energy", + "apiNotConfigured": true, + "firmware": 200, + "last_plug_seen": 1602798842, + "last_setup": 1580000589, + "last_status_store": 1602798842, + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "plug_connected_boiler": true, + "station_name": "Relais Test", + "type": "NAPlug", + "udp_conn": true, + "wifi_status": 45 + }, + "rf_status": 60, + "setpoint": { + "setpoint_endtime": 1703096987, + "setpoint_mode": "manual", + "setpoint_temp": 14 + }, + "setpoint_history": [], + "therm_orientation": 1, + "therm_program_list": [], + "therm_relay_cmd": 100, + "type": "NATherm1" + } + ] + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "battery_state": "medium", + "battery_level": 3020, + "firmware_revision": 65, + "rf_strength": 60, + "reachable": true, + "boiler_valve_comfort_boost": false, + "bridge": "70:ee:50:xx:xx:xx", + "boiler_status": true, + "name": "Thermostat Test", + "setup_date": 1580500000, + "room_id": "0987654321", + "_id": "04:00:00:xx:xx:xx", + "firmware": 60, + "last_message": 1602798840, + "rf_status": 60, + "battery_vp": 3000, + "therm_orientation": 1, + "therm_relay_cmd": 100, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 50, + "event_history": {}, + "setpoint_history": [], + "last_therm_seen": 1602798840, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [], + "measured": { "time": 1602798668, "temperature": 19, "setpoint_temp": 19 }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "0987654321", + "name": "Maison Test", + "type": "livingroom", + "module_ids": ["04:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 50, + "open_window": false, + "therm_measured_temperature": 19.4, + "therm_setpoint_temperature": 19.5, + "therm_setpoint_start_time": 1600500000, + "therm_setpoint_end_time": 1600800000, + "therm_setpoint_mode": "manual" + }, + "plug": { + "_id": "70:ee:50:xx:xx:xx", + "categoryAPI": "Energy", + "apiNotConfigured": true, + "firmware": 200, + "last_plug_seen": 1602798842, + "last_setup": 1580000589, + "last_status_store": 1602798842, + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "plug_connected_boiler": true, + "station_name": "Relais Test", + "type": "NAPlug", + "udp_conn": true, + "wifi_status": 45 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "room_id": "9876543210", + "modules_bridged": ["03:00:00:xx:xx:xx", "04:00:00:xx:xx:xx", "05:00:00:xx:xx:xx"], + "_id": "70:ee:50:yy:yy:yy", + "firmware": 124, + "last_status_store": 1601000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "9876543210", + "name": "Salon Test", + "type": "livingroom", + "module_ids": ["03:00:00:xx:xx:xx", "04:00:00:xx:xx:xx", "05:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 40, + "open_window": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20, + "therm_setpoint_start_time": 1601000000, + "therm_setpoint_mode": "schedule" + }, + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["09:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 100, + "open_window": false, + "therm_measured_temperature": 18.5, + "therm_setpoint_temperature": 19, + "therm_setpoint_start_time": 1706421605, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "room_id": "2812958323", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"] + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 38, + "ts": 1706464169, + "temperature": 20.2, + "co2": 500, + "humidity": 50, + "noise": 32, + "pressure": 1022.5, + "absolute_pressure": 1007.8, + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20.5, + "therm_setpoint_start_time": 1706461202, + "therm_setpoint_mode": "schedule" + }, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "modules": [ + { + "_id": "02:00:00:yy:yy:yy", + "battery_percent": 32, + "battery_vp": 4784, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "dashboard_data": { + "Humidity": 78, + "Temperature": 14.2, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "max_temp": 14.8, + "min_temp": 5.1, + "temp_trend": "up", + "time_utc": 1706525814 + }, + "data_type": ["Temperature", "Humidity"], + "firmware": 53, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "last_message": 1706525865, + "last_seen": 1706517393, + "last_setup": 1605564992, + "module_name": "Outdoor Hygrometer", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "reachable": true, + "rf_status": 59, + "type": "NAModule1" + }, + { + "_id": "06:00:00:yy:yy:yy", + "battery_percent": 51, + "battery_vp": 4993, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "dashboard_data": { + "GustAngle": 303, + "GustStrength": 6, + "WindAngle": 270, + "WindStrength": 1, + "date_max_wind_str": 1706714453, + "max_wind_angle": 137, + "max_wind_str": 11, + "time_utc": 1706723822 + }, + "data_type": ["Wind"], + "firmware": 27, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "last_message": 1706723822, + "last_seen": 1706723822, + "last_setup": 1605604559, + "module_name": "Anemometer STARK Home", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "reachable": true, + "rf_status": 70, + "type": "NAModule2" + }, + { + "_id": "05:00:00:yy:yy:yy", + "battery_percent": 52, + "battery_vp": 4944, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "dashboard_data": { + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1, + "time_utc": 1608764760 + }, + "data_type": ["Rain"], + "firmware": 14, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "last_message": 1706021182, + "last_seen": 1705945317, + "last_setup": 1605604312, + "module_name": "STARK House Rain Gauge", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "reachable": false, + "rf_status": 96, + "type": "NAModule3" + }, + { + "_id": "03:00:00:yy:yy:yy", + "battery_percent": 41, + "battery_vp": 4935, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "dashboard_data": { + "CO2": 922, + "Humidity": 60, + "Temperature": 18.1, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "max_temp": 18.2, + "min_temp": 15.7, + "temp_trend": "stable", + "time_utc": 1706517393 + }, + "data_type": ["Temperature", "CO2", "Humidity"], + "firmware": 53, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "last_message": 1706517399, + "last_seen": 1706517393, + "last_setup": 1608046641, + "module_name": "STARK House kitchen hygrometer", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "reachable": true, + "rf_status": 72, + "type": "NAModule4" + } + ] + }, + { + "id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "battery_state": "very_low", + "battery_level": 4788, + "firmware_revision": 53, + "rf_state": "high", + "rf_strength": 59, + "last_seen": 1706517393, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706525814, + "temperature": 14.2, + "humidity": 78, + "name": "Outdoor Hygrometer", + "setup_date": 1605564992, + "room_id": "8765432109", + "_id": "02:00:00:yy:yy:yy", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "firmware": 53, + "last_message": 1706525865, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "home_id": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "battery_state": "medium", + "battery_level": 4993, + "firmware_revision": 27, + "rf_state": "very_low", + "rf_strength": 120, + "last_seen": 1706723822, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706723822, + "wind_strength": 1, + "wind_angle": 276, + "wind_gust": 6, + "wind_gust_angle": 303, + "name": "Anemometer STARK Home", + "setup_date": 1605604559, + "room_id": "8765432109", + "_id": "06:00:00:yy:yy:yy", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "firmware": 27, + "last_message": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "home_id": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "battery_state": "medium", + "battery_level": 4944, + "firmware_revision": 14, + "rf_state": "very_low", + "rf_strength": 96, + "last_seen": 1705945317, + "reachable": false, + "bridge": "70:ee:50:jj:jj:jj", + "rain": 0, + "sum_rain_1": 0, + "name": "STARK House Rain Gauge", + "setup_date": 1605604312, + "room_id": "8765432109", + "_id": "05:00:00:yy:yy:yy", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "battery_percent": 52, + "firmware": 14, + "last_message": 1706021182, + "rf_status": 96, + "battery_vp": 4944, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "battery_state": "medium", + "battery_level": 4933, + "firmware_revision": 53, + "rf_state": "medium", + "rf_strength": 72, + "last_seen": 1706517393, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706517393, + "temperature": 18.2, + "co2": 922, + "humidity": 60, + "name": "STARK House kitchen hygrometer", + "setup_date": 1608046641, + "room_id": "8765432109", + "_id": "03:00:00:yy:yy:yy", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "firmware": 53, + "last_message": 1706517399, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Kitchen", + "type": "home_kitchen", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true, + "home_id": "5e1xxxxxxxxxxxxxxxxx" + }, + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "firmware_revision": 3018000, + "wifi_state": "high", + "wifi_strength": 59, + "sd_status": 4, + "alim_status": 2, + "siren_status": "no_sound", + "monitoring": "on", + "name": "Outdoor Parking Camera", + "setup_date": 1608493953, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Extérieur", + "type": "outdoor", + "module_ids": ["70:ee:00:xx:xx:xx", "02:00:00:xx:xx:xx", "05:00:00:xx:xx:xx", "06:00:00:xx:xx:xx"] + }, + "not_handled": true + } +] diff --git a/server/test/services/tessie/netatmo.loadDevicesDetails.mock.test.json b/server/test/services/tessie/netatmo.loadDevicesDetails.mock.test.json new file mode 100644 index 0000000000..561f587862 --- /dev/null +++ b/server/test/services/tessie/netatmo.loadDevicesDetails.mock.test.json @@ -0,0 +1,740 @@ +[ + { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx", "02:00:00:xx:xx:xx"], + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 100, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "1234567890", + "name": "Garage Test", + "type": "garage", + "module_ids": ["01:00:00:xx:xx:xx"] + }, + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "battery_state": "medium", + "battery_level": 3020, + "firmware_revision": 65, + "rf_strength": 60, + "reachable": true, + "boiler_valve_comfort_boost": false, + "bridge": "70:ee:50:xx:xx:xx", + "boiler_status": true, + "name": "Thermostat Test", + "setup_date": 1580500000, + "room_id": "0987654321", + "_id": "04:00:00:xx:xx:xx", + "firmware": 65, + "last_message": 1600500000, + "rf_status": 60, + "battery_vp": 3020, + "therm_orientation": 2, + "therm_relay_cmd": 80, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 60, + "event_history": { + "boiler_not_responding_events": ["event1", "event2"], + "boiler_responding_events": ["event3", "event4"] + }, + "setpoint_history": [ + { "time": 1600500000, "setpoint_temp": 19 }, + { "time": 1600600000, "setpoint_temp": 20 } + ], + "last_therm_seen": 1600500000, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [ + { "monday": [{ "start": "06:00", "end": "09:00", "temp": 19 }] }, + { "tuesday": [{ "start": "06:00", "end": "09:00", "temp": 19.5 }] } + ], + "measured": { + "time": 1600498668, + "temperature": 19.6, + "setpoint_temp": 19 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "0987654321", + "name": "Maison Test", + "type": "livingroom", + "module_ids": ["04:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 50, + "open_window": false, + "therm_measured_temperature": 19.4, + "therm_setpoint_temperature": 19.5, + "therm_setpoint_start_time": 1600500000, + "therm_setpoint_end_time": 1600800000, + "therm_setpoint_mode": "manual" + }, + "plug": { + "id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "name": "Relais Test", + "setup_date": 1580000000, + "room_id": "1234567890", + "modules_bridged": ["01:00:00:xx:xx:xx"], + "firmware_revision": 123, + "rf_strength": 70, + "wifi_strength": 45, + "_id": "70:ee:50:xx:xx:xx", + "last_setup": 1580000000, + "firmware": 123, + "last_status_store": 1600000000, + "plug_connected_boiler": false, + "wifi_status": 50, + "last_bilan": { "y": 2022, "m": 6 }, + "station_name": "Relais Test", + "place": { + "altitude": 100, + "city": "Ville Test", + "continent": "Europe", + "country": "FR", + "country_name": "France", + "location": [1.234, 2.345], + "street": "Rue de Test", + "timezone": "Europe/Paris", + "trust_location": true + }, + "udp_conn": true, + "last_plug_seen": 1600000000 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "room_id": "9876543210", + "modules_bridged": ["03:00:00:xx:xx:xx", "04:00:00:xx:xx:xx", "05:00:00:xx:xx:xx"], + "_id": "70:ee:50:yy:yy:yy", + "firmware": 124, + "last_status_store": 1601000000, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "9876543210", + "name": "Salon Test", + "type": "livingroom", + "module_ids": ["03:00:00:xx:xx:xx", "04:00:00:xx:xx:xx", "05:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 40, + "open_window": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20, + "therm_setpoint_start_time": 1601000000, + "therm_setpoint_mode": "schedule" + }, + "plug": null, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "09:00:00:xx:xx:xx", + "type": "NRV", + "battery_state": "full", + "battery_level": 2956, + "firmware_revision": 100, + "rf_strength": 80, + "reachable": true, + "bridge": "70:ee:50:yy:yy:yy", + "name": "Valve Test", + "setup_date": 1705912152, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["09:00:00:xx:xx:xx"], + "reachable": true, + "anticipating": false, + "heating_power_request": 100, + "open_window": false, + "therm_measured_temperature": 18.5, + "therm_setpoint_temperature": 19, + "therm_setpoint_start_time": 1706421605, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:yy:yy:yy", + "type": "NAPlug", + "firmware_revision": 124, + "rf_strength": 65, + "wifi_strength": 55, + "name": "Relais Salon Test", + "setup_date": 1581000000, + "room_id": "2812958323", + "modules_bridged": ["09:00:00:xx:xx:xx", "09:00:00:yy:yy:yy", "09:00:00:zz:zz:zz"] + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + }, + { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 38, + "ts": 1706464169, + "temperature": 20.2, + "co2": 500, + "humidity": 50, + "noise": 32, + "pressure": 1022.5, + "absolute_pressure": 1007.8, + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": false, + "therm_measured_temperature": 20.5, + "therm_setpoint_temperature": 20.5, + "therm_setpoint_start_time": 1706461202, + "therm_setpoint_mode": "schedule" + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "battery_state": "very_low", + "battery_level": 4788, + "firmware_revision": 53, + "rf_state": "high", + "rf_strength": 59, + "last_seen": 1706525865, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706525814, + "temperature": 14.2, + "humidity": 78, + "name": "Outdoor Hygrometer", + "setup_date": 1605564992, + "room_id": "8765432109", + "_id": "02:00:00:yy:yy:yy", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "firmware": 53, + "last_message": 1706525865, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Office", + "type": "home_office", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "battery_state": "medium", + "battery_level": 4993, + "firmware_revision": 27, + "rf_state": "very_low", + "rf_strength": 120, + "last_seen": 1706723822, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706723822, + "wind_strength": 1, + "wind_angle": 276, + "wind_gust": 6, + "wind_gust_angle": 303, + "name": "Anemometer STARK Home", + "setup_date": 1605604559, + "room_id": "8765432109", + "_id": "06:00:00:yy:yy:yy", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "firmware": 27, + "last_message": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "battery_state": "medium", + "battery_level": 4944, + "firmware_revision": 14, + "rf_state": "very_low", + "rf_strength": 96, + "last_seen": 1705945317, + "reachable": false, + "bridge": "70:ee:50:jj:jj:jj", + "rain": 0, + "sum_rain_1": 0, + "name": "STARK House Rain Gauge", + "setup_date": 1605604312, + "room_id": "8765432109", + "_id": "05:00:00:yy:yy:yy", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "battery_percent": 52, + "firmware": 14, + "last_message": 1706021182, + "rf_status": 96, + "battery_vp": 4944, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Outdoor", + "type": "outdoor", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "battery_state": "medium", + "battery_level": 4933, + "firmware_revision": 53, + "rf_state": "medium", + "rf_strength": 72, + "last_seen": 1706517393, + "reachable": true, + "bridge": "70:ee:50:jj:jj:jj", + "ts": 1706517393, + "temperature": 18.2, + "co2": 922, + "humidity": 60, + "name": "STARK House kitchen hygrometer", + "setup_date": 1608046641, + "room_id": "8765432109", + "_id": "03:00:00:yy:yy:yy", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "firmware": 53, + "last_message": 1706517399, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Kitchen", + "type": "home_kitchen", + "module_ids": ["70:ee:50:ii:ii:ii", "70:ee:50:jj:jj:jj", "70:ee:50:kk:kk:kk", "09:00:00:ll:ll:ll"], + "reachable": true, + "anticipating": false, + "heating_power_request": 0, + "open_window": false, + "therm_measured_temperature": 19, + "therm_setpoint_temperature": 18.5, + "therm_setpoint_start_time": 1706508008, + "therm_setpoint_mode": "schedule" + }, + "plug": { + "id": "70:ee:50:jj:jj:jj", + "type": "NAMain", + "name": "Weather Station Test", + "setup_date": 1605564990, + "room_id": "8765432109", + "modules_bridged": ["02:00:00:yy:yy:yy", "05:00:00:yy:yy:yy", "06:00:00:yy:yy:yy", "03:00:00:yy:yy:yy"], + "firmware_revision": 202, + "wifi_state": "full", + "wifi_strength": 39, + "ts": 1706517404, + "temperature": 20, + "co2": 497, + "humidity": 51, + "noise": 32, + "pressure": 1025.3, + "absolute_pressure": 1010.6, + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "last_status_store": 1706517404, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 40, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706517404, + "Temperature": 20.1, + "CO2": 492, + "Humidity": 51, + "Noise": 31, + "Pressure": 1025.2, + "AbsolutePressure": 1010.5, + "min_temp": 18.9, + "max_temp": 20.4, + "date_max_temp": 1706512562, + "date_min_temp": 1706507724, + "temp_trend": "stable", + "pressure_trend": "stable" + } + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "id": "70:ee:00:xx:xx:xx", + "type": "NOC", + "firmware_revision": 3018000, + "wifi_state": "high", + "wifi_strength": 59, + "sd_status": 4, + "alim_status": 2, + "siren_status": "no_sound", + "monitoring": "on", + "name": "Outdoor Parking Camera", + "setup_date": 1608493953, + "room_id": "8765432109", + "home": "5e1xxxxxxxxxxxxxxxxx", + "room": { + "id": "8765432109", + "name": "Extérieur", + "type": "outdoor", + "module_ids": ["70:ee:00:xx:xx:xx", "02:00:00:xx:xx:xx", "05:00:00:xx:xx:xx", "06:00:00:xx:xx:xx"] + }, + "not_handled": true + } +] diff --git a/server/test/services/tessie/netatmo.loadThermostatDetails.mock.test.json b/server/test/services/tessie/netatmo.loadThermostatDetails.mock.test.json new file mode 100644 index 0000000000..46f340a7af --- /dev/null +++ b/server/test/services/tessie/netatmo.loadThermostatDetails.mock.test.json @@ -0,0 +1,126 @@ +{ + "plugs": [ + { + "_id": "70:ee:50:xx:xx:xx", + "type": "NAPlug", + "last_setup": 1580000589, + "firmware": 200, + "last_status_store": 1602798842, + "plug_connected_boiler": true, + "wifi_status": 45, + "modules": [ + { + "_id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "firmware": 60, + "last_message": 1602798840, + "rf_status": 60, + "battery_vp": 3000, + "therm_orientation": 1, + "therm_relay_cmd": 100, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 50, + "event_history": {}, + "setpoint_history": [], + "last_therm_seen": 1602798840, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [], + "measured": { + "time": 1602798668, + "temperature": 19, + "setpoint_temp": 19 + }, + "plug": { + "_id": "70:ee:50:xx:xx:xx", + "categoryAPI": "Energy", + "apiNotConfigured": true, + "firmware": 200, + "last_plug_seen": 1602798842, + "last_setup": 1580000589, + "last_status_store": 1602798842, + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "plug_connected_boiler": true, + "station_name": "Relais Test", + "type": "NAPlug", + "udp_conn": true, + "wifi_status": 45 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + } + ], + "station_name": "Relais Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "udp_conn": true, + "last_plug_seen": 1602798842, + "categoryAPI": "Energy", + "apiNotConfigured": true + } + ], + "thermostats": [ + { + "_id": "04:00:00:xx:xx:xx", + "type": "NATherm1", + "firmware": 60, + "last_message": 1602798840, + "rf_status": 60, + "battery_vp": 3000, + "therm_orientation": 1, + "therm_relay_cmd": 100, + "anticipating": false, + "module_name": "Thermostat Test", + "battery_percent": 50, + "event_history": {}, + "setpoint_history": [], + "last_therm_seen": 1602798840, + "setpoint": { + "setpoint_mode": "manual", + "setpoint_temp": 14, + "setpoint_endtime": 1703096987 + }, + "therm_program_list": [], + "measured": { + "time": 1602798668, + "temperature": 19, + "setpoint_temp": 19 + }, + "plug": { + "_id": "70:ee:50:xx:xx:xx", + "categoryAPI": "Energy", + "apiNotConfigured": true, + "firmware": 200, + "last_plug_seen": 1602798842, + "last_setup": 1580000589, + "last_status_store": 1602798842, + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris" + }, + "plug_connected_boiler": true, + "station_name": "Relais Test", + "type": "NAPlug", + "udp_conn": true, + "wifi_status": 45 + }, + "categoryAPI": "Energy", + "apiNotConfigured": true + } + ] +} diff --git a/server/test/services/tessie/netatmo.loadWeatherStationDetails.mock.test.json b/server/test/services/tessie/netatmo.loadWeatherStationDetails.mock.test.json new file mode 100644 index 0000000000..b74c46c95d --- /dev/null +++ b/server/test/services/tessie/netatmo.loadWeatherStationDetails.mock.test.json @@ -0,0 +1,578 @@ +{ + "weatherStations": [ + { + "_id": "70:ee:50:jj:jj:jj", + "station_name": "Maison STARK (Weather Station Test)", + "date_setup": 1605564990, + "last_setup": 1605564990, + "type": "NAMain", + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "firmware": 202, + "wifi_status": 39, + "reachable": true, + "co2_calibrating": false, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "timezone": "Europe/Paris", + "location": [1.234, 2.345] + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "dashboard_data": { + "time_utc": 1706464169, + "Temperature": 20.1, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "AbsolutePressure": 1007.8, + "min_temp": 18.5, + "max_temp": 20.7, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "temp_trend": "stable", + "pressure_trend": "down" + }, + "modules": [ + { + "_id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "reachable": true, + "firmware": 53, + "last_message": 1706525865, + "last_seen": 1706517393, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "reachable": true, + "firmware": 27, + "last_message": 1706723822, + "last_seen": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "battery_percent": 52, + "reachable": false, + "firmware": 14, + "last_message": 1706021182, + "last_seen": 1705945317, + "rf_status": 96, + "battery_vp": 4944, + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "reachable": true, + "firmware": 53, + "last_message": 1706517399, + "last_seen": 1706517393, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + } + ], + "categoryAPI": "Weather", + "apiNotConfigured": true + } + ], + "modulesWeatherStations": [ + { + "_id": "02:00:00:yy:yy:yy", + "type": "NAModule1", + "module_name": "Outdoor Hygrometer", + "last_setup": 1605564992, + "data_type": ["Temperature", "Humidity"], + "battery_percent": 32, + "reachable": true, + "firmware": 53, + "last_message": 1706525865, + "last_seen": 1706517393, + "rf_status": 59, + "battery_vp": 4784, + "dashboard_data": { + "time_utc": 1706525814, + "Temperature": 14.2, + "Humidity": 78, + "min_temp": 5.1, + "max_temp": 14.8, + "date_max_temp": 1706524020, + "date_min_temp": 1706487105, + "temp_trend": "up" + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "06:00:00:yy:yy:yy", + "type": "NAModule2", + "module_name": "Anemometer STARK Home", + "last_setup": 1605604559, + "data_type": ["Wind"], + "battery_percent": 51, + "reachable": true, + "firmware": 27, + "last_message": 1706723822, + "last_seen": 1706723822, + "rf_status": 70, + "battery_vp": 4993, + "dashboard_data": { + "time_utc": 1706723822, + "WindStrength": 1, + "WindAngle": 270, + "GustStrength": 6, + "GustAngle": 303, + "max_wind_str": 11, + "max_wind_angle": 137, + "date_max_wind_str": 1706714453 + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "05:00:00:yy:yy:yy", + "type": "NAModule3", + "module_name": "STARK House Rain Gauge", + "last_setup": 1605604312, + "data_type": ["Rain"], + "battery_percent": 52, + "reachable": false, + "firmware": 14, + "last_message": 1706021182, + "last_seen": 1705945317, + "rf_status": 96, + "battery_vp": 4944, + "dashboard_data": { + "time_utc": 1608764760, + "Rain": 0.101, + "sum_rain_1": 0.505, + "sum_rain_24": 0.1 + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + }, + { + "_id": "03:00:00:yy:yy:yy", + "type": "NAModule4", + "module_name": "STARK House kitchen hygrometer", + "last_setup": 1608046641, + "data_type": ["Temperature", "CO2", "Humidity"], + "battery_percent": 41, + "reachable": true, + "firmware": 53, + "last_message": 1706517399, + "last_seen": 1706517393, + "rf_status": 72, + "battery_vp": 4935, + "dashboard_data": { + "time_utc": 1706517393, + "Temperature": 18.1, + "CO2": 922, + "Humidity": 60, + "min_temp": 15.7, + "max_temp": 18.2, + "date_max_temp": 1706513445, + "date_min_temp": 1706503755, + "temp_trend": "stable" + }, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "plug": { + "_id": "70:ee:50:jj:jj:jj", + "categoryAPI": "Weather", + "apiNotConfigured": true, + "co2_calibrating": false, + "dashboard_data": { + "AbsolutePressure": 1007.8, + "CO2": 506, + "Humidity": 51, + "Noise": 32, + "Pressure": 1022.5, + "Temperature": 20.1, + "date_max_temp": 1706396673, + "date_min_temp": 1706420184, + "max_temp": 20.7, + "min_temp": 18.5, + "pressure_trend": "down", + "temp_trend": "stable", + "time_utc": 1706464169 + }, + "data_type": ["Temperature", "CO2", "Humidity", "Noise", "Pressure"], + "date_setup": 1605564990, + "firmware": 202, + "home_id": "5e1xxxxxxxxxxxxxxxxx", + "home_name": "Maison STARK", + "last_setup": 1605564990, + "last_status_store": 1706464170, + "module_name": "Weather Station Test", + "place": { + "altitude": 50, + "city": "Ville Test", + "country": "FR", + "location": [1.234, 2.345], + "timezone": "Europe/Paris" + }, + "reachable": true, + "station_name": "Maison STARK (Weather Station Test)", + "type": "NAMain", + "wifi_status": 39 + }, + "categoryAPI": "Weather", + "apiNotConfigured": true + } + ] +} diff --git a/server/test/services/tessie/netatmo.mock.test.js b/server/test/services/tessie/netatmo.mock.test.js new file mode 100644 index 0000000000..60d1f43f50 --- /dev/null +++ b/server/test/services/tessie/netatmo.mock.test.js @@ -0,0 +1,64 @@ +const sinon = require('sinon'); + +const devicesMock = require('./netatmo.loadDevices.mock.test.json'); +const deviceDetailsMock = require('./netatmo.loadDevicesDetails.mock.test.json'); +const thermostatsDetailsMock = require('./netatmo.loadThermostatDetails.mock.test.json'); +const discoverDevicesMock = require('./netatmo.discoverDevices.mock.test.json'); +const { STATUS, SCOPES } = require('../../../services/netatmo/lib/utils/netatmo.constants'); + +const NetatmoHandlerMock = { + serviceId: 'serviceId', + configuration: { + clientId: null, + clientSecret: null, + scopes: { + scopeEnergy: `${SCOPES.ENERGY.read} ${SCOPES.ENERGY.write}`, + }, + }, + configured: false, + connected: false, + redirectUri: null, + accessToken: null, + refreshToken: null, + expireInToken: null, + stateGetAccessToken: null, + status: STATUS.NOT_INITIALIZED, + pollRefreshToken: undefined, + pollRefreshValues: undefined, + init: sinon.stub().resolves(), + connect: sinon.stub().resolves(), + disconnect: sinon.stub().resolves(), + retrieveTokens: sinon.stub().resolves({ + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expireIn: 10800, + }), + setTokens: sinon.stub().resolves(), + getStatus: sinon.stub().returns(STATUS.NOT_INITIALIZED), + saveStatus: sinon.stub().resolves(), + getAccessToken: sinon.stub().resolves('mock_access_token'), + getRefreshToken: sinon.stub().resolves('mock_refresh_token'), + refreshingTokens: sinon.stub().resolves({ + accessToken: 'mock_access_token', + refreshToken: 'mock_refresh_token', + expireIn: 10800, + }), + getConfiguration: sinon.stub().resolves({ + clientId: 'mock_client_id', + clientSecret: 'mock_client_secret', + redirectUri: 'mock_redirect_uri', + }), + saveConfiguration: sinon.stub().resolves(), + discoverDevices: sinon.stub().resolves(discoverDevicesMock), + loadDevices: sinon.stub().resolves(devicesMock), + loadDeviceDetails: sinon.stub().resolves(deviceDetailsMock), + loadThermostatDetails: sinon.stub().resolves(thermostatsDetailsMock), + pollRefreshingValuess: sinon.stub().resolves(), + pollRefreshingToken: sinon.stub().resolves(), + setValue: sinon.stub().resolves(), + updateValues: sinon.stub().resolves(), +}; + +module.exports = { + NetatmoHandlerMock, +}; From d147ed21d8c4a5a00a7e6412aef088057c486dc3 Mon Sep 17 00:00:00 2001 From: Terdious Date: Sat, 17 May 2025 19:34:54 +0200 Subject: [PATCH 02/69] Refactor Tessie service: update API integration, remove unused files, and enhance configuration handling --- .../all/tessie/setup-page/SetupTab.jsx | 135 ++++----------- .../all/tessie/setup-page/index.js | 12 +- server/services/index.js | 1 + .../services/tessie/api/tessie.controller.js | 28 +-- server/services/tessie/index.js | 18 +- ...viceMapping.js => tessie.deviceMapping.js} | 0 server/services/tessie/lib/index.js | 91 +++------- .../services/tessie/lib/netatmo.disconnect.js | 24 --- .../tessie/lib/netatmo.getAccessToken.js | 36 ---- .../tessie/lib/netatmo.getRefreshToken.js | 37 ---- server/services/tessie/lib/netatmo.init.js | 20 --- .../tessie/lib/netatmo.loadDevices.js | 161 ------------------ .../lib/netatmo.loadThermostatDetails.js | 62 ------- .../lib/netatmo.loadWeatherStationDetails.js | 64 ------- .../tessie/lib/netatmo.pollRefreshingToken.js | 29 ---- .../tessie/lib/netatmo.refreshingTokens.js | 73 -------- .../tessie/lib/netatmo.retrieveTokens.js | 83 --------- .../tessie/lib/netatmo.saveConfiguration.js | 34 ---- .../services/tessie/lib/netatmo.setTokens.js | 32 ---- .../services/tessie/lib/netatmo.setValue.js | 84 --------- .../tessie/lib/netatmo.updateValues.js | 65 ------- server/services/tessie/lib/tessie.connect.js | 47 +++-- .../tessie/lib/tessie.convertVehicle.js | 91 ++++++++++ .../services/tessie/lib/tessie.disconnect.js | 35 ++++ ...erDevices.js => tessie.discoverDevices.js} | 41 ++--- ...guration.js => tessie.getConfiguration.js} | 9 +- ...tatmo.getStatus.js => tessie.getStatus.js} | 0 server/services/tessie/lib/tessie.init.js | 39 +++++ ...Details.js => tessie.loadDeviceDetails.js} | 0 .../tessie/lib/tessie.loadVehicles.js | 72 ++++++++ ...lues.js => tessie.pollRefreshingValues.js} | 6 +- .../tessie/lib/tessie.saveConfiguration.js | 28 +++ ...tmo.saveStatus.js => tessie.saveStatus.js} | 0 server/services/tessie/lib/tessie.setValue.js | 90 ++++++++++ .../tessie/lib/tessie.updateValues.js | 110 ++++++++++++ .../lib/utils/tessie.buildScopesConfig.js | 26 --- .../tessie/lib/utils/tessie.constants.js | 98 +++++------ 37 files changed, 648 insertions(+), 1133 deletions(-) rename server/services/tessie/lib/device/{netatmo.deviceMapping.js => tessie.deviceMapping.js} (100%) delete mode 100644 server/services/tessie/lib/netatmo.disconnect.js delete mode 100644 server/services/tessie/lib/netatmo.getAccessToken.js delete mode 100644 server/services/tessie/lib/netatmo.getRefreshToken.js delete mode 100644 server/services/tessie/lib/netatmo.init.js delete mode 100644 server/services/tessie/lib/netatmo.loadDevices.js delete mode 100644 server/services/tessie/lib/netatmo.loadThermostatDetails.js delete mode 100644 server/services/tessie/lib/netatmo.loadWeatherStationDetails.js delete mode 100644 server/services/tessie/lib/netatmo.pollRefreshingToken.js delete mode 100644 server/services/tessie/lib/netatmo.refreshingTokens.js delete mode 100644 server/services/tessie/lib/netatmo.retrieveTokens.js delete mode 100644 server/services/tessie/lib/netatmo.saveConfiguration.js delete mode 100644 server/services/tessie/lib/netatmo.setTokens.js delete mode 100644 server/services/tessie/lib/netatmo.setValue.js delete mode 100644 server/services/tessie/lib/netatmo.updateValues.js create mode 100644 server/services/tessie/lib/tessie.convertVehicle.js create mode 100644 server/services/tessie/lib/tessie.disconnect.js rename server/services/tessie/lib/{netatmo.discoverDevices.js => tessie.discoverDevices.js} (52%) rename server/services/tessie/lib/{netatmo.getConfiguration.js => tessie.getConfiguration.js} (55%) rename server/services/tessie/lib/{netatmo.getStatus.js => tessie.getStatus.js} (100%) create mode 100644 server/services/tessie/lib/tessie.init.js rename server/services/tessie/lib/{netatmo.loadDeviceDetails.js => tessie.loadDeviceDetails.js} (100%) create mode 100644 server/services/tessie/lib/tessie.loadVehicles.js rename server/services/tessie/lib/{netatmo.pollRefreshingValues.js => tessie.pollRefreshingValues.js} (93%) create mode 100644 server/services/tessie/lib/tessie.saveConfiguration.js rename server/services/tessie/lib/{netatmo.saveStatus.js => tessie.saveStatus.js} (100%) create mode 100644 server/services/tessie/lib/tessie.setValue.js create mode 100644 server/services/tessie/lib/tessie.updateValues.js delete mode 100644 server/services/tessie/lib/utils/tessie.buildScopesConfig.js diff --git a/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx b/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx index c58d154c67..7bf360e4b6 100644 --- a/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx +++ b/front/src/routes/integration/all/tessie/setup-page/SetupTab.jsx @@ -9,7 +9,8 @@ import { Component } from 'preact'; import { connect } from 'unistore/preact'; class SetupTab extends Component { - showClientSecretTimer = null; + showApiKeyTimer = null; + async disconnectTessie(e) { e.preventDefault(); @@ -28,45 +29,30 @@ class SetupTab extends Component { }); } } - updateClientId = e => { - this.props.updateStateInIndex({ tessieClientId: e.target.value }); - }; - updateClientSecret = e => { - this.props.updateStateInIndex({ tessieClientSecret: e.target.value }); - }; - updateEnergyApi = () => { - if (this.props.tessieEnergyApi === true) { - this.props.updateStateInIndex({ tessieEnergyApi: false }); - } else { - this.props.updateStateInIndex({ tessieEnergyApi: true }); - } - }; - updateWeatherApi = () => { - if (this.props.tessieWeatherApi === true) { - this.props.updateStateInIndex({ tessieWeatherApi: false }); - } else { - this.props.updateStateInIndex({ tessieWeatherApi: true }); - } + + updateApiKey = e => { + this.props.updateStateInIndex({ tessieApiKey: e.target.value }); }; - toggleClientSecret = () => { - const { showClientSecret } = this.state; - if (this.showClientSecretTimer) { - clearTimeout(this.showClientSecretTimer); - this.showClientSecretTimer = null; + toggleApiKey = () => { + const { showApiKey } = this.state; + + if (this.showApiKeyTimer) { + clearTimeout(this.showApiKeyTimer); + this.showApiKeyTimer = null; } - this.setState({ showClientSecret: !showClientSecret }); + this.setState({ showApiKey: !showApiKey }); - if (!showClientSecret) { - this.showClientSecretTimer = setTimeout(() => this.setState({ showClientSecret: false }), 5000); + if (!showApiKey) { + this.showApiKeyTimer = setTimeout(() => this.setState({ showApiKey: false }), 5000); } }; componentWillUnmount() { - if (this.showClientSecretTimer) { - clearTimeout(this.showClientSecretTimer); - this.showClientSecretTimer = null; + if (this.showApiKeyTimer) { + clearTimeout(this.showApiKeyTimer); + this.showApiKeyTimer = null; } } @@ -90,101 +76,38 @@ class SetupTab extends Component {

- - -

-

- -

-

- - -

-

- - +

- - - } - value={props.tessieClientId} - className="form-control" - autocomplete="off" - onInput={this.updateClientId} - /> - -
- -
-
-
- -
- -
- -
-