├── docs └── sampleOutput.png ├── packageManifest.json ├── README.md ├── LICENSE └── mideaAC_localController /docs/sampleOutput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomwpublic/hubitat_midea/HEAD/docs/sampleOutput.png -------------------------------------------------------------------------------- /packageManifest.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "packageName": "hubitat_midea", 4 | "author": "tomw", 5 | "version": "0.9.5", 6 | "minimumHEVersion": "2.1.9", 7 | "dateReleased": "2021-09-15", 8 | "drivers": [ 9 | { 10 | "id": "ff338f24-c818-4159-a724-16288fd99add", 11 | "name": "Midea AC local controller", 12 | "namespace": "tomw", 13 | "location": "https://raw.githubusercontent.com/tomwpublic/hubitat_midea/main/mideaAC_localController", 14 | "required": true 15 | } 16 | ], 17 | "licenseFile": "https://raw.githubusercontent.com/tomwpublic/hubitat_midea/main/LICENSE", 18 | "releaseNotes": "0.9.5 - tomw - Support for Turbo mode (command and attribute).\n0.9.4 - jcox10 - Bugfixes for \"off\" thermostat mode.\n0.9.3 - tcurtin + tomw - Add FanControl capability. Command dropdown lists on device page now match actual supported settings.\n0.9.2 - tomw - Fix for data format of attributes: supportedThermostatFanModes and supportedThermostatModes (press Configure on device page to finalize update)\n0.9.1 - tomw - Add 'off' as a thermostatMode. Some improvements to Fahrenheit setpoints (still in progress).\n0.9.0 - tomw - Initial release.", 19 | "documentationLink": "https://github.com/tomwpublic/hubitat_midea/blob/main/README.md", 20 | "communityLink": "https://community.hubitat.com/t/midea-mini-split-wifi-support/78838/11" 21 | } 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hubitat_midea 2 | 3 | This provides Thermostat driver capabilities for Hubitat with Midea branded and Midea-supplied air conditioning units. Many common brands are supplied by Midea and are compatible with this integration. 4 | 5 | This component is substantially based on and is mostly a direct port of this excellent work: https://github.com/mac-zhou/midea-msmart 6 | 7 | Special thanks to ghgeiger and etienne from the Hubitat forums for their testing and troubleshooting help. 8 | 9 | # Manual Installation instructions: 10 | * In the *Drivers Code* section of Hubitat, add the mideaAC_localController driver. 11 | 12 | # Configuration instructions: 13 | * Follow the instructions on this page to install and execute the `midea-discover` utility: https://github.com/mac-zhou/midea-msmart/blob/master/README.md 14 | * Note that this requires a separate computer running Python3 and can not be run directly from Hubitat. This is a one-time configuration step. 15 | * A reboot of the machine may be necessary after the `pip3` installation step before you will be able to run the `midea-discover` utility. 16 | * Sample Output: 17 | * ![Alt text](docs/sampleOutput.png?raw=true "midea-discover output") 18 | 19 | * In the *Devices* section of Hubitat, add a *New Virtual Device* of type Midea AC local controller. 20 | * On the configuration page for the newly created *Device*, enter the token, key, and device id from `midea-discover`, along with the IP address and port of your Midea AC unit. 21 | * Indicate whether you plan to use Celsius or Fahrenheit for your temperature setpoints and temperature reporting. 22 | 23 | 24 | # Usage instructions: 25 | 26 | * Utilize the various commands to control your AC unit. 27 | 28 | # Disclaimer 29 | 30 | I have no affiliation with any of the companies mentioned in this readme or in the code. 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /mideaAC_localController: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Copyright 2021 - tomw 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | ------------------------------------------- 18 | 19 | Change history: 20 | 21 | 0.9.0 - tomw - Initial release. 22 | 0.9.1 - tomw - Add 'off' as a thermostatMode. Some improvements to Fahrenheit setpoints (still in progress). 23 | 0.9.2 - tomw - Fix for data format of attributes: supportedThermostatFanModes and supportedThermostatModes 24 | 0.9.3 - 25 | tcurtin - Add FanControl capability. 26 | tomw - Command dropdown lists on device page now match actual supported settings. 27 | 0.9.4 - jcox10 - Bugfixes for "off" thermostat mode. 28 | 0.9.5 - tomw - Support for Turbo mode (command and attribute). 29 | 30 | */ 31 | 32 | metadata 33 | { 34 | definition(name: "Midea AC local controller", namespace: "tomw", author: "tomw", importUrl: "") 35 | { 36 | attribute "outdoorTemperature", "number" 37 | 38 | capability "Configuration" 39 | capability "FanControl" 40 | capability "Initialize" 41 | capability "Refresh" 42 | capability "Thermostat" 43 | 44 | command "dry" 45 | command "fan_only" 46 | 47 | command "setSpeed", [[name: "Fan speed*", type: "ENUM", constraints: supportedFanSpeedsList()]] 48 | command "setThermostatFanMode", [[name: "Fan mode*", type: "ENUM", constraints: supportedThermoFanModesList()]] 49 | command "setThermostatMode", [[name: "Thermostat mode*", type: "ENUM", constraints: supportedThermoModesList()]] 50 | 51 | command "controlTurboMode", [[name: "Turbo mode*", type: "ENUM", constraints: ["on", "off"]]] 52 | attribute "turboMode", "enum", ["on", "off"] 53 | } 54 | } 55 | 56 | preferences 57 | { 58 | section 59 | { 60 | input name: "ipAddress", type: "text", title: "IP address", required: true 61 | input name: "port", type: "number", title: "port", required: true, defaultValue: 6444 62 | input name: "id", type: "number", title: "id", required: true 63 | input name: "token", type: "text", title: "token", required: true 64 | input name: "key", type: "text", title: "key", required: true 65 | } 66 | section 67 | { 68 | input name: "degC", type: "bool", title: "Use degrees C? (Off for Degrees F)", defaultValue: true 69 | input name: "refreshInterval", type: "number", title: "Polling refresh interval in seconds", defaultValue: 60 70 | input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true 71 | } 72 | } 73 | 74 | def updated() 75 | { 76 | configure() 77 | } 78 | 79 | def configure() 80 | { 81 | def jsonOut = new groovy.json.JsonOutput() 82 | 83 | def fanModes = jsonOut.toJson(supportedThermoFanModesList()) 84 | sendEvent(name: "supportedThermostatFanModes", value: fanModes) 85 | 86 | def fanSpeeds = jsonOut.toJson(supportedFanSpeedsList()) 87 | sendEvent(name: "supportedFanSpeeds", value: fanSpeeds) 88 | 89 | def thermoModes = jsonOut.toJson(supportedThermoModesList()) 90 | sendEvent(name: "supportedThermostatModes", value: thermoModes) 91 | 92 | initialize() 93 | } 94 | 95 | def supportedThermoFanModesList() 96 | { 97 | return ["auto", "full", "high", "medium", "low", "silent"] 98 | } 99 | 100 | def supportedFanSpeedsList() 101 | { 102 | return ["auto", "full", "high", "medium", "low", "silent"] 103 | } 104 | 105 | def supportedThermoModesList() 106 | { 107 | return ["auto", "cool", "dry", "fan_only", "heat", "off"] 108 | } 109 | 110 | def initialize() 111 | { 112 | refresh() 113 | } 114 | 115 | def refresh() 116 | { 117 | if(getVolatileState("sessionActive")) 118 | { 119 | logDebug("refresh(): socket access failed. another session is already in progress.") 120 | return 121 | } 122 | 123 | try 124 | { 125 | unschedule(refresh) 126 | 127 | setVolatileState("sessionActive", true) 128 | 129 | if(!openSocket()) {throw new Exception("failed to open socket")} 130 | 131 | syncWait([waitSetter: "auth", timeoutSec: 4]) 132 | runInMillis(200, '_authenticate') 133 | doWait() 134 | 135 | syncWait([waitSetter: "refresh", timeoutSec: 4]) 136 | runInMillis(200, '_refresh') 137 | doWait() 138 | } 139 | catch (Exception e) 140 | { 141 | log.debug "error: refresh(): ${e.message}" 142 | } 143 | finally 144 | { 145 | closeSocket() 146 | setVolatileState("sessionActive", false) 147 | 148 | runIn(refreshInterval ?: 60, refresh) 149 | } 150 | } 151 | 152 | def apply(cmdParams) 153 | { 154 | if(getVolatileState("sessionActive")) 155 | { 156 | logDebug("apply(): socket access failed. another session is already in progress.") 157 | return 158 | } 159 | 160 | try 161 | { 162 | setVolatileState("sessionActive", true) 163 | 164 | if(!openSocket()) {throw new Exception("failed to open socket")} 165 | 166 | syncWait([waitSetter: "auth", timeoutSec: 4]) 167 | runInMillis(200, '_authenticate') 168 | doWait() 169 | 170 | syncWait([waitSetter: "apply", timeoutSec: 4]) 171 | runInMillis(200, '_apply', [data: cmdParams]) 172 | doWait() 173 | } 174 | catch (Exception e) 175 | { 176 | log.debug "error: apply(): ${e.message}" 177 | } 178 | finally 179 | { 180 | closeSocket() 181 | setVolatileState("sessionActive", false) 182 | } 183 | } 184 | 185 | def logDebug(msg) 186 | { 187 | if (logEnable) 188 | { 189 | log.debug(msg) 190 | } 191 | } 192 | 193 | def logHexBytes(data) 194 | { 195 | String res = "" 196 | 197 | data.each 198 | { 199 | res += String.format("%02X ", it & 0xFF) 200 | } 201 | 202 | logDebug("bytes: ${res}") 203 | logDebug("string: ${hubitat.helper.HexUtils.byteArrayToHexString(data as byte[])}") 204 | } 205 | 206 | def socketStatus(String message) 207 | { 208 | logDebug("socketStatus: ${message}") 209 | } 210 | 211 | def parse(String message) 212 | { 213 | logDebug("parse: ${message}") 214 | def rdB = hubitat.helper.HexUtils.hexStringToByteArray(message) 215 | //logDebug("parse: ${rdB}") 216 | 217 | if(rdB.size() < 13) 218 | { 219 | logDebug("ignoring short response") 220 | 221 | // clear this syncWait, likely set by connectSocket 222 | // note: the Midea units appear to return one 0x00 byte on connect. 223 | // if this behavior changes, we will need a different signal to clear the wait. 224 | clearWait([waitSetter: "open"]) 225 | 226 | return 227 | } 228 | 229 | if(rdB.size() == 13) 230 | { 231 | // just catch 'ERROR' 232 | rdB = subBytes(rdB, 8, 5) 233 | //logDebug("rdB = ${new String(rdB)}") 234 | return 235 | } 236 | 237 | switch(getMsgType(rdB)) 238 | { 239 | case MSGTYPE_HANDSHAKE_RESPONSE: 240 | def tcp_key = tcp_key(rdB, key) 241 | if(tcp_key) 242 | { 243 | setTcpKey(tcp_key) 244 | 245 | // clear this syncWait, likely set by _authenticate 246 | clearWait([waitSetter: "auth"]) 247 | } 248 | break 249 | 250 | case MSGTYPE_ENCRYPTED_RESPONSE: 251 | //logDebug("parse enc resp = ${rdB}") 252 | //logDebug("decode_8370 = ${decode_8370(rdB)}") 253 | 254 | def dec_8370 = decode_8370(rdB) 255 | if(([] != dec_8370) && (dec_8370.size() > (40 + 16))) 256 | { 257 | def dec_resp = aes_ecb(subBytes(dec_8370, 40, dec_8370.size() - (40 + 16)), "dec") 258 | _process_response(dec_resp) 259 | } 260 | break 261 | } 262 | } 263 | 264 | def openSocket() 265 | { 266 | logDebug("opening socket") 267 | interfaces.rawSocket.connect(ipAddress, port.toInteger(), byteInterface: true) 268 | 269 | // workaround: wait a little while to ensure the socket opened 270 | pauseExecution(500) 271 | 272 | return true 273 | } 274 | 275 | def closeSocket() 276 | { 277 | try 278 | { 279 | logDebug("closing socket") 280 | interfaces.rawSocket.close() 281 | 282 | // workaround: wait a little while to ensure the socket closed 283 | pauseExecution(500) 284 | } 285 | catch (Exception e) 286 | { 287 | // swallow errors 288 | } 289 | 290 | return true 291 | } 292 | 293 | def _writeBytes(byte[] bytes) 294 | { 295 | def wrStr = hubitat.helper.HexUtils.byteArrayToHexString(bytes) 296 | logDebug("writeBytes: ${wrStr}") 297 | 298 | interfaces.rawSocket.sendMessage(wrStr) 299 | } 300 | 301 | def updateSettingParams(paramsIn) 302 | { 303 | // do a refresh first... 304 | refresh() 305 | 306 | // ...then tweak the settings that we need 307 | def paramsOut = getVolatileState("state") 308 | 309 | paramsIn.each() 310 | { 311 | paramsOut[it.key] = it.value 312 | } 313 | 314 | // always set these -- they won't be in the response but need to be in the command 315 | paramsOut['fahrenheit_enabled'] = !(degC) 316 | paramsOut['screen_display'] = true 317 | paramsOut['feedback_enabled'] = true 318 | 319 | return paramsOut 320 | } 321 | 322 | def auto() 323 | { 324 | setThermostatMode("auto") 325 | } 326 | 327 | def cool() 328 | { 329 | setThermostatMode("cool") 330 | } 331 | 332 | def dry() 333 | { 334 | setThermostatMode("dry") 335 | } 336 | 337 | def emergencyHeat() 338 | { 339 | log.warn "emergencyHeat() not supported by this device" 340 | } 341 | 342 | def controlTurboMode(turboMode) 343 | { 344 | def turboOn = ["on", "true", true].contains(turboMode) 345 | 346 | def params = updateSettingParams([turbo_mode: turboOn]) 347 | if(params) 348 | { 349 | apply(params) 350 | } 351 | } 352 | 353 | def fanAuto() 354 | { 355 | def params = updateSettingParams([fan_speed: translateFanMode("auto", "string")]) 356 | if(params) 357 | { 358 | apply(params) 359 | } 360 | } 361 | 362 | def setSpeed(fanSpeed) 363 | { 364 | if(supportedFanSpeedsList().contains(fanSpeed)) 365 | { 366 | def params = updateSettingParams([fan_speed: translateFanMode(fanSpeed, "string")]) 367 | if(params) 368 | { 369 | apply(params) 370 | } 371 | } 372 | else 373 | { 374 | log.warn("setSpeed(): unsupported mode ${fanSpeed}") 375 | } 376 | } 377 | 378 | def cycleSpeed() 379 | { 380 | log.warn "cycleSpeed() not supported by this device" 381 | } 382 | 383 | def fanCirculate() 384 | { 385 | def params = updateSettingParams([fan_speed: translateFanMode("medium", "string")]) 386 | if(params) 387 | { 388 | apply(params) 389 | } 390 | } 391 | 392 | def fanOn() 393 | { 394 | def params = updateSettingParams([fan_speed: translateFanMode("high", "string")]) 395 | if(params) 396 | { 397 | apply(params) 398 | } 399 | } 400 | 401 | def fan_only() 402 | { 403 | setThermostatMode("fan_only") 404 | } 405 | 406 | def heat() 407 | { 408 | setThermostatMode("heat") 409 | } 410 | 411 | def off() 412 | { 413 | def params = updateSettingParams([power_state: false]) 414 | if(params) 415 | { 416 | apply(params) 417 | } 418 | } 419 | 420 | def setCoolingSetpoint(temperature) 421 | { 422 | logDebug("setCoolingSetpoint(${temperature})") 423 | temperature = ensureCelsius(temperature) 424 | 425 | def params = updateSettingParams([target_temperature: temperature]) 426 | if(params) 427 | { 428 | apply(params) 429 | } 430 | } 431 | 432 | def setHeatingSetpoint(temperature) 433 | { 434 | logDebug("setHeatingSetpoint(${temperature})") 435 | setCoolingSetpoint(temperature) 436 | } 437 | 438 | def setSchedule(JSON_OBJECT) 439 | { 440 | log.warn "setSchedule() not supported by this device" 441 | } 442 | 443 | def setThermostatFanMode(fanmode) 444 | { 445 | if(supportedThermoFanModesList().contains(fanmode)) 446 | { 447 | def params = updateSettingParams([fan_speed: translateFanMode(fanmode, "string")]) 448 | if(params) 449 | { 450 | apply(params) 451 | } 452 | } 453 | else 454 | { 455 | log.warn("setThermostatFanMode(): unsupported mode ${fanmode}") 456 | } 457 | } 458 | 459 | def setThermostatMode(thermostatmode) 460 | { 461 | if("off" == thermostatmode) 462 | { 463 | off() 464 | 465 | return 466 | } 467 | 468 | if(supportedThermoModesList().contains(thermostatmode)) 469 | { 470 | def params = updateSettingParams([operational_mode: translateMode(thermostatmode, "string")]) 471 | 472 | if(params) 473 | { 474 | // all of these modes indicate power_state should be 'on' 475 | params.power_state = true 476 | 477 | apply(params) 478 | } 479 | 480 | return 481 | } 482 | 483 | log.warn("setThermostatMode(): unsupported mode") 484 | } 485 | 486 | def _authenticate() 487 | { 488 | def tokenBytes = hubitat.helper.HexUtils.hexStringToByteArray(token) 489 | 490 | logDebug("_authenticate packet follows:") 491 | 492 | request = encode_8370(tokenBytes, MSGTYPE_HANDSHAKE_REQUEST) 493 | _writeBytes(request) 494 | } 495 | 496 | def _refresh() 497 | { 498 | def packet = packet_builder(id.toLong(), request_status_command()) 499 | logDebug("_refresh packet follows:") 500 | 501 | appliance_transparent_send_8370(packet, MSGTYPE_ENCRYPTED_REQUEST) 502 | } 503 | 504 | def _apply(cmdParams) 505 | { 506 | def packet = packet_builder(id.toLong(), set_command(cmdParams)) 507 | logDebug("_apply packet follows:") 508 | 509 | appliance_transparent_send_8370(packet, MSGTYPE_ENCRYPTED_REQUEST) 510 | } 511 | 512 | def _process_response(data) 513 | { 514 | def resp 515 | 516 | if(data == 'ERROR'.getBytes()) 517 | { 518 | logDebug("response ERROR") 519 | return null 520 | } 521 | 522 | if(0xC0 == i8Tou8(data[0xA])) 523 | { 524 | resp = appliance_response(data) 525 | logDebug("appliance_response: ${resp}") 526 | 527 | if(resp) 528 | { 529 | _updateAttributes(resp) 530 | setVolatileState("state", resp) 531 | } 532 | 533 | // clear this syncWait, possibly set by refresh() 534 | clearWait([waitSetter: "refresh"]) 535 | // clear this syncWait, possibly set by _apply() 536 | clearWait([waitSetter: "apply"]) 537 | } 538 | 539 | return resp 540 | } 541 | 542 | def correctTemp(input, inputScale) 543 | { 544 | switch(inputScale) 545 | { 546 | case "F": 547 | return (degC ? fahrenheitToCelsius(new BigDecimal(input)) : (Math.round(new BigDecimal(input) * 2) / 2).setScale(1, BigDecimal.ROUND_HALF_UP)) 548 | 549 | case "C": 550 | return (degC ? new BigDecimal(input) : (Math.round(celsiusToFahrenheit(new BigDecimal(input)) * 2) / 2).setScale(1, BigDecimal.ROUND_HALF_UP)) 551 | 552 | default: 553 | return input 554 | } 555 | } 556 | 557 | def ensureCelsius(input) 558 | { 559 | return (degC) ? new BigDecimal(input) : fahrenheitToCelsius(new BigDecimal(input)) 560 | } 561 | 562 | def _updateAttributes(resp) 563 | { 564 | if(!resp) 565 | { 566 | return 567 | } 568 | 569 | def events = [[:]] 570 | 571 | events += [name: "coolingSetpoint", value: correctTemp(resp.target_temperature, "C"), unit: getUnit()] 572 | events += [name: "heatingSetpoint", value: correctTemp(resp.target_temperature, "C"), unit: getUnit()] 573 | events += [name: "temperature", value: correctTemp(resp.indoor_temperature, "C"), unit: getUnit()] 574 | events += [name: "thermostatFanMode", value: translateFanMode(resp.fan_speed?.toInteger(), "integer")] 575 | events += [name: "thermostatMode", value: translateMode(resp.operational_mode?.toInteger(), "integer")] 576 | events += [name: "thermostatOperatingState", value: resp.power_state ? "on" : "off" ] 577 | events += [name: "thermostatSetpoint", value: correctTemp(resp.target_temperature, "C"), unit: getUnit()] 578 | events += [name: "outdoorTemperature", value: correctTemp(resp.outdoor_temperature, "C"), unit: getUnit()] 579 | events += [name: "turboMode", value: resp.turbo_mode ? "on" : "off"] 580 | 581 | events.each 582 | { 583 | sendEvent(it) 584 | } 585 | } 586 | 587 | def getUnit() 588 | { 589 | return (degC) ? "°C" : "°F" 590 | } 591 | 592 | def translateMode(value, inputType) 593 | { 594 | switch(inputType) 595 | { 596 | case "integer": 597 | switch(value.toInteger()) 598 | { 599 | case 1: return "auto" 600 | case 2: return "cool" 601 | case 3: return "dry" 602 | case 4: return "heat" 603 | case 5: return "fan_only" 604 | default: return "unknown" 605 | } 606 | 607 | case "string": 608 | switch(value) 609 | { 610 | case "auto": return 1 611 | case "cool": return 2 612 | case "dry": return 3 613 | case "heat": return 4 614 | case "fan_only": return 5 615 | default: return 616 | } 617 | 618 | default: 619 | return 620 | } 621 | } 622 | 623 | def translateFanMode(value, inputType) 624 | { 625 | switch(inputType) 626 | { 627 | case "integer": 628 | switch(value.toInteger()) 629 | { 630 | case 102: return "auto" 631 | case 100: return "full" 632 | case 80: return "high" 633 | case 60: return "medium" 634 | case 40: return "low" 635 | case 20: return "silent" 636 | default: return "unknown" 637 | } 638 | 639 | case "string": 640 | switch(value) 641 | { 642 | case "auto": return 102 643 | case "full": return 100 644 | case "high": return 80 645 | case "medium": return 60 646 | case "low": return 40 647 | case "silent": return 20 648 | default: return 649 | } 650 | 651 | default: 652 | return 653 | } 654 | } 655 | 656 | def setTcpKey(key) 657 | { 658 | setVolatileState('tcp_key', key) 659 | } 660 | 661 | def getTcpKey() 662 | { 663 | getVolatileState('tcp_key') 664 | } 665 | 666 | import groovy.transform.Field 667 | @Field MSGTYPE_HANDSHAKE_REQUEST = 0x0 668 | @Field MSGTYPE_HANDSHAKE_RESPONSE = 0x1 669 | @Field MSGTYPE_ENCRYPTED_RESPONSE = 0x3 670 | @Field MSGTYPE_ENCRYPTED_REQUEST = 0x6 671 | @Field MSGTYPE_TRANSPARENT = 0xf 672 | 673 | import java.security.MessageDigest 674 | 675 | def getMsgType(header) 676 | { 677 | if(i8Tou8(bytesToInt([header[0]], "big")) != 0x83 || i8Tou8(bytesToInt([header[1]], "big")) != 0x70) 678 | { 679 | logDebug("not an 8370 message") 680 | return -1 681 | } 682 | 683 | if(header.size() < 6) 684 | { 685 | logDebug("header too short") 686 | return -1 687 | } 688 | 689 | def msgtype = i8Tou8(bytesToInt([header[5]], "big")) & 0xf 690 | } 691 | 692 | def encode_8370(data, msgtype) 693 | { 694 | byte[] header = [i8Tou8(0x83), i8Tou8(0x70)] 695 | 696 | def size = data.size() 697 | def padding = 0 698 | 699 | if(msgtype in [MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST]) 700 | { 701 | if((size + 2) % 16 != 0) 702 | { 703 | padding = 16 - ((size + 2) & 0xf) 704 | size += (padding + 32) 705 | 706 | byte[] pBytes = new byte[padding] 707 | new Random().nextBytes(pBytes) 708 | data = appendByteArr(data, pBytes) 709 | } 710 | } 711 | 712 | header = appendByteArr(header, intToBytes(size, 2, "big")) 713 | header = appendByteArr(header, [i8Tou8(0x20), i8Tou8(padding << 4 | msgtype)]) 714 | 715 | def request_count = state.request_count ?: 0 716 | data = appendByteArr(intToBytes(request_count, 2, "big"), data) 717 | state.request_count = request_count + 1 718 | 719 | if(msgtype in [MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST]) 720 | { 721 | MessageDigest digest = MessageDigest.getInstance("SHA-256") 722 | def sign = digest.digest(appendByteArr(header, data)) 723 | 724 | // if tcp_key isn't available, just use a random key 725 | data = aes_cbc(data, "enc", getTcpKey() ?: '4D67055D53288313335D65FB2CBA3DDB04001F8AF6880CBDB5BC45DA67EC8A35') 726 | 727 | data = appendByteArr(data, sign) 728 | } 729 | 730 | return appendByteArr(header, data) 731 | } 732 | 733 | def decode_8370(data) 734 | { 735 | def header = subBytes(data, 0, 6) 736 | data = subBytes(data, 6, data.size() - 6) 737 | 738 | if(i8Tou8(bytesToInt([header[0]], "big")) != 0x83 || i8Tou8(bytesToInt([header[1]], "big")) != 0x70) 739 | { 740 | logDebug("not an 8370 message") 741 | return [] 742 | } 743 | 744 | if(i8Tou8(bytesToInt([header[4]], "big")) != 0x20) 745 | { 746 | logDebug("missing byte 4") 747 | return [] 748 | } 749 | 750 | def padding = i8Tou8(bytesToInt([header[5]], "big")) >> 4 751 | def msgtype = getMsgType(header) 752 | 753 | def size = i16Tou16(bytesToInt(subBytes(header, 2, 2), "big")) 754 | 755 | if(data.size() < (size + 2)) 756 | { 757 | // request_count was not in size, so count 2 extra bytes here 758 | logDebug("data.size() = ${data.size()}, size + 2 = ${size + 2}") 759 | return [] 760 | } 761 | 762 | if(msgtype in [MSGTYPE_ENCRYPTED_RESPONSE, MSGTYPE_ENCRYPTED_REQUEST]) 763 | { 764 | sign = subBytes(data, data.size() - 32, 32) 765 | data = subBytes(data, 0, data.size() - 32) 766 | 767 | // if tcp_key isn't available, just use a random key 768 | data = aes_cbc(data, "dec", getTcpKey() ?: '4D67055D53288313335D65FB2CBA3DDB04001F8AF6880CBDB5BC45DA67EC8A35') 769 | 770 | MessageDigest digest = MessageDigest.getInstance("SHA-256") 771 | def check = digest.digest(appendByteArr(header, data)) 772 | 773 | if(check != sign) 774 | { 775 | logDebug("sign does not match") 776 | return [] 777 | } 778 | 779 | if(padding) 780 | { 781 | data = subBytes(data, 0, data.size() - padding) 782 | } 783 | } 784 | 785 | state.response_count = i16Tou16(bytesToInt(subBytes(data, 0, 2), "big")) 786 | data = subBytes(data, 2, data.size() - 2) 787 | 788 | return data 789 | } 790 | 791 | import javax.crypto.spec.SecretKeySpec 792 | import javax.crypto.spec.IvParameterSpec 793 | import javax.crypto.Cipher 794 | 795 | @Field signKey = 'xhdiwjnchekd4d512chdjx5d8e4c394D2D7S'.getBytes() 796 | 797 | def aes_cbc(data, op = "enc", key = key) 798 | { 799 | // thanks: https://community.hubitat.com/t/groovy-aes-encryption-driver/31556 800 | 801 | def cipher = Cipher.getInstance("AES/CBC/NoPadding", "SunJCE") 802 | 803 | // note: we already have the key from midea-smart 804 | byte[] keyBytes = hubitat.helper.HexUtils.hexStringToByteArray(key) 805 | SecretKeySpec aKey = new SecretKeySpec(keyBytes, 0, keyBytes.length, "AES") 806 | 807 | //self.iv = b'\0' * 16 808 | def IVKey = '\0' * 16 809 | IvParameterSpec iv = new IvParameterSpec(IVKey.getBytes("UTF-8")) 810 | 811 | cipher.init(op == "enc" ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, aKey, iv) 812 | 813 | return cipher.doFinal(data) 814 | } 815 | 816 | def aes_ecb(data, op = "enc") 817 | { 818 | def encKey = md5(signKey) 819 | SecretKeySpec aKey = new SecretKeySpec(encKey, 0, encKey.length, "AES") 820 | 821 | def cipher = Cipher.getInstance("AES/ECB/PKCS5Padding", "SunJCE") 822 | cipher.init(op == "enc" ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, aKey) 823 | 824 | return cipher.doFinal(data) 825 | } 826 | 827 | def md5(data) 828 | { 829 | MessageDigest digest = MessageDigest.getInstance("MD5") 830 | digest.update(data) 831 | byte[] md5sum = digest.digest() 832 | 833 | return md5sum 834 | } 835 | 836 | def tcp_key(response, key = key) 837 | { 838 | if(subBytes(response, 8, 5) == 'ERROR'.getBytes()) 839 | { 840 | logDebug("authentication failed") 841 | return null 842 | } 843 | 844 | if(response.size() != 72) 845 | { 846 | logDebug("unexpected data length") 847 | return null 848 | } 849 | 850 | response = subBytes(response, 8, 64) 851 | 852 | def payload = subBytes(response, 0, 32) 853 | def sign = subBytes(response, 32, 32) 854 | 855 | def plain = aes_cbc(payload, "dec", key) 856 | 857 | MessageDigest digest = MessageDigest.getInstance("SHA-256") 858 | if(sign != digest.digest(plain)) 859 | { 860 | logDebug("sign does not match") 861 | return null 862 | } 863 | 864 | byte[] keyBytes = hubitat.helper.HexUtils.hexStringToByteArray(key) 865 | 866 | if(plain.size() != keyBytes.size()) 867 | { 868 | logDebug("size mismatch") 869 | return null 870 | } 871 | 872 | (0..(plain.size() - 1)).each 873 | { 874 | // tcp_key = strxor(plain, key) 875 | plain[it] = plain[it] ^ keyBytes[it] 876 | } 877 | 878 | def tcp_key = hubitat.helper.HexUtils.byteArrayToHexString(plain) 879 | 880 | state.request_count = 0 881 | state.response_count = 0 882 | 883 | return tcp_key 884 | } 885 | 886 | def appliance_response(data) 887 | { 888 | // The response data from the appliance includes a packet header which we don't want 889 | data = subBytes(data, 0xA, data.size() - 0xA) 890 | 891 | def resp = [:] 892 | 893 | resp += [power_state: (data[0x01] & 0x1) > 0] 894 | resp += [imode_resume: (data[0x01] & 0x4) > 0] 895 | resp += [timer_mode: (data[0x01] & 0x10) > 0] 896 | resp += [appliance_error: (data[0x01] & 0x80) > 0] 897 | resp += [target_temperature: (data[0x02] & 0xf) + 16.0 + (((data[0x02] & 0x10) > 0) ? 0.5 : 0.0)] 898 | resp += [operational_mode: (data[0x02] & 0xe0) >> 5] 899 | resp += [fan_speed: data[0x03] & 0x7f] 900 | 901 | 902 | def on_timer_value = data[0x04] 903 | def on_timer_minutes = data[0x06] 904 | resp += [on_timer_status: ((on_timer_value & 0x80) >> 7) > 0] 905 | resp += [on_timer_hour: (on_timer_value & 0x7c) >> 2] 906 | resp += [on_timer_minutes: (on_timer_value & 0x3) | ((on_timer_minutes & 0xf0) >> 4)] 907 | 908 | 909 | off_timer_value = data[0x05] 910 | off_timer_minutes = data[0x06] 911 | resp += [off_timer_status: ((off_timer_value & 0x80) >> 7) > 0] 912 | resp += [off_timer_hour: (off_timer_value & 0x7c) >> 2] 913 | resp += [off_timer_minutes: (off_timer_value & 0x3) | (off_timer_minutes & 0xf)] 914 | 915 | 916 | resp += [swing_mode: data[0x07] & 0x0f] 917 | resp += [cozy_sleep: data[0x08] & 0x03] 918 | resp += [save: (data[0x08] & 0x08) > 0] 919 | resp += [low_frequency_fan: (data[0x08] & 0x10) > 0] 920 | resp += [super_fan: (data[0x08] & 0x20) > 0] 921 | 922 | resp += [feel_own: (data[0x08] & 0x80) > 0] 923 | resp += [child_sleep_mode: (data[0x09] & 0x01) > 0] 924 | resp += [exchange_air: (data[0x09] & 0x02) > 0] 925 | resp += [dry_clean: (data[0x09] & 0x04) > 0] 926 | resp += [aux_heat: (data[0x09] & 0x08) > 0] 927 | resp += [eco_mode: (data[0x09] & 0x10) > 0] 928 | resp += [clean_up: (data[0x09] & 0x20) > 0] 929 | resp += [temp_unit: (data[0x09] & 0x80) > 0] 930 | resp += [sleep_function: (data[0x0a] & 0x01) > 0] 931 | resp += [turbo_mode: ((data[0x08] & 0x20) | (data[0x0a] & 0x02)) > 0] 932 | 933 | resp += [catch_cold: (data[0x0a] & 0x08) > 0] 934 | resp += [night_light: (data[0x0a] & 0x10) > 0] 935 | resp += [peak_elec: (data[0x0a] & 0x20) > 0] 936 | resp += [natural_fan: (data[0x0a] & 0x40) > 0] 937 | 938 | resp += [outdoor_temperature: ((data[0x0c] - 50) / 2.0).toFloat()] 939 | resp += [humidity: (data[0x0d] & 0x7f)] 940 | 941 | // indoor_temperature 942 | Integer indoorTempInteger = 0 943 | if(((i8Tou8(data[11]) - 50) / 2).toInteger() < -19 || ((i8Tou8(data[11]) - 50) / 2).toInteger() > 50) 944 | { 945 | resp += [indoor_temperature: 0xFF] 946 | } 947 | else 948 | { 949 | indoorTempInteger = ((i8Tou8(data[11]) - 50) / 2).toInteger() 950 | Float indoorTempDecimal = getBits(data, 15, 0, 3) * 0.1 951 | 952 | indoorTempDecimal = (i8Tou8(data[11]) > 49) ? (indoorTempInteger.toFloat() + indoorTempDecimal) : (indoorTempInteger.toFloat() - indoorTempDecimal) 953 | 954 | resp += [indoor_temperature: indoorTempDecimal] 955 | } 956 | 957 | return resp 958 | } 959 | 960 | def appliance_transparent_send_8370(data, msgtype=MSGTYPE_ENCRYPTED_REQUEST) 961 | { 962 | if(!getTcpKey()) 963 | { 964 | logDebug("missing tcp_key. need to _authenticate") 965 | return 966 | } 967 | 968 | def sData = subBytes(data, 0, data.size()) 969 | sData = encode_8370(sData, msgtype) 970 | _writeBytes(sData) 971 | } 972 | 973 | def request_status_command() 974 | { 975 | return base_command() 976 | } 977 | 978 | def set_command(Map cmdParams) 979 | { 980 | def data = base_command() 981 | 982 | data[0x01] = 0x23 983 | data[0x09] = 0x02 984 | 985 | // Set up Mode 986 | data[0x0a] = 0x40 987 | 988 | // prompt_tone 989 | data[0x0b] = 0x40 990 | 991 | data = appendByteArr(data, [0x00, 0x00, 0x00]) 992 | 993 | // tone.setter 994 | data[0x0b] &= ~ 0x42 995 | data[0x0b] |= (cmdParams.feedback_enabled ? 0x42 : 0) 996 | 997 | // power_state.setter 998 | data[0x0b] &= ~ 0x01 999 | data[0x0b] |= (cmdParams.power_state ? 0x01 : 0) 1000 | 1001 | // target_temperature.setter 1002 | def target_temperature = cmdParams.target_temperature 1003 | // bound the temperature setpoint to within the range of 17C to 30C 1004 | target_temperature = (target_temperature < 17) ? 17 : ((target_temperature > 30) ? 30 : target_temperature) 1005 | data[0x0c] &= ~ 0x0f 1006 | data[0x0c] |= (target_temperature?.toInteger() & 0xf) 1007 | // temperature_dot5.setter 1008 | if((target_temperature * 2).toInteger() % 2 != 0) 1009 | { 1010 | data[0x0c] |= 0x10 1011 | } 1012 | else 1013 | { 1014 | data[0x0c] &= (~0x10) 1015 | } 1016 | 1017 | // operational_mode.setter 1018 | data[0x0c] &= ~ 0xe0 1019 | data[0x0c] |= (cmdParams.operational_mode << 5) & 0xe0 1020 | 1021 | // fan_speed.setter 1022 | data[0x0d] = cmdParams.fan_speed 1023 | 1024 | // eco_mode.setter 1025 | data[0x13] = (cmdParams.eco_mode ? 0xFF : 0) 1026 | 1027 | // swing_mode.setter 1028 | data[0x11] = 0x30 1029 | data[0x11] |= (cmdParams.swing_mode & 0x3f) 1030 | 1031 | // turbo_mode.setter 1032 | if(cmdParams.turbo_mode) 1033 | { 1034 | data[0x12] = 0x20 1035 | data[0x14] |= 0x02 1036 | } 1037 | else 1038 | { 1039 | data[0x12] = 0 1040 | data[0x14] &= (~0x02) 1041 | } 1042 | 1043 | // screen_display.setter 1044 | if(cmdParams.screen_display) 1045 | { 1046 | data[0x14] |= 0x10 1047 | } 1048 | else 1049 | { 1050 | data[0x14] &= (~0x10) 1051 | } 1052 | 1053 | // fahrenheit.setter 1054 | if(cmdParams.fahrenheit_enabled) 1055 | { 1056 | data[0x14] |= 0x04 1057 | } 1058 | else 1059 | { 1060 | data[0x14] &= (~0x04) 1061 | } 1062 | 1063 | return data 1064 | } 1065 | 1066 | def base_command() 1067 | { 1068 | byte[] req = 1069 | [ 1070 | // 0 header 1071 | 0xaa, 1072 | // 1 command lenght: N+10 1073 | 0x20, 1074 | // 2 device type (0xAC for air conditioner) 1075 | 0xac, 1076 | // 3 Frame SYN CheckSum 1077 | 0x00, 1078 | // 4-5 Reserved 1079 | 0x00, 0x00, 1080 | // 6 Message ID 1081 | 0x00, 1082 | // 7 Frame Protocol Version 1083 | 0x00, 1084 | // 8 Device Protocol Version 1085 | 0x00, 1086 | // 9 Message Type: request is 0x03; setting is 0x02 1087 | 0x03, 1088 | 1089 | // Byte0 - Data request/response type: 0x41 - check status; 0x40 - Set up 1090 | 0x41, 1091 | // Byte1 1092 | 0x81, 1093 | // Byte2 - operational_mode 1094 | 0x00, 1095 | // Byte3 1096 | 0xff, 1097 | // Byte4 1098 | 0x03, 1099 | // Byte5 1100 | 0xff, 1101 | // Byte6 1102 | 0x00, 1103 | // Byte7 - Room Temperature Request: 0x02 - indoor_temperature, 0x03 - outdoor_temperature 1104 | // when set, this is swing_mode 1105 | 0x02, 1106 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 1107 | 0x00, 0x00, 0x00, 0x00, 1108 | // Message ID 1109 | (state.request_count ?: 1) & 0xFF 1110 | ] 1111 | 1112 | return req 1113 | } 1114 | 1115 | def packet_builder(device_id, command) 1116 | { 1117 | // Init the packet with the header data 1118 | def packet = 1119 | [ 1120 | // 2 bytes - StaicHeader 1121 | 0x5a, 0x5a, 1122 | // 2 bytes - mMessageType 1123 | 0x01, 0x11, 1124 | // 2 bytes - PacketLenght 1125 | 0x00, 0x00, 1126 | // 2 bytes 1127 | 0x20, 0x00, 1128 | // 4 bytes - MessageId 1129 | 0x00, 0x00, 0x00, 0x00, 1130 | // 8 bytes - Date&Time 1131 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 1132 | // 6 bytes - mDeviceID 1133 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 1134 | // 12 bytes 1135 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 1136 | ] 1137 | 1138 | //self.packet[12:20] = self.packet_time() 1139 | //'%Y%m%d%H%M%S%f' 1140 | def dateBytes = hubitat.helper.HexUtils.hexStringToByteArray(new Date().format('yyyyMMddHHmmssSSSS')) 1141 | packet = replaceSubArr(packet, subBytes(dateBytes, 0, 8), 12) 1142 | 1143 | //self.packet[20:28] = device_id.to_bytes(8, 'little') 1144 | packet = replaceSubArr(packet, intToBytes(device_id, 8, "little"), 20) 1145 | 1146 | 1147 | // base_command.finalize() 1148 | // Add the CRC8 1149 | def crc = crc8(subBytes(command, 10, command.size() - 10)) 1150 | command = appendByteArr(command, [i8Tou8(crc)]) 1151 | // Set the length of the command data 1152 | // self.data[0x01] = len(self.data) 1153 | // Add checksum 1154 | def checksum = checksum(subBytes(command, 1, command.size() - 1)) 1155 | command = appendByteArr(command, [i8Tou8(checksum)]) 1156 | 1157 | 1158 | // packet_builder.finalize() 1159 | def encCmd = aes_ecb(command, "enc") 1160 | // Append the command data(48 bytes) to the packet 1161 | packet = appendByteArr(packet, subBytes(encCmd, 0, 48)) 1162 | // PacketLength 1163 | packet = replaceSubArr(packet, intToBytes(packet.size() + 16, 2, "little"), 4) 1164 | // Append a basic checksum data(16 bytes) to the packet 1165 | packet = appendByteArr(packet, md5(appendByteArr(packet, signKey))) 1166 | 1167 | return packet 1168 | } 1169 | 1170 | def appendByteArr(a, b) 1171 | { 1172 | byte[] c = new byte[a.size() + b.size()] 1173 | 1174 | a.eachWithIndex() 1175 | { 1176 | it, i -> 1177 | c[i] = it 1178 | } 1179 | 1180 | def aSz = a.size() 1181 | 1182 | b.eachWithIndex() 1183 | { 1184 | it, i -> 1185 | c[i + aSz] = it 1186 | } 1187 | 1188 | return c 1189 | } 1190 | 1191 | def replaceSubArr(orig_arr, new_arr, start) 1192 | { 1193 | def tmp_arr = orig_arr.collect() 1194 | new_arr.eachWithIndex 1195 | { 1196 | it, i -> 1197 | tmp_arr[i + start] = it 1198 | } 1199 | 1200 | return tmp_arr 1201 | } 1202 | 1203 | private subBytes(arr, start, length) 1204 | { 1205 | byte[] sub = new byte[length] 1206 | 1207 | for(int i = 0; i < length; i++) 1208 | { 1209 | sub[i] = arr[i + start] 1210 | } 1211 | 1212 | return sub 1213 | } 1214 | 1215 | def swapEndiannessU16(input) 1216 | { 1217 | return [i8Tou8(input[1]), i8Tou8(input[0])] 1218 | } 1219 | 1220 | def swapEndiannessU32(input) 1221 | { 1222 | return [input[3], input[2], input[1], input[0]] 1223 | } 1224 | 1225 | def swapEndiannessU64(input) 1226 | { 1227 | return [input[7], input[6], input[5], input[4], 1228 | input[3], input[2], input[1], input[0]] 1229 | } 1230 | 1231 | def intToBytes(input, width, endian = "little") 1232 | { 1233 | def output = new BigInteger(input).toByteArray() 1234 | 1235 | if(output.size() > width) 1236 | { 1237 | // if we got too many bytes, lop off the MSB(s) 1238 | output = subBytes(output, output.size() - width, width) 1239 | output = output.collect{it & 0xFF} 1240 | } 1241 | 1242 | byte[] pad 1243 | 1244 | if(output.size() < width) 1245 | { 1246 | def padding = width - output.size() 1247 | pad = [0] * padding 1248 | output = appendByteArr(pad, output) 1249 | } 1250 | 1251 | if("little" == endian) 1252 | { 1253 | switch(width) 1254 | { 1255 | case 1: 1256 | break 1257 | case 2: 1258 | output = swapEndiannessU16(output) 1259 | break 1260 | case 4: 1261 | output = swapEndiannessU32(output) 1262 | break 1263 | case 8: 1264 | output = swapEndiannessU64(output) 1265 | break 1266 | } 1267 | } 1268 | 1269 | return output.collect{it & 0xFF} 1270 | } 1271 | 1272 | def bytesToInt(input, endian = "little") 1273 | { 1274 | def output = subBytes(input, 0, input.size()) 1275 | 1276 | long retVal = 0 1277 | output.eachWithIndex 1278 | { 1279 | it, i -> 1280 | 1281 | switch(endian) 1282 | { 1283 | case "little": 1284 | retVal += ((it & 0xFF).toLong() << (i * 8)) 1285 | break 1286 | case "big": 1287 | default: 1288 | retVal += (it & 0xFF).toLong() << ((output.size() - 1 - i) * 8) 1289 | break 1290 | } 1291 | } 1292 | 1293 | if(input.size() == 8) 1294 | { 1295 | // 8 bytes is too big for integer 1296 | return retVal 1297 | } 1298 | 1299 | return retVal as Integer 1300 | } 1301 | 1302 | def i8Tou8(input) 1303 | { 1304 | return input & 0xFF 1305 | } 1306 | 1307 | def i16Tou16(input) 1308 | { 1309 | return input & 0xFFFF 1310 | } 1311 | 1312 | def getBit(pByte, pIndex) 1313 | { 1314 | return (pByte >> pIndex) & 0x01 1315 | } 1316 | 1317 | def getBits(pBytes, pIndex, pStartIndex, pEndIndex) 1318 | { 1319 | if(pStartIndex > pEndIndex) 1320 | { 1321 | StartIndex = pEndIndex 1322 | EndIndex = pStartIndex 1323 | } 1324 | else 1325 | { 1326 | StartIndex = pStartIndex 1327 | EndIndex = pEndIndex 1328 | } 1329 | 1330 | tempVal = 0x00 1331 | StartIndex.upto(EndIndex) 1332 | { 1333 | tempVal = tempVal | getBit(pBytes[pIndex], it) << (it - StartIndex) 1334 | } 1335 | 1336 | return tempVal 1337 | } 1338 | 1339 | @Field crc8_854_table = 1340 | [ 1341 | 0x00, 0x5E, 0xBC, 0xE2, 0x61, 0x3F, 0xDD, 0x83, 1342 | 0xC2, 0x9C, 0x7E, 0x20, 0xA3, 0xFD, 0x1F, 0x41, 1343 | 0x9D, 0xC3, 0x21, 0x7F, 0xFC, 0xA2, 0x40, 0x1E, 1344 | 0x5F, 0x01, 0xE3, 0xBD, 0x3E, 0x60, 0x82, 0xDC, 1345 | 0x23, 0x7D, 0x9F, 0xC1, 0x42, 0x1C, 0xFE, 0xA0, 1346 | 0xE1, 0xBF, 0x5D, 0x03, 0x80, 0xDE, 0x3C, 0x62, 1347 | 0xBE, 0xE0, 0x02, 0x5C, 0xDF, 0x81, 0x63, 0x3D, 1348 | 0x7C, 0x22, 0xC0, 0x9E, 0x1D, 0x43, 0xA1, 0xFF, 1349 | 0x46, 0x18, 0xFA, 0xA4, 0x27, 0x79, 0x9B, 0xC5, 1350 | 0x84, 0xDA, 0x38, 0x66, 0xE5, 0xBB, 0x59, 0x07, 1351 | 0xDB, 0x85, 0x67, 0x39, 0xBA, 0xE4, 0x06, 0x58, 1352 | 0x19, 0x47, 0xA5, 0xFB, 0x78, 0x26, 0xC4, 0x9A, 1353 | 0x65, 0x3B, 0xD9, 0x87, 0x04, 0x5A, 0xB8, 0xE6, 1354 | 0xA7, 0xF9, 0x1B, 0x45, 0xC6, 0x98, 0x7A, 0x24, 1355 | 0xF8, 0xA6, 0x44, 0x1A, 0x99, 0xC7, 0x25, 0x7B, 1356 | 0x3A, 0x64, 0x86, 0xD8, 0x5B, 0x05, 0xE7, 0xB9, 1357 | 0x8C, 0xD2, 0x30, 0x6E, 0xED, 0xB3, 0x51, 0x0F, 1358 | 0x4E, 0x10, 0xF2, 0xAC, 0x2F, 0x71, 0x93, 0xCD, 1359 | 0x11, 0x4F, 0xAD, 0xF3, 0x70, 0x2E, 0xCC, 0x92, 1360 | 0xD3, 0x8D, 0x6F, 0x31, 0xB2, 0xEC, 0x0E, 0x50, 1361 | 0xAF, 0xF1, 0x13, 0x4D, 0xCE, 0x90, 0x72, 0x2C, 1362 | 0x6D, 0x33, 0xD1, 0x8F, 0x0C, 0x52, 0xB0, 0xEE, 1363 | 0x32, 0x6C, 0x8E, 0xD0, 0x53, 0x0D, 0xEF, 0xB1, 1364 | 0xF0, 0xAE, 0x4C, 0x12, 0x91, 0xCF, 0x2D, 0x73, 1365 | 0xCA, 0x94, 0x76, 0x28, 0xAB, 0xF5, 0x17, 0x49, 1366 | 0x08, 0x56, 0xB4, 0xEA, 0x69, 0x37, 0xD5, 0x8B, 1367 | 0x57, 0x09, 0xEB, 0xB5, 0x36, 0x68, 0x8A, 0xD4, 1368 | 0x95, 0xCB, 0x29, 0x77, 0xF4, 0xAA, 0x48, 0x16, 1369 | 0xE9, 0xB7, 0x55, 0x0B, 0x88, 0xD6, 0x34, 0x6A, 1370 | 0x2B, 0x75, 0x97, 0xC9, 0x4A, 0x14, 0xF6, 0xA8, 1371 | 0x74, 0x2A, 0xC8, 0x96, 0x15, 0x4B, 0xA9, 0xF7, 1372 | 0xB6, 0xE8, 0x0A, 0x54, 0xD7, 0x89, 0x6B, 0x35 1373 | ] 1374 | 1375 | int crc8(value) 1376 | { 1377 | // thanks: http://www.java2s.com/example/java-utility-method/crc-calculate/crc8-string-value-6f7a7.html 1378 | 1379 | int crc = 0 1380 | for (int i = 0; i < value.size(); i++) 1381 | { 1382 | crc = crc8_854_table[value[i] ^ (crc & 0xFF)] 1383 | } 1384 | 1385 | return crc 1386 | } 1387 | 1388 | def checksum(data) 1389 | { 1390 | def sum = data.sum() 1391 | return (~ sum + 1) & 0xFF 1392 | } 1393 | 1394 | def encode(byte[] data) 1395 | { 1396 | data.collect { it & 0xFF } 1397 | } 1398 | 1399 | 1400 | ////////////////////////////////////// 1401 | // volatile state and sync code below 1402 | ////////////////////////////////////// 1403 | 1404 | @Field static volatileState = [:].asSynchronized() 1405 | 1406 | def setVolatileState(name, value) 1407 | { 1408 | def tempState = volatileState[device.getDeviceNetworkId()] ?: [:] 1409 | tempState.putAt(name, value) 1410 | 1411 | volatileState.putAt(device.getDeviceNetworkId(), tempState) 1412 | 1413 | return volatileState 1414 | } 1415 | 1416 | def getVolatileState(name) 1417 | { 1418 | return volatileState.getAt(device.getDeviceNetworkId())?.getAt(name) ?: null 1419 | } 1420 | 1421 | def syncWait(data) 1422 | { 1423 | // set up for checking 5x per second 1424 | setVolatileState("syncWaitDetails", [waitSetter: data.waitSetter, retryCount: data.timeoutSec * 5]) 1425 | } 1426 | 1427 | def doWait() 1428 | { 1429 | def wtDetails = getVolatileState("syncWaitDetails") 1430 | 1431 | // check every 200 ms whether the wait was cleared... 1432 | if(wtDetails?.waitSetter == "") 1433 | { 1434 | return 1435 | } 1436 | 1437 | // ...or throw an exception if we ran out of tries 1438 | if(wtDetails?.retryCount == 0) 1439 | { 1440 | throw new Exception("wait timed out") 1441 | } 1442 | 1443 | wtDetails.putAt("retryCount", wtDetails.getAt("retryCount") - 1) 1444 | 1445 | setVolatileState("syncWaitDetails", wtDetails) 1446 | 1447 | pauseExecution(200) 1448 | doWait() 1449 | } 1450 | 1451 | def clearWait(data) 1452 | { 1453 | def wtDetails = getVolatileState("syncWaitDetails") 1454 | 1455 | if(data.waitSetter == wtDetails?.getAt("waitSetter")) 1456 | { 1457 | syncWait([waitSetter: "", timeoutSec: 0]) 1458 | } 1459 | } 1460 | 1461 | def clearAllWaits() 1462 | { 1463 | syncWait([waitSetter: "", timeoutSec: 0]) 1464 | } 1465 | --------------------------------------------------------------------------------