├── LICENSE ├── README.md └── custom_components └── havcs ├── __init__.py ├── aligenie.py ├── bind.py ├── ca.crt ├── config_flow.py ├── const.py ├── device.py ├── dueros.py ├── helper.py ├── html ├── css │ ├── highlight.default.css │ ├── jquery-weui.min.css │ ├── materialdesignicons.min.css │ ├── weui.min.css │ └── weuix.css ├── fonts │ ├── materialdesignicons-webfont.eot │ ├── materialdesignicons-webfont.ttf │ ├── materialdesignicons-webfont.woff │ └── materialdesignicons-webfont.woff2 ├── images │ ├── favicon.ico │ ├── icon_nav_article.png │ ├── icon_nav_button.png │ └── icon_nav_cell.png ├── index.html ├── js │ ├── ha.js │ ├── highlight.pack.js │ ├── jquery-weui.min.js │ ├── jquery.min.js │ ├── main.min.js │ ├── vue-resource.min.js │ ├── vue-router.min.js │ └── vue.min.js └── login.html ├── http.py ├── jdwhale.py ├── manifest.json ├── services.yaml ├── translations ├── en.json └── zh-Hans.json ├── util.py └── weixin.py /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 备注 2 | - master分支为最新功能并适配更高的HA版本号,如要使用旧版本插件到[release][1]页面下载 3 | - havcs经历3次大更新(配置使用方法有变动),目前为havcs v3,使用教程[传送门][2] 4 | - HA版本迭代较快,无法保证一一适配,进行功能更新会使用新版本HA进行测试 5 | - 建议使用版本: 2021.5.4 6 | ## 更新日志 7 | - 2021-05-26 8 | 1. HA 2021.5.4版本测试 9 | 2. 测试小度音箱平台定时任务逻辑有调整:之前是平台发送定时指令,由插件定时触发;现在是平台定时后发送控制指令 10 | 3. 修复添加集成重复注册web菜单错误 11 | 4. 集成设置增加ha_url设置(自动判断会优先读取旧版本.storage/core.config的ip配置导致不准确) 12 | - 2020-10-19 13 | 1. 增加fan类型设置模式指令支持(部分,不同平台模式的定义与插件的定义有差异) 14 | - 2020-10-14 15 | 1. 修复自动生成平台设备类型的逻辑导致无法正确返回设备类型 16 | 2. 修复manifest.json的mqtt依赖导致不启用mqtt插件无法启动本插件 17 | - 2020-09-22 18 | 1. 修复设置actions但不设置自定义指令造成无法控制 19 | - 2020-09-18 20 | 1. 修复小度音箱定时指令失效 21 | 2. 修复自定义指令失效 22 | 3. 修复天猫精灵"Detected I/O inside the event loop"警告 23 | - 2020-08-31 24 | 1. HA 0.114.4版本测试 25 | 2. 调整UI管理界面,增加指令过滤设置选项,避免配置了自建技能情况下使用havcs APP技能,会重复返回2次设备(小度音箱会有这种情况) 26 | 3. 修复UI管理界面嵌套显示 27 | - 2020-05-05 28 | 1. 修复刷新token校验条件不正确 29 | - 2020-04-15 30 | 1. 修复web管理页面平台可见属性与后台不匹配 31 | - 2020-04-12 32 | 1. 修复dueros获取未知属性会返回错误 33 | - 2020-04-10 34 | 1. 修复修改集成配置clients不生效 35 | 2. 修复group寻找entity_id错误 36 | - 2020-04-09 37 | 1. 修复页面注册方法兼容问题 38 | - 2020-04-06 39 | 1. 增加页面get方法方便检测 40 | 2. 去除失效的自动测试提醒 41 | - 2020-04-05(注意:服务url以及插件配置有调整) 42 | 1. 增加授权页面,可自定义客户端ID,调整各服务url进同一层级(/havcs/) 43 | 2. 设备管理页面增加导出、导入、同步设备、查看插件配置信息的功能 44 | 3. 修复若干bug 45 | - 2020-04-01 46 | 1. 修复页面新增按钮失效、增加新增设备简单校验 47 | - 2020-03-30 48 | 1. havcs v3版本,增加UI配置管理 49 | - 2020-02-23 50 | 1. 旧证书有问题重新进行更新,如使用app的havcs技能(连接mqtt服务器)需要重新替换本地ca.crt文件 51 | - 2020-01-28 52 | 1. 调整"entity_key"配置项(加密entity_id)为可选 53 | - 2020-01-23 54 | 1. 调整设备类型生成逻辑,优先尝试根据设备名称识别生成 55 | - 2020-01-17 56 | 1. 增加企业微信控制功能(测试) 57 | 2. ca证书过期,更新证书 58 | - 2020-01-12 59 | 1. 修复天猫精灵名称识别总是匹配到大类的问题 60 | 2. 修复查询指令返回属性不正确 61 | - 2020-01-02 62 | 1. 刷新token增加返回refresh_token值(天猫精灵要求) 63 | - 2019-11-21 64 | 1. 修复旧版本(0.89)mqtt初始化成功判断失效 65 | - 2019-10-20 66 | 1. 修复havcs_device_type不设置会导致错误、自动生成device_tpye代码出错 67 | - 2019-10-03 68 | 1. 修复指定device_type没有匹配相应的actions 69 | 2. 修复叮咚生成设备信息中的话术信息可能导致的错 70 | - 2019-09-25 71 | 1. 重构音箱网关代码,改用独立文件配置设备信息 72 | 2. 修复取消配置后不能正常清除config entry信息的问题 73 | 3. 调整设备信息属性havcs_enable为havcs_visable,可以设置该设备只对指定平台可见 74 | - 2019-09-17 75 | 1. 修复天猫精灵获取变量信息失败导致初始化失败 76 | - 2019-08-23 77 | 1. HA 0.97.2版本下测试,修复一些小度音箱定时控制指令功能的bug 78 | - 2019-08-20 79 | 1. 小度音箱支持light、switch、inputboolean类型定时打开/关闭指令,需配合common_timer插件使用 80 | - 2019-05-10 81 | 1. 采用新方案,在不影响HA的token超时时间参数情况下,现在可以为token独立设置超时时间 82 | - 2019-05-07 83 | 1. 修复原生input_boolean打开关闭指令失效 84 | 2. 修复token正则匹配不正确 85 | - 2019-05-06 86 | 1. 重新设计配置项更容易设置 87 | 2. 优化三种模式代码逻辑 88 | 3. 整合模式一服务网关,重新测试 89 | 4. 优化调试日志的样式 90 | - 2019-05-03 91 | 1. input_boolean实体支持对调节亮度操作指令映射service(aihome_actions属性) 92 | 2. 指令映射模式下,支持执行多条指令(设置方法有变化,请查看教程) 93 | 2. HA 0.92.1版本测试 94 | - 2019-04-09 95 | 1. 增加设备配置样例(使用packages方式导入即可测试) 96 | 2. 修复叮咚启动不同步信息bug 97 | - 2019-04-07 98 | 1. 精简配置项,增加模式三简略配置说明 99 | - 2019-04-01 100 | 1. 增加首次启动连接mqtt测试功能,如果正常INFO级别日志会显示提示信息,否则请检查appkey以及网络连接 101 | - 2019-03-29 102 | 1. 设备开关状态主动上报(小度音箱),可通过配置文件设置是否上报 103 | 2. HA 0.90.2版本测试 104 | 3. 前端页面优化,上线了密码找回功能 105 | - 2019-03-06 106 | 1. HA 0.88.2版本测试 107 | - 2019-02-27 108 | 1. input_boolean支持直接调用service指令 109 | - 2019-02-22 110 | 1. 增加叮咚设备更新(插件启动触发更新) 111 | 2. 修复京东音箱、小度音箱对input_boolean类的开/关控制 112 | 3. 天猫精灵设备配置命名风格统一 113 | 4. 更改设备发现模式为非主动发现,使用"aihome_device"属性设置设备可被发现 114 | 5. 传感器类设备配置属性精简 115 | 6. 说明文档补充设备配置样例 116 | - 2019-01-xx 117 | HA 0.86.4 和 HA 0.82.1,本地单机测试 118 | 119 | ## 调试Tips 120 | 1. 根据[教程][3]调整插件的调试级别查看详细的运行日志 121 | 2. 配置好插件,启动HA,观看是否有mqtt连接成功信息(app技能接入方式) 122 | 3. 授权过程,观看是否有相关的处理日志 123 | 4. 说音响指令后,观看是否有相关的处理日志 124 | 5. web页面->开发者工具->服务->havcs.reload,观看是否生成设备信息 125 | > WARN: 如需要帮助,请提供以上步骤相对应的日志信息方便定位原因。 126 | 127 | 128 | [1]: https://github.com/cnk700i/havcs/releases "历史版本" 129 | [2]: https://ljr.im/articles/plugins-havcs-edible-instructions/ "【插件】HAVCS食用说明" 130 | [3]: https://ljr.im/articles/home-assistant-novice-question-set/#3-%E8%B0%83%E8%AF%95%E5%8F%8A%E6%9F%A5%E7%9C%8B%E7%A8%8B%E5%BA%8F%E8%BF%90%E8%A1%8C%E6%97%A5%E5%BF%97 "调试及查看程序运行日志" 131 | 132 | 133 | -------------------------------------------------------------------------------- /custom_components/havcs/aligenie.py: -------------------------------------------------------------------------------- 1 | import json 2 | from urllib.request import urlopen 3 | import logging 4 | 5 | import async_timeout 6 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 7 | from .util import decrypt_device_id, encrypt_device_id 8 | from .helper import VoiceControlProcessor, VoiceControlDeviceManager 9 | from .const import ATTR_DEVICE_ACTIONS 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | # _LOGGER.setLevel(logging.DEBUG) 13 | LOGGER_NAME = 'aligenie' 14 | 15 | DOMAIN = 'aligenie' 16 | 17 | async def createHandler(hass, entry): 18 | mode = ['handler'] 19 | try: 20 | placelist_url = 'https://open.bot.tmall.com/oauth/api/placelist' 21 | aliaslist_url = 'https://open.bot.tmall.com/oauth/api/aliaslist' 22 | session = async_get_clientsession(hass, verify_ssl=False) 23 | with async_timeout.timeout(5, loop=hass.loop): 24 | response = await session.get(placelist_url) 25 | placelist = (await response.json())['data'] 26 | with async_timeout.timeout(5, loop=hass.loop): 27 | response = await session.get(aliaslist_url) 28 | aliaslist = (await response.json())['data'] 29 | placelist.append({'key': '电视', 'value': ['电视机']}) 30 | aliaslist.append({'key': '传感器', 'value': ['传感器']}) 31 | except: 32 | placelist = [] 33 | aliaslist = [] 34 | import traceback 35 | _LOGGER.info("[%s] can get places and aliases data from website, set None.\n%s", LOGGER_NAME, traceback.format_exc()) 36 | return VoiceControlAligenie(hass, mode, entry, placelist, aliaslist) 37 | 38 | class PlatformParameter: 39 | device_attribute_map_h2p = { 40 | 'power_state': 'powerstate', 41 | 'color': 'color', 42 | 'temperature': 'temperature', 43 | 'humidity': 'humidity', 44 | # '': 'windspeed', 45 | 'brightness': 'brightness', 46 | # '': 'direction', 47 | # '': 'angle', 48 | 'pm25': 'pm2.5', 49 | } 50 | device_action_map_h2p ={ 51 | 'turn_on': 'TurnOn', 52 | 'turn_off': 'TurnOff', 53 | 'increase_brightness': 'AdjustUpBrightness', 54 | 'decrease_brightness': 'AdjustDownBrightness', 55 | 'set_brightness': 'SetBrightness', 56 | 'increase_temperature': 'AdjustUpTemperature', 57 | 'decrease_temperature': 'AdjustDownTemperature', 58 | 'set_temperature': 'SetTemperature', 59 | 'set_color': 'SetColor', 60 | 'pause': 'Pause', 61 | 'continue': 'Continue', 62 | 'play': 'Play', 63 | 'query_color': 'QueryColor', 64 | 'query_power_state': 'QueryPowerState', 65 | 'query_temperature': 'QueryTemperature', 66 | 'query_humidity': 'QueryHumidity', 67 | 'set_mode': 'SetMode' 68 | # '': 'QueryWindSpeed', 69 | # '': 'QueryBrightness', 70 | # '': 'QueryFog', 71 | # '': 'QueryMode', 72 | # '': 'QueryPM25', 73 | # '': 'QueryDirection', 74 | # '': 'QueryAngle' 75 | } 76 | _device_type_alias = { 77 | 'television': '电视', 78 | 'light': '灯', 79 | 'aircondition': '空调', 80 | 'airpurifier': '空气净化器', 81 | 'outlet': '插座', 82 | 'switch': '开关', 83 | 'roboticvacuum': '扫地机器人', 84 | 'curtain': '窗帘', 85 | 'humidifier': '加湿器', 86 | 'fan': '风扇', 87 | 'bottlewarmer': '暖奶器', 88 | 'soymilkmaker': '豆浆机', 89 | 'kettle': '电热水壶', 90 | 'waterdispenser': '饮水机', 91 | 'camera': '摄像头', 92 | 'router': '路由器', 93 | 'cooker': '电饭煲', 94 | 'waterheater': '热水器', 95 | 'oven': '烤箱', 96 | 'waterpurifier': '净水器', 97 | 'fridge': '冰箱', 98 | 'STB': '机顶盒', 99 | 'sensor': '传感器', 100 | 'washmachine': '洗衣机', 101 | 'smartbed': '智能床', 102 | 'aromamachine': '香薰机', 103 | 'window': '窗', 104 | 'kitchenventilator': '抽油烟机', 105 | 'fingerprintlock': '指纹锁', 106 | 'telecontroller': '万能遥控器', 107 | 'dishwasher': '洗碗机', 108 | 'dehumidifier': '除湿机', 109 | 'dryer': '干衣机', 110 | 'wall-hung-boiler': '壁挂炉', 111 | 'microwaveoven': '微波炉', 112 | 'heater': '取暖器', 113 | 'mosquitoDispeller': '驱蚊器', 114 | 'treadmill': '跑步机', 115 | 'smart-gating': '智能门控', 116 | 'smart-band': '智能手环', 117 | 'hanger': '晾衣架', 118 | 'bloodPressureMeter': '血压仪', 119 | 'bloodGlucoseMeter': '血糖仪', 120 | } 121 | 122 | device_type_map_h2p = { 123 | 'climate': 'aircondition', 124 | 'fan': 'fan', 125 | 'light': 'light', 126 | 'media_player': 'television', 127 | 'remote': 'telecontroller', 128 | 'switch': 'switch', 129 | 'sensor': 'sensor', 130 | 'cover': 'curtain', 131 | 'vacuum': 'roboticvacuum', 132 | } 133 | 134 | _service_map_p2h = { 135 | # 测试,暂无找到播放指定音乐话术,继续播放指令都是Play 136 | # 'media_player': { 137 | # 'Play': lambda state, attributes, payload: (['play_media'], ['play_media'], [{"media_content_id": payload['value'], "media_content_type": "playlist"}]), 138 | # 'Pause': 'media_pause', 139 | # 'Continue': 'media_play' 140 | # }, 141 | # 模式和平台设备类型有关,自动模式 静音模式 睡眠风模式(fan类型) 睡眠模式(airpurifier类型) 142 | 'fan': { 143 | 'SetMode': lambda state, attributes, payload: (['fan'], ['set_speed'], [{"speed": payload['value']}]) 144 | }, 145 | 'cover': { 146 | 'TurnOn': 'open_cover', 147 | 'TurnOff': 'close_cover', 148 | 'Pause': 'stop_cover', 149 | }, 150 | 'vacuum': { 151 | 'TurnOn': 'start', 152 | 'TurnOff': 'return_to_base', 153 | }, 154 | 'light': { 155 | 'TurnOn': 'turn_on', 156 | 'TurnOff': 'turn_off', 157 | 'SetBrightness': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': payload['value']}]), 158 | 'AdjustUpBrightness': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': min(attributes['brightness_pct'] + payload['value'], 100)}]), 159 | 'AdjustDownBrightness': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': max(attributes['brightness_pct'] - payload['value'], 0)}]), 160 | 'SetColor': lambda state, attributes, payload: (['light'], ['turn_on'], [{"color_name": payload['value']}]) 161 | }, 162 | 'havcs':{ 163 | 'TurnOn': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 164 | 'TurnOff': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_off'], [{}]), 165 | 'AdjustUpBrightness': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 166 | 'AdjustDownBrightness': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 167 | } 168 | 169 | } 170 | # action:[{Platfrom Attr: HA Attr},{}] 171 | _query_map_p2h = { 172 | 173 | } 174 | 175 | class VoiceControlAligenie(PlatformParameter, VoiceControlProcessor): 176 | def __init__(self, hass, mode, entry, zone_constraints, device_name_constraints): 177 | self._hass = hass 178 | self._mode = mode 179 | self._zone_constraints = zone_constraints 180 | self._device_name_constraints = device_name_constraints 181 | # try: 182 | # self._zone_constraints = json.loads(urlopen('https://open.bot.tmall.com/oauth/api/placelist').read().decode('utf-8'))['data'] 183 | # self._device_name_constraints = json.loads(urlopen('https://open.bot.tmall.com/oauth/api/aliaslist').read().decode('utf-8'))['data'] 184 | # self._device_name_constraints.append({'key': '电视', 'value': ['电视机']}) 185 | # self._device_name_constraints.append({'key': '传感器', 'value': ['传感器']}) 186 | # except: 187 | # self._zone_constraints = [] 188 | # self._device_name_constraints = [] 189 | # import traceback 190 | # _LOGGER.info("[%s] can get places and aliases data from website, set None.\n%s", LOGGER_NAME, traceback.format_exc()) 191 | self.vcdm = VoiceControlDeviceManager(entry, DOMAIN, self.device_action_map_h2p, self.device_attribute_map_h2p, self._service_map_p2h, self.device_type_map_h2p, self._device_type_alias, self._device_name_constraints, self._zone_constraints) 192 | 193 | def _errorResult(self, errorCode, messsage=None): 194 | """Generate error result""" 195 | messages = { 196 | 'INVALIDATE_CONTROL_ORDER': 'invalidate control order', 197 | 'SERVICE_ERROR': 'service error', 198 | 'DEVICE_NOT_SUPPORT_FUNCTION': 'device not support', 199 | 'INVALIDATE_PARAMS': 'invalidate params', 200 | 'DEVICE_IS_NOT_EXIST': 'device is not exist', 201 | 'IOT_DEVICE_OFFLINE': 'device is offline', 202 | 'ACCESS_TOKEN_INVALIDATE': ' access_token is invalidate' 203 | } 204 | return {'errorCode': errorCode, 'message': messsage if messsage else messages[errorCode]} 205 | 206 | async def handleRequest(self, data, auth = False, request_from = "http"): 207 | """Handle request""" 208 | _LOGGER.info("[%s] Handle Request:\n%s", LOGGER_NAME, data) 209 | 210 | header = self._prase_command(data, 'header') 211 | payload = self._prase_command(data, 'payload') 212 | action = self._prase_command(data, 'action') 213 | namespace = self._prase_command(data, 'namespace') 214 | properties = None 215 | content = {} 216 | 217 | if auth: 218 | if namespace == 'AliGenie.Iot.Device.Discovery': 219 | err_result, discovery_devices, entity_ids = self.process_discovery_command(request_from) 220 | content = {'devices': discovery_devices} 221 | elif namespace == 'AliGenie.Iot.Device.Control': 222 | err_result, content = await self.process_control_command(data) 223 | elif namespace == 'AliGenie.Iot.Device.Query': 224 | err_result, content = self.process_query_command(data) 225 | if not err_result: 226 | properties = content 227 | content = {} 228 | else: 229 | err_result = self._errorResult('SERVICE_ERROR') 230 | else: 231 | err_result = self._errorResult('ACCESS_TOKEN_INVALIDATE') 232 | 233 | # Check error and fill response name 234 | if err_result: 235 | header['name'] = 'ErrorResponse' 236 | content = err_result 237 | else: 238 | header['name'] = action + 'Response' 239 | 240 | # Fill response deviceId 241 | if 'deviceId' in payload: 242 | content['deviceId'] = payload['deviceId'] 243 | 244 | response = {'header': header, 'payload': content} 245 | if properties: 246 | response['properties'] = properties 247 | _LOGGER.info("[%s] Respnose:\n%s", LOGGER_NAME, response) 248 | return response 249 | 250 | def _prase_command(self, command, arg): 251 | header = command['header'] 252 | payload = command['payload'] 253 | 254 | if arg == 'device_id': 255 | return payload['deviceId'] 256 | elif arg == 'action': 257 | return header['name'] 258 | elif arg == 'user_uid': 259 | return payload.get('openUid','') 260 | elif arg == 'namespace': 261 | return header['namespace'] 262 | else: 263 | return command.get(arg) 264 | 265 | def _discovery_process_propertites(self, device_properties): 266 | properties = [] 267 | for device_property in device_properties: 268 | name = self.device_attribute_map_h2p.get(device_property.get('attribute')) 269 | state = self._hass.states.get(device_property.get('entity_id')) 270 | if name: 271 | value = state.state if state else 'unavailable' 272 | properties += [{'name': name.lower(), 'value': value}] 273 | return properties if properties else [{'name': 'powerstate', 'value': 'off'}] 274 | 275 | def _discovery_process_actions(self, device_properties, raw_actions): 276 | actions = [] 277 | for device_property in device_properties: 278 | name = self.device_attribute_map_h2p.get(device_property.get('attribute')) 279 | if name: 280 | action = self.device_action_map_h2p.get('query_'+name) 281 | if action: 282 | actions += [action,] 283 | for raw_action in raw_actions: 284 | action = self.device_action_map_h2p.get(raw_action) 285 | if action: 286 | actions += [action,] 287 | return list(set(actions)) 288 | 289 | def _discovery_process_device_type(self, raw_device_type): 290 | # raw_device_type guess from device_id's domain transfer to platform style 291 | return raw_device_type if raw_device_type in self._device_type_alias else self.device_type_map_h2p.get(raw_device_type) 292 | 293 | def _discovery_process_device_info(self, device_id, device_type, device_name, zone, properties, actions): 294 | return { 295 | 'deviceId': encrypt_device_id(device_id), 296 | 'deviceName': device_name, 297 | 'deviceType': device_type, 298 | 'zone': zone, 299 | 'model': device_name, 300 | 'brand': 'HomeAssistant', 301 | 'icon': 'https://d33wubrfki0l68.cloudfront.net/cbf939aa9147fbe89f0a8db2707b5ffea6c192cf/c7c55/images/favicon-192x192-full.png', 302 | 'properties': properties, 303 | 'actions': actions 304 | #'extensions':{'extension1':'','extension2':''} 305 | } 306 | 307 | 308 | def _control_process_propertites(self, device_properties, action) -> None: 309 | return {} 310 | 311 | def _query_process_propertites(self, device_properties, action) -> None: 312 | properties = [] 313 | action = action.replace('Request', '').replace('Get', '') 314 | if action in self._query_map_p2h: 315 | for property_name, attr_template in self._query_map_p2h[action].items(): 316 | formattd_property = self.vcdm.format_property(self._hass, device_properties, attr_template) 317 | properties.append({property_name:formattd_property}) 318 | else: 319 | for device_property in device_properties: 320 | state = self._hass.states.get(device_property.get('entity_id')) 321 | value = state.attributes.get(device_property.get('attribute'), state.state) if state else None 322 | if value: 323 | if action == 'Query': 324 | formattd_property = {'name': self.device_attribute_map_h2p.get(device_property.get('attribute')), 'value': value} 325 | properties.append(formattd_property) 326 | elif device_property.get('attribute') in action.lower(): 327 | formattd_property = {'name': self.device_attribute_map_h2p.get(device_property.get('attribute')), 'value': value} 328 | properties = [formattd_property] 329 | break 330 | return properties 331 | 332 | def _decrypt_device_id(self, device_id) -> None: 333 | return decrypt_device_id(device_id) -------------------------------------------------------------------------------- /custom_components/havcs/bind.py: -------------------------------------------------------------------------------- 1 | import json 2 | import aiohttp 3 | import asyncio 4 | import async_timeout 5 | import logging 6 | import traceback 7 | 8 | from homeassistant.const import EVENT_STATE_CHANGED, ATTR_ENTITY_ID 9 | from homeassistant.core import callback 10 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 11 | 12 | from .const import HAVCS_SERVICE_URL, DATA_HAVCS_HANDLER, DATA_HAVCS_BIND_MANAGER, STORAGE_VERSION, INTEGRATION 13 | from . import util as havcs_util 14 | 15 | _LOGGER = logging.getLogger(__name__) 16 | LOGGER_NAME = 'bind' 17 | 18 | STORAGE_KEY='havcs_bind_manager' 19 | STORAGE_VERSION 20 | 21 | # 用于管理哪些平台哪些用户有哪些设备 22 | class HavcsBindManager: 23 | _privious_upload_devices = {} 24 | _new_upload_devices = {} 25 | _discovery = set() 26 | def __init__(self, hass, platforms, bind_device = False, sync_device = False, app_key = None, decrypt_key = None): 27 | _LOGGER.debug("[bindManager] ----init bindManager----") 28 | self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) 29 | self._platforms = platforms 30 | for platform in platforms: 31 | self._new_upload_devices[platform] = {} 32 | self._hass = hass 33 | self._sync_manager = { 34 | 'bind_device': bind_device, 35 | 'sync_device': sync_device, 36 | 'app_key': app_key, 37 | 'decrypt_key': decrypt_key 38 | } 39 | async def async_init(self): 40 | await self.async_load() 41 | if self._sync_manager.get('bind_device'): 42 | await self.async_bind_device() 43 | if self._sync_manager.get('sync_device'): 44 | self.sync_device() 45 | 46 | async def async_load(self): 47 | data = await self._store.async_load() # load task config from disk 48 | if data: 49 | self._privious_upload_devices = { 50 | device['device_id']: {'device_id': device['device_id'], 'linked_account': set(device['linked_account'])} for device in data.get('upload_devices',[]) 51 | } 52 | self._discovery = set(data.get('discovery',[])) 53 | _LOGGER.debug("[bindManager] discovery:\n%s", self.discovery) 54 | 55 | def get_bind_entity_ids(self, platform, p_user_id = '', repeat_upload = True): 56 | _LOGGER.debug("[bindManager] privious_upload_devices:\n%s", self._privious_upload_devices) 57 | _LOGGER.debug("[bindManager] new_upload_devices:\n%s", self._new_upload_devices.get(platform)) 58 | search = set([p_user_id + '@' + platform, '*@' + platform]) # @jdwhale获取平台所有设备,*@jdwhale表示该不限定用户 59 | if repeat_upload: 60 | bind_entity_ids = [device['device_id'] for device in self._new_upload_devices.get(platform).values() if search & device['linked_account'] ] 61 | else: 62 | bind_entity_ids = [device['device_id'] for device in self._new_upload_devices.get(platform).values() if (search & device['linked_account']) and not(search & self._privious_upload_devices.get(device['device_id'],{}).get('linked_account',set()))] 63 | return bind_entity_ids 64 | 65 | def get_unbind_entity_ids(self, platform, p_user_id = ''): 66 | search = set([p_user_id + '@' + platform, '*@' + platform]) 67 | unbind_devices = [device['device_id'] for device in self._privious_upload_devices.values() if (search & device['linked_account']) and not(search & self._new_upload_devices.get(platform).get(device['device_id'],{}).get('linked_account',set()))] 68 | return unbind_devices 69 | 70 | def update_lists(self, devices, platform, p_user_id= '*',repeat_upload = True): 71 | if platform is None: 72 | platforms = [platform for platform in self._platforms] 73 | else: 74 | platforms = [platform] 75 | 76 | linked_account = set([p_user_id + '@' + platform for platform in platforms]) 77 | # _LOGGER.debug("[bindManager] 0.linked_account:%s", linked_account) 78 | for device_id in devices: 79 | if device_id in self._new_upload_devices.get(platform): 80 | device = self._new_upload_devices.get(platform).get(device_id) 81 | device['linked_account'] = device['linked_account'] | linked_account 82 | # _LOGGER.debug("[bindManager] 1.linked_account:%s", device['linked_account']) 83 | else: 84 | linked_account =linked_account | set(['@' + platform for pplatform in platform]) 85 | device = { 86 | 'device_id': device_id, 87 | 'linked_account': linked_account, 88 | } 89 | # _LOGGER.debug("[bindManager] 1.linked_account:%s", device['linked_account']) 90 | self._new_upload_devices.get(platform)[device_id] = device 91 | 92 | async def async_save(self, platform, p_user_id= '*'): 93 | devices = {} 94 | for device_id in self.get_unbind_entity_ids(platform, p_user_id): 95 | if device_id in devices: 96 | device = devices.get(device_id) 97 | device['linked_account'] = device['linked_account'] | linked_account 98 | # _LOGGER.debug("1.linked_account:%s", device['linked_account']) 99 | else: 100 | linked_account =set([p_user_id +'@'+platform]) 101 | device = { 102 | 'device_id': device_id, 103 | 'linked_account': linked_account, 104 | } 105 | # _LOGGER.debug("1.linked_account:%s", device['linked_account']) 106 | devices[device_id] = device 107 | _LOGGER.debug("[bindManager] all_unbind_devices:\n%s", devices) 108 | 109 | upload_devices = [ 110 | { 111 | 'device_id': device_id, 112 | 'linked_account': list((self._privious_upload_devices.get(device_id,{}).get('linked_account',set()) | self._new_upload_devices.get(platform).get(device_id,{}).get('linked_account',set())) - devices.get(device_id,{}).get('linked_account',set())) 113 | } for device_id in set(list(self._privious_upload_devices.keys())+list(self._new_upload_devices.get(platform).keys())) 114 | ] 115 | _LOGGER.debug("[bindManager] upload_devices:\n%s", upload_devices) 116 | data = { 117 | 'upload_devices':upload_devices, 118 | 'discovery':self.discovery 119 | } 120 | await self._store.async_save(data) 121 | self._privious_upload_devices = { 122 | device['device_id']: {'device_id': device['device_id'], 'linked_account': set(device['linked_account'])} for device in upload_devices 123 | } 124 | 125 | async def async_save_changed_devices(self, new_devices, platform, p_user_id = '*', force_save = False): 126 | self.update_lists(new_devices, platform) 127 | uid = p_user_id+'@'+platform 128 | if self.check_discovery(uid) and not force_save: 129 | # _LOGGER.debug("[bindManager] 用户(%s)已执行discovery", uid) 130 | bind_entity_ids = [] 131 | unbind_entity_ids = [] 132 | else: 133 | # _LOGGER.debug("用户(%s)启动首次执行discovery", uid) 134 | self.add_discovery(uid) 135 | bind_entity_ids = self.get_bind_entity_ids(platform = platform,p_user_id =p_user_id, repeat_upload = False) 136 | unbind_entity_ids = self.get_unbind_entity_ids(platform = platform,p_user_id=p_user_id) 137 | await self.async_save(platform, p_user_id=p_user_id) 138 | # _LOGGER.debug("[bindManager] p_user_id:%s',p_user_id) 139 | # _LOGGER.debug("[bindManager] get_bind_entity_ids:%s", bind_entity_ids) 140 | # _LOGGER.debug("[bindManager] get_unbind_entity_ids:%s", unbind_entity_ids) 141 | return bind_entity_ids,unbind_entity_ids 142 | 143 | def check_discovery(self, uid): 144 | if uid in self._discovery: 145 | return True 146 | else: 147 | return False 148 | def add_discovery(self, uid): 149 | self._discovery = self._discovery | set([uid]) 150 | 151 | @property 152 | def discovery(self): 153 | return list(self._discovery) 154 | 155 | def get_uids(self, platform, device_id): 156 | # _LOGGER.debug("[bindManager] %s", self._discovery) 157 | # _LOGGER.debug("[bindManager] %s", self._privious_upload_devices) 158 | p_user_ids = [] 159 | for uid in self._discovery: 160 | p_user_id = uid.split('@')[0] 161 | p = uid.split('@')[1] 162 | if p == platform and (set([uid, '*@' + platform]) & self._privious_upload_devices.get(device_id,{}).get('linked_account',set())): 163 | p_user_ids.append(p_user_id) 164 | return p_user_ids 165 | 166 | async def async_bind_device(self): 167 | for uuid in self._hass.data[INTEGRATION][DATA_HAVCS_BIND_MANAGER].discovery: 168 | p_user_id = uuid.split('@')[0] 169 | platform = uuid.split('@')[1] 170 | if platform in self._hass.data[INTEGRATION][DATA_HAVCS_HANDLER] and getattr(self._hass.data[INTEGRATION][DATA_HAVCS_HANDLER].get(platform), 'should_report_when_starup', False) and hasattr(self._hass.data[INTEGRATION][DATA_HAVCS_HANDLER].get(platform), 'bind_device'): 171 | err_result, devices, entity_ids = self._hass.data[INTEGRATION][DATA_HAVCS_HANDLER][platform].process_discovery_command() 172 | if err_result: 173 | return 174 | bind_entity_ids, unbind_entity_ids = await self._hass.data[INTEGRATION][DATA_HAVCS_BIND_MANAGER].async_save_changed_devices(entity_ids,platform, p_user_id,True) 175 | payload = await self._hass.data[INTEGRATION][DATA_HAVCS_HANDLER][platform].bind_device(p_user_id, entity_ids , unbind_entity_ids, devices) 176 | _LOGGER.debug("[skill] bind device to %s:\nbind_entity_ids = %s, unbind_entity_ids = %s", platform, bind_entity_ids, unbind_entity_ids) 177 | 178 | if payload: 179 | url = HAVCS_SERVICE_URL + '/skill/smarthome.php?v=update&AppKey='+self._sync_manager.get('app_key') 180 | data = havcs_util.AESCipher(self._sync_manager.get('decrypt_key')).encrypt(json.dumps(payload, ensure_ascii = False).encode('utf8')) 181 | try: 182 | session = async_get_clientsession(self._hass, verify_ssl=False) 183 | with async_timeout.timeout(5, loop=self._hass.loop): 184 | response = await session.post(url, data=data) 185 | _LOGGER.debug("[skill] get bind device result from %s: %s", platform, await response.text()) 186 | except(asyncio.TimeoutError, aiohttp.ClientError): 187 | _LOGGER.error("[skill] fail to access %s, bind device fail: timeout", url) 188 | except: 189 | _LOGGER.error("[skill] fail to access %s, bind device fail: %s", url, traceback.format_exc()) 190 | 191 | def sync_device(self): 192 | 193 | @callback 194 | def report_device(event): 195 | # _LOGGER.debug("[skill] %s changed, try to report", event.data[ATTR_ENTITY_ID]) 196 | self._hass.add_job(async_report_device(event)) 197 | 198 | async def async_report_device(event): 199 | """report device state when changed. """ 200 | entity = self._hass.states.get(event.data[ATTR_ENTITY_ID]) 201 | if entity is None: 202 | return 203 | for platform, handler in self._hass.data[INTEGRATION][DATA_HAVCS_HANDLER].items(): 204 | if hasattr(handler, 'report_device'): 205 | device_ids = handler.vcdm.get_entity_related_device_ids(self._hass, entity.entity_id) 206 | for device_id in device_ids: 207 | payload = handler.report_device(device_id) 208 | _LOGGER.debug("[skill] report device to %s: platform = %s, device_id = %s (entity_id = %s), data = %s", platform, device_id, event.data[ATTR_ENTITY_ID], platform, payload) 209 | if payload: 210 | url = HAVCS_SERVICE_URL + '/skill/'+platform+'.php?v=report&AppKey=' + self._sync_manager.get('app_key') 211 | data = havcs_util.AESCipher(self._sync_manager.get('decrypt_key')).encrypt(json.dumps(payload, ensure_ascii = False).encode('utf8')) 212 | try: 213 | session = async_get_clientsession(self._hass, verify_ssl=False) 214 | with async_timeout.timeout(5, loop=self._hass.loop): 215 | response = await session.post(url, data=data) 216 | _LOGGER.debug("[skill] get report device result from %s: %s", platform, await response.text()) 217 | except(asyncio.TimeoutError, aiohttp.ClientError): 218 | _LOGGER.error("[skill] fail to access %s, report device fail: timeout", url) 219 | except: 220 | _LOGGER.error("[skill] fail to access %s, report device fail: %s", url, traceback.format_exc()) 221 | 222 | self.clear() 223 | self._sync_manager['remove_listener'] = self._hass.bus.async_listen(EVENT_STATE_CHANGED, report_device) 224 | 225 | def clear(self): 226 | remove_listener = self._sync_manager.get('remove_listener') 227 | if remove_listener: 228 | remove_listener() 229 | self._sync_manager.pop('remove_listener') -------------------------------------------------------------------------------- /custom_components/havcs/ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh 3 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 4 | d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD 5 | QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT 6 | MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j 7 | b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG 8 | 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB 9 | CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97 10 | nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt 11 | 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P 12 | T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4 13 | gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO 14 | BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR 15 | TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw 16 | DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr 17 | hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg 18 | 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF 19 | PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls 20 | YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk 21 | CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4= 22 | -----END CERTIFICATE----- -------------------------------------------------------------------------------- /custom_components/havcs/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for MQTT.""" 2 | from collections import OrderedDict 3 | import queue 4 | import ssl 5 | from hashlib import sha1 6 | import json 7 | import voluptuous as vol 8 | import os 9 | import re 10 | import logging 11 | 12 | from homeassistant import config_entries 13 | from homeassistant.const import (CONF_PORT, CONF_PROTOCOL, CONF_HOST) 14 | 15 | from .const import HAVCS_SERVICE_URL, DEVICE_PLATFORM_DICT, CONF_MODE, CONF_DEVICE_CONFIG, CONF_APP_KEY, CONF_APP_SECRET, CONF_BROKER, CONF_ENTITY_KEY, CONF_URL, CONF_PROXY_URL, CONF_SKIP_TEST, CONF_DISCOVERY, DEFAULT_DISCOVERY, INTEGRATION, CONF_HA_URL 16 | from . import util as havcs_util 17 | # from .__init__ import HavcsTestView 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | LOGGER_NAME = 'config_flow' 21 | 22 | SOURCE_PLATFORM = 'platform' 23 | 24 | @config_entries.HANDLERS.register(INTEGRATION) 25 | class FlowHandler(config_entries.ConfigFlow): 26 | """Handle a config flow.""" 27 | 28 | VERSION = 1 29 | CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH 30 | 31 | async def async_step_user(self, user_input=None): 32 | """Handle a flow initialized by the user.""" 33 | self._user_entries = [entry for entry in self._async_current_entries() if entry.source == config_entries.SOURCE_USER] 34 | if self._user_entries: 35 | # return self.async_abort(reason='single_instance_allowed') 36 | return await self.async_step_clear() 37 | 38 | return await self.async_step_base() 39 | 40 | async def async_step_clear(self, user_input=None): 41 | errors = {} 42 | if user_input is not None: 43 | if user_input.get('comfirm'): 44 | for entry in self._user_entries: 45 | await self.hass.async_create_task(self.hass.config_entries.async_remove(entry.entry_id)) 46 | return self.async_abort(reason='clear_finish') 47 | else: 48 | return self.async_abort(reason='clear_cancel') 49 | else: 50 | user_input = {} 51 | fields = OrderedDict() 52 | fields[vol.Required('comfirm', default = user_input.get('comfirm', False))] = bool 53 | return self.async_show_form( 54 | step_id='clear', data_schema=vol.Schema(fields), errors=errors) 55 | 56 | async def async_step_base(self, user_input=None): 57 | errors = {} 58 | if user_input is not None: 59 | self._mode = user_input.get('mode') 60 | self._device_config = 'ui' if user_input[CONF_DEVICE_CONFIG] else 'text' 61 | user_input.pop(CONF_DEVICE_CONFIG) 62 | self._platform=[key for key in user_input if user_input[key] is True] 63 | if not self._platform: 64 | errors['base'] = 'platform_validation' 65 | elif self._mode == 0: 66 | errors[CONF_MODE] = 'mode_validation' 67 | else: 68 | return await self.async_step_access() 69 | else: 70 | user_input = {} 71 | fields = OrderedDict() 72 | for platform in DEVICE_PLATFORM_DICT.keys(): 73 | fields[vol.Optional(platform, default = user_input.get(platform, False))] = bool 74 | fields[vol.Optional(CONF_MODE, default = user_input.get(CONF_MODE, 0))] = vol.In({0: '选择运行模式', 1: '模式1 - http(自建技能)', 2: '模式2 - http+proxy(自建技能)', 3: '模式3 - HAVCS服务(音箱APP技能)'}) 75 | fields[vol.Optional(CONF_DEVICE_CONFIG, default = user_input.get(CONF_DEVICE_CONFIG, True))] = bool 76 | return self.async_show_form( 77 | step_id='base', data_schema=vol.Schema(fields), errors=errors) 78 | 79 | async def async_step_access(self, user_input=None): 80 | """Confirm the setup.""" 81 | errors = {} 82 | check = True 83 | if user_input is not None: 84 | if len(user_input[CONF_ENTITY_KEY]) != 0 and len(user_input[CONF_ENTITY_KEY]) != 16: 85 | errors[CONF_ENTITY_KEY] = 'entity_key_validation' 86 | check = False 87 | if user_input.get(CONF_SKIP_TEST, False) is False: 88 | if len(user_input.get(CONF_PROXY_URL, '')) != 0: 89 | matchObj = re.match(r'' + HAVCS_SERVICE_URL + '/h2m2h/(.+?)/(.*)', user_input[CONF_PROXY_URL], re.M|re.I) 90 | if not matchObj: 91 | errors[CONF_PROXY_URL] = 'proxy_url_validation' 92 | check = False 93 | # check mqtt 94 | if not errors: 95 | if CONF_BROKER in user_input: 96 | test_results = await self.hass.async_add_executor_job( 97 | test_mqtt, user_input.get(CONF_BROKER), user_input.get(CONF_PORT), 98 | user_input.get(CONF_APP_KEY), user_input.get(CONF_APP_SECRET), user_input.get(CONF_PROXY_URL)) 99 | if not test_results[0][0]: 100 | errors['base'] = 'connecttion_test_' + str(test_results[0][1]) 101 | check = False 102 | # check proxy url 103 | elif CONF_PROXY_URL in user_input and len(test_results) !=2 : 104 | errors['base'] = 'proxy_test' 105 | check = False 106 | _LOGGER.debug("[%s] mqtt test result: %s", LOGGER_NAME, test_results) 107 | # check api 108 | elif CONF_URL in user_input: 109 | test_result = await self.hass.async_add_executor_job( 110 | test_http, self.hass, user_input[CONF_URL]) 111 | if not test_result[0]: 112 | _LOGGER.debug("[%s] http test result: %s", LOGGER_NAME, test_result) 113 | errors['base'] = 'http_test' 114 | check = False 115 | 116 | for entry in [entry for entry in self._async_current_entries() if entry.source == config_entries.SOURCE_IMPORT]: 117 | _LOGGER.info("[%s] overwrite Intergation generated by configuration.yml with the new one from the web", LOGGER_NAME) 118 | await self.hass.async_create_task(self.hass.config_entries.async_remove(entry.entry_id)) 119 | if check: 120 | conf = {'platform': self._platform} 121 | clients = {user_input[platform+'_id']: user_input[platform+'_secret'] for platform in self._platform if platform+'_id' in user_input} 122 | mode = [] 123 | if self._mode == 1: 124 | conf.update({ 125 | 'http': {'clients':clients, 'ha_url': user_input[CONF_HA_URL]} if user_input[CONF_HA_URL] else {'clients':clients}, 126 | 'setting':{}, 127 | 'device_config': self._device_config 128 | }) 129 | mode.append('http') 130 | elif self._mode == 2: 131 | conf.update({ 132 | 'http':{'clients':clients}, 133 | 'http_proxy':{'ha_url': user_input[CONF_HA_URL]} if user_input[CONF_HA_URL] else {}, 134 | 'setting': {'broker': user_input[CONF_BROKER], 'port': user_input[CONF_PORT], 'app_key': user_input[CONF_APP_KEY], 'app_secret': user_input[CONF_APP_SECRET], 'entity_key': user_input[CONF_ENTITY_KEY]}, 135 | 'device_config': self._device_config 136 | }) 137 | mode.append('http_proxy') 138 | elif self._mode == 3: 139 | conf.update({ 140 | 'skill': {}, 141 | 'setting': {'broker': user_input[CONF_BROKER], 'port': user_input[CONF_PORT], 'app_key': user_input[CONF_APP_KEY], 'app_secret': user_input[CONF_APP_SECRET], 'entity_key': user_input[CONF_ENTITY_KEY]}, 142 | 'device_config': self._device_config 143 | }) 144 | mode.append('skill') 145 | 146 | havcs_entries = self.hass.config_entries.async_entries(INTEGRATION) 147 | # sub entry for every platform 148 | entry_platforms = set([entry.data.get('platform') for entry in havcs_entries if entry.source == SOURCE_PLATFORM]) 149 | conf_platforms = set(self._platform) 150 | new_platforms = conf_platforms - entry_platforms 151 | for platform in new_platforms: 152 | self.hass.async_create_task(self.hass.config_entries.flow.async_init( 153 | INTEGRATION, context={'source': SOURCE_PLATFORM}, 154 | data={'platform': platform, 'mode': mode} 155 | )) 156 | remove_platforms = entry_platforms - conf_platforms 157 | for entry in [entry for entry in havcs_entries if entry.source == SOURCE_PLATFORM]: 158 | if entry.data.get('platform') in remove_platforms: 159 | self.hass.async_create_task(self.hass.config_entries.async_remove(entry.entry_id)) 160 | else: 161 | entry.title=f"接入平台[{entry.data.get('platform')}-{DEVICE_PLATFORM_DICT[entry.data.get('platform')]['cn_name']}],接入方式{mode}" 162 | self.hass.config_entries.async_update_entry(entry) 163 | 164 | return self.async_create_entry( 165 | title='主配置[Web界面]', data=conf) 166 | else: 167 | user_input = {} 168 | fields = OrderedDict() 169 | 170 | if self._mode == 1: 171 | for platform in self._platform: 172 | fields[vol.Required(platform+'_id', default = user_input.get(platform+'_id', platform))] = str 173 | fields[vol.Required(platform+'_secret', default = user_input.get(platform+'_secret', ''))] = str 174 | fields[vol.Optional(CONF_ENTITY_KEY, default = user_input.get(CONF_ENTITY_KEY, ''))] = str 175 | fields[vol.Required(CONF_SKIP_TEST, default = user_input.get(CONF_SKIP_TEST, False))] = bool 176 | fields[vol.Optional(CONF_URL, default = user_input.get(CONF_URL, 'https://[你的公网域名或IP:端口号]/havcs/auth/authorize'))] = str 177 | fields[vol.Optional(CONF_HA_URL, default = user_input.get(CONF_HA_URL, ''))] = str 178 | else: 179 | fields[vol.Required(CONF_BROKER, default = user_input.get(CONF_BROKER, 'mqtt.ljr.im'))] = str 180 | fields[vol.Required(CONF_PORT, default = user_input.get(CONF_PORT, 28883))] = vol.Coerce(int) 181 | fields[vol.Required(CONF_APP_KEY, default = user_input.get(CONF_APP_KEY, ''))] = str 182 | fields[vol.Required(CONF_APP_SECRET, default = user_input.get(CONF_APP_SECRET, ''))] = str 183 | fields[vol.Optional(CONF_ENTITY_KEY, default = user_input.get(CONF_ENTITY_KEY, ''))] = str 184 | fields[vol.Required(CONF_SKIP_TEST, default = user_input.get(CONF_SKIP_TEST, False))] = bool 185 | if self._mode == 2: 186 | fields[vol.Optional(CONF_PROXY_URL, default = user_input.get(CONF_PROXY_URL, HAVCS_SERVICE_URL + '/h2m2h/[你的AppKey]/havcs/auth/authorize'))] = str 187 | fields[vol.Optional(CONF_HA_URL, default = user_input.get(CONF_HA_URL, ''))] = str 188 | 189 | # fields[vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY)] = bool 190 | 191 | return self.async_show_form( 192 | step_id='access', data_schema=vol.Schema(fields), errors=errors) 193 | 194 | async def async_step_import(self, user_input): 195 | """Import a config entry. 196 | 197 | Special type of import, we're not actually going to store any data. 198 | Instead, we're going to rely on the values that are in config file. 199 | """ 200 | if [entry for entry in self._async_current_entries() if entry.source in [config_entries.SOURCE_USER, config_entries.SOURCE_IMPORT]]: 201 | return self.async_abort(reason='single_instance_allowed') 202 | return self.async_create_entry(title='主配置[configuration.yml]', data={'platform': user_input['platform']}) 203 | 204 | async def async_step_platform(self, user_input): 205 | return self.async_create_entry(title=f"接入平台[{user_input['platform']}-{DEVICE_PLATFORM_DICT[user_input['platform']]['cn_name']}],接入模块{user_input['mode']}", data=user_input) 206 | 207 | def test_mqtt(broker, port, username, password, proxy_url, protocol='3.1'): 208 | """Test if we can connect to an MQTT broker.""" 209 | 210 | import paho.mqtt.client as mqtt 211 | 212 | if protocol == '3.1': 213 | proto = mqtt.MQTTv31 214 | else: 215 | proto = mqtt.MQTTv311 216 | 217 | client = mqtt.Client(protocol=proto) 218 | if username and password: 219 | client.username_pw_set(username, password) 220 | 221 | certificate = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ca.crt') 222 | client.tls_set(ca_certs = certificate, cert_reqs = ssl.CERT_NONE) 223 | 224 | result = queue.Queue(maxsize=2) 225 | 226 | def on_connect(client_, userdata, flags, result_code): 227 | """Handle connection result.""" 228 | _LOGGER.debug("[%s] connection check: %s, result code [%s]", LOGGER_NAME, result_code == mqtt.CONNACK_ACCEPTED, result_code) 229 | result.put((result_code == mqtt.CONNACK_ACCEPTED, result_code)) 230 | if proxy_url: 231 | client.subscribe('ai-home/http2mqtt2hass/'+username+'/request/#',qos=0) 232 | 233 | def on_subscribe(client, userdata, mid, granted_qos): 234 | from urllib.request import urlopen 235 | import urllib.error 236 | import urllib.parse 237 | 238 | data = urllib.parse.urlencode({'data': 'test'}).encode('utf-8') 239 | # 会阻塞on_message,不等待回复(mqtt回复) 240 | response = urlopen(proxy_url, data = data, timeout= 2) 241 | # try: 242 | # data = urllib.parse.urlencode({'data': 'test'}).encode('utf-8') 243 | # response = urlopen(proxy_url, data = data, timeout= 2) 244 | # print("response 的返回类型:",type(response)) 245 | # print("反应地址信息: ",response) 246 | # print("头部信息1(http header):\n",response.info()) 247 | # print("头部信息2(http header):\n",response.getheaders()) 248 | # print("输出头部属性信息:",response.getheader("Server")) 249 | # print("响应状态信息1(http status):\n",response.status) 250 | # print("响应状态信息2(http status):\n",response.getcode()) 251 | # print("响应 url 地址:\n",response.geturl()) 252 | # page = response.read() 253 | # print("输出网页源码:",page.decode('utf-8')) 254 | # except urllib.error.URLError as e: 255 | # print('访问代理转发服务器失败: ', e.reason) 256 | # except Exception as e: 257 | # import traceback 258 | # print(type(e)) 259 | # print('访问代理转发服务器失败: ', traceback.format_exc()) 260 | # else: 261 | # print('访问代理转发服务器正常.') 262 | def on_message(client, userdata, msg): 263 | _LOGGER.debug("[%s] proxy check: success receive messge from proxy [%s]", LOGGER_NAME, msg.topic+", "+str(msg.payload)) 264 | try: 265 | matchObj = re.match(r''+HAVCS_SERVICE_URL+'/h2m2h/(.+?)/(.*)', proxy_url, re.M|re.I) 266 | if matchObj: 267 | forward_uri = '/' + matchObj.group(2) 268 | decrypt_key = bytes().fromhex(sha1(password.encode("utf-8")).hexdigest())[0:16] 269 | payload = havcs_util.AESCipher(decrypt_key).decrypt(msg.payload) 270 | req = json.loads(payload) 271 | req_uri = req.get('uri', '/') 272 | if forward_uri == req_uri: 273 | result.put((True, 0)) 274 | _LOGGER.debug("[%s] proxy check: ok, forward uri [%s], receive uri [%s]", LOGGER_NAME, forward_uri, req_uri) 275 | except: 276 | import traceback 277 | _LOGGER.error("[%s] %s", LOGGER_NAME, traceback.format_exc()) 278 | finally: 279 | client.publish(msg.topic.replace('/request/','/response/'), payload=msg.payload, qos=0, retain=False) 280 | 281 | client.on_connect = on_connect 282 | client.on_subscribe = on_subscribe 283 | client.on_message = on_message 284 | 285 | client.connect_async(broker, port) 286 | client.loop_start() # 与loop_start()一起使用以非阻塞方式连接。 直到调用loop_start()之前,连接才会完成。 287 | 288 | connection_result = None 289 | proxy_result = None 290 | try: 291 | connection_result = result.get(timeout=5) 292 | proxy_result = result.get(timeout=5) 293 | return [connection_result, proxy_result] 294 | except queue.Empty: 295 | if connection_result: 296 | return [connection_result] 297 | else: 298 | return [(False, 99)] 299 | finally: 300 | client.disconnect() 301 | client.loop_stop() 302 | 303 | def test_http(hass, url): 304 | # hass.http.register_view(HavcsTestView()) 305 | import requests 306 | try: 307 | response = requests.head(HAVCS_SERVICE_URL + '/api/forward.php?url=' + url, timeout= 5) 308 | return (response.status_code == 200, response.status_code) 309 | except: 310 | return (False, 0) -------------------------------------------------------------------------------- /custom_components/havcs/const.py: -------------------------------------------------------------------------------- 1 | """Constants used by havcs.""" 2 | HAVCS_SERVICE_URL = 'https://havcs.ljr.im:8123' 3 | 4 | ATTR_DEVICE_VISABLE = 'visable' 5 | ATTR_DEVICE_ID = 'device_id' 6 | ATTR_DEVICE_ENTITY_ID = 'entity_id' 7 | ATTR_DEVICE_TYPE = 'type' 8 | ATTR_DEVICE_NAME = 'name' 9 | ATTR_DEVICE_ZONE = 'zone' 10 | ATTR_DEVICE_ICON = 'icon' 11 | ATTR_DEVICE_ATTRIBUTES = 'attributes' 12 | ATTR_DEVICE_ACTIONS = 'actions' 13 | ATTR_DEVICE_PROPERTIES = 'properties' 14 | 15 | DATA_HAVCS_CONFIG = 'config' 16 | DATA_HAVCS_MQTT = 'mqtt' 17 | DATA_HAVCS_BIND_MANAGER = 'bind_manager' 18 | DATA_HAVCS_HTTP_MANAGER = 'http_manager' 19 | DATA_HAVCS_ITEMS = 'items' 20 | DATA_HAVCS_SETTINGS = 'settings' 21 | DATA_HAVCS_HANDLER = 'handler' 22 | 23 | CONF_BROKER = 'broker' 24 | CONF_DISCOVERY = 'discovery' 25 | DEFAULT_DISCOVERY = False 26 | INTEGRATION = 'havcs' 27 | STORAGE_VERSION = 1 28 | STORAGE_KEY = 'havcs' 29 | 30 | CONF_ENTITY_KEY = 'entity_key' 31 | CONF_APP_KEY = 'app_key' 32 | CONF_APP_SECRET = 'app_secret' 33 | CONF_URL = 'url' 34 | CONF_PROXY_URL = 'proxy_url' 35 | CONF_SKIP_TEST = 'skip_test' 36 | CONF_DEVICE_CONFIG = 'device_config' 37 | CONF_DEVICE_CONFIG_PATH = 'device_config_path' 38 | CONF_SETTINGS_CONFIG_PATH = 'settings_config_path' 39 | CONF_HA_URL = 'ha_url' 40 | 41 | CONF_MODE = 'mode' 42 | 43 | CLIENT_PALTFORM_DICT = { 44 | 'jdwhale': 'https://alphadev.jd.com', 45 | 'dueros': 'https://xiaodu.baidu.com', 46 | 'aligenie': 'https://open.bot.tmall.com' 47 | } 48 | 49 | HAVCS_ACTIONS_ALIAS = { 50 | 'aligenie':{ 51 | 'turn_on': 'turnOn', 52 | 'turn_off': 'turnOff', 53 | 'increase_brightness': 'incrementBrightnessPercentage', 54 | 'decrease_brightness': 'decrementBrightnessPercentage' 55 | }, 56 | 'dueros':{ 57 | 'turn_on': 'turnOn', 58 | 'turn_off': 'turnOff', 59 | 'increase_brightness': 'AdjustUpBrightness', 60 | 'decrease_brightness': 'AdjustDownBrightness', 61 | 'timing_turn_on': 'timingTurnOn', 62 | 'timing_turn_off': 'timingTurnOff' 63 | }, 64 | 'jdwhale':{ 65 | 'turn_on': 'TurnOn', 66 | 'turn_off': 'TurnOff', 67 | 'increase_brightness': 'AdjustUpBrightness', 68 | 'decrease_brightness': 'AdjustDownBrightness' 69 | } 70 | } 71 | 72 | DEVICE_PLATFORM_DICT = { 73 | 'aligenie': { 74 | 'cn_name': '天猫精灵' 75 | }, 76 | 'dueros': { 77 | 'cn_name': '小度' 78 | }, 79 | 'jdwhale': { 80 | 'cn_name': '小京鱼' 81 | }, 82 | 'weixin': { 83 | 'cn_name': '企业微信' 84 | } 85 | } 86 | DEVICE_TYPE_DICT = { 87 | 'airpurifier': { 88 | 'cn_name': '空气净化器', 89 | 'icon': 'mdi-air-conditioner' 90 | }, 91 | 'climate': { 92 | 'cn_name': '空调', 93 | 'icon': 'mdi-air-conditioner' 94 | }, 95 | 'fan': { 96 | 'cn_name': '风扇', 97 | 'icon': 'mdi-pinwheel' 98 | }, 99 | 'light': { 100 | 'cn_name': '灯', 101 | 'icon': 'mdi-lightbulb' 102 | }, 103 | 'media_player': { 104 | 'cn_name': '播放器', 105 | 'icon': 'mdi-television-classic' 106 | }, 107 | 'switch': { 108 | 'cn_name': '开关', 109 | 'icon': 'mdi-toggle-switch' 110 | }, 111 | 'sensor': { 112 | 'cn_name': '传感器', 113 | 'icon': 'mdi-access-point-network' 114 | }, 115 | 'cover': { 116 | 'cn_name': '窗帘', 117 | 'icon': 'mdi-window-shutter' 118 | }, 119 | 'vacuum': { 120 | 'cn_name': '扫地机', 121 | 'icon': 'mdi-robot-vacuum-variant' 122 | } 123 | } 124 | DEVICE_ACTION_DICT ={ 125 | 'turn_on': { 126 | 'cn_name': '打开' 127 | }, 128 | 'turn_off': { 129 | 'cn_name': '关闭' 130 | }, 131 | 'timing_turn_on': { 132 | 'cn_name': '延时打开' 133 | }, 134 | 'timing_turn_off': { 135 | 'cn_name': '延时关闭' 136 | }, 137 | 'query_temperature': { 138 | 'cn_name': '查询温度' 139 | }, 140 | 'query_humidity': { 141 | 'cn_name': '查询湿度' 142 | }, 143 | 'increase_brightness': { 144 | 'cn_name': '调高亮度' 145 | }, 146 | 'decrease_brightness': { 147 | 'cn_name': '调低亮度' 148 | }, 149 | 'set_mode': { 150 | 'cn_name': '设置模式' 151 | }, 152 | 'play': { 153 | 'cn_name': '播放' 154 | }, 155 | 'pause': { 156 | 'cn_name': '暂停' 157 | }, 158 | 'continue': { 159 | 'cn_name': '继续' 160 | } 161 | } 162 | 163 | DEVICE_ATTRIBUTE_DICT = { 164 | 'power_state': { 165 | 'scale': '', 166 | 'legalValue': '(ON, OFF)', 167 | 'cn_name': '电源' 168 | }, 169 | 'temperature': { 170 | 'scale': '°C', 171 | 'legalValue': 'DOUBLE', 172 | 'cn_name': '温度' 173 | }, 174 | 'brightness': { 175 | 'scale': '%', 176 | 'legalValue': '[0.0, 100.0]', 177 | 'cn_name': '亮度' 178 | }, 179 | 'illumination': { 180 | 'scale': 'lm', 181 | 'legalValue': '[0.0, 1000.0]', 182 | 'cn_name': '照度' 183 | }, 184 | 'humidity': { 185 | 'scale': '%', 186 | 'legalValue': '[0.0, 100.0]', 187 | 'cn_name': '湿度' 188 | }, 189 | 'formaldehyde': { 190 | 'scale': 'mg/m3', 191 | 'legalValue': 'DOUBLE', 192 | 'cn_name': '甲醛浓度' 193 | }, 194 | 'pm25': { 195 | 'scale': 'μg/m3', 196 | 'legalValue': '[0.0, 1000.0]', 197 | 'cn_name': 'PM2.5浓度' 198 | }, 199 | 'co2': { 200 | 'scale': 'ppm', 201 | 'legalValue': 'INTEGER', 202 | 'cn_name': '二氧化碳浓度' 203 | }, 204 | 'mode': { 205 | 'scale': '', 206 | 'legalValue': '', 207 | 'cn_name': '工作模式' 208 | }, 209 | } -------------------------------------------------------------------------------- /custom_components/havcs/device.py: -------------------------------------------------------------------------------- 1 | from .const import INTEGRATION 2 | 3 | class VoiceControllDevice: 4 | 5 | def __init__(self, hass, config_entry, attributes, raw_attributes): 6 | """Initialize the device.""" 7 | self.hass = hass 8 | self.config_entry = config_entry 9 | self._attributes = attributes 10 | self._raw_attributes = raw_attributes 11 | self.available = True 12 | self.sw_version='v3' 13 | self.product_type = None 14 | self._id = None 15 | 16 | @property 17 | def custom_actions(self): 18 | return self._raw_attributes.get('actions', {}) 19 | 20 | @property 21 | def raw_attributes(self): 22 | return self._raw_attributes 23 | 24 | @property 25 | def attributes(self): 26 | return self._attributes 27 | 28 | @property 29 | def id(self): 30 | """Return the device_id of this device.""" 31 | return self._id 32 | 33 | @property 34 | def device_id(self): 35 | """Return the device_id of this device.""" 36 | return self._attributes['device_id'] 37 | 38 | @property 39 | def entity_id(self): 40 | """Return the entity_ids of this device.""" 41 | return self._attributes['entity_id'] 42 | 43 | @property 44 | def properties(self): 45 | return self._attributes['properties'] 46 | 47 | @property 48 | def model(self): 49 | """Return the model of this device.""" 50 | return f"{self._attributes['device_id']} <-> {self._attributes['entity_id']}" 51 | 52 | @property 53 | def name(self): 54 | """Return the name of this device.""" 55 | return self._attributes['name'] 56 | 57 | @property 58 | def serial(self): 59 | """Return the serial number of this device.""" 60 | return self._attributes['device_id'] 61 | 62 | async def async_update_device_registry(self): 63 | """Update device registry.""" 64 | device_registry = await self.hass.helpers.device_registry.async_get_registry() 65 | device = device_registry.async_get_or_create( 66 | config_entry_id=self.config_entry.entry_id, 67 | connections={('CONNECTION_NETWORK_MAC', self.serial)}, 68 | identifiers={(INTEGRATION, self.serial)}, 69 | manufacturer="HAVCS", 70 | model=self.model, 71 | name=self.name, 72 | sw_version=self.sw_version, 73 | ) 74 | self._id = device.id 75 | 76 | async def async_setup(self): 77 | 78 | return True -------------------------------------------------------------------------------- /custom_components/havcs/dueros.py: -------------------------------------------------------------------------------- 1 | import json 2 | import uuid 3 | import time 4 | import logging 5 | 6 | from .util import decrypt_device_id, encrypt_device_id 7 | from .helper import VoiceControlProcessor, VoiceControlDeviceManager 8 | from .const import DATA_HAVCS_BIND_MANAGER, INTEGRATION, ATTR_DEVICE_ACTIONS 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | # _LOGGER.setLevel(logging.DEBUG) 12 | 13 | DOMAIN = 'dueros' 14 | LOGGER_NAME = 'dueros' 15 | 16 | async def createHandler(hass, entry): 17 | mode = ['handler'] 18 | return VoiceControlDueros(hass, mode, entry) 19 | 20 | class PlatformParameter: 21 | device_attribute_map_h2p = { 22 | 'temperature': 'temperature', 23 | 'brightness': 'brightness', 24 | 'humidity': 'humidity', 25 | 'pm25': 'pm2.5', 26 | 'co2': 'co2', 27 | 'power_state': 'turnOnState', 28 | 'mode': 'mode' 29 | } 30 | device_action_map_h2p ={ 31 | 'turn_on': 'turnOn', 32 | 'turn_off': 'turnOff', 33 | 'timing_turn_on': 'timingTurnOn', 34 | 'timing_turn_off': 'timingTurnOff', 35 | 'increase_brightness': 'incrementBrightnessPercentage', 36 | 'decrease_brightness': 'decrementBrightnessPercentage', 37 | 'set_brightness': 'setBrightnessPercentage', 38 | # 'increase_temperature': 'incrementTemperature', 39 | # 'decrease_temperature': 'decrementTemperature', 40 | # 'set_temperature': 'setTemperature', 41 | 'set_color': 'setColor', 42 | 'pause': 'pause', 43 | # 'query_color': 'QueryColor', 44 | # 'query_power_state': 'getTurnOnState', 45 | 'query_temperature': 'getTemperatureReading', 46 | 'query_humidity': 'getHumidity', 47 | 'set_mode': 'setMode' 48 | # '': 'QueryWindSpeed', 49 | # '': 'QueryBrightness', 50 | # '': 'QueryFog', 51 | # '': 'QueryMode', 52 | # '': 'QueryPM25', 53 | # '': 'QueryDirection', 54 | # '': 'QueryAngle' 55 | } 56 | _device_type_alias = { 57 | 'LIGHT': '灯', 58 | 'SWITCH': '开关', 59 | 'SOCKET': '插座', 60 | 'CURTAIN': '窗帘', 61 | 'CURT_SIMP': '窗纱', 62 | 'AIR_CONDITION': '空调', 63 | 'TV_SET': '电视机', 64 | 'SET_TOP_BOX': '机顶盒', 65 | 'AIR_MONITOR': '空气监测器', 66 | 'AIR_PURIFIER': '空气净化器', 67 | 'WATER_PURIFIER': '净水器', 68 | 'HUMIDIFIER': '加湿器', 69 | 'FAN': '电风扇', 70 | 'WATER_HEATER': '热水器', 71 | 'HEATER': '电暖器', 72 | 'WASHING_MACHINE': '洗衣机', 73 | 'CLOTHES_RACK': '晾衣架', 74 | 'GAS_STOVE': '燃气灶', 75 | 'RANGE_HOOD': '油烟机', 76 | 'OVEN': '烤箱设备', 77 | 'MICROWAVE_OVEN': '微波炉', 78 | 'PRESSURE_COOKER': '压力锅', 79 | 'RICE_COOKER': '电饭煲', 80 | 'INDUCTION_COOKER': '电磁炉', 81 | 'HIGH_SPEED_BLENDER': '破壁机', 82 | 'SWEEPING_ROBOT': '扫地机器人', 83 | 'FRIDGE': '冰箱', 84 | 'PRINTER': '打印机', 85 | 'AIR_FRESHER': '新风机', 86 | 'KETTLE': '热水壶', 87 | 'WEBCAM': '摄像头', 88 | 'ROBOT': '机器人', 89 | 'WINDOW_OPENER': '开窗器', 90 | 'DISINFECTION_CABINET': '消毒柜', 91 | 'DISHWASHER': '洗碗机', 92 | 'ACTIVITY_TRIGGER': '描述特定设备的组合场景', 93 | 'SCENE_TRIGGER': '描述特定设备的组合场景', 94 | 'SOFA': '沙发', 95 | 'BED': '床', 96 | 'SHOE_CABINET': '鞋柜', 97 | } 98 | 99 | device_type_map_h2p = { 100 | 'climate': 'AIR_CONDITION', 101 | 'fan': 'FAN', 102 | 'light': 'LIGHT', 103 | 'media_player': 'TV_SET', 104 | 'switch': 'SWITCH', 105 | 'sensor': 'SENSOR', 106 | 'cover': 'CURTAIN', 107 | 'vacuum': 'SWEEPING_ROBOT', 108 | } 109 | 110 | _service_map_p2h = { 111 | # 模式和平台设备类型不影响 112 | 'fan': { 113 | 'SetModeRequest': lambda state, attributes, payload: (['fan'], ['set_speed'], [{"speed": payload['mode']['value'].lower()}]) 114 | }, 115 | 'cover': { 116 | 'TurnOnRequest': 'open_cover', 117 | 'TurnOffRequest': 'close_cover', 118 | 'TimingTurnOnRequest': 'open_cover', 119 | 'TimingTurnOffRequest': 'close_cover', 120 | 'PauseRequest': 'stop_cover', 121 | }, 122 | 'vacuum': { 123 | 'TurnOnRequest': 'start', 124 | 'TurnOffRequest': 'return_to_base', 125 | 'TimingTurnOnRequest': 'start', 126 | 'TimingTurnOffRequest': 'return_to_base', 127 | 'SetSuctionRequest': lambda state, attributes, payload: (['vacuum'], ['set_fan_speed'], [{'fan_speed': 90 if payload['suction']['value'] == 'STRONG' else 60}]), 128 | }, 129 | 'switch': { 130 | 'TurnOnRequest': 'turn_on', 131 | 'TurnOffRequest': 'turn_off', 132 | 'TimingTurnOnRequest': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'on', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 133 | 'TimingTurnOffRequest': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'off', 'duration': int(payload['timestamp']['value']) - int(time.time())}]) 134 | }, 135 | 'light': { 136 | 'TurnOnRequest': 'turn_on', 137 | 'TurnOffRequest': 'turn_off', 138 | 'TimingTurnOnRequest': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'on', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 139 | 'TimingTurnOffRequest': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'off', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 140 | 'SetBrightnessPercentageRequest': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': payload['brightness']['value']}]), 141 | 'IncrementBrightnessPercentageRequest': lambda state, attributes, payload: (['light'], ['turn_on'],[ {'brightness_pct': min(state.attributes['brightness'] / 255 * 100 + payload['deltaPercentage']['value'], 100)}]), 142 | 'DecrementBrightnessPercentageRequest': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': max(state.attributes['brightness'] / 255 * 100 - payload['deltaPercentage']['value'], 0)}]), 143 | 'SetColorRequest': lambda state, attributes, payload: (['light'], ['turn_on'], [{'hs_color': [float(payload['color']['hue']), float(payload['color']['saturation']) * 100], 'brightness_pct': float(payload['color']['brightness']) * 100}]) 144 | }, 145 | 'havcs':{ 146 | 'TurnOnRequest': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 147 | 'TurnOffRequest': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_off'], [{}]), 148 | 'IncrementBrightnessPercentageRequest': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 149 | 'DecrementBrightnessPercentageRequest': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 150 | 'TimingTurnOnRequest': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'custom:havcs_actions/timing_turn_on', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 151 | 'TimingTurnOffRequest': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'custom:havcs_actions/timing_turn_off', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 152 | } 153 | 154 | } 155 | # action:[{Platfrom Attr: HA Attr},{}] 156 | _query_map_p2h = { 157 | 'GetTemperatureReadingRequest':{'temperatureReading':{'value':'%temperature',"scale": "CELSIUS"}} 158 | } 159 | 160 | class VoiceControlDueros(PlatformParameter, VoiceControlProcessor): 161 | def __init__(self, hass, mode, entry): 162 | self._hass = hass 163 | self._mode = mode 164 | self.vcdm = VoiceControlDeviceManager(entry, DOMAIN, self.device_action_map_h2p, self.device_attribute_map_h2p, self._service_map_p2h, self.device_type_map_h2p, self._device_type_alias) 165 | def _errorResult(self, errorCode, messsage=None): 166 | """Generate error result""" 167 | error_code_map = { 168 | 'INVALIDATE_CONTROL_ORDER': 'invalidate control order', 169 | 'SERVICE_ERROR': 'TargetConnectivityUnstableError', 170 | 'DEVICE_NOT_SUPPORT_FUNCTION': 'NotSupportedInCurrentModeError', 171 | 'INVALIDATE_PARAMS': 'ValueOutOfRangeError', 172 | 'DEVICE_IS_NOT_EXIST': 'DriverInternalError', 173 | 'IOT_DEVICE_OFFLINE': 'TargetOfflineError', 174 | 'ACCESS_TOKEN_INVALIDATE': 'InvalidAccessTokenError' 175 | } 176 | messages = { 177 | 'INVALIDATE_CONTROL_ORDER': 'invalidate control order', 178 | 'SERVICE_ERROR': 'service error', 179 | 'DEVICE_NOT_SUPPORT_FUNCTION': 'device not support', 180 | 'INVALIDATE_PARAMS': 'invalidate params', 181 | 'DEVICE_IS_NOT_EXIST': 'device is not exist', 182 | 'IOT_DEVICE_OFFLINE': 'device is offline', 183 | 'ACCESS_TOKEN_INVALIDATE': 'access_token is invalidate' 184 | } 185 | return {'errorCode': error_code_map.get(errorCode, 'undefined'), 'message': messsage if messsage else messages.get(errorCode, 'undefined')} 186 | 187 | async def handleRequest(self, data, auth = False, request_from = "http"): 188 | """Handle request""" 189 | _LOGGER.info("[%s] Handle Request:\n%s", LOGGER_NAME, data) 190 | 191 | header = self._prase_command(data, 'header') 192 | action = self._prase_command(data, 'action') 193 | namespace = self._prase_command(data, 'namespace') 194 | p_user_id = self._prase_command(data, 'user_uid') 195 | result = {} 196 | # uid = p_user_id+'@'+DOMAIN 197 | 198 | if auth: 199 | namespace = header['namespace'] 200 | if namespace == 'DuerOS.ConnectedHome.Discovery': 201 | action = 'DiscoverAppliancesResponse' 202 | err_result, discovery_devices, entity_ids = self.process_discovery_command(request_from) 203 | result = {'discoveredAppliances': discovery_devices} 204 | if DATA_HAVCS_BIND_MANAGER in self._hass.data[INTEGRATION]: 205 | await self._hass.data[INTEGRATION][DATA_HAVCS_BIND_MANAGER].async_save_changed_devices(entity_ids, DOMAIN, p_user_id) 206 | elif namespace == 'DuerOS.ConnectedHome.Control': 207 | err_result, properties = await self.process_control_command(data) 208 | result = err_result if err_result else {'attributes': properties} 209 | action = action.replace('Request', 'Confirmation') # fix 210 | elif namespace == 'DuerOS.ConnectedHome.Query': 211 | err_result, properties = self.process_query_command(data) 212 | result = err_result if err_result else properties 213 | action = action.replace('Request', 'Response') # fix 主动上报会收到ReportStateRequest action,可以返回设备的其他属性信息不超过10个 214 | else: 215 | result = self._errorResult('SERVICE_ERROR') 216 | else: 217 | result = self._errorResult('ACCESS_TOKEN_INVALIDATE') 218 | 219 | # Check error 220 | header['name'] = action 221 | if 'errorCode' in result: 222 | header['name'] = result['errorCode'] 223 | result={} 224 | 225 | response = {'header': header, 'payload': result} 226 | 227 | _LOGGER.info("[%s] Respnose:\n%s", LOGGER_NAME, response) 228 | return response 229 | 230 | def _prase_command(self, command, arg): 231 | header = command['header'] 232 | payload = command['payload'] 233 | 234 | if arg == 'device_id': 235 | return payload['appliance']['applianceId'] 236 | elif arg == 'action': 237 | return header['name'] 238 | elif arg == 'user_uid': 239 | return payload.get('openUid','') 240 | else: 241 | return command.get(arg) 242 | 243 | def _discovery_process_propertites(self, device_properties): 244 | properties = [] 245 | for device_property in device_properties: 246 | name = self.device_attribute_map_h2p.get(device_property.get('attribute')) 247 | state = self._hass.states.get(device_property.get('entity_id')) 248 | if name: 249 | value = state.state if state else 'unavailable' 250 | if name == 'temperature': 251 | scale = 'CELSIUS' 252 | legalValue = 'DOUBLE' 253 | elif name == 'brightness': 254 | scale = '%' 255 | legalValue = '[0.0, 100.0]' 256 | elif name == 'formaldehyde': 257 | scale = 'mg/m3' 258 | legalValue = 'DOUBLE' 259 | elif name == 'humidity': 260 | scale = '%' 261 | legalValue = '[0.0, 100.0]' 262 | elif name == 'pm25': 263 | scale = 'μg/m3' 264 | legalValue = '[0.0, 1000.0]' 265 | elif name == 'co2': 266 | scale = 'ppm' 267 | legalValue = 'INTEGER' 268 | elif name == 'turnOnState': 269 | if value != 'on': 270 | value = 'OFF' 271 | else: 272 | value = 'ON' 273 | scale = '' 274 | legalValue = '(ON, OFF)' 275 | elif name == 'mode': 276 | scale = '' 277 | legalValue = '(POWERFUL, NORMAL, QUIET)' 278 | else: 279 | _LOGGER.warning("[%s] %s has unsport attribute %s", LOGGER_NAME, device_property.get('entity_id'), name) 280 | continue 281 | properties += [{'name': name, 'value': value, 'scale': scale, 'timestampOfSample': int(time.time()), 'uncertaintyInMilliseconds': 1000, 'legalValue': legalValue }] 282 | 283 | return properties if properties else [{'name': 'turnOnState', 'value': 'OFF', 'scale': '', 'timestampOfSample': int(time.time()), 'uncertaintyInMilliseconds': 1000, 'legalValue': '(ON, OFF)' }] 284 | 285 | def _discovery_process_actions(self, device_properties, raw_actions): 286 | actions = [] 287 | for device_property in device_properties: 288 | name = self.device_attribute_map_h2p.get(device_property.get('attribute')) 289 | if name: 290 | action = self.device_action_map_h2p.get('query_'+name) 291 | if action: 292 | actions += [action,] 293 | for raw_action in raw_actions: 294 | action = self.device_action_map_h2p.get(raw_action) 295 | if action: 296 | actions += [action,] 297 | return list(set(actions)) 298 | 299 | def _discovery_process_device_type(self, raw_device_type): 300 | # raw_device_type guess from device_id's domain transfer to platform style 301 | return raw_device_type if raw_device_type in self._device_type_alias else self.device_type_map_h2p.get(raw_device_type) 302 | 303 | def _discovery_process_device_info(self, device_id, device_type, device_name, zone, properties, actions): 304 | return { 305 | 'applianceId': encrypt_device_id(device_id), 306 | 'friendlyName': device_name, 307 | 'friendlyDescription': device_name, 308 | 'additionalApplianceDetails': [], 309 | 'applianceTypes': [device_type], 310 | 'isReachable': True, 311 | 'manufacturerName': 'HomeAssistant', 312 | 'modelName': 'HomeAssistant', 313 | 'version': '1.0', 314 | 'actions': actions, 315 | 'attributes': properties, 316 | } 317 | 318 | 319 | def _control_process_propertites(self, device_properties, action) -> None: 320 | 321 | return self._discovery_process_propertites(device_properties) 322 | 323 | def _query_process_propertites(self, device_properties, action) -> None: 324 | properties = {} 325 | action = action.replace('Request', '').replace('Get', '') 326 | if action in self._query_map_p2h: 327 | for property_name, attr_template in self._query_map_p2h[action].items(): 328 | formattd_property = self.vcdm.format_property(self._hass, device_properties, attr_template) 329 | properties.update({property_name:formattd_property}) 330 | else: 331 | for device_property in device_properties: 332 | state = self._hass.states.get(device_property.get('entity_id')) 333 | value = state.attributes.get(device_property.get('attribute'), state.state) if state else None 334 | if value: 335 | if device_property.get('attribute').lower() in action.lower(): 336 | name = action[0].lower() + action[1:] 337 | formattd_property = {name: {'value': value}} 338 | properties.update(formattd_property) 339 | return properties 340 | 341 | def _decrypt_device_id(self, device_id) -> None: 342 | return decrypt_device_id(device_id) 343 | 344 | def report_device(self, device_id): 345 | 346 | payload = [] 347 | for p_user_id in self._hass.data[INTEGRATION][DATA_HAVCS_BIND_MANAGER].get_uids(DOMAIN, device_id): 348 | _LOGGER.info("[%s] report device for %s:\n", LOGGER_NAME, p_user_id) 349 | report = { 350 | "header": { 351 | "namespace": "DuerOS.ConnectedHome.Control", 352 | "name": "ChangeReportRequest", 353 | "messageId": str(uuid.uuid4()), 354 | "payloadVersion": "1" 355 | }, 356 | "payload": { 357 | "botId": "", 358 | "openUid": p_user_id, 359 | "appliance": { 360 | "applianceId": encrypt_device_id(device_id), 361 | "attributeName": "turnOnState" 362 | } 363 | } 364 | } 365 | payload.append(report) 366 | return payload -------------------------------------------------------------------------------- /custom_components/havcs/html/css/highlight.default.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Original highlight.js style (c) Ivan Sagalaev 4 | 5 | */ 6 | 7 | .hljs { 8 | display: block; 9 | overflow-x: auto; 10 | padding: 0.5em; 11 | background: #F0F0F0; 12 | } 13 | 14 | 15 | /* Base color: saturation 0; */ 16 | 17 | .hljs, 18 | .hljs-subst { 19 | color: #444; 20 | } 21 | 22 | .hljs-comment { 23 | color: #888888; 24 | } 25 | 26 | .hljs-keyword, 27 | .hljs-attribute, 28 | .hljs-selector-tag, 29 | .hljs-meta-keyword, 30 | .hljs-doctag, 31 | .hljs-name { 32 | font-weight: bold; 33 | } 34 | 35 | 36 | /* User color: hue: 0 */ 37 | 38 | .hljs-type, 39 | .hljs-string, 40 | .hljs-number, 41 | .hljs-selector-id, 42 | .hljs-selector-class, 43 | .hljs-quote, 44 | .hljs-template-tag, 45 | .hljs-deletion { 46 | color: #880000; 47 | } 48 | 49 | .hljs-title, 50 | .hljs-section { 51 | color: #880000; 52 | font-weight: bold; 53 | } 54 | 55 | .hljs-regexp, 56 | .hljs-symbol, 57 | .hljs-variable, 58 | .hljs-template-variable, 59 | .hljs-link, 60 | .hljs-selector-attr, 61 | .hljs-selector-pseudo { 62 | color: #BC6060; 63 | } 64 | 65 | 66 | /* Language color: hue: 90; */ 67 | 68 | .hljs-literal { 69 | color: #78A960; 70 | } 71 | 72 | .hljs-built_in, 73 | .hljs-bullet, 74 | .hljs-code, 75 | .hljs-addition { 76 | color: #397300; 77 | } 78 | 79 | 80 | /* Meta color: hue: 200 */ 81 | 82 | .hljs-meta { 83 | color: #1f7199; 84 | } 85 | 86 | .hljs-meta-string { 87 | color: #4d99bf; 88 | } 89 | 90 | 91 | /* Misc effects */ 92 | 93 | .hljs-emphasis { 94 | font-style: italic; 95 | } 96 | 97 | .hljs-strong { 98 | font-weight: bold; 99 | } 100 | -------------------------------------------------------------------------------- /custom_components/havcs/html/fonts/materialdesignicons-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnk700i/havcs/246c5dd38fa7788ad9635e3094479f4e297c5159/custom_components/havcs/html/fonts/materialdesignicons-webfont.eot -------------------------------------------------------------------------------- /custom_components/havcs/html/fonts/materialdesignicons-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnk700i/havcs/246c5dd38fa7788ad9635e3094479f4e297c5159/custom_components/havcs/html/fonts/materialdesignicons-webfont.ttf -------------------------------------------------------------------------------- /custom_components/havcs/html/fonts/materialdesignicons-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnk700i/havcs/246c5dd38fa7788ad9635e3094479f4e297c5159/custom_components/havcs/html/fonts/materialdesignicons-webfont.woff -------------------------------------------------------------------------------- /custom_components/havcs/html/fonts/materialdesignicons-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnk700i/havcs/246c5dd38fa7788ad9635e3094479f4e297c5159/custom_components/havcs/html/fonts/materialdesignicons-webfont.woff2 -------------------------------------------------------------------------------- /custom_components/havcs/html/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnk700i/havcs/246c5dd38fa7788ad9635e3094479f4e297c5159/custom_components/havcs/html/images/favicon.ico -------------------------------------------------------------------------------- /custom_components/havcs/html/images/icon_nav_article.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnk700i/havcs/246c5dd38fa7788ad9635e3094479f4e297c5159/custom_components/havcs/html/images/icon_nav_article.png -------------------------------------------------------------------------------- /custom_components/havcs/html/images/icon_nav_button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnk700i/havcs/246c5dd38fa7788ad9635e3094479f4e297c5159/custom_components/havcs/html/images/icon_nav_button.png -------------------------------------------------------------------------------- /custom_components/havcs/html/images/icon_nav_cell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cnk700i/havcs/246c5dd38fa7788ad9635e3094479f4e297c5159/custom_components/havcs/html/images/icon_nav_cell.png -------------------------------------------------------------------------------- /custom_components/havcs/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | havcs设备管理平台 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 210 | 211 | 212 | 213 |
214 | 215 |

216 | 217 | 218 | 219 | 221 |

222 | 223 | 224 | 225 |
226 | 233 | 253 | 254 |
255 | 256 | 342 | 425 | 440 | 463 | 476 | 488 | 489 | 490 | -------------------------------------------------------------------------------- /custom_components/havcs/html/js/ha.js: -------------------------------------------------------------------------------- 1 | class HA { 2 | constructor() { 3 | // url参数 4 | let query = new URLSearchParams(location.search) 5 | this.query = (key) => { 6 | let val = query.get(key) 7 | if (val) { 8 | return decodeURIComponent(val) 9 | } 10 | return val 11 | } 12 | this.ver = this.query('ver') 13 | } 14 | 15 | fullscreen() { 16 | try { 17 | let haPanelIframe = top.document.body 18 | .querySelector("home-assistant") 19 | .shadowRoot.querySelector("home-assistant-main") 20 | .shadowRoot.querySelector("app-drawer-layout partial-panel-resolver ha-panel-iframe").shadowRoot 21 | let ha_card = haPanelIframe.querySelector("iframe"); 22 | ha_card.style.position = 'absolute' 23 | haPanelIframe.querySelector('app-toolbar').style.display = 'none' 24 | ha_card.style.top = '0' 25 | ha_card.style.height = '100%' 26 | } catch{ 27 | 28 | } 29 | } 30 | 31 | // 触发事件 32 | fire(type, data, ele = null) { 33 | console.log(type, data) 34 | const event = new top.Event(type, { 35 | bubbles: true, 36 | cancelable: false, 37 | composed: true 38 | }); 39 | event.detail = data; 40 | if (!ele) { 41 | ele = top.document.querySelector("home-assistant") 42 | .shadowRoot.querySelector("home-assistant-main") 43 | .shadowRoot.querySelector("app-drawer-layout") 44 | } 45 | ele.dispatchEvent(event); 46 | } 47 | 48 | async getAuthorization(){ 49 | let hass = top.document.querySelector('home-assistant').hass 50 | let auth = hass.auth 51 | let authorization = '' 52 | if (auth._saveTokens) { 53 | // 过期 54 | if (auth.expired) { 55 | await auth.refreshAccessToken() 56 | } 57 | authorization = `${auth.data.token_type} ${auth.accessToken}` 58 | } else { 59 | authorization = `Bearer ${auth.data.access_token}` 60 | } 61 | return authorization 62 | } 63 | 64 | async device(params) { 65 | let url = '/havcs/device' 66 | return this.post(url, params) 67 | } 68 | 69 | async settings(params) { 70 | let url = '/havcs/settings' 71 | return this.post(url, params) 72 | } 73 | 74 | async post(url, params) { 75 | let data 76 | if(params instanceof FormData){ 77 | data = params 78 | }else if(params instanceof Object){ 79 | data = JSON.stringify(params) 80 | }else{ 81 | data = params 82 | } 83 | let authorization = await this.getAuthorization() 84 | return fetch(url, { 85 | method: 'post', 86 | headers: { 87 | authorization 88 | }, 89 | body: data 90 | }).then(res => res.json()) 91 | } 92 | 93 | async file(params) { 94 | let url = '/havcs/device' 95 | let authorization = await this.getAuthorization() 96 | 97 | fetch(url, { 98 | method: 'post', 99 | headers: { 100 | authorization 101 | }, 102 | body: JSON.stringify(params) 103 | }).then(res => res.blob().then(blob => { 104 | // It is necessary to create a new blob object with mime-type explicitly set 105 | // otherwise only Chrome works like it should 106 | var newBlob = new Blob([blob], {type: "application/x-yaml"}) 107 | console.log(res.headers) 108 | console.log(res.headers.get('Content-Type')) 109 | // IE doesn't allow using a blob object directly as link href 110 | // instead it is necessary to use msSaveOrOpenBlob 111 | if (window.navigator && window.navigator.msSaveOrOpenBlob) { 112 | window.navigator.msSaveOrOpenBlob(newBlob); 113 | return; 114 | } 115 | // For other browsers: 116 | // Create a link pointing to the ObjectURL containing the blob. 117 | 118 | var a = document.createElement('a'); 119 | var url = window.URL.createObjectURL(newBlob); // 获取 blob 本地文件连接 (blob 为纯二进制对象,不能够直接保存到磁盘上) 120 | var filename = res.headers.get('Content-Disposition'); 121 | a.href = url; 122 | a.download = filename; 123 | a.click(); 124 | setTimeout(function(){ 125 | // For Firefox it is necessary to delay revoking the ObjectURL 126 | window.URL.revokeObjectURL(url); 127 | }, 100); 128 | })); 129 | } 130 | } 131 | 132 | window.ha = new HA() -------------------------------------------------------------------------------- /custom_components/havcs/html/js/highlight.pack.js: -------------------------------------------------------------------------------- 1 | /* 2 | Highlight.js 10.1.2 (edd73d24) 3 | License: BSD-3-Clause 4 | Copyright (c) 2006-2020, Ivan Sagalaev 5 | */ 6 | var hljs=function(){"use strict";function e(n){Object.freeze(n);var t="function"==typeof n;return Object.getOwnPropertyNames(n).forEach((function(r){!Object.hasOwnProperty.call(n,r)||null===n[r]||"object"!=typeof n[r]&&"function"!=typeof n[r]||t&&("caller"===r||"callee"===r||"arguments"===r)||Object.isFrozen(n[r])||e(n[r])})),n}class n{constructor(e){void 0===e.data&&(e.data={}),this.data=e.data}ignoreMatch(){this.ignore=!0}}function t(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function r(e,...n){var t={};for(const n in e)t[n]=e[n];return n.forEach((function(e){for(const n in e)t[n]=e[n]})),t}function a(e){return e.nodeName.toLowerCase()}var i=Object.freeze({__proto__:null,escapeHTML:t,inherit:r,nodeStream:function(e){var n=[];return function e(t,r){for(var i=t.firstChild;i;i=i.nextSibling)3===i.nodeType?r+=i.nodeValue.length:1===i.nodeType&&(n.push({event:"start",offset:r,node:i}),r=e(i,r),a(i).match(/br|hr|img|input/)||n.push({event:"stop",offset:r,node:i}));return r}(e,0),n},mergeStreams:function(e,n,r){var i=0,s="",o=[];function l(){return e.length&&n.length?e[0].offset!==n[0].offset?e[0].offset"}function u(e){s+=""}function d(e){("start"===e.event?c:u)(e.node)}for(;e.length||n.length;){var g=l();if(s+=t(r.substring(i,g[0].offset)),i=g[0].offset,g===e){o.reverse().forEach(u);do{d(g.splice(0,1)[0]),g=l()}while(g===e&&g.length&&g[0].offset===i);o.reverse().forEach(c)}else"start"===g[0].event?o.push(g[0].node):o.pop(),d(g.splice(0,1)[0])}return s+t(r.substr(i))}});const s="",o=e=>!!e.kind;class l{constructor(e,n){this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){this.buffer+=t(e)}openNode(e){if(!o(e))return;let n=e.kind;e.sublanguage||(n=`${this.classPrefix}${n}`),this.span(n)}closeNode(e){o(e)&&(this.buffer+=s)}value(){return this.buffer}span(e){this.buffer+=``}}class c{constructor(){this.rootNode={children:[]},this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){this.top.children.push(e)}openNode(e){const n={kind:e,children:[]};this.add(n),this.stack.push(n)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n),n.children.forEach(n=>this._walk(e,n)),e.closeNode(n)),e}static _collapse(e){"string"!=typeof e&&e.children&&(e.children.every(e=>"string"==typeof e)?e.children=[e.children.join("")]:e.children.forEach(e=>{c._collapse(e)}))}}class u extends c{constructor(e){super(),this.options=e}addKeyword(e,n){""!==e&&(this.openNode(n),this.addText(e),this.closeNode())}addText(e){""!==e&&this.add(e)}addSublanguage(e,n){const t=e.root;t.kind=n,t.sublanguage=!0,this.add(t)}toHTML(){return new l(this,this.options).value()}finalize(){return!0}}function d(e){return e?"string"==typeof e?e:e.source:null}const g="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",h={begin:"\\\\[\\s\\S]",relevance:0},f={className:"string",begin:"'",end:"'",illegal:"\\n",contains:[h]},p={className:"string",begin:'"',end:'"',illegal:"\\n",contains:[h]},b={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},m=function(e,n,t={}){var a=r({className:"comment",begin:e,end:n,contains:[]},t);return a.contains.push(b),a.contains.push({className:"doctag",begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),a},v=m("//","$"),x=m("/\\*","\\*/"),E=m("#","$");var _=Object.freeze({__proto__:null,IDENT_RE:"[a-zA-Z]\\w*",UNDERSCORE_IDENT_RE:"[a-zA-Z_]\\w*",NUMBER_RE:"\\b\\d+(\\.\\d+)?",C_NUMBER_RE:g,BINARY_NUMBER_RE:"\\b(0b[01]+)",RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(e={})=>{const n=/^#![ ]*\//;return e.binary&&(e.begin=function(...e){return e.map(e=>d(e)).join("")}(n,/.*\b/,e.binary,/\b.*/)),r({className:"meta",begin:n,end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)},BACKSLASH_ESCAPE:h,APOS_STRING_MODE:f,QUOTE_STRING_MODE:p,PHRASAL_WORDS_MODE:b,COMMENT:m,C_LINE_COMMENT_MODE:v,C_BLOCK_COMMENT_MODE:x,HASH_COMMENT_MODE:E,NUMBER_MODE:{className:"number",begin:"\\b\\d+(\\.\\d+)?",relevance:0},C_NUMBER_MODE:{className:"number",begin:g,relevance:0},BINARY_NUMBER_MODE:{className:"number",begin:"\\b(0b[01]+)",relevance:0},CSS_NUMBER_MODE:{className:"number",begin:"\\b\\d+(\\.\\d+)?(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[h,{begin:/\[/,end:/\]/,relevance:0,contains:[h]}]}]},TITLE_MODE:{className:"title",begin:"[a-zA-Z]\\w*",relevance:0},UNDERSCORE_TITLE_MODE:{className:"title",begin:"[a-zA-Z_]\\w*",relevance:0},METHOD_GUARD:{begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:function(e){return Object.assign(e,{"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{n.data._beginMatch!==e[1]&&n.ignoreMatch()}})}}),N="of and for in not or if then".split(" ");function w(e,n){return n?+n:function(e){return N.includes(e.toLowerCase())}(e)?0:1}const R=t,y=r,{nodeStream:O,mergeStreams:k}=i,M=Symbol("nomatch");return function(t){var a=[],i=Object.create(null),s=Object.create(null),o=[],l=!0,c=/(^(<[^>]+>|\t|)+|\n)/gm,g="Could not find the language '{}', did you forget to load/include a language module?";const h={disableAutodetect:!0,name:"Plain text",contains:[]};var f={noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:null,__emitter:u};function p(e){return f.noHighlightRe.test(e)}function b(e,n,t,r){var a={code:n,language:e};S("before:highlight",a);var i=a.result?a.result:m(a.language,a.code,t,r);return i.code=a.code,S("after:highlight",i),i}function m(e,t,a,s){var o=t;function c(e,n){var t=E.case_insensitive?n[0].toLowerCase():n[0];return Object.prototype.hasOwnProperty.call(e.keywords,t)&&e.keywords[t]}function u(){null!=y.subLanguage?function(){if(""!==A){var e=null;if("string"==typeof y.subLanguage){if(!i[y.subLanguage])return void k.addText(A);e=m(y.subLanguage,A,!0,O[y.subLanguage]),O[y.subLanguage]=e.top}else e=v(A,y.subLanguage.length?y.subLanguage:null);y.relevance>0&&(I+=e.relevance),k.addSublanguage(e.emitter,e.language)}}():function(){if(!y.keywords)return void k.addText(A);let e=0;y.keywordPatternRe.lastIndex=0;let n=y.keywordPatternRe.exec(A),t="";for(;n;){t+=A.substring(e,n.index);const r=c(y,n);if(r){const[e,a]=r;k.addText(t),t="",I+=a,k.addKeyword(n[0],e)}else t+=n[0];e=y.keywordPatternRe.lastIndex,n=y.keywordPatternRe.exec(A)}t+=A.substr(e),k.addText(t)}(),A=""}function h(e){return e.className&&k.openNode(e.className),y=Object.create(e,{parent:{value:y}})}function p(e){return 0===y.matcher.regexIndex?(A+=e[0],1):(L=!0,0)}var b={};function x(t,r){var i=r&&r[0];if(A+=t,null==i)return u(),0;if("begin"===b.type&&"end"===r.type&&b.index===r.index&&""===i){if(A+=o.slice(r.index,r.index+1),!l){const n=Error("0 width match regex");throw n.languageName=e,n.badRule=b.rule,n}return 1}if(b=r,"begin"===r.type)return function(e){var t=e[0],r=e.rule;const a=new n(r),i=[r.__beforeBegin,r["on:begin"]];for(const n of i)if(n&&(n(e,a),a.ignore))return p(t);return r&&r.endSameAsBegin&&(r.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")),r.skip?A+=t:(r.excludeBegin&&(A+=t),u(),r.returnBegin||r.excludeBegin||(A=t)),h(r),r.returnBegin?0:t.length}(r);if("illegal"===r.type&&!a){const e=Error('Illegal lexeme "'+i+'" for mode "'+(y.className||"")+'"');throw e.mode=y,e}if("end"===r.type){var s=function(e){var t=e[0],r=o.substr(e.index),a=function e(t,r,a){let i=function(e,n){var t=e&&e.exec(n);return t&&0===t.index}(t.endRe,a);if(i){if(t["on:end"]){const e=new n(t);t["on:end"](r,e),e.ignore&&(i=!1)}if(i){for(;t.endsParent&&t.parent;)t=t.parent;return t}}if(t.endsWithParent)return e(t.parent,r,a)}(y,e,r);if(!a)return M;var i=y;i.skip?A+=t:(i.returnEnd||i.excludeEnd||(A+=t),u(),i.excludeEnd&&(A=t));do{y.className&&k.closeNode(),y.skip||y.subLanguage||(I+=y.relevance),y=y.parent}while(y!==a.parent);return a.starts&&(a.endSameAsBegin&&(a.starts.endRe=a.endRe),h(a.starts)),i.returnEnd?0:t.length}(r);if(s!==M)return s}if("illegal"===r.type&&""===i)return 1;if(B>1e5&&B>3*r.index)throw Error("potential infinite loop, way more iterations than matches");return A+=i,i.length}var E=T(e);if(!E)throw console.error(g.replace("{}",e)),Error('Unknown language: "'+e+'"');var _=function(e){function n(n,t){return RegExp(d(n),"m"+(e.case_insensitive?"i":"")+(t?"g":""))}class t{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(e,n){n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]),this.matchAt+=function(e){return RegExp(e.toString()+"|").exec("").length-1}(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const e=this.regexes.map(e=>e[1]);this.matcherRe=n(function(e,n="|"){for(var t=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./,r=0,a="",i=0;i0&&(a+=n),a+="(";o.length>0;){var l=t.exec(o);if(null==l){a+=o;break}a+=o.substring(0,l.index),o=o.substring(l.index+l[0].length),"\\"===l[0][0]&&l[1]?a+="\\"+(+l[1]+s):(a+=l[0],"("===l[0]&&r++)}a+=")"}return a}(e),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex;const n=this.matcherRe.exec(e);if(!n)return null;const t=n.findIndex((e,n)=>n>0&&void 0!==e),r=this.matchIndexes[t];return n.splice(0,t),Object.assign(n,r)}}class a{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t;return this.rules.slice(e).forEach(([e,t])=>n.addRule(e,t)),n.compile(),this.multiRegexes[e]=n,n}considerAll(){this.regexIndex=0}addRule(e,n){this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex;const t=n.exec(e);return t&&(this.regexIndex+=t.position+1,this.regexIndex===this.count&&(this.regexIndex=0)),t}}function i(e,n){const t=e.input[e.index-1],r=e.input[e.index+e[0].length];"."!==t&&"."!==r||n.ignoreMatch()}if(e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return function t(s,o){const l=s;if(s.compiled)return l;s.compiled=!0,s.__beforeBegin=null,s.keywords=s.keywords||s.beginKeywords;let c=null;if("object"==typeof s.keywords&&(c=s.keywords.$pattern,delete s.keywords.$pattern),s.keywords&&(s.keywords=function(e,n){var t={};return"string"==typeof e?r("keyword",e):Object.keys(e).forEach((function(n){r(n,e[n])})),t;function r(e,r){n&&(r=r.toLowerCase()),r.split(" ").forEach((function(n){var r=n.split("|");t[r[0]]=[e,w(r[0],r[1])]}))}}(s.keywords,e.case_insensitive)),s.lexemes&&c)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ");return l.keywordPatternRe=n(s.lexemes||c||/\w+/,!0),o&&(s.beginKeywords&&(s.begin="\\b("+s.beginKeywords.split(" ").join("|")+")(?=\\b|\\s)",s.__beforeBegin=i),s.begin||(s.begin=/\B|\b/),l.beginRe=n(s.begin),s.endSameAsBegin&&(s.end=s.begin),s.end||s.endsWithParent||(s.end=/\B|\b/),s.end&&(l.endRe=n(s.end)),l.terminator_end=d(s.end)||"",s.endsWithParent&&o.terminator_end&&(l.terminator_end+=(s.end?"|":"")+o.terminator_end)),s.illegal&&(l.illegalRe=n(s.illegal)),void 0===s.relevance&&(s.relevance=1),s.contains||(s.contains=[]),s.contains=[].concat(...s.contains.map((function(e){return function(e){return e.variants&&!e.cached_variants&&(e.cached_variants=e.variants.map((function(n){return r(e,{variants:null},n)}))),e.cached_variants?e.cached_variants:function e(n){return!!n&&(n.endsWithParent||e(n.starts))}(e)?r(e,{starts:e.starts?r(e.starts):null}):Object.isFrozen(e)?r(e):e}("self"===e?s:e)}))),s.contains.forEach((function(e){t(e,l)})),s.starts&&t(s.starts,o),l.matcher=function(e){const n=new a;return e.contains.forEach(e=>n.addRule(e.begin,{rule:e,type:"begin"})),e.terminator_end&&n.addRule(e.terminator_end,{type:"end"}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n}(l),l}(e)}(E),N="",y=s||_,O={},k=new f.__emitter(f);!function(){for(var e=[],n=y;n!==E;n=n.parent)n.className&&e.unshift(n.className);e.forEach(e=>k.openNode(e))}();var A="",I=0,S=0,B=0,L=!1;try{for(y.matcher.considerAll();;){B++,L?L=!1:(y.matcher.lastIndex=S,y.matcher.considerAll());const e=y.matcher.exec(o);if(!e)break;const n=x(o.substring(S,e.index),e);S=e.index+n}return x(o.substr(S)),k.closeAllNodes(),k.finalize(),N=k.toHTML(),{relevance:I,value:N,language:e,illegal:!1,emitter:k,top:y}}catch(n){if(n.message&&n.message.includes("Illegal"))return{illegal:!0,illegalBy:{msg:n.message,context:o.slice(S-100,S+100),mode:n.mode},sofar:N,relevance:0,value:R(o),emitter:k};if(l)return{illegal:!1,relevance:0,value:R(o),emitter:k,language:e,top:y,errorRaised:n};throw n}}function v(e,n){n=n||f.languages||Object.keys(i);var t=function(e){const n={relevance:0,emitter:new f.__emitter(f),value:R(e),illegal:!1,top:h};return n.emitter.addText(e),n}(e),r=t;return n.filter(T).filter(I).forEach((function(n){var a=m(n,e,!1);a.language=n,a.relevance>r.relevance&&(r=a),a.relevance>t.relevance&&(r=t,t=a)})),r.language&&(t.second_best=r),t}function x(e){return f.tabReplace||f.useBR?e.replace(c,e=>"\n"===e?f.useBR?"
":e:f.tabReplace?e.replace(/\t/g,f.tabReplace):e):e}function E(e){let n=null;const t=function(e){var n=e.className+" ";n+=e.parentNode?e.parentNode.className:"";const t=f.languageDetectRe.exec(n);if(t){var r=T(t[1]);return r||(console.warn(g.replace("{}",t[1])),console.warn("Falling back to no-highlight mode for this block.",e)),r?t[1]:"no-highlight"}return n.split(/\s+/).find(e=>p(e)||T(e))}(e);if(p(t))return;S("before:highlightBlock",{block:e,language:t}),f.useBR?(n=document.createElement("div")).innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n"):n=e;const r=n.textContent,a=t?b(t,r,!0):v(r),i=O(n);if(i.length){const e=document.createElement("div");e.innerHTML=a.value,a.value=k(i,O(e),r)}a.value=x(a.value),S("after:highlightBlock",{block:e,result:a}),e.innerHTML=a.value,e.className=function(e,n,t){var r=n?s[n]:t,a=[e.trim()];return e.match(/\bhljs\b/)||a.push("hljs"),e.includes(r)||a.push(r),a.join(" ").trim()}(e.className,t,a.language),e.result={language:a.language,re:a.relevance,relavance:a.relevance},a.second_best&&(e.second_best={language:a.second_best.language,re:a.second_best.relevance,relavance:a.second_best.relevance})}const N=()=>{if(!N.called){N.called=!0;var e=document.querySelectorAll("pre code");a.forEach.call(e,E)}};function T(e){return e=(e||"").toLowerCase(),i[e]||i[s[e]]}function A(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach(e=>{s[e]=n})}function I(e){var n=T(e);return n&&!n.disableAutodetect}function S(e,n){var t=e;o.forEach((function(e){e[t]&&e[t](n)}))}Object.assign(t,{highlight:b,highlightAuto:v,fixMarkup:x,highlightBlock:E,configure:function(e){f=y(f,e)},initHighlighting:N,initHighlightingOnLoad:function(){window.addEventListener("DOMContentLoaded",N,!1)},registerLanguage:function(e,n){var r=null;try{r=n(t)}catch(n){if(console.error("Language definition for '{}' could not be registered.".replace("{}",e)),!l)throw n;console.error(n),r=h}r.name||(r.name=e),i[e]=r,r.rawDefinition=n.bind(null,t),r.aliases&&A(r.aliases,{languageName:e})},listLanguages:function(){return Object.keys(i)},getLanguage:T,registerAliases:A,requireLanguage:function(e){var n=T(e);if(n)return n;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))},autoDetection:I,inherit:y,addPlugin:function(e){o.push(e)}}),t.debugMode=function(){l=!1},t.safeMode=function(){l=!0},t.versionString="10.1.2";for(const n in _)"object"==typeof _[n]&&e(_[n]);return Object.assign(t,_),t}({})}();"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);hljs.registerLanguage("json",function(){"use strict";return function(n){var e={literal:"true false null"},i=[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE],t=[n.QUOTE_STRING_MODE,n.C_NUMBER_MODE],a={end:",",endsWithParent:!0,excludeEnd:!0,contains:t,keywords:e},l={begin:"{",end:"}",contains:[{className:"attr",begin:/"/,end:/"/,contains:[n.BACKSLASH_ESCAPE],illegal:"\\n"},n.inherit(a,{begin:/:/})].concat(i),illegal:"\\S"},s={begin:"\\[",end:"\\]",contains:[n.inherit(a)],illegal:"\\S"};return t.push(l,s),i.forEach((function(n){t.push(n)})),{name:"JSON",contains:t,keywords:e,illegal:"\\S"}}}());hljs.registerLanguage("ruby",function(){"use strict";return function(e){var n="[a-zA-Z_]\\w*[!?=]?|[-+~]\\@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?",a={keyword:"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor",literal:"true false nil"},s={className:"doctag",begin:"@[A-Za-z]+"},i={begin:"#<",end:">"},r=[e.COMMENT("#","$",{contains:[s]}),e.COMMENT("^\\=begin","^\\=end",{contains:[s],relevance:10}),e.COMMENT("^__END__","\\n$")],c={className:"subst",begin:"#\\{",end:"}",keywords:a},t={className:"string",contains:[e.BACKSLASH_ESCAPE,c],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:"%[qQwWx]?\\(",end:"\\)"},{begin:"%[qQwWx]?\\[",end:"\\]"},{begin:"%[qQwWx]?{",end:"}"},{begin:"%[qQwWx]?<",end:">"},{begin:"%[qQwWx]?/",end:"/"},{begin:"%[qQwWx]?%",end:"%"},{begin:"%[qQwWx]?-",end:"-"},{begin:"%[qQwWx]?\\|",end:"\\|"},{begin:/\B\?(\\\d{1,3}|\\x[A-Fa-f0-9]{1,2}|\\u[A-Fa-f0-9]{4}|\\?\S)\b/},{begin:/<<[-~]?'?(\w+)(?:.|\n)*?\n\s*\1\b/,returnBegin:!0,contains:[{begin:/<<[-~]?'?/},e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,contains:[e.BACKSLASH_ESCAPE,c]})]}]},b={className:"params",begin:"\\(",end:"\\)",endsParent:!0,keywords:a},d=[t,i,{className:"class",beginKeywords:"class module",end:"$|;",illegal:/=/,contains:[e.inherit(e.TITLE_MODE,{begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|\\!)?"}),{begin:"<\\s*",contains:[{begin:"("+e.IDENT_RE+"::)?"+e.IDENT_RE}]}].concat(r)},{className:"function",beginKeywords:"def",end:"$|;",contains:[e.inherit(e.TITLE_MODE,{begin:n}),b].concat(r)},{begin:e.IDENT_RE+"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(\\!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[t,{begin:n}],relevance:0},{className:"number",begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b",relevance:0},{begin:"(\\$\\W)|((\\$|\\@\\@?)(\\w+))"},{className:"params",begin:/\|/,end:/\|/,keywords:a},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[i,{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:"%r{",end:"}[a-z]*"},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(r),relevance:0}].concat(r);c.contains=d,b.contains=d;var g=[{begin:/^\s*=>/,starts:{end:"$",contains:d}},{className:"meta",begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d(p\\d+)?[^>]+>)",starts:{end:"$",contains:d}}];return{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:a,illegal:/\/\*/,contains:r.concat(g).concat(d)}}}());hljs.registerLanguage("yaml",function(){"use strict";return function(e){var n="true false yes no null",a="[\\w#;/?:@&=+$,.~*\\'()[\\]]+",s={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:"{{",end:"}}"},{begin:"%{",end:"}"}]}]},i=e.inherit(s,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={end:",",endsWithParent:!0,excludeEnd:!0,contains:[],keywords:n,relevance:0},t={begin:"{",end:"}",contains:[l],illegal:"\\n",relevance:0},g={begin:"\\[",end:"\\]",contains:[l],illegal:"\\n",relevance:0},b=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---s*$",relevance:10},{className:"string",begin:"[\\|>]([0-9]?[+-])?[ ]*\\n( *)[\\S ]+\\n(\\2[\\S ]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type",begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"\\-(?=[ ]|$)",relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},{className:"number",begin:e.C_NUMBER_RE+"\\b"},t,g,s],c=[...b];return c.pop(),c.push(i),l.contains=c,{name:"YAML",case_insensitive:!0,aliases:["yml","YAML"],contains:b}}}()); -------------------------------------------------------------------------------- /custom_components/havcs/html/js/main.min.js: -------------------------------------------------------------------------------- 1 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,c){a instanceof String&&(a=String(a));for(var d=a.length,e=0;e"+JSON.stringify(b.data[1],null,4)+"",title=b.data[0],a.config={title:title,content:content}):$.toptip(b.Msg,3E3,"error")})}}}),ComponentSettings=Vue.extend({template:"#settingsTemplate", 25 | data:function(){return{settings:{}}},created:function(){this.getSettings(!1)},mounted:function(){var a=this;$("#command_filter").select({title:"\u9009\u62e9\u8fc7\u6ee4\u6307\u4ee4\uff1a",multi:!1,closeText:"\u5b8c\u6210",items:[{title:"none",value:"none",annotation:"\u4e0d\u8fc7\u6ee4"},{title:"http",value:"http",annotation:"\u6765\u81ea\u5e73\u53f0\u81ea\u5efa\u6280\u80fd"},{title:"mqtt",value:"mqtt",annotation:"\u6765\u81eaAPP\u6280\u80fd\uff08havcs\uff09"}],onClose:function(b){a.settings.command_filter= 26 | b.data.values}})},methods:{getSettings:function(a){var b=this;ha.settings({action:"get"}).then(function(c){"ok"==c.code?(b.settings=c.data,(void 0===a||a)&&$.toptip(c.Msg,3E3,"success")):$.toptip(c.Msg,3E3,"error")})},updateSettings:function(){ha.settings({action:"update",data:this.settings}).then(function(a){"ok"==a.code?$.toptip(a.Msg,3E3,"success"):$.toptip(a.Msg,3E3,"error")})}}}),router=new VueRouter({routes:[{path:"/",redirect:"/device"},{path:"/device",name:"device",component:ComponentDevice}, 27 | {path:"/device/edit/:device_id",name:"device:edit",component:ComponentDeviceEdit},{path:"/device/add",name:"device:add",component:ComponentDeviceEdit},{path:"/config",name:"config",component:ComponentConfig},,{path:"/settings",name:"settings",component:ComponentSettings}]}),vm=new Vue({el:"#app",router:router,components:{component_device:ComponentDevice,component_device_edit:ComponentDeviceEdit,component_config:ComponentConfig,component_settings:ComponentSettings},data:{notice:{title:"Home Assistant Voice Control Service - \u8bbe\u5907\u7ba1\u7406", 28 | noticeList:[]},deviceList:[],deviceCount:0,isRouterAlive:!0},provide:function(){return{reloadDevice:this.reloadDevice,reloadConfig:this.reloadConfig}},created:function(){this.getNotice();this.getDevice()},mounted:function(){},methods:{reloadDevice:function(){this.getDevice()},getDevice:function(){var a=this;ha.device({action:"getList"}).then(function(b){"ok"==b.code?(a.deviceList=b.data,a.deviceCount=a.deviceList.length):$.toast(b.Msg,"forbidden")})},getNotice:function(){var a=this;Date.parse(new Date); 29 | this.$http.post("https://havcs.ljr.im:8123/account/service.php?v=getClientNotice",{},{emulateJSON:!0}).then(function(b){"ok"==b.data.code&&(a.notice=b.data.data)},function(a){})}}}); 30 | (function(a){var b=function(b){b=a(b);if(!b.hasClass("weui-bar__item--on")){var c=b.attr("tab");b.parent().find(".weui-bar__item--on").removeClass("weui-bar__item--on");b.addClass("weui-bar__item--on");vm.$router.push({name:c})}};a.showTab=b;a(document).on("click",".weui-navbar__item, .weui-tabbar__item",function(c){var d=a(c.currentTarget);d.hasClass("weui-bar__item--on")||(c.preventDefault(),b(d))})})($); 31 | -------------------------------------------------------------------------------- /custom_components/havcs/html/js/vue-resource.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * vue-resource v1.5.1 3 | * https://github.com/pagekit/vue-resource 4 | * Released under the MIT License. 5 | */ 6 | 7 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):t.VueResource=e()}(this,function(){"use strict";function u(t){this.state=2,this.value=void 0,this.deferred=[];var e=this;try{t(function(t){e.resolve(t)},function(t){e.reject(t)})}catch(t){e.reject(t)}}u.reject=function(n){return new u(function(t,e){e(n)})},u.resolve=function(n){return new u(function(t,e){t(n)})},u.all=function(s){return new u(function(n,t){var o=0,r=[];function e(e){return function(t){r[e]=t,(o+=1)===s.length&&n(r)}}0===s.length&&n(r);for(var i=0;i 2 | 3 | 4 | 5 | 授权登陆页面 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 37 | 38 | 39 |
40 |
41 |

42 | {{ notice.title }} 43 |

44 |
45 | 46 |
47 |
公告
48 |
49 |
50 |
51 | 57 |
58 | 59 |
60 |
61 |
62 |
63 |
64 |
65 | 66 | 您即将授权 "{{client_id}}" 访问 Home Assistant 实例 67 |
68 |
69 |
用户登录(Home Assistant用户)
70 |
71 |
72 |
73 |
74 | 75 |
76 |
77 |
78 |
79 |
80 | 81 |
82 |
83 |
84 |
85 |
86 | 87 | 88 |
89 |
90 | 91 |
92 | 99 |
100 | 209 | 210 | -------------------------------------------------------------------------------- /custom_components/havcs/jdwhale.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import uuid 4 | import time 5 | 6 | from .util import decrypt_device_id, encrypt_device_id 7 | from .helper import VoiceControlProcessor, VoiceControlDeviceManager 8 | from .const import DATA_HAVCS_BIND_MANAGER, INTEGRATION, ATTR_DEVICE_ACTIONS 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | # _LOGGER.setLevel(logging.DEBUG) 12 | 13 | DOMAIN = 'jdwhale' 14 | LOGGER_NAME = 'jdwhale' 15 | 16 | REPORT_WHEN_STARUP = True 17 | 18 | async def createHandler(hass, entry): 19 | mode = ['handler'] 20 | if REPORT_WHEN_STARUP: 21 | mode.append('report_when_starup') 22 | return VoiceControlJdwhale(hass, mode, entry) 23 | 24 | class PlatformParameter: 25 | device_attribute_map_h2p = { 26 | 'power_state': 'PowerState', 27 | 'color': 'Color', 28 | 'temperature': 'Temperature', 29 | 'humidity': 'Humidity', 30 | # '': 'windspeed', 31 | 'brightness': 'Brightness', 32 | # '': 'direction', 33 | # '': 'angle', 34 | 'pm25': 'PM25', 35 | } 36 | device_action_map_h2p ={ 37 | 'turn_on': 'TurnOn', 38 | 'turn_off': 'TurnOff', 39 | 'increase_brightness': 'AdjustUpBrightness', 40 | 'decrease_brightness': 'AdjustDownBrightness', 41 | 'set_brightness': 'SetBrightness', 42 | 'increase_temperature': 'AdjustUpTemperature', 43 | 'decrease_temperature': 'AdjustDownTemperature', 44 | 'set_temperature': 'SetTemperature', 45 | 'set_color': 'SetColor', 46 | 'pause': 'Pause', 47 | 'query': 'Query', 48 | 'query_color': 'QueryColor', 49 | 'query_power_state': 'QueryPowerState', 50 | 'query_temperature': 'QueryTemperature', 51 | 'query_humidity': 'QueryHumidity', 52 | 'query_pm25': 'QueryPM25', 53 | 'set_mode': 'SetMode' 54 | } 55 | _device_type_alias = { 56 | 'WASHING_MACHINE': '洗衣机', 57 | 'SWEEPING_ROBOT': '扫地机器人', 58 | 'WATER_HEATER': '热水器', 59 | 'AIR_CONDITION': '空调', 60 | 'AIR_CLEANER': '空气净化器', 61 | 'SWITCH': '开关', 62 | 'LIGHT': '灯', 63 | 'SOCKET': '插座', 64 | 'FRIDGE': '冰箱', 65 | 'FAN': '风扇', 66 | 'MICROWAVE_OVEN': '微波炉', 67 | 'TV_SET': '电视', 68 | 'DISHWASHER': '洗碗机', 69 | 'OVEN': '烤箱', 70 | 'WATER_CLEANER': '净水器', 71 | 'HUMIDIFIER': '加湿器', 72 | 'SETTOP_BOX': '机顶盒', 73 | 'HEATER': '电暖气', 74 | 'INDUCTION_COOKER': '电饭煲', 75 | 'CURTAIN': '窗帘', 76 | 'RANGE_HOOD': '抽油烟机', 77 | 'BREAD_MAKER': '面包机', 78 | } 79 | 80 | device_type_map_h2p = { 81 | 'climate': 'AIR_CONDITION', 82 | 'fan': 'FAN', 83 | 'light': 'LIGHT', 84 | 'media_player': 'TV_SET', 85 | 'switch': 'SWITCH', 86 | 'sensor': 'AIR_CLEANER', 87 | 'cover': 'CURTAIN', 88 | 'vacuum': 'SWEEPING_ROBOT', 89 | } 90 | 91 | _service_map_p2h = { 92 | # 模式和平台设备类型不影响 93 | 'fan': { 94 | 'SetModeRequest': lambda state, attributes, payload: (['fan'], ['set_speed'], [{"speed": payload['properties']['value'].lower()}]) 95 | }, 96 | 'cover': { 97 | 'TurnOnRequest': 'open_cover', 98 | 'TurnOffRequest': 'close_cover', 99 | 'PauseRequest': 'stop_cover', 100 | }, 101 | 'vacuum': { 102 | 'TurnOnRequest': 'start', 103 | 'TurnOffRequest': 'return_to_base', 104 | 'SetSuctionRequest': lambda state, attributes, payload: (['fan'], ['set_fan_speed'], [{'fan_speed': 90 if payload['suction']['value'] == 'STRONG' else 60}]), 105 | }, 106 | 'switch': { 107 | 'TurnOnRequest': 'turn_on', 108 | 'TurnOffRequest': 'turn_off', 109 | }, 110 | 'light': { 111 | 'TurnOnRequest': 'turn_on', 112 | 'TurnOffRequest': 'turn_off', 113 | 'SetBrightnessPercentageRequest': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': payload['brightness']['value']}]), 114 | 'IncrementBrightnessPercentageRequest': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': min(attributes['brightness'] / 255 * 100 + payload['deltaPercentage']['value'], 100)}]), 115 | 'DecrementBrightnessPercentageRequest': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': max(attributes['brightness'] / 255 * 100 - payload['deltaPercentage']['value'], 0)}]), 116 | 'SetColorRequest': lambda state, attributes, payload: (['light'], ['turn_on'], [{"hs_color": [float(payload['color']['hue']), float(payload['color']['saturation']) * 100]}]) 117 | }, 118 | 'havcs':{ 119 | 'TurnOnRequest': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 120 | 'TurnOffRequest': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_off'], [{}]), 121 | 'AdjustUpBrightnessRequest': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 122 | 'AdjustDownBrightnessRequest': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 123 | } 124 | } 125 | _controlSpeech_template = { 126 | 'TurnOn': '打开%s', 127 | 'TurnOff': '关闭%s', 128 | 'AdjustUpBrightness': '调高%s亮度', 129 | 'AdjustDownBrightness': '调低%s亮度', 130 | 'SetBrightness': '设置%s亮度', 131 | 'SetColor': '设置%s颜色', 132 | 'AdjustUpTemperature': '调高%s温度', 133 | 'AdjustDownTemperature': '调低%s温度', 134 | 'SetTemperature': '设置%s温度', 135 | 'AdjustUpWindSpeed': '调高%s风速', 136 | 'AdjustDownWindSpeed': '调低%s风速', 137 | 'SetWindSpeed': '设置%s风速', 138 | 'AdjustUpVolume': '调高%s音量', 139 | 'AdjustDownVolume': '调低%s音量', 140 | 'SetVolume': '设置%s音量', 141 | 'SetMute': '设置%s静音', 142 | 'AdjustUpTVChannel': '调高%s频道数字', 143 | 'AdjustDownTVChannel': '调低%s频道数字', 144 | 'SetTVChannel': '设置%s频道', 145 | 'ReturnTVChannel': '返回上一个频道', 146 | 'Play': '播放', 147 | 'Stop': '停止', 148 | 'Next': '下一个', 149 | 'Pause': '暂停', 150 | 'Previous': '上一个', 151 | 'SetMode': '设置%s模式', 152 | 'Query': '查询%s的状态', 153 | 'QueryPowerState': '查询%s的电源状态', 154 | 'QueryColor': '查询%s的颜色', 155 | 'QueryTemperature': '查询%s的温度', 156 | 'QueryWindspeed': '查询%s的风速', 157 | 'QueryBrightness': '查询%s的亮度', 158 | 'QueryHumidity': '查询%s的湿度', 159 | 'QueryPM25': '查询%s的PM2.5', 160 | 'QueryMode': '查询%s的模式', 161 | } 162 | _query_map_p2h = { 163 | 164 | } 165 | 166 | class VoiceControlJdwhale(PlatformParameter, VoiceControlProcessor): 167 | def __init__(self, hass, mode, entry): 168 | self._hass = hass 169 | self._mode = mode 170 | self.vcdm = VoiceControlDeviceManager(entry, DOMAIN, self.device_action_map_h2p, self.device_attribute_map_h2p, self._service_map_p2h, self.device_type_map_h2p, self._device_type_alias) 171 | def _errorResult(self, errorCode, messsage=None): 172 | """Generate error result""" 173 | messages = { 174 | 'INVALIDATE_CONTROL_ORDER': 'invalidate control order', 175 | 'SERVICE_ERROR': 'service error', 176 | 'DEVICE_NOT_SUPPORT_FUNCTION': 'device not support', 177 | 'INVALIDATE_PARAMS': 'invalidate params', 178 | 'DEVICE_IS_NOT_EXIST': 'device is not exist', 179 | 'IOT_DEVICE_OFFLINE': 'device is offline', 180 | 'IOT_DEVICE_POWEROFF': 'device is poweroff', 181 | 'ACCESS_TOKEN_INVALIDATE': 'access_token is invalidate', 182 | 'PARAMS_OVERSTEP_MAX': 'params overstep max', 183 | 'PARAMS_OVERSTEP_MIN': 'params overstep min' 184 | } 185 | return {'errorCode': errorCode, 'message': messsage if messsage else messages[errorCode]} 186 | 187 | async def handleRequest(self, data, auth = False, request_from = "http"): 188 | """Handle request""" 189 | _LOGGER.info("[%s] Handle Request:\n%s", LOGGER_NAME, data) 190 | 191 | header = self._prase_command(data, 'header') 192 | payload = self._prase_command(data, 'payload') 193 | action = self._prase_command(data, 'action') 194 | namespace = self._prase_command(data, 'namespace') 195 | p_user_id = self._prase_command(data, 'user_uid') 196 | # uid = p_user_id+'@'+DOMAIN 197 | content = {} 198 | 199 | if auth: 200 | if namespace == 'Alpha.Iot.Device.Discover': 201 | err_result, discovery_devices, entity_ids = self.process_discovery_command(request_from) 202 | content = {'deviceInfo': discovery_devices} 203 | if DATA_HAVCS_BIND_MANAGER in self._hass.data[INTEGRATION]: 204 | await self._hass.data[INTEGRATION][DATA_HAVCS_BIND_MANAGER].async_save_changed_devices(entity_ids, DOMAIN, p_user_id) 205 | elif namespace == 'Alpha.Iot.Device.Control': 206 | err_result, content = await self.process_control_command(data) 207 | elif namespace == 'Alpha.Iot.Device.Query': 208 | err_result, content = self.process_query_command(data) 209 | if not err_result: 210 | if len(content)==1: 211 | content = content[0] 212 | content = {'deviceId': payload['deviceId'], 'properties': content} 213 | else: 214 | err_result = self._errorResult('SERVICE_ERROR') 215 | else: 216 | err_result = self._errorResult('ACCESS_TOKEN_INVALIDATE') 217 | 218 | # Check error and fill response name 219 | if err_result: 220 | header['name'] = 'ErrorResponse' 221 | content = err_result 222 | if 'deviceId' in payload: 223 | content['deviceId'] = payload['deviceId'] 224 | else: 225 | header['name'] = action.replace('Request','Response') 226 | 227 | response = {'header': header, 'payload': content} 228 | 229 | _LOGGER.info("[%s] Respnose:\n%s", LOGGER_NAME, response) 230 | return response 231 | 232 | def _prase_command(self, command, arg): 233 | header = command['header'] 234 | payload = command['payload'] 235 | 236 | if arg == 'device_id': 237 | return payload['deviceId'] 238 | elif arg == 'action': 239 | return header['name'] 240 | elif arg == 'user_uid': 241 | return header.get('userId','') 242 | elif arg == 'namespace': 243 | return header['namespace'] 244 | else: 245 | return command.get(arg) 246 | 247 | def _discovery_process_propertites(self, device_properties): 248 | return {"result": "SUCCESS"} 249 | 250 | def _discovery_process_actions(self, device_properties, raw_actions): 251 | actions = [] 252 | for device_property in device_properties: 253 | name = self.device_attribute_map_h2p.get(device_property.get('attribute')) 254 | if name: 255 | action = self.device_action_map_h2p.get('query_'+name) 256 | if action: 257 | actions += [action,] 258 | for raw_action in raw_actions: 259 | action = self.device_action_map_h2p.get(raw_action) 260 | if action: 261 | actions += [action,] 262 | return list(set(actions)) 263 | 264 | def _discovery_process_device_type(self, raw_device_type): 265 | # raw_device_type guess from device_id's domain transfer to platform style 266 | return raw_device_type if raw_device_type in self._device_type_alias else self.device_type_map_h2p.get(raw_device_type) 267 | 268 | def _discovery_process_device_info(self, device_id, device_type, device_name, zone, properties, actions): 269 | return { 270 | 'actions': actions, 271 | 'controlSpeech': [self._controlSpeech_template.get(action,'')%(device_name) if '%' in self._controlSpeech_template.get(action,'') else self._controlSpeech_template.get(action,'') for action in actions ], 272 | 'deviceId': encrypt_device_id(device_id), 273 | 'deviceTypes': device_type, 274 | 'extensions': {'manufacturerName': 'HomeAssistant'}, 275 | 'friendlyDescription': device_name, 276 | 'friendlyName': device_name, 277 | 'isReachable': '1', 278 | 'modelName': 'HomeAssistantDevice', 279 | } 280 | 281 | def _control_process_propertites(self, device_properties, action) -> None: 282 | return self._discovery_process_propertites(device_properties) 283 | 284 | def _query_process_propertites(self, device_properties, action) -> None: 285 | properties = [] 286 | action = action.replace('Request', '').replace('Get', '') 287 | if action in self._query_map_p2h: 288 | for property_name, attr_template in self._query_map_p2h[action].items(): 289 | formattd_property = self.vcdm.format_property(self._hass, device_properties, attr_template) 290 | properties.append({property_name:formattd_property}) 291 | else: 292 | for device_property in device_properties: 293 | state = self._hass.states.get(device_property.get('entity_id')) 294 | value = state.attributes.get(device_property.get('attribute'), state.state) if state else None 295 | if value: 296 | if action == 'Query': 297 | formattd_property = {'name': self.device_attribute_map_h2p.get(device_property.get('attribute')), 'value': value} 298 | properties.append(formattd_property) 299 | elif device_property.get('attribute') in action.lower(): 300 | formattd_property = {'name': self.device_attribute_map_h2p.get(device_property.get('attribute')), 'value': value} 301 | properties = [formattd_property] 302 | break 303 | return properties 304 | 305 | def _decrypt_device_id(self, device_id) -> None: 306 | return decrypt_device_id(device_id) 307 | 308 | 309 | @property 310 | def should_report_when_starup(self): 311 | return True if 'report_when_starup' in self._mode else False 312 | 313 | async def bind_device(self,p_user_id, bind_device_ids, unbind_entity_ids, devices): 314 | payload = [] 315 | for device in devices: 316 | devicd_id = decrypt_device_id(device['deviceId']) 317 | if devicd_id in bind_device_ids: 318 | bind_payload ={ 319 | "header": { 320 | "namespace": "Alpha.Iot.Device.Report", 321 | "name": "BindDeviceEvent", 322 | "messageId": str(uuid.uuid4()), 323 | "payLoadVersion": "1" 324 | }, 325 | "payload": { 326 | "skillId": "", 327 | "userId": p_user_id, 328 | "deviceInfo": device 329 | } 330 | } 331 | payload.append(bind_payload) 332 | for devicd_id in unbind_entity_ids: 333 | unbind_payload ={ 334 | "header": { 335 | "namespace": "Alpha.Iot.Device.Report", 336 | "name": "UnBindDeviceEvent", 337 | "messageId": str(uuid.uuid4()), 338 | "payLoadVersion": "1" 339 | }, 340 | "payload": { 341 | "skillId": "", 342 | "userId": p_user_id, 343 | "deviceId":encrypt_device_id(devicd_id) 344 | } 345 | } 346 | payload.append(unbind_payload) 347 | return payload -------------------------------------------------------------------------------- /custom_components/havcs/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "havcs", 3 | "name": "Home Assistant Voice Control Skill", 4 | "documentation": "https://ljr.im/articles/plugins-havcs-edible-instructions/", 5 | "config_flow": true, 6 | "requirements": ["paho-mqtt>=1.4.0", "pycryptodome>=3.9.8"], 7 | "codeowners": ["@cnk700i"], 8 | "version": "3.0.0" 9 | } 10 | -------------------------------------------------------------------------------- /custom_components/havcs/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | description: 重新加载设备配置。 -------------------------------------------------------------------------------- /custom_components/havcs/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "HAVCS", 4 | "step": { 5 | "clear":{ 6 | "title": "HAVCS配置-清除原有集成配置", 7 | "description": "插件更新前往[github](https://github.com/cnk700i/havcs)下载;详细使用说明见[博客](https://ljr.im/articles/plugins-havcs-edible-instructions/);如使用mqtt功能访问[服务网站](https://havcs.ljr.im:8123/account)注册账号", 8 | "data": { 9 | "comfirm": "确认清除配置" 10 | } 11 | }, 12 | "base": { 13 | "title": "HAVCS配置-步骤1(1/2)", 14 | "description": "插件更新前往[github](https://github.com/cnk700i/havcs)下载;详细使用说明见[博客](https://ljr.im/articles/plugins-havcs-edible-instructions/);如使用mqtt功能访问[服务网站](https://havcs.ljr.im:8123/account)注册账号", 15 | "data": { 16 | "mode": "运行模式", 17 | "device_config": "启用设备信息管理UI", 18 | "aligenie": "天猫精灵", 19 | "dueros": "小度音箱", 20 | "jdwhale": "叮咚音箱", 21 | "weixin": "微信企业号" 22 | } 23 | }, 24 | "access": { 25 | "title": "HAVCS配置-步骤2(2/2)", 26 | "description": "插件更新前往[github](https://github.com/cnk700i/havcs)下载;详细使用说明见[博客](https://ljr.im/articles/plugins-havcs-edible-instructions/);如使用mqtt功能访问[服务网站](https://havcs.ljr.im:8123/account)注册账号", 27 | "data": { 28 | "url": "测试url(格式https://[你的公网域名或IP:端口号]/[HA的服务uri])", 29 | "proxy_url": "测试url(格式https://havcs.ljr.im:8123/h2m2h/[你的AppKey]/[HA的服务uri])", 30 | "broker": "MQTT服务器域名", 31 | "port": "MQTT服务器端口", 32 | "app_key": "AppKey(服务网站获取)", 33 | "app_secret": "AppSecret(服务网站获取)", 34 | "entity_key": "设备ID加密钥匙(选填,16个任意字符)", 35 | "skip_test": "跳过连通性测试", 36 | "aligenie_id": "天猫精灵Client Id(注意要以aligenie开头)", 37 | "aligenie_secret": "天猫精灵Client Secret", 38 | "dueros_id": "小度音箱Client Id(注意要以dueros开头)", 39 | "dueros_secret": "小度音箱Client Secret", 40 | "jdwhale_id": "叮咚音箱Client Id(注意要以jdwhale开头)", 41 | "jdwhale_secret": "叮咚音箱Client Secret", 42 | "weixin_id": "叮咚音箱Client Id(注意要以jdwhale开头)", 43 | "weixin_secret": "叮咚音箱Client Secret", 44 | "ha_url": "HA url(留空自动检测,如有错误设置为http(s)://127.0.0.1:8123,端口号按实际调整)" 45 | } 46 | } 47 | }, 48 | "error": { 49 | "platform_validation": "请选择至少一个服务平台", 50 | "mode_validation": "请选择运行模式", 51 | "entity_key_validation": "需要16个字符长度", 52 | "proxy_url_validation": "测试代理url格式有误", 53 | "connecttion_test_0": "mqtt连接正常", 54 | "connecttion_test_1": "mqtt连接失败 - 协议版本错误", 55 | "connecttion_test_2": "mqtt连接失败 - 客户端标识不可用", 56 | "connecttion_test_3": "mqtt连接失败 - 服务器不可用", 57 | "connecttion_test_4": "mqtt连接失败 - 错误的用户名/密码", 58 | "connecttion_test_5": "mqtt连接失败 - 未授权", 59 | "proxy_test": "mqtt连接正常,但代理转发失败:测试url不正确/代理转发服务异常,重试或勾选\"跳过连通性测试\"继续", 60 | "http_test": "外网访问测试失败:测试url不正确/外网访问相关配置不正确,重试或勾选\"跳过连通性测试\"继续" 61 | }, 62 | "abort": { 63 | "single_instance_allowed": "不能重复添加HAVCS集成", 64 | "clear_finish": "已清除HAVCS集成配置", 65 | "clear_cancel": "取消操作" 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /custom_components/havcs/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "title": "HAVCS", 4 | "step": { 5 | "clear":{ 6 | "title": "HAVCS配置-清除原有集成配置", 7 | "description": "插件更新前往[github](https://github.com/cnk700i/havcs)下载;详细使用说明见[博客](https://ljr.im/articles/plugins-havcs-edible-instructions/);如使用mqtt功能访问[服务网站](https://havcs.ljr.im:8123/account)注册账号", 8 | "data": { 9 | "comfirm": "确认清除配置" 10 | } 11 | }, 12 | "base": { 13 | "title": "HAVCS配置-步骤1(1/2)", 14 | "description": "插件更新前往[github](https://github.com/cnk700i/havcs)下载;详细使用说明见[博客](https://ljr.im/articles/plugins-havcs-edible-instructions/);如使用mqtt功能访问[服务网站](https://havcs.ljr.im:8123/account)注册账号", 15 | "data": { 16 | "mode": "运行模式", 17 | "device_config": "启用设备信息管理UI", 18 | "aligenie": "天猫精灵", 19 | "dueros": "小度音箱", 20 | "jdwhale": "叮咚音箱", 21 | "weixin": "微信企业号" 22 | } 23 | }, 24 | "access": { 25 | "title": "HAVCS配置-步骤2(2/2)", 26 | "description": "插件更新前往[github](https://github.com/cnk700i/havcs)下载;详细使用说明见[博客](https://ljr.im/articles/plugins-havcs-edible-instructions/);如使用mqtt功能访问[服务网站](https://havcs.ljr.im:8123/account)注册账号", 27 | "data": { 28 | "url": "测试url(格式https://[你的公网域名或IP:端口号]/[HA的服务uri])", 29 | "proxy_url": "测试url(格式https://havcs.ljr.im:8123/h2m2h/[你的AppKey]/[HA的服务uri])", 30 | "broker": "MQTT服务器域名", 31 | "port": "MQTT服务器端口", 32 | "app_key": "AppKey(服务网站获取)", 33 | "app_secret": "AppSecret(服务网站获取)", 34 | "entity_key": "设备ID加密钥匙(选填,16个任意字符)", 35 | "skip_test": "跳过连通性测试", 36 | "aligenie_id": "天猫精灵Client Id(注意要以aligenie开头)", 37 | "aligenie_secret": "天猫精灵Client Secret", 38 | "dueros_id": "小度音箱Client Id(注意要以dueros开头)", 39 | "dueros_secret": "小度音箱Client Secret", 40 | "jdwhale_id": "叮咚音箱Client Id(注意要以jdwhale开头)", 41 | "jdwhale_secret": "叮咚音箱Client Secret", 42 | "weixin_id": "叮咚音箱Client Id(注意要以jdwhale开头)", 43 | "weixin_secret": "叮咚音箱Client Secret", 44 | "ha_url": "HA url(留空自动检测,如有错误设置为http(s)://127.0.0.1:8123,端口号按实际调整)" 45 | } 46 | } 47 | }, 48 | "error": { 49 | "platform_validation": "请选择至少一个服务平台", 50 | "mode_validation": "请选择运行模式", 51 | "entity_key_validation": "需要16个字符长度", 52 | "proxy_url_validation": "测试代理url格式有误", 53 | "connecttion_test_0": "mqtt连接正常", 54 | "connecttion_test_1": "mqtt连接失败 - 协议版本错误", 55 | "connecttion_test_2": "mqtt连接失败 - 客户端标识不可用", 56 | "connecttion_test_3": "mqtt连接失败 - 服务器不可用", 57 | "connecttion_test_4": "mqtt连接失败 - 错误的用户名/密码", 58 | "connecttion_test_5": "mqtt连接失败 - 未授权", 59 | "proxy_test": "mqtt连接正常,但代理转发失败:测试url不正确/代理转发服务异常,重试或勾选\"跳过连通性测试\"继续", 60 | "http_test": "外网访问测试失败:测试url不正确/外网访问相关配置不正确,重试或勾选\"跳过连通性测试\"继续" 61 | }, 62 | "abort": { 63 | "single_instance_allowed": "不能重复添加HAVCS集成", 64 | "clear_finish": "已清除HAVCS集成配置", 65 | "clear_cancel": "取消操作" 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /custom_components/havcs/util.py: -------------------------------------------------------------------------------- 1 | from Crypto.Cipher import AES 2 | from base64 import b64decode, b64encode 3 | import homeassistant.util.color as color_util 4 | import time 5 | import re 6 | import jwt 7 | from typing import cast 8 | import logging 9 | 10 | _LOGGER = logging.getLogger(__name__) 11 | # _LOGGER.setLevel(logging.DEBUG) 12 | LOGGER_NAME = 'util' 13 | 14 | ENTITY_KEY = '' 15 | 16 | class AESCipher: 17 | """ 18 | Tested under Python 3.x and PyCrypto 2.6.1. 19 | """ 20 | def __init__(self, key): 21 | #加密需要的key值 22 | self.key=key 23 | self.mode = AES.MODE_CBC 24 | def encrypt(self, raw): 25 | # Padding for the input string --not 26 | # related to encryption itself. 27 | BLOCK_SIZE = 16 # Bytes 28 | pad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * \ 29 | chr(BLOCK_SIZE - len(s) % BLOCK_SIZE).encode('utf8') 30 | raw = pad(raw) 31 | #通过key值,使用ECB模式进行加密 32 | cipher = AES.new(self.key, self.mode, b'0000000000000000') 33 | #返回得到加密后的字符串进行解码然后进行64位的编码 34 | return b64encode(cipher.encrypt(raw)).decode('utf8') 35 | 36 | def decrypt(self, enc): 37 | unpad = lambda s: s[:-ord(s[len(s) - 1:])] 38 | #首先对已经加密的字符串进行解码 39 | enc = b64decode(enc) 40 | #通过key值,使用ECB模式进行解密 41 | cipher = AES.new(self.key, self.mode, b'0000000000000000') 42 | return unpad(cipher.decrypt(enc)).decode('utf8') 43 | 44 | def decrypt_device_id(device_id): 45 | try: 46 | if not ENTITY_KEY: 47 | new_device_id = device_id 48 | else: 49 | device_id = device_id.replace('-', '+') 50 | device_id = device_id.replace('_', '/') 51 | pad4 = '====' 52 | device_id += pad4[0:len(device_id) % 4] 53 | new_device_id = AESCipher(ENTITY_KEY.encode('utf-8')).decrypt(device_id) 54 | except: 55 | new_device_id = None 56 | finally: 57 | return new_device_id 58 | def encrypt_device_id(device_id): 59 | if not ENTITY_KEY: 60 | new_device_id = device_id 61 | else: 62 | new_device_id = AESCipher(ENTITY_KEY.encode('utf-8')).encrypt(device_id.encode('utf8')) 63 | new_device_id = new_device_id.replace('+', '-') 64 | new_device_id = new_device_id.replace('/', '_') 65 | new_device_id = new_device_id.replace('=', '') 66 | return new_device_id 67 | 68 | def hsv2rgb(hsvColorDic): 69 | 70 | h = float(hsvColorDic['hue']) 71 | s = float(hsvColorDic['saturation']) 72 | v = float(hsvColorDic['brightness']) 73 | rgb = color_util.color_hsv_to_RGB(h, s, v) 74 | 75 | return rgb 76 | 77 | def timestamp2Delay(timestamp): 78 | delay = abs(int(time.time()) - timestamp) 79 | return delay 80 | 81 | def get_platform_from_command(command): 82 | if 'AliGenie' in command: 83 | platform = 'aligenie' 84 | elif 'DuerOS' in command: 85 | platform = 'dueros' 86 | elif 'Alpha' in command: 87 | platform = 'jdwhale' 88 | else: 89 | platform = 'unknown' 90 | return platform 91 | 92 | def get_token_from_command(command): 93 | result = re.search(r'(?:accessToken|token)[\'\"\s:]+(.*?)[\'\"\s]+(,|\})', command, re.M|re.I) 94 | return result.group(1) if result else None 95 | 96 | async def async_update_token_expiration(access_token, hass, expiration): 97 | try: 98 | unverif_claims = jwt.decode(access_token, verify=False) 99 | refresh_token = await hass.auth.async_get_refresh_token(cast(str, unverif_claims.get('iss'))) 100 | for user in hass.auth._store._users.values(): 101 | if refresh_token.id in user.refresh_tokens and refresh_token.access_token_expiration != expiration: 102 | _LOGGER.debug("[util] set new access token expiration for refresh_token[%s]", refresh_token.id) 103 | refresh_token.access_token_expiration = expiration 104 | user.refresh_tokens[refresh_token.id] = refresh_token 105 | hass.auth._store._async_schedule_save() 106 | break 107 | except jwt.InvalidTokenError: 108 | _LOGGER.debug("[util] access_token[%s] is invalid, try another reauthorization on website", access_token) -------------------------------------------------------------------------------- /custom_components/havcs/weixin.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import uuid 4 | import copy 5 | import time 6 | 7 | from .util import decrypt_device_id, encrypt_device_id 8 | from .helper import VoiceControlProcessor, VoiceControlDeviceManager 9 | from .const import DEVICE_ATTRIBUTE_DICT, ATTR_DEVICE_ACTIONS, INTEGRATION, DATA_HAVCS_BIND_MANAGER 10 | 11 | _LOGGER = logging.getLogger(__name__) 12 | # _LOGGER.setLevel(logging.DEBUG) 13 | 14 | DOMAIN = 'weixin' 15 | LOGGER_NAME = 'weixin' 16 | 17 | def createHandler(hass, enrty): 18 | mode = ['handler'] 19 | return VoiceControlWeixin(hass, mode, enrty) 20 | 21 | class PlatformParameter: 22 | device_attribute_map_h2p = { 23 | 'temperature': 'temperature', 24 | 'brightness': 'brightness', 25 | 'humidity': 'humidity', 26 | 'pm25': 'pm2.5', 27 | 'co2': 'co2', 28 | 'power_state': 'power_state' 29 | } 30 | device_action_map_h2p ={ 31 | 'turn_on': 'turn_on', 32 | 'turn_off': 'turn_off', 33 | 'timing_turn_on': 'timing_turn_on', 34 | 'timing_turn_off': 'timing_turn_off', 35 | 'increase_brightness': 'increase_brightness', 36 | 'decrease_brightness': 'decrease_brightness', 37 | 'set_brightness': 'set_brightness', 38 | # 'increase_temperature': 'incrementTemperature', 39 | # 'decrease_temperature': 'decrementTemperature', 40 | # 'set_temperature': 'setTemperature', 41 | 'set_color': 'set_color', 42 | 'pause': 'pause', 43 | # 'query_color': 'QueryColor', 44 | # 'query_power_state': 'getTurnOnState', 45 | 'query_temperature': 'query_temperature', 46 | 'query_humidity': 'query_humidity', 47 | # '': 'QueryWindSpeed', 48 | # '': 'QueryBrightness', 49 | # '': 'QueryFog', 50 | # '': 'QueryMode', 51 | # '': 'QueryPM25', 52 | # '': 'QueryDirection', 53 | # '': 'QueryAngle' 54 | } 55 | _device_type_alias = { 56 | 'LIGHT': '灯', 57 | 'FAN': '风扇', 58 | 'SWITCH': '开关', 59 | 'COVER': '窗帘', 60 | 'CLIMATE': '空调', 61 | 'SENSOR': '传感器', 62 | 'VACUUM': '扫地机器人', 63 | } 64 | 65 | device_type_map_h2p = { 66 | 'climate': 'CLIMATE', 67 | 'fan': 'FAN', 68 | 'light': 'LIGHT', 69 | 'media_player': 'MEDIA_PLAYER', 70 | 'switch': 'SWITCH', 71 | 'sensor': 'SENSOR', 72 | 'cover': 'COVER', 73 | 'vacuum': 'VACUUM', 74 | } 75 | 76 | _service_map_p2h = { 77 | 'cover': { 78 | 'turn_on': 'open_cover', 79 | 'turn_off': 'close_cover', 80 | 'timing_turn_on': 'open_cover', 81 | 'timing_turn_off': 'close_cover', 82 | 'pause': 'stop_cover', 83 | }, 84 | 'vacuum': { 85 | 'turn_on': 'start', 86 | 'turn_off': 'return_to_base', 87 | 'timing_turn_on': 'start', 88 | 'timing_turn_off': 'return_to_base', 89 | 'set_suction': lambda state, attributes, payload: (['vacuum'], ['set_fan_speed'], [{'fan_speed': 90 if payload['suction']['value'] == 'STRONG' else 60}]), 90 | }, 91 | 'switch': { 92 | 'turn_on': 'turn_on', 93 | 'turn_off': 'turn_off', 94 | 'timing_turn_on': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'on', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 95 | 'timing_turn_off': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'off', 'duration': int(payload['timestamp']['value']) - int(time.time())}]) 96 | }, 97 | 'light': { 98 | 'turn_on': 'turn_on', 99 | 'turn_off': 'turn_off', 100 | 'timing_turn_on': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'on', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 101 | 'timing_turn_off': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'off', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 102 | 'set_brightness': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': payload['brightness']['value']}]), 103 | 'increase_brightness': lambda state, attributes, payload: (['light'], ['turn_on'],[ {'brightness_pct': min(state.attributes['brightness'] / 255 * 100 + payload['deltaPercentage']['value'], 100)}]), 104 | 'decrease_brightness': lambda state, attributes, payload: (['light'], ['turn_on'], [{'brightness_pct': max(state.attributes['brightness'] / 255 * 100 - payload['deltaPercentage']['value'], 0)}]), 105 | 'SetColorRequest': lambda state, attributes, payload: (['light'], ['turn_on'], [{'hs_color': [float(payload['color']['hue']), float(payload['color']['saturation']) * 100], 'brightness_pct': float(payload['color']['brightness']) * 100}]) 106 | }, 107 | 'havcs':{ 108 | 'turn_on': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_on']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 109 | 'turn_off': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['turn_off']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_off'], [{}]), 110 | 'increase_brightness': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['increase_brightness']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 111 | 'decrease_brightness': lambda state, attributes, payload:([cmnd[0] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']], [cmnd[1] for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']], [json.loads(cmnd[2]) for cmnd in attributes[ATTR_DEVICE_ACTIONS]['decrease_brightness']]) if attributes.get(ATTR_DEVICE_ACTIONS) else (['input_boolean'], ['turn_on'], [{}]), 112 | 'timing_turn_on': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'custom:havcs_actions/timing_turn_on', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 113 | 'timing_turn_off': lambda state, attributes, payload: (['common_timer'], ['set'], [{'operation': 'custom:havcs_actions/timing_turn_off', 'duration': int(payload['timestamp']['value']) - int(time.time())}]), 114 | } 115 | 116 | } 117 | 118 | _query_map_p2h = { 119 | 'query_temperature': { 120 | 'temperature': {'value':'%temperature', 'scale': "°C"} 121 | } 122 | } 123 | 124 | class VoiceControlWeixin(PlatformParameter, VoiceControlProcessor): 125 | def __init__(self, hass, mode, enrty): 126 | self._hass = hass 127 | self._mode = mode 128 | self.vcdm = VoiceControlDeviceManager(enrty, DOMAIN, self.device_action_map_h2p, self.device_attribute_map_h2p, self._service_map_p2h, self.device_type_map_h2p, self._device_type_alias) 129 | def _errorResult(self, errorCode, messsage=None): 130 | """Generate error result""" 131 | messages = { 132 | 'INVALIDATE_CONTROL_ORDER': 'invalidate control order', 133 | 'SERVICE_ERROR': 'service error', 134 | 'DEVICE_NOT_SUPPORT_FUNCTION': 'device not support', 135 | 'INVALIDATE_PARAMS': 'invalidate params', 136 | 'DEVICE_IS_NOT_EXIST': 'device is not exist', 137 | 'IOT_DEVICE_OFFLINE': 'device is offline', 138 | 'ACCESS_TOKEN_INVALIDATE': 'access_token is invalidate' 139 | } 140 | return {'errorCode': errorCode, 'message': messsage if messsage else messages[errorCode]} 141 | 142 | async def handleRequest(self, data, auth = False): 143 | """Handle request""" 144 | _LOGGER.info("[%s] Handle Request:\n%s", LOGGER_NAME, data) 145 | 146 | header = self._prase_command(data, 'header') 147 | # action = self._prase_command(data, 'action') 148 | namespace = self._prase_command(data, 'namespace') 149 | p_user_id = self._prase_command(data, 'user_uid') 150 | result = {} 151 | # uid = p_user_id+'@'+DOMAIN 152 | 153 | if auth: 154 | namespace = header['namespace'] 155 | if 'Discoverer' in namespace: 156 | err_result, discovery_devices, entity_ids = self.process_discovery_command() 157 | result = {'discoveredDevices': discovery_devices} 158 | if DATA_HAVCS_BIND_MANAGER in self._hass.data[INTEGRATION]: 159 | await self._hass.data[INTEGRATION][DATA_HAVCS_BIND_MANAGER].async_save_changed_devices(entity_ids, DOMAIN, p_user_id) 160 | elif 'Controller' in namespace: 161 | err_result, properties = await self.process_control_command(data) 162 | result = err_result if err_result else {'properties': properties} 163 | elif 'Reporter' in namespace: 164 | err_result, properties = self.process_query_command(data) 165 | result = err_result if err_result else {'properties': properties} 166 | else: 167 | result = self._errorResult('SERVICE_ERROR') 168 | else: 169 | result = self._errorResult('ACCESS_TOKEN_INVALIDATE') 170 | 171 | # Check error 172 | header['name'] = 'Response' 173 | if 'errorCode' in result: 174 | header['name'] = 'Error' 175 | 176 | response = {'header': header, 'payload': result} 177 | 178 | _LOGGER.info("[%s] Respnose:\n%s", LOGGER_NAME, response) 179 | return response 180 | 181 | def _prase_command(self, command, arg): 182 | header = command['header'] 183 | payload = command['payload'] 184 | 185 | if arg == 'device_id': 186 | return payload['device']['id'] 187 | elif arg == 'action': 188 | return header['name'] 189 | elif arg == 'user_uid': 190 | return payload.get('openUid','') 191 | else: 192 | return command.get(arg) 193 | 194 | def _discovery_process_propertites(self, device_properties): 195 | properties = [] 196 | for device_property in device_properties: 197 | name = self.device_attribute_map_h2p.get(device_property.get('attribute')) 198 | state = self._hass.states.get(device_property.get('entity_id')) 199 | if name and state: 200 | value = state.state 201 | if name == 'power_state': 202 | if state.state != 'off': 203 | value = 'on' 204 | else: 205 | value = 'off' 206 | 207 | properties += [{'name': name, 'value': value, 'scale': DEVICE_ATTRIBUTE_DICT.get(name, {}).get('scale'), 'timestampOfSample': int(time.time()), 'uncertaintyInMilliseconds': 1000, 'legalValue': DEVICE_ATTRIBUTE_DICT.get(name, {}).get('legalValue') }] 208 | 209 | return properties if properties else [{'name': 'power_state', 'value': 'off', 'scale': DEVICE_ATTRIBUTE_DICT.get('power_state', {}).get('scale'), 'timestampOfSample': int(time.time()), 'uncertaintyInMilliseconds': 1000, 'legalValue': DEVICE_ATTRIBUTE_DICT.get('power_state', {}).get('legalValue') }] 210 | 211 | def _discovery_process_actions(self, device_properties, raw_actions): 212 | actions = [] 213 | for device_property in device_properties: 214 | name = self.device_attribute_map_h2p.get(device_property.get('attribute')) 215 | if name: 216 | action = self.device_action_map_h2p.get('query_'+name) 217 | if action: 218 | actions += [action,] 219 | for raw_action in raw_actions: 220 | action = self.device_action_map_h2p.get(raw_action) 221 | if action: 222 | actions += [action,] 223 | return list(set(actions)) 224 | 225 | def _discovery_process_device_type(self, raw_device_type): 226 | return self.device_type_map_h2p.get(raw_device_type) 227 | 228 | def _discovery_process_device_info(self, device_id, device_type, device_name, zone, properties, actions): 229 | return { 230 | 'deviceId': encrypt_device_id(device_id), 231 | 'deviceName': {'cn':device_name,'en':'undefined'}, 232 | 'type': device_type, 233 | 'zone': zone, 234 | 'isReachable': True, 235 | 'manufacturerName': 'HomeAssistant', 236 | 'modelName': 'HomeAssistant', 237 | 'version': '1.0', 238 | 'actions': actions, 239 | 'properties': properties, 240 | } 241 | 242 | 243 | def _control_process_propertites(self, device_properties, action) -> None: 244 | 245 | return self._discovery_process_propertites(device_properties) 246 | 247 | def _query_process_propertites(self, device_properties, action) -> None: 248 | properties = [ ] 249 | if action in self._query_map_p2h: 250 | for property_name, attr_template in self._query_map_p2h[action].items(): 251 | formattd_property = self.vcdm.format_property(self._hass, device_properties, attr_template) 252 | formattd_property.update({'name': property_name}) 253 | properties += [formattd_property] 254 | else: 255 | for device_property in device_properties: 256 | state = self._hass.states.get(device_property.get('entity_id')) 257 | value = state.attributes.get(device_property.get('attribute'), state.state) if state else None 258 | if value: 259 | if device_property.get('attribute').lower() in action.lower() or action == 'query_all': 260 | name = device_property.get('attribute') 261 | formattd_property = {'name': name, 'value': value, 'scale': DEVICE_ATTRIBUTE_DICT.get(name, {}).get('scale')} 262 | properties += [formattd_property] 263 | return properties 264 | 265 | def _decrypt_device_id(self, device_id) -> None: 266 | return decrypt_device_id(device_id) 267 | 268 | # def report_device(self, device_id): 269 | 270 | # payload = [] 271 | # for p_user_id in self._hass.data[INTEGRATION][DATA_HAVCS_BIND_MANAGER].get_uids(DOMAIN, entity_device_idid): 272 | # _LOGGER.info("[%s] report device for %s:\n", LOGGER_NAME, p_user_id) 273 | # report = { 274 | # "header": { 275 | # "namespace": "DuerOS.ConnectedHome.Control", 276 | # "name": "ChangeReportRequest", 277 | # "messageId": str(uuid.uuid4()), 278 | # "payloadVersion": "1" 279 | # }, 280 | # "payload": { 281 | # "botId": "", 282 | # "openUid": p_user_id, 283 | # "appliance": { 284 | # "applianceId": encrypt_entity_id(device_id), 285 | # "attributeName": "turnOnState" 286 | # } 287 | # } 288 | # } 289 | # payload.append(report) 290 | # return payload --------------------------------------------------------------------------------