├── images ├── cover.png ├── light.png ├── flow │ ├── 1.png │ ├── 2.png │ ├── 3.png │ ├── 4.png │ ├── 5.png │ ├── 6.png │ ├── 7.png │ ├── 8.png │ └── climate.png ├── switch.png ├── donation.png └── smartlock.jpg ├── .gitignore ├── hacs.json ├── .github └── workflows │ └── hassfest.yaml ├── custom_components └── xiaomi_miot_raw │ ├── manifest.json │ ├── system_health.py │ ├── deps │ ├── miio_new.py │ ├── miot_coordinator.py │ ├── ble_event_parser.py │ ├── const.py │ ├── special_devices.py │ └── xiaomi_cloud_new.py │ ├── services.yaml │ ├── number.py │ ├── switch.py │ ├── binary_sensor.py │ ├── humidifier.py │ ├── select.py │ ├── lock.py │ ├── translations │ ├── zh-Hant.json │ ├── zh-Hans.json │ └── en.json │ ├── water_heater.py │ ├── cover.py │ ├── vacuum.py │ ├── media_player.py │ ├── light.py │ └── fan.py ├── config_example ├── fan.yaml ├── light.yaml ├── cover.yaml ├── sensor.yaml ├── switch.yaml ├── xiaoai.yaml └── README.md ├── tools ├── 小米信息获取.py ├── autoconfig.py └── xiaomi_cloud.py ├── README_en.md ├── README.md └── LICENSE /images/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/cover.png -------------------------------------------------------------------------------- /images/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/light.png -------------------------------------------------------------------------------- /images/flow/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/1.png -------------------------------------------------------------------------------- /images/flow/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/2.png -------------------------------------------------------------------------------- /images/flow/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/3.png -------------------------------------------------------------------------------- /images/flow/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/4.png -------------------------------------------------------------------------------- /images/flow/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/5.png -------------------------------------------------------------------------------- /images/flow/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/6.png -------------------------------------------------------------------------------- /images/flow/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/7.png -------------------------------------------------------------------------------- /images/flow/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/8.png -------------------------------------------------------------------------------- /images/switch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/switch.png -------------------------------------------------------------------------------- /images/donation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/donation.png -------------------------------------------------------------------------------- /images/smartlock.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/smartlock.jpg -------------------------------------------------------------------------------- /images/flow/climate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ha0y/xiaomi_miot_raw/HEAD/images/flow/climate.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .idea/ 7 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "name": "Xiaomi MIoT", 4 | "content_in_root": false, 5 | "render_readme": true, 6 | "iot_class": "local_polling" 7 | } -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "xiaomi_miot_raw", 3 | "name": "Xiaomi MIoT", 4 | "config_flow": true, 5 | "iot_class": "cloud_polling", 6 | "documentation": "https://github.com/ha0y/xiaomi_miot_raw", 7 | "issue_tracker": "https://github.com/ha0y/xiaomi_miot_raw/issues", 8 | "requirements": [ 9 | "construct", 10 | "python-miio>=0.5.3" 11 | ], 12 | "dependencies": [], 13 | "codeowners": [ 14 | "@ha0y" 15 | ], 16 | "version": "v1.3.1" 17 | } 18 | -------------------------------------------------------------------------------- /config_example/fan.yaml: -------------------------------------------------------------------------------- 1 | fan: 2 | - platform: xiaomi_miot_raw 3 | name: testfan 4 | host: 192.168.0.2 5 | token: 07xxxxxxxxxxxxxxxxxxxxxxxxxxxx92 6 | mapping: 7 | switch_status: 8 | siid: 3 9 | piid: 1 10 | # oscillate: #不支持摇头,注释掉即可 11 | # siid: 2 12 | # piid: 1 13 | speed: 14 | siid: 3 15 | piid: 2 16 | params: 17 | switch_status: 18 | power_on: True 19 | power_off: False 20 | # oscillate: 21 | # on: True 22 | # off: False 23 | speed: 24 | 'off': 0 #HA要求有一个off,否则会有warning 25 | 低: 1 26 | 中: 2 27 | 高: 3 28 | scan_interval: 10 29 | -------------------------------------------------------------------------------- /config_example/light.yaml: -------------------------------------------------------------------------------- 1 | light: 2 | - platform: xiaomi_miot_raw 3 | name: 客厅灯 4 | host: 192.168.0.11 5 | token: c5xxxxxxxxxxxxxxxxxxxxxxxxxxxxa7 6 | mapping: 7 | switch_status: 8 | siid: 2 9 | piid: 1 10 | brightness: 11 | siid: 2 12 | piid: 2 13 | color_temperature: 14 | siid: 2 15 | piid: 3 16 | # color: #支持调色灯 17 | # siid: 2 18 | # piid: 3 19 | params: 20 | switch_status: 21 | power_on: True 22 | power_off: False 23 | brightness: 24 | value_range: [1, 100, 1] 25 | color_temperature: 26 | value_range: [3000, 6400, 1] 27 | scan_interval: 10 28 | -------------------------------------------------------------------------------- /config_example/cover.yaml: -------------------------------------------------------------------------------- 1 | cover: 2 | - platform: xiaomi_miot_raw 3 | name: '晾衣架' 4 | host: 192.168.0.19 5 | token: 1fxxxxxxxxxxxxxxxxxxxxxxxxxxxx70 6 | mapping: 7 | motor_control: 8 | siid: 2 9 | piid: 2 10 | motor_status: 11 | siid: 2 12 | piid: 4 13 | current_position: 14 | siid: 2 15 | piid: 10 16 | target_position: 17 | siid: 2 18 | piid: 13 19 | params: 20 | motor_control: 21 | open: 1 22 | close: 2 23 | stop: 0 24 | motor_status: 25 | open: 1 26 | close: 2 27 | stop: 3 28 | update_from_cloud: 29 | userId: '12345678' 30 | serviceToken: 'abCdeF/gHij=' 31 | ssecurity: 'abcDef123==' 32 | did: '87654321' 33 | -------------------------------------------------------------------------------- /config_example/sensor.yaml: -------------------------------------------------------------------------------- 1 | ######################################## 2 | # Thanks to @countrysideboy, @ptbsare 3 | ######################################## 4 | sensor: 5 | - platform: xiaomi_miot_raw 6 | name: air_monitor 7 | host: 10.0.0.42 8 | token: xxxxxxx 9 | mapping: 10 | humidity: 11 | siid: 3 12 | piid: 1 13 | pm2_5: 14 | siid: 3 15 | piid: 4 16 | pm10: 17 | siid: 3 18 | piid: 5 19 | temperature: 20 | siid: 3 21 | piid: 7 22 | co2: 23 | siid: 3 24 | piid: 8 25 | battery: 26 | siid: 4 27 | piid: 1 28 | charging: 29 | siid: 4 30 | piid: 2 31 | voltage: 32 | siid: 4 33 | piid: 3 34 | params: 35 | sensor_property: co2 36 | sensor_unit: 'ppm' 37 | scan_interval: 3 38 | -------------------------------------------------------------------------------- /config_example/switch.yaml: -------------------------------------------------------------------------------- 1 | switch: 2 | - platform: xiaomi_miot_raw 3 | name: 蓝牙网关插座 4 | host: 192.168.0.201 5 | token: 81xxxxxxxxxxxxxxxxxxxxxxxxxxxx02 6 | mapping: 7 | switch_status: 8 | siid: 2 9 | piid: 1 10 | temperature: 11 | siid: 2 12 | piid: 6 13 | power: 14 | siid: 5 15 | piid: 6 16 | params: 17 | switch_status: 18 | power_on: True 19 | power_off: False 20 | power: 21 | value_ratio: 0.01 # 对应的数值会被乘以0.01,用以适配功率显示 22 | 23 | - platform: xiaomi_miot_raw 24 | name: 港兴达智能插座 25 | host: 192.168.0.203 26 | token: c0xxxxxxxxxxxxxxxxxxxxxxxxxxxxd9 27 | mapping: 28 | switch_status: 29 | siid: 2 30 | piid: 1 31 | params: 32 | switch_status: 33 | power_on: True 34 | power_off: False 35 | 36 | -------------------------------------------------------------------------------- /config_example/xiaoai.yaml: -------------------------------------------------------------------------------- 1 | media_player: 2 | - platform: xiaomi_miot_raw 3 | name: 小爱音箱 4 | host: 192.168.0.130 5 | token: 38C6xxxxxxxxxxxxxxxxxxxxxxxxFB46 6 | mapping: 7 | mp_play: 8 | siid: 2 9 | aiid: 2 10 | mp_pause: 11 | siid: 2 12 | aiid: 1 13 | mp_next: 14 | siid: 2 15 | aiid: 3 16 | mp_previous: 17 | siid: 2 18 | aiid: 4 19 | mp_sound_mode: 20 | siid: 3 21 | aiid: 1 22 | playing_state: 23 | siid: 2 24 | piid: 1 25 | volume: 26 | siid: 4 27 | piid: 1 28 | params: 29 | playing_state: 30 | playing: 1 31 | pause: 0 32 | volume: 33 | value_range: [5,100,5] 34 | mp_source: 35 | 播放私人电台: 36 | siid: 3 37 | aiid: 3 38 | 播放音乐: 39 | siid: 3 40 | aiid: 4 41 | 停止闹钟: 42 | siid: 6 43 | aiid: 1 44 | mp_sound_mode: 45 | 你好: 0 46 | update_from_cloud: 47 | userId: '' 48 | serviceToken: '' 49 | ssecurity: '' 50 | did: '' 51 | cloud_write: yes 52 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/system_health.py: -------------------------------------------------------------------------------- 1 | """Provide info to system health.""" 2 | from yarl import URL 3 | 4 | from homeassistant.components import system_health 5 | from homeassistant.core import HomeAssistant, callback 6 | 7 | from .deps.const import DOMAIN 8 | 9 | 10 | @callback 11 | def async_register( 12 | hass: HomeAssistant, register: system_health.SystemHealthRegistration 13 | ) -> None: 14 | """Register system health callbacks.""" 15 | register.async_register_info(system_health_info, "/config/integrations") 16 | 17 | 18 | async def system_health_info(hass): 19 | """Get info for the info page.""" 20 | is_logged_in = bool(hass.data[DOMAIN]['cloud_instance_list']) 21 | 22 | data = { 23 | "logged_in": is_logged_in, 24 | } 25 | 26 | if is_logged_in: 27 | data["can_reach_micloud_server"] = system_health.async_check_can_reach_url( 28 | hass, "https://api.io.mi.com" 29 | ) 30 | data["accounts_count"] = len(hass.data[DOMAIN]['cloud_instance_list']) 31 | data["account_devices_count"] = len(hass.data[DOMAIN]['micloud_devices']) 32 | 33 | if hass.data[DOMAIN].get('configs'): 34 | data["added_devices"] = len(hass.data[DOMAIN]['configs']) - (1 if is_logged_in else 0) 35 | 36 | return data 37 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/deps/miio_new.py: -------------------------------------------------------------------------------- 1 | from miio.miot_device import MiotDevice as MiotDeviceOriginal 2 | 3 | class MiotDevice(MiotDeviceOriginal): 4 | def __init__( 5 | self, 6 | mapping: dict = {}, 7 | ip: str = None, 8 | token: str = None, 9 | start_id: int = 0, 10 | debug: int = 0, 11 | lazy_discover: bool = True, 12 | ) -> None: 13 | try: 14 | super().__init__(ip=ip, token=str(token), start_id=start_id, 15 | debug=debug, lazy_discover=lazy_discover, mapping=mapping) 16 | except TypeError: 17 | super().__init__(ip=ip, token=str(token), start_id=start_id, 18 | debug=debug, lazy_discover=lazy_discover) 19 | self.mapping = mapping 20 | 21 | def get_properties_for_mapping(self, max_properties=10) -> list: 22 | """Retrieve raw properties based on mapping.""" 23 | 24 | # We send property key in "did" because it's sent back via response and we can identify the property. 25 | properties = [ 26 | {"did": k, **v} for k, v in self.mapping.items() if "aiid" not in v 27 | ] 28 | 29 | return self.get_properties( 30 | properties, property_getter="get_properties", max_properties=max_properties 31 | ) 32 | -------------------------------------------------------------------------------- /config_example/README.md: -------------------------------------------------------------------------------- 1 | 各个设备类型公用的配置参数: 2 | - **host** (*Required*): 设备 IP。 3 | - **token** (*Required*): 设备 token。 4 | - **name** (*Optional*): 设备名称。 5 | - **mapping** (*Required*): 设备的功能与 id 的映射。 6 | - **params** (*Optional*): 与 mapping 对应,指定关于属性值的一些信息。 7 | - **scan_interval** (*Optional*): 状态刷新周期。 8 | 9 | - **sensor_property** (*Required*,仅限 sensor): 把 mapping 中的哪一个作为传感器的状态。其他的将作为传感器的属性。 10 | - **sensor_unit** (*Optional*,仅限 senso): 传感器单位。 11 | 12 | - **update_from_cloud** 从米家服务器读取设备状态。 13 | 14 | **mapping** 和 **params** 中的项目具有对应关系。params 是为了指定关于属性值的一些信息。比如说对于 switch_status,它代表开关状态,这一点是确定的;可是有的设备,值为 1 为开,值为 2 为关;有的设备值为 True 为开,值为 False 为关。这就需要在 params 中指定具体的状态值了。又如,蓝牙网关插座,显示的功率数值没有小数点,实际功率要除以 100;而某品牌插座,同样没有小数点,可实际功率要除以 10……这种问题同样可以在 params 中解决。二者的一些选项: 15 | 16 | - **switch_status** (*Required* 适用于 light switch fan): 插件通过读写这个属性来获取和控制开关状态。其下的 **power_on** 和 **power_off** 指定开和关的状态值。 17 | - **motor_control** (*Required* 适用于 cover),插件通过读写这个属性来控制电机状态。其下的 **open**、**close** 和 **stop** 指定升/降/停的状态值。 18 | - **motor_status** (*Optional* 适用于 cover),插件通过读写这个属性来获取电机状态。其下的 **open**、**close** 和 **stop** 指定升/降/停的状态值。注意这些值可能与上面的控制值不同。 19 | - **brightness** (*Optional* 适用于 light):设置此项后支持亮度调节。 20 | - **color_temperature** (*Optional* 适用于 light):设置此项后支持色温调节。 21 | - **oscillate** (*Optional* 适用于 fan):设置此项后支持摇头。 22 | - **speed** (*Optional* 适用于 fan):设置此项后支持风速调节。 23 | - **mode** (*Optional* 适用于 light fan):灯、加湿器等设备的运行模式。 24 | -------------------------------------------------------------------------------- /tools/小米信息获取.py: -------------------------------------------------------------------------------- 1 | try: 2 | import aiohttp 3 | except ModuleNotFoundError: 4 | input("需要安装 aiohttp,请运行: pip install aiohttp") 5 | from aiohttp import ClientSession 6 | import getpass 7 | try: 8 | from xiaomi_cloud import * 9 | except ImportError: 10 | input("需要“xiaomi_cloud.py”文件,请确保文件夹内容完整!") 11 | 12 | async def login(username: str, password: str): 13 | async with aiohttp.ClientSession() as cs: 14 | mc = MiCloud(cs) 15 | if await mc.login(username, password): 16 | print("以下是登录信息,请保存下来\n注意!拥有此信息即拥有小米账号的所有权限,请勿泄露!") 17 | # print (mc.auth) 18 | print(f"userId: '{mc.auth['user_id']}'") 19 | print(f"ssecurity: '{mc.auth['ssecurity']}'") 20 | print(f"serviceToken: '{mc.auth['service_token']}'") 21 | input("按回车键读取名下所有设备") 22 | devices_list = await mc.get_devices("cn") 23 | # print(devices_list) 24 | parse_device_list(devices_list) 25 | else: 26 | print("登录失败") 27 | # return None 28 | 29 | 30 | def parse_device_list(devices_list: list): 31 | SHOW_LIST=[('did','did'), 32 | ('model','model'), 33 | ('IP','localip'), 34 | ('token','token'), 35 | ] 36 | for idx, d in enumerate(devices_list): 37 | desc = f"({d['desc']})" if d['desc'] else "" 38 | print(f"{idx}. {d['name']} {desc}") 39 | for key, value in SHOW_LIST: 40 | print(f" {key}: {d[value]}") 41 | if __name__ == '__main__': 42 | event_loop = asyncio.get_event_loop() 43 | user = input("请输入小米账号: ") 44 | pwd = getpass.getpass("请输入密码(输入时不显示): ") 45 | tasks = [login(user, pwd)] 46 | results = event_loop.run_until_complete(asyncio.gather(*tasks)) 47 | # print(results) 48 | input("按回车键退出...") -------------------------------------------------------------------------------- /tools/autoconfig.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | url_all = 'http://miot-spec.org/miot-spec-v2/instances?status=all' 4 | url_spec = 'http://miot-spec.org/miot-spec-v2/instance' 5 | 6 | 7 | def deviceinfo(j): 8 | print(f"设备描述:{j['description']}") 9 | print("设备属性:") 10 | for s in j['services']: 11 | print(f"\nsiid {s['iid']}: {s['description']}\n") 12 | for p in s.get('properties', []): 13 | print(f" piid {p['iid']}: {p['description']}", end=' ') 14 | if 'read' in p['access']: 15 | print("可读取", end=' ') 16 | if 'write' in p['access']: 17 | print("可控制", end=' ') 18 | print() 19 | if 'format' in p: 20 | print(f" 数据类型:{p['format']}") 21 | 22 | if 'value-range' in p: 23 | print(f" 取值范围:{p['value-range']}") 24 | if 'value-list' in p: 25 | print(f" 取值范围:") 26 | for item in p['value-list']: 27 | print(f" {item['value']}: {item['description']}") 28 | for a in s.get('actions', []): 29 | print(f" aiid {a['iid']}: {a['description']}", end=' ') 30 | print() 31 | print() 32 | 33 | if __name__ == '__main__': 34 | print("正在加载设备列表...") 35 | dev_list = requests.get(url_all).json().get('instances') 36 | print(f"加载成功,现已支持{len(dev_list)}个设备") 37 | 38 | model_ = input("请输入设备model:") 39 | 40 | result = [] 41 | for item in dev_list: 42 | if model_ in item['model'] or model_ in item['type']: 43 | result.append(item) 44 | 45 | # print(result) 46 | if result: 47 | print("已发现以下设备") 48 | print("--------------------------------------") 49 | print("序号\t model \t urn") 50 | for idx, item in enumerate(result): 51 | print(f"{idx+1}\t{item['model']}\t{item['type']}") 52 | if len(result) > 1: 53 | inp = input("请确认哪个是你的设备,输入序号:") 54 | urn = result[int(inp)-1]['type'] 55 | else: 56 | urn = result[0]['type'] 57 | 58 | params = {'type': urn} 59 | r = requests.get(url_spec, params=params).json() 60 | # print(r) 61 | 62 | deviceinfo(r) 63 | 64 | else: 65 | print("未找到相关设备") 66 | 67 | input("按任意键退出...") -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/services.yaml: -------------------------------------------------------------------------------- 1 | speak_text: 2 | description: 让小爱播报指定文本 3 | fields: 4 | entity_id: 5 | description: 实体 ID 6 | example: "media_player.xiao_ai" 7 | required: true 8 | selector: 9 | entity: 10 | integration: xiaomi_miot_raw 11 | domain: media_player 12 | text: 13 | description: 要播报的文本 14 | example: "你好" 15 | required: true 16 | selector: 17 | object: 18 | execute_text: 19 | description: 让小爱执行指定指令 20 | fields: 21 | entity_id: 22 | description: 实体 ID 23 | example: "media_player.xiao_ai" 24 | required: true 25 | selector: 26 | entity: 27 | integration: xiaomi_miot_raw 28 | domain: media_player 29 | text: 30 | description: 要执行的文本 31 | example: "播放轻音乐" 32 | required: true 33 | selector: 34 | object: 35 | silent: 36 | description: (可选,默认否)静默执行 37 | example: "true" 38 | required: false 39 | selector: 40 | boolean: 41 | call_action: 42 | description: 执行 action(供高级用户使用) 43 | fields: 44 | entity_id: 45 | description: 实体 ID 46 | example: "media_player.xiao_ai" 47 | required: true 48 | selector: 49 | entity: 50 | integration: xiaomi_miot_raw 51 | siid: 52 | description: siid 53 | example: 3 54 | required: true 55 | selector: 56 | number: 57 | min: 1 58 | max: 256 59 | mode: box 60 | aiid: 61 | description: aiid 62 | example: 1 63 | required: true 64 | selector: 65 | number: 66 | min: 1 67 | max: 256 68 | mode: box 69 | inn: 70 | description: in 参数,列表形式 71 | example: "" 72 | required: false 73 | selector: 74 | object: 75 | set_miot_property: 76 | description: Set property(供高级用户使用) 77 | fields: 78 | entity_id: 79 | description: 实体 ID 80 | example: "switch.212a01_abcd" 81 | required: true 82 | selector: 83 | entity: 84 | integration: xiaomi_miot_raw 85 | siid: 86 | description: siid 87 | example: 2 88 | required: true 89 | selector: 90 | number: 91 | min: 1 92 | max: 256 93 | mode: box 94 | piid: 95 | description: piid 96 | example: 1 97 | required: true 98 | selector: 99 | number: 100 | min: 1 101 | max: 256 102 | mode: box 103 | value: 104 | description: 值 105 | example: "true" 106 | required: true 107 | selector: 108 | object: 109 | -------------------------------------------------------------------------------- /README_en.md: -------------------------------------------------------------------------------- 1 | # Xiaomi MIoT 2 | 3 | [简体中文](https://github.com/ha0y/xiaomi_miot_raw/blob/add-miot-support/README.md) | English 4 | 5 | Recently, Xiaomi brought out a new specification named ``MIoT-Spec``. Let's call it ``MIoT`` for short. It is used to communicate with ALL Xiaomi IoT devices. Compared with the old ``miio``, this new specification is clear, highly adaptive, and open to all. Thanks to MIoT, nearly all Xiaomi IoT devices can be integrated into HASS in a very easy way. So I worked out this integration. 6 | 7 | Currently this custom component supports: 8 | * sensor (get properties from device) 9 | * switch (set binary properties to device) 10 | * cover (all motor devices) 11 | * light (all kinds of light) 12 | * fan (turn on/off, set oscillation and speed) 13 | * humidifier (turn on/off, set target humidity and mode) 14 | * media player (Xiaomi AI Speaker) 15 | * climate (air conditioner, heater, ...) 16 | * water heater (water heater, kettles...) 17 | 18 | ## Installation 19 | 20 | Using HACS (recommended): 21 | 22 | * Search for the integration “Xiaomi MIoT” and add it 23 | 24 | Manual installation: 25 | 26 | * Download https://github.com/ha0y/xiaomi_miot_raw/archive/master.zip 27 | * Extract the ‘./custom_components/’ folder to your installation 28 | 29 | ## Configuration 30 | ### Configure In UI: 31 | 32 | * After installation, add ``Xiaomi MIoT`` in 'Integrations' page, just like any other integrations. 33 | (If you can't find 'Xiaomi MIoT' but you are sure you have installed and restarted, this may because of your browser cache. Please try with another web browser.) 34 | 35 | **If you want to add devices from your Xiaomi account:** 36 | 37 | * Select "登录小米账号". Enter your credientals. Click "submit". 38 | * If your devices are not in China server, select corresponding server in "Options". 39 | * Add the integration again. Now, all devices in your account should appear in the list. Select the one you want to add. 40 | * You will see a window with two textboxes and two checkboxes. The textbox describes how device is read and controlled. You don't need to change them unless you have some special needs. The checkboxes are for "Cloud Access". Try uncheck them at first and click submit, if fails, check both of them. 41 | * If "Success" is shown, your device is ready to use! 42 | 43 | **Or if you prefer to add them by IP and token:** 44 | * Select "接入设备". A form will be shown shown to input them. 45 | 46 | ### Configure In YAML (**Deprecated**): 47 | * Please refer to [these example files](https://github.com/ha0y/xiaomi_miot_raw/tree/add-miot-support/config_example) 48 | 49 | ## What is "Cloud Access"? 50 | Although Xiaomi has switched to MIoT, device manufacturers seems unwilling to use it. They stick to the old, unopened miio protocol. **The device that implements miio instead of MIoT cannot be accessed via LAN. But don't worry. Xiaomi Cloud is able to convert MIoT commands to miio commands and send them to the device via Internet.** With your Xiaomi account, the integration sends commands - instead of to devices - to Xiaomi Cloud, so that all devices can work properly. 51 | 52 | You can specify each device whether use Cloud Access or not. Please not that these devives will not work without Internet. Take care when use them in some important automations. To help you tell out devices with Cloud Access, there will be ``cloud`` in their entity ids. 53 | 54 | ## Update log 55 | 56 | Please refer to Chinese readme. 57 | 58 | ## Debug 59 | If the custom component doesn't work out of the box for your device please update your configuration to increase log level: 60 | ```yaml 61 | # configuration.yaml 62 | 63 | logger: 64 | default: warn 65 | logs: 66 | custom_components.xiaomi_miot_raw: debug 67 | miio: debug 68 | ``` -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/number.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from functools import partial 4 | 5 | from datetime import timedelta 6 | import json 7 | from collections import OrderedDict 8 | import homeassistant.helpers.config_validation as cv 9 | import voluptuous as vol 10 | from homeassistant.components.number import PLATFORM_SCHEMA, NumberEntity 11 | from homeassistant.const import * 12 | from homeassistant.exceptions import PlatformNotReady 13 | from miio.exceptions import DeviceException 14 | from .deps.miio_new import MiotDevice 15 | 16 | from .basic_dev_class import ( 17 | MiotSubDevice, 18 | MiotSubToggleableDevice 19 | ) 20 | from . import async_generic_setup_platform 21 | from .sensor import MiotSubSensor 22 | from .deps.const import ( 23 | DOMAIN, 24 | CONF_UPDATE_INSTANT, 25 | CONF_MAPPING, 26 | CONF_CONTROL_PARAMS, 27 | CONF_CLOUD, 28 | CONF_MODEL, 29 | ATTR_STATE_VALUE, 30 | ATTR_MODEL, 31 | ATTR_FIRMWARE_VERSION, 32 | ATTR_HARDWARE_VERSION, 33 | SCHEMA, 34 | MAP, 35 | DUMMY_IP, 36 | DUMMY_TOKEN, 37 | ) 38 | import copy 39 | 40 | TYPE = 'number' 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | DEFAULT_NAME = "Generic MIoT " + TYPE 45 | DATA_KEY = TYPE + '.' + DOMAIN 46 | SCAN_INTERVAL = timedelta(seconds=10) 47 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 48 | SCHEMA 49 | ) 50 | # pylint: disable=unused-argument 51 | @asyncio.coroutine 52 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 53 | hass.data[DOMAIN]['add_handler'].setdefault(TYPE, {}) 54 | if 'config_entry' in config: 55 | id = config['config_entry'].entry_id 56 | hass.data[DOMAIN]['add_handler'][TYPE].setdefault(id, async_add_devices) 57 | 58 | async def async_setup_entry(hass, config_entry, async_add_entities): 59 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 60 | await async_setup_platform(hass, config, async_add_entities) 61 | 62 | class MiotNumberInput(NumberEntity, MiotSubDevice): 63 | def __init__(self, parent_device, **kwargs): 64 | self._parent_device = parent_device 65 | self._full_did = kwargs.get('full_did') 66 | self._value_range = kwargs.get('value_range') 67 | self._name = f'{parent_device.name} {self._full_did}' 68 | self._unique_id = f"{parent_device.unique_id}-{kwargs.get('full_did')}" 69 | self._entity_id = f"{parent_device._entity_id}-{kwargs.get('full_did')}" 70 | self.entity_id = f"{DOMAIN}.{self._entity_id}" 71 | self._available = True 72 | self._skip_update = False 73 | self._icon = None 74 | 75 | @property 76 | def name(self): 77 | return f'{self._parent_device.name} {self._full_did.replace("_", " ").title()}' 78 | 79 | @property 80 | def state(self): 81 | """Return the state of the device.""" 82 | try: 83 | return self._parent_device.extra_state_attributes[self._full_did] 84 | except: 85 | return None 86 | 87 | @property 88 | def value(self): 89 | if self.state is not None: 90 | try: 91 | return float(self.state) 92 | except Exception as ex: 93 | _LOGGER.error(ex) 94 | 95 | async def async_set_value(self, value): 96 | result = await self._parent_device.set_property_new(self._full_did, value) 97 | if result: 98 | self._state_attrs[self._full_did] = value 99 | self._parent_device.schedule_update_ha_state(force_refresh=True) 100 | self._skip_update = True 101 | 102 | @property 103 | def min_value(self): 104 | """Return the minimum value.""" 105 | return self._value_range[0] 106 | 107 | @property 108 | def max_value(self): 109 | """Return the maximum value.""" 110 | return self._value_range[1] 111 | 112 | @property 113 | def step(self): 114 | """Return the increment/decrement step.""" 115 | return self._value_range[2] 116 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/switch.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from functools import partial 4 | 5 | from datetime import timedelta 6 | import json 7 | from collections import OrderedDict 8 | import homeassistant.helpers.config_validation as cv 9 | import voluptuous as vol 10 | from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity 11 | from homeassistant.const import * 12 | from homeassistant.exceptions import PlatformNotReady 13 | from miio.exceptions import DeviceException 14 | from .deps.miio_new import MiotDevice 15 | 16 | from .basic_dev_class import ( 17 | GenericMiotDevice, 18 | ToggleableMiotDevice, 19 | MiotSubDevice, 20 | MiotSubToggleableDevice 21 | ) 22 | from . import async_generic_setup_platform 23 | from .deps.const import ( 24 | DOMAIN, 25 | CONF_UPDATE_INSTANT, 26 | CONF_MAPPING, 27 | CONF_CONTROL_PARAMS, 28 | CONF_CLOUD, 29 | CONF_MODEL, 30 | ATTR_STATE_VALUE, 31 | ATTR_MODEL, 32 | ATTR_FIRMWARE_VERSION, 33 | ATTR_HARDWARE_VERSION, 34 | SCHEMA, 35 | MAP, 36 | DUMMY_IP, 37 | DUMMY_TOKEN, 38 | ) 39 | import copy 40 | 41 | TYPE = 'switch' 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | DEFAULT_NAME = "Generic MIoT " + TYPE 46 | DATA_KEY = TYPE + '.' + DOMAIN 47 | SCAN_INTERVAL = timedelta(seconds=10) 48 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 49 | SCHEMA 50 | ) 51 | # pylint: disable=unused-argument 52 | @asyncio.coroutine 53 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 54 | hass.data[DOMAIN]['add_handler'].setdefault(TYPE, {}) 55 | if 'config_entry' in config: 56 | id = config['config_entry'].entry_id 57 | hass.data[DOMAIN]['add_handler'][TYPE].setdefault(id, async_add_devices) 58 | 59 | await async_generic_setup_platform( 60 | hass, 61 | config, 62 | async_add_devices, 63 | discovery_info, 64 | TYPE, 65 | {'default': MiotSwitch}, 66 | {'default': MiotSubSwitch} 67 | ) 68 | 69 | async def async_setup_entry(hass, config_entry, async_add_entities): 70 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 71 | await async_setup_platform(hass, config, async_add_entities) 72 | 73 | class MiotSwitch(ToggleableMiotDevice, SwitchEntity): 74 | def __init__(self, device, config, device_info, hass, main_mi_type): 75 | ToggleableMiotDevice.__init__(self, device, config, device_info, hass, main_mi_type) 76 | 77 | 78 | class MiotSubSwitch(MiotSubToggleableDevice, SwitchEntity): 79 | pass 80 | 81 | class BinarySelectorEntity(MiotSubToggleableDevice, SwitchEntity): 82 | def __init__(self, parent_device, **kwargs): 83 | self._parent_device = parent_device 84 | self._did_prefix = f"{kwargs.get('did_prefix')[:10]}_" if kwargs.get('did_prefix') else "" 85 | self._field = kwargs.get('field') 86 | # self._value_list = kwargs.get('value_list') 87 | self._name_suffix = kwargs.get('name') or self._field.replace("_", " ").title() 88 | self._name = f'{parent_device.name} {self._name_suffix}' 89 | self._unique_id = f"{parent_device.unique_id}-{kwargs.get('field')}" 90 | self._entity_id = f"{parent_device._entity_id}-{kwargs.get('field')}" 91 | self.entity_id = f"{DOMAIN}.{self._entity_id}" 92 | self._available = True 93 | self._icon = "mdi:tune-variant" 94 | 95 | async def async_turn_on(self) -> None: 96 | result = await self._parent_device.set_property_new(self._did_prefix + self._field, True) 97 | if result: 98 | self._state = STATE_ON 99 | self.schedule_update_ha_state() 100 | 101 | async def async_turn_off(self) -> None: 102 | result = await self._parent_device.set_property_new(self._did_prefix + self._field, False) 103 | if result: 104 | self._state = STATE_OFF 105 | self.schedule_update_ha_state() 106 | 107 | @property 108 | def is_on(self): 109 | return self._parent_device.extra_state_attributes.get(self._did_prefix + self._field) 110 | 111 | @property 112 | def state(self): 113 | return self._state 114 | 115 | @property 116 | def extra_state_attributes(self): 117 | return {} 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Xiaomi MIoT for Home Assistant 2 | 3 | 简体中文 | [English](https://github.com/ha0y/xiaomi_miot_raw/blob/add-miot-support/README_en.md) 4 | 5 | [![version](https://img.shields.io/github/manifest-json/v/ha0y/xiaomi_miot_raw?filename=custom_components%2Fxiaomi_miot_raw%2Fmanifest.json)](https://github.com/ha0y/xiaomi_miot_raw/releases/latest) [![stars](https://img.shields.io/github/stars/ha0y/xiaomi_miot_raw)](https://github.com/ha0y/xiaomi_miot_raw/stargazers) [![issues](https://img.shields.io/github/issues/ha0y/xiaomi_miot_raw)](https://github.com/ha0y/xiaomi_miot_raw/issues) [![HACS](https://img.shields.io/badge/HACS-Default-orange.svg)](https://hacs.xyz) 6 | 7 | ### 2021.9.14:🎉现在支持小爱万能遥控模拟出的红外设备啦!首批支持`灯`、`电视`、`空调`品类虚拟设备,接入 HA 后直接显示为对应类型设备并可以控制!欢迎试用,[如有问题可讨论>>](https://github.com/ha0y/xiaomi_miot_raw/issues/239) 8 | 9 | ## 介绍 10 | 11 | MIoT 协议是小米智能家居从 2018 年起推行的智能设备通信协议规范,此后凡是已接入米家的设备均可通过此协议进行通信。此插件按照 MIoT 协议规范与米家设备或小米服务器通信,实现对设备的状态读取及控制。 12 | 13 | 通过本插件,已接入米家的智能设备均可快速高效地接入 Home Assistant,而无关设备的具体型号。**本插件已全面支持图形界面配置,全程无需编辑配置文件。** 14 | 15 | 本插件具有本地(局域网)和云端两种工作方式。两种方式结合,可以接入绝大多数米家智能设备(包括蓝牙、蓝牙 Mesh 和 ZigBee 设备)。 16 | 17 | 如果对您有帮助,欢迎给个 Star!🌟 18 | 欢迎加入 MIoT 插件交流群,在这里您不仅可以快速地得到插件相关问题的解答,还可与大家交流智能家居心得,畅所欲言! 19 | 1 群: **982 100 289**(已满) 2 群: **789 221 593** 20 | 21 | ## 安装或升级 22 | 23 | 以下两种安装/升级方法,选择其中一种即可。 24 | 请不要使用一种方法安装然后用另一种方法升级,可能导致问题。 25 | 26 | ### 通过 HACS 自动安装 27 | 28 | 1. 打开 Home Assistant 的 HACS 页面。 29 | 2. 点击`集成`。 30 | 3. 点击右下角的`浏览并添加存储库`。 31 | 4. 在新打开的页面中找到`Xiaomi MIoT`,安装即可。 32 | **注意**:如果您刚刚安装好 HACS,或者网络不通畅,您可能看不到`Xiaomi MIoT`插件,而能看到带有其他后缀的插件。 33 | **`Xiaomi MIoT`插件名称不带任何后缀,请注意识别。** 如果在 HACS 中找不到此插件,可以使用下面的手动安装方法。 34 | 5. 重新启动 Home Assistant。 35 | 36 | - **如需升级:** 在您打开 HACS 页面时,会自动出现升级提示。按照提示操作即可。 37 | 38 | ### 手动安装 39 | 1. 下载插件 [zip 压缩包](https://github.com/ha0y/xiaomi_miot_raw/archive/refs/heads/master.zip)(该链接始终为最新版本)。 40 | 2. 依次打开压缩包中的`xiaomi_miot_raw-master`/`custom_components`文件夹。 41 | 3. 将该文件夹中的`xiaomi_miot_raw`文件夹拷贝至自己 HA 安装目录的`custom_components`文件夹。 42 | 4. 重新启动 Home Assistant。 43 | 44 | > 若不知道自己的 HA 安装目录:在 HA 中点击`配置`-底部`信息`,页面中的`configuration.yaml 路径`即为 HA 的安装目录。 45 | > 若无`custom_components`文件夹,可自己新建。 46 | 47 | - **如需升级:** 下载最新版插件压缩包后,按照上述方法,覆盖原有文件即可。 48 | 49 | 50 | ## 使用方法 51 | **本插件已实现全面 UI 化,所有配置均可通过 UI(网页端)完成,无需您编辑配置文件。** 52 | 53 | **对于您可能遇到的细节问题,例如空调没有温度传感器、晾衣架显示方向是反的、需要指示灯童锁开关等,作者都已经考虑到了,别忘了看[特色功能](https://github.com/ha0y/xiaomi_miot_raw#%E7%89%B9%E8%89%B2%E5%8A%9F%E8%83%BD)部分!** 54 | 55 | 对本插件进行配置有以下两种途径: 56 | 57 | **途径 1**:点击集成页面右下角的`添加集成`,然后选择`Xiaomi MIoT`。 58 | 如果您想要登录账号、添加设备,请使用此途径。 59 | 60 | **途径 2**:在集成页面找到已添加的设备/账号,然后点击`选项`。 61 | 如果您想要修改账号地区、修改已添加设备的选项,请使用此途径。 62 | 63 | --- 64 | **首次使用建议您先登录小米账号。** 65 | 66 |
67 | 登录小米账号的方法,请点击查看 68 | 69 | ![HACS中不支持显示图片,请在浏览器中打开查看](images/flow/1.png) 70 | 71 | 72 | 73 | 74 | 75 | 76 |
77 | 78 | **登录账号后,即可立即选择要添加的设备。** 79 | 80 | **后续如需添加更多设备,再次通过`途径 1`进入插件,即可选择要添加的设备。** 81 |
82 | 图片步骤说明 83 | 84 | ![1](images/flow/1.png) 85 | 86 | 87 |
88 | 89 | **如需添加多个传感器设备,使用此方法会更加快捷:** 90 |
91 | 批量添加设备步骤说明 92 | 93 | 94 | 95 | 96 | 97 | 98 |
99 | 100 | ## 特色功能 101 | 102 | **对设备进行细微调整,使之更适合您的实际情况:** 103 | 1. 对于所有类型的设备,指示灯和童锁的开关默认是隐藏的。您可以通过`途径 2`启用它们。 104 | 2. 对于空调类实体,可以指定一个温度传感器作为该空调实体的温度来源。您可以通过`途径 2`来设置。 105 | 3. 对于卷帘类实体,如果出现上/下位置颠倒的情况,您可以通过`途径 2`来反转位置。注:此功能只改变 HA 的显示,不会反转电机方向。 106 | 4. 设备的部分选项(如模式)支持删除或修改名称,例如将风扇的模式修改为中文。您可以在`途径 1`添加设备时,修改“配置参数”来实现。 107 | 108 | ## 技术支持 109 | 如果在插件使用上遇到问题,可加入 QQ 群,大多数问题并非插件本身的问题,在 QQ 群中能更快地得到解答。若是插件存在 Bug,可通过 GitHub 的 Issue 功能提交问题,这样便于跟踪。 110 | 111 | 这里有一个[常见问题列表](https://github.com/ha0y/xiaomi_miot_raw/issues?q=is%3Aissue+label%3AFAQ+),汇总了许多常见的或具有共性的问题。 112 | 113 | 您可以先[看看 Issue 列表](https://github.com/ha0y/xiaomi_miot_raw/issues),找找您的问题是否已经有人提出或者已经有办法解决了? 114 | 如果没有的话,可点击绿色的`New Issue`按钮提交新问题。 115 | 116 | 在某些情况下,插件作者可能需要您提供更为详细的调试日志。启用详细日志的方法是: 117 | 118 | ```yaml 119 | # configuration.yaml 120 | 121 | logger: 122 | default: warn 123 | logs: 124 | custom_components.xiaomi_miot_raw: debug 125 | miio: debug 126 | ``` 127 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/binary_sensor.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from functools import partial 4 | 5 | from datetime import timedelta 6 | import json 7 | from collections import OrderedDict 8 | import homeassistant.helpers.config_validation as cv 9 | import voluptuous as vol 10 | from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity 11 | from homeassistant.const import * 12 | from homeassistant.exceptions import PlatformNotReady 13 | from miio.exceptions import DeviceException 14 | from .deps.miio_new import MiotDevice 15 | 16 | from .basic_dev_class import ( 17 | GenericMiotDevice, 18 | ToggleableMiotDevice, 19 | MiotSubDevice, 20 | MiotSubToggleableDevice 21 | ) 22 | from . import async_generic_setup_platform 23 | from .deps.const import ( 24 | DOMAIN, 25 | CONF_UPDATE_INSTANT, 26 | CONF_MAPPING, 27 | CONF_CONTROL_PARAMS, 28 | CONF_CLOUD, 29 | CONF_MODEL, 30 | ATTR_STATE_VALUE, 31 | ATTR_MODEL, 32 | ATTR_FIRMWARE_VERSION, 33 | ATTR_HARDWARE_VERSION, 34 | SCHEMA, 35 | MAP, 36 | DUMMY_IP, 37 | DUMMY_TOKEN, 38 | ) 39 | import copy 40 | 41 | TYPE = 'binary_sensor' 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | DEFAULT_NAME = "Generic MIoT " + TYPE 46 | DATA_KEY = TYPE + '.' + DOMAIN 47 | 48 | CONF_SENSOR_TYPE = "sensor_type" 49 | 50 | SCAN_INTERVAL = timedelta(seconds=10) 51 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 52 | SCHEMA 53 | ) 54 | DEVCLASS_MAPPING = { 55 | "door" : ["contact_state"], 56 | "moisture" : ["submersion_state"], 57 | "motion" : ["motion_state"], 58 | } 59 | 60 | # pylint: disable=unused-argument 61 | @asyncio.coroutine 62 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 63 | hass.data[DOMAIN]['add_handler'].setdefault(TYPE, {}) 64 | if 'config_entry' in config: 65 | id = config['config_entry'].entry_id 66 | hass.data[DOMAIN]['add_handler'][TYPE].setdefault(id, async_add_devices) 67 | 68 | await async_generic_setup_platform( 69 | hass, 70 | config, 71 | async_add_devices, 72 | discovery_info, 73 | TYPE, 74 | {'default': None}, 75 | {'default': MiotSubBinarySensor} 76 | ) 77 | 78 | async def async_setup_entry(hass, config_entry, async_add_entities): 79 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 80 | await async_setup_platform(hass, config, async_add_entities) 81 | 82 | """因为目前二元传感器只作为传感器的子实体,所以不写主实体""" 83 | 84 | class MiotSubBinarySensor(MiotSubDevice, BinarySensorEntity): 85 | def __init__(self, parent_device, mapping, params, mitype, others={}): 86 | super().__init__(parent_device, mapping, params, mitype) 87 | self._sensor_property = others.get('sensor_property') 88 | self.entity_id = f"{DOMAIN}.{parent_device._entity_id}-{others.get('sensor_property').split('_')[-1]}" 89 | self._reverse_state = False 90 | if self._ctrl_params: 91 | if self._ctrl_params.get(self._sensor_property): 92 | self._reverse_state = self._ctrl_params[self._sensor_property].get('reverse', False) 93 | 94 | @property 95 | def state(self): 96 | """Return the state of the device.""" 97 | if self.is_on == True: 98 | return STATE_ON if not self._reverse_state else STATE_OFF 99 | elif self.is_on == False: 100 | return STATE_OFF if not self._reverse_state else STATE_ON 101 | else: 102 | return STATE_UNKNOWN 103 | 104 | @property 105 | def is_on(self): 106 | """Return true if the binary sensor is on.""" 107 | try: 108 | return self._parent_device.extra_state_attributes[self._sensor_property] 109 | except: 110 | return None 111 | 112 | 113 | @property 114 | def device_info(self): 115 | return { 116 | 'identifiers': {(DOMAIN, self._parent_device.unique_id)}, 117 | } 118 | 119 | @property 120 | def device_class(self): 121 | """Return the device class of the sensor.""" 122 | try: 123 | return next(k for k,v in DEVCLASS_MAPPING.items() for item in v if item in self._sensor_property) 124 | except StopIteration: 125 | return None 126 | 127 | @property 128 | def unique_id(self): 129 | """Return an unique ID.""" 130 | return f"{self._parent_device.unique_id}-{self._sensor_property}" 131 | 132 | @property 133 | def name(self): 134 | """Return the name of this entity, if any.""" 135 | return f"{self._parent_device.name} {self._sensor_property.replace('_', ' ').capitalize()}" 136 | 137 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/deps/miot_coordinator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from datetime import timedelta, datetime 5 | from functools import partial 6 | from dataclasses import dataclass 7 | 8 | import async_timeout 9 | import homeassistant.helpers.config_validation as cv 10 | import voluptuous as vol 11 | from aiohttp import ClientSession 12 | from homeassistant.const import * 13 | from homeassistant.core import callback 14 | from homeassistant.components import persistent_notification 15 | from homeassistant.exceptions import PlatformNotReady 16 | from homeassistant.helpers import aiohttp_client, discovery 17 | from homeassistant.helpers.entity import Entity, ToggleEntity 18 | from homeassistant.helpers.entity_component import EntityComponent 19 | from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 20 | from homeassistant.helpers.storage import Store 21 | from homeassistant.util import color 22 | from miio.exceptions import DeviceException 23 | from .miio_new import MiotDevice 24 | import copy 25 | import math 26 | from collections import OrderedDict 27 | 28 | from .const import ( 29 | DOMAIN, 30 | CONF_UPDATE_INSTANT, 31 | CONF_MAPPING, 32 | CONF_CONTROL_PARAMS, 33 | CONF_CLOUD, 34 | CONF_MODEL, 35 | ATTR_STATE_VALUE, 36 | ATTR_MODEL, 37 | ATTR_FIRMWARE_VERSION, 38 | ATTR_HARDWARE_VERSION, 39 | SUPPORTED_DOMAINS, 40 | ) 41 | 42 | from .xiaomi_cloud_new import * 43 | from .xiaomi_cloud_new import MiCloud 44 | from asyncio.exceptions import CancelledError 45 | 46 | _LOGGER = logging.getLogger(__name__) 47 | 48 | class MiotCloudCoordinator(DataUpdateCoordinator): 49 | """Manages polling for state changes from the device. 50 | One for each account.""" 51 | 52 | def __init__(self, hass, cloud: MiCloud): 53 | """Initialize the data update coordinator.""" 54 | DataUpdateCoordinator.__init__( 55 | self, 56 | hass, 57 | _LOGGER, 58 | name=f"{DOMAIN}-{cloud.auth['user_id']}", 59 | update_interval=timedelta(seconds=6), 60 | ) 61 | self._cloud_instance = cloud 62 | self._error_count = 0 63 | self._fixed_list = [] 64 | self._waiting_list = [] # 请求的params 65 | self._results = {} 66 | 67 | def add_fixed_by_mapping(self, cloudconfig, mapping): 68 | did = cloudconfig.get("did") 69 | for value in mapping.values(): 70 | if 'aiid' not in value: 71 | self._fixed_list.append({**{'did':did},**value}) 72 | 73 | 74 | async def _async_update_data(self): 75 | """ 覆盖定期执行的方法 """ 76 | # _LOGGER.info(f"{self._name} is updating from cloud.") 77 | data1 = {} 78 | data1['datasource'] = 1 79 | data1['params'] = self._fixed_list + self._waiting_list 80 | data2 = json.dumps(data1,separators=(',', ':')) 81 | 82 | a = await self._cloud_instance.get_props(data2) 83 | 84 | # dict1 = {} 85 | # statedict = {} 86 | if a: 87 | # self._available = True 88 | result = a['result'] 89 | results = {} 90 | for item in result: 91 | if item['did'] not in results: 92 | results[item['did']] = [item] 93 | else: 94 | results[item['did']].append(item) 95 | self._waiting_list = [] 96 | return results 97 | 98 | class MiotEventCoordinator(DataUpdateCoordinator): 99 | def __init__(self, hass, cloud: MiCloud, cloud_config, item): 100 | """Initialize the data update coordinator.""" 101 | DataUpdateCoordinator.__init__( 102 | self, 103 | hass, 104 | _LOGGER, 105 | name=f"{DOMAIN}-{cloud.auth['user_id']}-event-{item[0]}", 106 | update_interval=timedelta(seconds=6), 107 | ) 108 | self._cloud_instance = cloud 109 | self._cloud = cloud_config 110 | # self._mapping = mapping 111 | self._item = item 112 | self._error_count = 0 113 | self._results = {} 114 | 115 | async def _async_update_data(self): 116 | result = await self._cloud_instance.get_user_device_data( 117 | self._cloud.get("did"), 118 | self._item[1]['key'], 119 | self._item[1]['type'], 120 | self._cloud.get("server_location"), 121 | ) 122 | if result is None: 123 | return None 124 | if result['code'] != 0: 125 | _LOGGER.error(result) 126 | return result 127 | else: 128 | result2 = [(datetime.fromtimestamp(item['time']).isoformat(sep=' '), item['value']) for item in result['result']] 129 | # self._results[k] = result2 130 | return result2 -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/deps/ble_event_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from datetime import datetime 3 | 4 | DOOR_EVENTS = { 5 | 0x00: "开门", 6 | 0x01: "关门", 7 | 0x02: "超时未关", 8 | 0x03: "敲门", 9 | 0x04: "撬门", 10 | 0x05: "门卡住", 11 | } 12 | 13 | BLE_LOCK_ACTION = { 14 | 0b0000: "门外开锁", 15 | 0b0001: "上提把手锁门", 16 | 0b0010: "开启反锁", 17 | 0b0011: "解除反锁", 18 | 0b0100: "门内开锁", 19 | 0b0101: "门内上锁", 20 | 0b0110: "开启童锁", 21 | 0b0111: "关闭童锁", 22 | 0b1000: "门外上锁", 23 | 0b1111: "异常", 24 | } 25 | 26 | BLE_LOCK_METHOD = { 27 | 0b0000: "蓝牙", 28 | 0b0001: "密码", 29 | 0b0010: "指纹", 30 | 0b0011: "钥匙", 31 | 0b0100: "转盘", 32 | 0b0101: "NFC", 33 | 0b0110: "一次性密码", 34 | 0b0111: "双重验证", 35 | 0b1001: "Homekit", 36 | 0b1000: "胁迫", 37 | 0b1010: "人工", 38 | 0b1011: "自动", 39 | 0b1111: "异常", 40 | } 41 | 42 | BLE_LOCK_ERROR = { 43 | 0xC0DE0000: "错误密码频繁开锁", 44 | 0xC0DE0001: "错误指纹频繁开锁", 45 | 0xC0DE0002: "操作超时(密码输入超时)", 46 | 0xC0DE0003: "撬锁", 47 | 0xC0DE0004: "重置按键按下", 48 | 0xC0DE0005: "错误钥匙频繁开锁", 49 | 0xC0DE0006: "钥匙孔异物", 50 | 0xC0DE0007: "钥匙未取出", 51 | 0xC0DE0008: "错误NFC频繁开锁", 52 | 0xC0DE0009: "超时未按要求上锁", 53 | 0xC0DE000A: "多种方式频繁开锁失败", 54 | 0xC0DE000B: "人脸频繁开锁失败", 55 | 0xC0DE000C: "静脉频繁开锁失败", 56 | 0xC0DE000D: "劫持报警", 57 | 0xC0DE000E: "布防后门内开锁", 58 | 0xC0DE000F: "掌纹频繁开锁失败", 59 | 0xC0DE0010: "保险箱被移动", 60 | 0xC0DE1000: "电量低于10%", 61 | 0xC0DE1001: "电量低于5%", 62 | 0xC0DE1002: "指纹传感器异常", 63 | 0xC0DE1003: "配件电池电量低", 64 | 0xC0DE1004: "机械故障", 65 | 0xC0DE1005: "锁体传感器故障", 66 | } 67 | 68 | BUTTON_EVENTS = { 69 | 0: "single press", 70 | 1: "double press", 71 | 2: "long press", 72 | 3: "triple press", 73 | } 74 | 75 | class EventParser: 76 | def __init__(self, data): 77 | self.data = re.sub(r'\[\"(.*)\"\]', r'\1', data) 78 | 79 | def __int__(self): 80 | return int.from_bytes(bytes.fromhex(self.data), 'little') 81 | 82 | def __getitem__(self, key): 83 | return bytes.fromhex(self.data)[key] 84 | 85 | @property 86 | def timestamp(self): 87 | return 0 88 | 89 | @property 90 | def friendly_time(self): 91 | return datetime.fromtimestamp(self.timestamp).isoformat(sep=' ') 92 | 93 | 94 | class BleDoorParser(EventParser): #0x0007, 7 95 | @property 96 | def event_id(self): 97 | return self[0] 98 | 99 | @property 100 | def event_name(self): 101 | return DOOR_EVENTS[self[0]] 102 | 103 | @property 104 | def timestamp(self): 105 | if len(self.data) != 10: 106 | return None 107 | else: 108 | return int.from_bytes(self[1:5], 'little') 109 | 110 | class BleLockParser(EventParser): #0x000b, 11 111 | @property 112 | def action_id(self): 113 | return self[0] & 0x0F 114 | 115 | @property 116 | def method_id(self): 117 | return self[0] >> 4 118 | 119 | @property 120 | def action_name(self): 121 | return BLE_LOCK_ACTION[self[0] & 0x0F] 122 | 123 | @property 124 | def method_name(self): 125 | return BLE_LOCK_METHOD[self[0] >> 4] 126 | 127 | @property 128 | def key_id(self): 129 | return int.from_bytes(self[1:5], 'little') 130 | 131 | @property 132 | def error_name(self): 133 | return BLE_LOCK_ERROR.get(self.key_id) 134 | 135 | @property 136 | def key_id_short(self): 137 | if self.error_name is None and self.method_id > 0: 138 | return self.key_id & 0xFFFF 139 | elif self.error_name: 140 | return hex(self.key_id) 141 | else: 142 | return self.key_id 143 | 144 | @property 145 | def timestamp(self): 146 | return int.from_bytes(self[5:9], 'little') 147 | 148 | class BleMotionWithIlluParser(EventParser): #0x000f, 15 149 | @property 150 | def illumination(self): 151 | return int(self.data) 152 | 153 | class BleButtonParser(EventParser): #0x1001, 4097 154 | @property 155 | def action_id(self): 156 | return self[2] 157 | 158 | @property 159 | def action_name(self): 160 | if self[2] in BUTTON_EVENTS: 161 | return BUTTON_EVENTS[self[2]] 162 | else: 163 | return "" 164 | 165 | class TimestampParser(EventParser): 166 | @property 167 | def timestamp(self): 168 | return int(self.data.split('[')[1].split(',')[0]) 169 | 170 | class ZgbIlluminationParser(EventParser): 171 | @property 172 | def illumination(self): 173 | try: 174 | return int(self.data.split('[')[3].split(']')[0]) 175 | except ValueError: 176 | return None 177 | if __name__ == '__main__': 178 | a = TimestampParser(r'''["[1617026674,[\"event.motion\",[]]]"]''') 179 | # print(a.event_id) 180 | print(a.timestamp) 181 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/humidifier.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from datetime import timedelta 5 | from functools import partial 6 | from typing import Optional 7 | 8 | from collections import OrderedDict 9 | import async_timeout 10 | import homeassistant.helpers.config_validation as cv 11 | import voluptuous as vol 12 | from aiohttp import ClientSession 13 | from homeassistant.components.humidifier import ( 14 | HumidifierEntity, PLATFORM_SCHEMA) 15 | from homeassistant.const import * 16 | from homeassistant.components.humidifier.const import * 17 | from homeassistant.exceptions import PlatformNotReady 18 | from homeassistant.helpers import aiohttp_client 19 | from homeassistant.util import Throttle 20 | from miio.exceptions import DeviceException 21 | from .deps.miio_new import MiotDevice 22 | 23 | from .basic_dev_class import ( 24 | GenericMiotDevice, 25 | ToggleableMiotDevice, 26 | MiotSubDevice, 27 | MiotSubToggleableDevice 28 | ) 29 | from . import async_generic_setup_platform 30 | from .deps.const import ( 31 | DOMAIN, 32 | CONF_UPDATE_INSTANT, 33 | CONF_MAPPING, 34 | CONF_CONTROL_PARAMS, 35 | CONF_CLOUD, 36 | CONF_MODEL, 37 | ATTR_STATE_VALUE, 38 | ATTR_MODEL, 39 | ATTR_FIRMWARE_VERSION, 40 | ATTR_HARDWARE_VERSION, 41 | SCHEMA, 42 | MAP, 43 | DUMMY_IP, 44 | DUMMY_TOKEN, 45 | ) 46 | import copy 47 | TYPE = 'humidifier' 48 | 49 | _LOGGER = logging.getLogger(__name__) 50 | 51 | DEFAULT_NAME = "Generic MIoT " + TYPE 52 | DATA_KEY = TYPE + '.' + DOMAIN 53 | 54 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 55 | SCHEMA 56 | ) 57 | 58 | SCAN_INTERVAL = timedelta(seconds=10) 59 | # pylint: disable=unused-argument 60 | 61 | @asyncio.coroutine 62 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 63 | await async_generic_setup_platform( 64 | hass, 65 | config, 66 | async_add_devices, 67 | discovery_info, 68 | TYPE, 69 | {'default': MiotHumidifier}, 70 | ) 71 | 72 | async def async_setup_entry(hass, config_entry, async_add_entities): 73 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 74 | await async_setup_platform(hass, config, async_add_entities) 75 | 76 | class MiotHumidifier(ToggleableMiotDevice, HumidifierEntity): 77 | """Representation of a humidifier device.""" 78 | def __init__(self, device, config, device_info, hass, main_mi_type): 79 | ToggleableMiotDevice.__init__(self, device, config, device_info, hass, main_mi_type) 80 | self._target_humidity = None 81 | self._mode = None 82 | self._available_modes = None 83 | self._device_class = DEVICE_CLASS_HUMIDIFIER 84 | 85 | @property 86 | def supported_features(self): 87 | """Return the list of supported features.""" 88 | s = 0 89 | if self._did_prefix + 'mode' in self._mapping: 90 | s |= SUPPORT_MODES 91 | return s 92 | 93 | @property 94 | def min_humidity(self): 95 | try: 96 | return (self._ctrl_params['target_humidity']['value_range'][0]) 97 | except KeyError: 98 | return None 99 | 100 | @property 101 | def max_humidity(self): 102 | try: 103 | return (self._ctrl_params['target_humidity']['value_range'][1]) 104 | except KeyError: 105 | return None 106 | 107 | @property 108 | def target_humidity(self): 109 | """Return the humidity we try to reach.""" 110 | return self._target_humidity 111 | 112 | @property 113 | def mode(self): 114 | """Return current mode.""" 115 | return self._mode 116 | 117 | @property 118 | def available_modes(self): 119 | """Return available modes.""" 120 | return list(self._ctrl_params['mode'].keys()) 121 | 122 | @property 123 | def device_class(self): 124 | """Return the device class of the humidifier.""" 125 | return self._device_class 126 | 127 | async def async_set_humidity(self, humidity): 128 | """Set new humidity level.""" 129 | hum = self.convert_value(humidity, "target_humidity", True, self._ctrl_params['target_humidity']['value_range']) 130 | result = await self.set_property_new(self._did_prefix + "target_humidity", hum) 131 | if result: 132 | self._target_humidity = hum 133 | self.async_write_ha_state() 134 | 135 | async def async_set_mode(self, mode): 136 | """Update mode.""" 137 | result = await self.set_property_new(self._did_prefix + "mode", self._ctrl_params['mode'].get(mode)) 138 | 139 | if result: 140 | self._mode = mode 141 | self.async_write_ha_state() 142 | 143 | def _handle_platform_specific_attrs(self): 144 | super()._handle_platform_specific_attrs() 145 | self._target_humidity = self._state_attrs.get(self._did_prefix + 'target_humidity') 146 | self._mode = self.get_key_by_value(self._ctrl_params['mode'], self._state_attrs.get(self._did_prefix + 'mode')) 147 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/select.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from functools import partial 4 | 5 | from collections import OrderedDict 6 | from datetime import timedelta 7 | from homeassistant.const import __version__ as current_version 8 | from distutils.version import StrictVersion 9 | import homeassistant.helpers.config_validation as cv 10 | import voluptuous as vol 11 | from homeassistant.components import fan 12 | from homeassistant.components.select import ( 13 | PLATFORM_SCHEMA, 14 | SelectEntity 15 | ) 16 | from homeassistant.const import * 17 | from homeassistant.exceptions import PlatformNotReady 18 | from homeassistant.util import color 19 | from miio.exceptions import DeviceException 20 | from .deps.miio_new import MiotDevice 21 | 22 | from .basic_dev_class import ( 23 | GenericMiotDevice, 24 | ToggleableMiotDevice, 25 | MiotSubDevice, 26 | MiotSubToggleableDevice 27 | ) 28 | from . import async_generic_setup_platform 29 | from .deps.const import ( 30 | DOMAIN, 31 | CONF_UPDATE_INSTANT, 32 | CONF_MAPPING, 33 | CONF_CONTROL_PARAMS, 34 | CONF_CLOUD, 35 | CONF_MODEL, 36 | ATTR_STATE_VALUE, 37 | ATTR_MODEL, 38 | ATTR_FIRMWARE_VERSION, 39 | ATTR_HARDWARE_VERSION, 40 | SCHEMA, 41 | MAP, 42 | DUMMY_IP, 43 | DUMMY_TOKEN, 44 | ) 45 | import copy 46 | 47 | TYPE = 'select' 48 | _LOGGER = logging.getLogger(__name__) 49 | 50 | DEFAULT_NAME = "Generic MIoT " + TYPE 51 | DATA_KEY = TYPE + '.' + DOMAIN 52 | 53 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 54 | SCHEMA 55 | ) 56 | 57 | SCAN_INTERVAL = timedelta(seconds=10) 58 | 59 | # pylint: disable=unused-argument 60 | @asyncio.coroutine 61 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 62 | hass.data[DOMAIN]['add_handler'].setdefault(TYPE, {}) 63 | if 'config_entry' in config: 64 | id = config['config_entry'].entry_id 65 | hass.data[DOMAIN]['add_handler'][TYPE].setdefault(id, async_add_devices) 66 | 67 | await async_generic_setup_platform( 68 | hass, 69 | config, 70 | async_add_devices, 71 | discovery_info, 72 | TYPE, 73 | {'default': None}, 74 | {'default': MiotPropertySelector, 'a_l': MiotActionListNew} 75 | ) 76 | 77 | async def async_setup_entry(hass, config_entry, async_add_entities): 78 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 79 | await async_setup_platform(hass, config, async_add_entities) 80 | 81 | class MiotPropertySelector(SelectEntity, MiotSubDevice): 82 | def __init__(self, parent_device, **kwargs): 83 | self._parent_device = parent_device 84 | self._full_did = kwargs.get('full_did') 85 | self._value_list = kwargs.get('value_list') 86 | self._name = f'{parent_device.name} {self._full_did}' 87 | self._unique_id = f"{parent_device.unique_id}-{kwargs.get('full_did')}" 88 | self._entity_id = f"{parent_device._entity_id}-{kwargs.get('full_did')}" 89 | self.entity_id = f"{DOMAIN}.{self._entity_id}" 90 | self._available = True 91 | self._skip_update = False 92 | self._icon = None 93 | 94 | @property 95 | def name(self): 96 | return f'{self._parent_device.name} {self._full_did.replace("_", " ").title()}' 97 | 98 | @property 99 | def options(self): 100 | """Return a set of selectable options.""" 101 | return list(self._value_list.keys()) 102 | 103 | @property 104 | def current_option(self): 105 | """Return the selected entity option to represent the entity state.""" 106 | return self._parent_device.extra_state_attributes[self._full_did] 107 | 108 | async def async_select_option(self, option: str): 109 | """Change the selected option.""" 110 | result = await self._parent_device.set_property_new(self._full_did, self._value_list[option]) 111 | if result: 112 | self._state_attrs[self._full_did] = option 113 | self.schedule_update_ha_state() 114 | 115 | class MiotActionListNew(SelectEntity, MiotSubDevice): 116 | def __init__(self, parent_device, mapping, params, mitype): 117 | """params is not needed. We keep it here to make the ctor same.""" 118 | super().__init__(parent_device, mapping, {}, mitype) 119 | self._name_suffix = '动作列表' 120 | self._action_list = [] 121 | for k, v in mapping.items(): 122 | if 'aiid' in v: 123 | self._action_list.append(k) 124 | 125 | @property 126 | def options(self): 127 | """Return a set of selectable options.""" 128 | return self._action_list 129 | 130 | @property 131 | def current_option(self): 132 | """Return the selected entity option to represent the entity state.""" 133 | return None 134 | 135 | async def async_select_option(self, option: str): 136 | """Change the selected option.""" 137 | result = await self._parent_device.call_action_new(*self._mapping[option].values()) 138 | if result: 139 | self._state = None 140 | self.schedule_update_ha_state() 141 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/lock.py: -------------------------------------------------------------------------------- 1 | """ This component doesn't support lock yet. This is for child lock! """ 2 | import asyncio 3 | import logging 4 | from functools import partial 5 | 6 | from datetime import timedelta 7 | import json 8 | from collections import OrderedDict 9 | import homeassistant.helpers.config_validation as cv 10 | import voluptuous as vol 11 | from homeassistant.components.lock import LockEntity, PLATFORM_SCHEMA 12 | from homeassistant.const import * 13 | from homeassistant.exceptions import PlatformNotReady 14 | from homeassistant.util import color 15 | from miio.exceptions import DeviceException 16 | from .deps.miio_new import MiotDevice 17 | 18 | from .basic_dev_class import ( 19 | GenericMiotDevice, 20 | ToggleableMiotDevice, 21 | MiotSubDevice, 22 | MiotSubToggleableDevice 23 | ) 24 | from . import async_generic_setup_platform 25 | from .deps.const import ( 26 | DOMAIN, 27 | CONF_UPDATE_INSTANT, 28 | CONF_MAPPING, 29 | CONF_CONTROL_PARAMS, 30 | CONF_CLOUD, 31 | CONF_MODEL, 32 | ATTR_STATE_VALUE, 33 | ATTR_MODEL, 34 | ATTR_FIRMWARE_VERSION, 35 | ATTR_HARDWARE_VERSION, 36 | SCHEMA, 37 | MAP, 38 | DUMMY_IP, 39 | DUMMY_TOKEN, 40 | ) 41 | import copy 42 | 43 | TYPE = 'lock' 44 | 45 | _LOGGER = logging.getLogger(__name__) 46 | SCAN_INTERVAL = timedelta(seconds=10) 47 | DEFAULT_NAME = "Generic MIoT " + TYPE 48 | DATA_KEY = TYPE + '.' + DOMAIN 49 | 50 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 51 | SCHEMA 52 | ) 53 | 54 | # pylint: disable=unused-argument 55 | @asyncio.coroutine 56 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 57 | """Set up the light from config.""" 58 | if DATA_KEY not in hass.data: 59 | hass.data[DATA_KEY] = {} 60 | 61 | host = config.get(CONF_HOST) 62 | token = config.get(CONF_TOKEN) 63 | mapping = config.get(CONF_MAPPING) 64 | params = config.get(CONF_CONTROL_PARAMS) 65 | 66 | mappingnew = {} 67 | 68 | main_mi_type = None 69 | other_mi_type = [] 70 | 71 | for t in MAP[TYPE]: 72 | if mapping.get(t): 73 | other_mi_type.append(t) 74 | if 'main' in (params.get(t) or ""): 75 | main_mi_type = t 76 | 77 | try: 78 | other_mi_type.remove(main_mi_type) 79 | except: 80 | pass 81 | 82 | if other_mi_type: 83 | retry_time = 1 84 | while True: 85 | if parent_device := hass.data[DOMAIN]['miot_main_entity'].get(config['config_entry'].entry_id): 86 | break 87 | else: 88 | retry_time *= 2 89 | if retry_time > 120: 90 | _LOGGER.error(f"The main device of {config.get(CONF_NAME)}({host}) is still not ready after 120 seconds!") 91 | raise PlatformNotReady 92 | else: 93 | _LOGGER.debug(f"The main device of {config.get(CONF_NAME)}({host}) is still not ready after {retry_time - 1} seconds.") 94 | await asyncio.sleep(retry_time) 95 | 96 | for k,v in mapping.items(): 97 | if k in MAP[TYPE]: 98 | for kk,vv in v.items(): 99 | mappingnew[f"{k[:10]}_{kk}"] = vv 100 | 101 | devices = [] 102 | 103 | for item in other_mi_type: 104 | if item == "physical_controls_locked": 105 | if params[item].get('enabled') == True: 106 | devices.append(MiotPhysicalControlLock(parent_device, mapping.get(item), params.get(item), item)) 107 | async_add_devices(devices, update_before_add=True) 108 | 109 | async def async_setup_entry(hass, config_entry, async_add_entities): 110 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 111 | await async_setup_platform(hass, config, async_add_entities) 112 | 113 | class MiotPhysicalControlLock(MiotSubDevice, LockEntity): 114 | def __init__(self, parent_device, mapping, params, mitype): 115 | super().__init__(parent_device, mapping, params, mitype) 116 | 117 | @property 118 | def is_locked(self): 119 | return self.extra_state_attributes.get(f"{self._did_prefix}physical_controls_locked") == True 120 | 121 | async def async_lock(self, **kwargs): 122 | result = await self._parent_device.set_property_new(self._did_prefix + "physical_controls_locked", True) 123 | if result: 124 | self._state = STATE_LOCKED 125 | self._state_attrs[f"{self._did_prefix}physical_controls_locked"] = True 126 | self._parent_device.schedule_update_ha_state(force_refresh=True) 127 | self._skip_update = True 128 | 129 | async def async_unlock(self, **kwargs): 130 | result = await self._parent_device.set_property_new(self._did_prefix + "physical_controls_locked", False) 131 | if result: 132 | self._state = STATE_UNLOCKED 133 | self._state_attrs[f"{self._did_prefix}physical_controls_locked"] = False 134 | self._parent_device.schedule_update_ha_state(force_refresh=True) 135 | self._skip_update = True 136 | 137 | @property 138 | def supported_features(self): 139 | return 0 140 | 141 | @property 142 | def state(self): 143 | return STATE_LOCKED if self.is_locked else STATE_UNLOCKED -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/translations/zh-Hant.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "已設定過此裝置了", 5 | "success": "登入成功!請稍待片刻後更新頁面、即可看到所新增的裝置。\n如果要繼續新增其他裝置,可點選帳號下的`選項`,**或者**點選`新增整合`、並選擇`Xiaomi MIoT`。" 6 | }, 7 | "create_entry": { 8 | "default": "" 9 | }, 10 | "error": { 11 | "unexpected_error": "發生錯誤,請參閱日誌", 12 | "cannot_connect": "無法連線", 13 | "connection_failed": "網路連線失敗", 14 | "value_error": "輸入錯誤", 15 | "bad_params": "參數有誤", 16 | "no_local_access": "裝置不支援本地通訊、已啟用為雲端通訊。請再次點選“傳送”", 17 | "no_connect_warning": "連接裝置失敗。如果新增的裝置為小愛音箱,此為正常現象;否則,請檢查裝置訊息是否有誤。請在下方輸入小愛音箱的型號 model。", 18 | "wrong_pwd": "密碼錯誤", 19 | "need_auth": "需要進行多步驟驗證。請開啟上方連結、並跟隨頁面網頁提示。確認 Ok 後、再重新登入。", 20 | "account_tips": "登入帳號是為了方便後續添加裝置、並不會自動增加所有裝置。\n請於登入後再次於整合中添加此整合,即可於列表中選擇要添加的裝置。" 21 | }, 22 | "step": { 23 | "user": { 24 | "data": { 25 | "name": "名稱", 26 | "host": "IP", 27 | "token": "Token" 28 | }, 29 | "description": "登入小米帳號以後,可以更快速的新增裝置。", 30 | "title": "選擇操作" 31 | }, 32 | "xiaomi_account": { 33 | "title": "登入小米帳號", 34 | "description": "{hint}請輸入小米帳號與密碼。", 35 | "data": { 36 | "username": "電子郵件/小米 ID", 37 | "password": "密碼" 38 | } 39 | }, 40 | "localinfo": { 41 | "data": { 42 | "name": "名稱", 43 | "host": "IP", 44 | "token": "Token" 45 | }, 46 | "description": "本地端通訊需要獲取以下資訊", 47 | "title": "設定" 48 | }, 49 | "devinfo": { 50 | "title": "裝置資訊", 51 | "data": { 52 | "devtype": "請選擇裝置的類型", 53 | "mapping": "mapping", 54 | "params": "設定參數", 55 | "cloud_read": "自雲端獲取裝置狀態", 56 | "cloud_write": "通過雲端進行裝置控制" 57 | 58 | }, 59 | "description": "{device_info}" 60 | }, 61 | "cloudinfo": { 62 | "title": "帳號資訊", 63 | "data": { 64 | "did": "did", 65 | "userId": "userId", 66 | "serviceToken": "serviceToken", 67 | "ssecurity": "ssecurity" 68 | }, 69 | "description": "雲端通訊需要獲取以下資訊" 70 | }, 71 | "select_devices": { 72 | "data": { 73 | "devices": "裝置清單" 74 | }, 75 | "description": "共擁有 {dev_count} 個裝置!於下方選擇所要新增的裝置!\n假設無法一次設定完成,亦可稍後繼續進行新增。", 76 | "title": "選擇所要新增的裝置" 77 | } 78 | }, 79 | "title": "MIoT" 80 | }, 81 | "system_health": { 82 | "info": { 83 | "logged_in": "已登錄小米帳號", 84 | "can_reach_micloud_server": "可訪問米家服務器", 85 | "account_devices_count": "帳號米家裝置數", 86 | "added_devices": "附加元件接入裝置數", 87 | "accounts_count": "雲端接入帳號數" 88 | } 89 | }, 90 | "options": { 91 | "abort": { 92 | "no_configurable_options": "沒有可設定的選項", 93 | "no_configurable_account": "小米帳號沒有可進行設定的選項。\n如需退出登入,請刪除此設定項即可。刪除後不影響已添加的裝置。\n如需更新裝置列表,請點選「重新載入」。" 94 | }, 95 | "error": { 96 | "plz_agree": "請仔細閱讀說明" 97 | }, 98 | "step": { 99 | "account": { 100 | "data": { 101 | "server_location": "裝置所在伺服器", 102 | "batch_add": "單次新增多個裝置" 103 | }, 104 | "description": "可選擇裝置所在的伺服器。\n如需退出登入,請點選「刪除」。刪除後不影響已添加的裝置。\n如需更新裝置列表,請點選「重新載入」。", 105 | "title": "帳號設定" 106 | }, 107 | "sensor": { 108 | "data": { 109 | }, 110 | "description": "勾選後,每個屬性都會以單獨感測器實體顯示。", 111 | "title": "感測器設定" 112 | }, 113 | "cover": { 114 | "data": { 115 | "reverse_position_percentage": "反轉位置百分比" 116 | }, 117 | "description": "Home Assistant 預設窗簾位置百分比為:0 關閉狀態(放下)、100 為開啟狀態(升起)。\n因此當廠商之定義與預設相反時,會出現「下降到最底時、升起按鈕無法操作」的問題。\n勾選選項後、進行位置百分比反轉,即能解決此問題。", 118 | "title": "窗簾選項" 119 | }, 120 | "climate": { 121 | "data": { 122 | "current_temp_source": "環境溫度感測器實體 ID" 123 | }, 124 | "description": "如果空調沒有內建環境溫度感測器,空調實體的環境溫度會顯示成和目標溫度一樣。\n可以於此處輸入其他溫度感測器的實體 ID,即能作為環境溫度來源。", 125 | "title": "空調設定" 126 | }, 127 | "light_and_lock": { 128 | "data": { 129 | "show_indicator_light": "顯示指示燈開關", 130 | "show_physical_controls_locked": "顯示兒童鎖開關" 131 | }, 132 | "description": "", 133 | "title": "指示燈和兒童鎖設定" 134 | }, 135 | "select_devices": { 136 | "data": { 137 | "devices": "裝置清單" 138 | }, 139 | "description": "請選擇所要新增的裝置。\n\n點選送出後,裝置將於背景進行新增。請重新更新以查看新增的裝置。", 140 | "title": "單次新增多個裝置" 141 | }, 142 | "batch_agreement": { 143 | "data": { 144 | "iagree": "我已詳細閱讀並了解" 145 | }, 146 | "description": "接下来,可以一次同時選擇多個裝置進行新增。\n於點選送出後,裝置將於背景進行新增。請稍候一分鐘後、更新頁面即可查看新增裝置。\n假如新增的某個裝置無法正常工作。請先進行刪除、然後透過手動新增整合的方式進行新增。\n透過此方式新增的裝置,都為 **雲端接入**,需要網際網路連線才能正常工作。\n假如裝置支援本地端,請勿透過此方式進行新增。請以手動新增整合的方式進行。", 147 | "title": "請詳細閱讀以下說明" 148 | } 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/water_heater.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from collections import OrderedDict 5 | from datetime import timedelta 6 | from functools import partial 7 | from typing import Optional 8 | 9 | import async_timeout 10 | import homeassistant.helpers.config_validation as cv 11 | import voluptuous as vol 12 | from aiohttp import ClientSession 13 | from homeassistant.components import water_heater 14 | from homeassistant.components.water_heater import ( 15 | SUPPORT_AWAY_MODE, 16 | SUPPORT_OPERATION_MODE, 17 | SUPPORT_TARGET_TEMPERATURE, 18 | WaterHeaterEntity, 19 | PLATFORM_SCHEMA, 20 | ) 21 | from homeassistant.const import * 22 | from homeassistant.exceptions import PlatformNotReady 23 | from homeassistant.helpers import aiohttp_client 24 | from miio.exceptions import DeviceException 25 | from .deps.miio_new import MiotDevice 26 | 27 | import copy 28 | from .basic_dev_class import ( 29 | GenericMiotDevice, 30 | ToggleableMiotDevice, 31 | MiotSubDevice, 32 | MiotSubToggleableDevice 33 | ) 34 | from . import async_generic_setup_platform 35 | from .climate import MiotClimate 36 | from .deps.const import ( 37 | DOMAIN, 38 | CONF_UPDATE_INSTANT, 39 | CONF_MAPPING, 40 | CONF_CONTROL_PARAMS, 41 | CONF_CLOUD, 42 | CONF_MODEL, 43 | ATTR_STATE_VALUE, 44 | ATTR_MODEL, 45 | ATTR_FIRMWARE_VERSION, 46 | ATTR_HARDWARE_VERSION, 47 | SCHEMA, 48 | MAP, 49 | DUMMY_IP, 50 | DUMMY_TOKEN, 51 | ) 52 | 53 | TYPE = 'water_heater' 54 | _LOGGER = logging.getLogger(__name__) 55 | 56 | DEFAULT_NAME = "Generic MIoT " + TYPE 57 | DATA_KEY = TYPE + '.' + DOMAIN 58 | 59 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 60 | SCHEMA 61 | ) 62 | 63 | SCAN_INTERVAL = timedelta(seconds=10) 64 | # pylint: disable=unused-argument 65 | @asyncio.coroutine 66 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 67 | await async_generic_setup_platform( 68 | hass, 69 | config, 70 | async_add_devices, 71 | discovery_info, 72 | TYPE, 73 | {'default': MiotWaterHeater}, 74 | ) 75 | 76 | async def async_setup_entry(hass, config_entry, async_add_entities): 77 | config = hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data)) 78 | await async_setup_platform(hass, config, async_add_entities) 79 | 80 | async def async_unload_entry(hass, config_entry, async_add_entities): 81 | return True 82 | 83 | class MiotWaterHeater(ToggleableMiotDevice, WaterHeaterEntity): 84 | def __init__(self, device, config, device_info, hass, main_mi_type): 85 | ToggleableMiotDevice.__init__(self, device, config, device_info, hass, main_mi_type) 86 | self._target_temperature = None 87 | self._unit_of_measurement = TEMP_CELSIUS 88 | self._away = None 89 | self._current_operation = None 90 | self._current_temperature = None 91 | hass.async_add_job(self.create_sub_entities) 92 | 93 | @property 94 | def supported_features(self): 95 | """Return the list of supported features.""" 96 | s = SUPPORT_OPERATION_MODE 97 | if self._did_prefix + 'target_temperature' in self._mapping: 98 | s |= SUPPORT_TARGET_TEMPERATURE 99 | return s 100 | 101 | @property 102 | def temperature_unit(self): 103 | """Return the unit of measurement.""" 104 | return self._unit_of_measurement 105 | 106 | @property 107 | def current_operation(self): 108 | """Return current operation ie. heat, cool, idle.""" 109 | return self._current_operation 110 | 111 | @property 112 | def target_temperature(self): 113 | """Return the temperature we try to reach.""" 114 | return self._target_temperature 115 | 116 | @property 117 | def min_temp(self): 118 | """Return the lowbound target temperature we try to reach.""" 119 | return self._ctrl_params['target_temperature']['value_range'][0] 120 | 121 | @property 122 | def max_temp(self): 123 | """Return the lowbound target temperature we try to reach.""" 124 | return self._ctrl_params['target_temperature']['value_range'][1] 125 | 126 | @property 127 | def current_temperature(self): 128 | """Return the current temperature.""" 129 | return self._current_temperature 130 | 131 | @property 132 | def operation_list(self): 133 | """Return the list of available operation modes.""" 134 | return (["on","off"] if self._did_prefix + 'switch_status' in self._mapping else []) + (list(self._ctrl_params['mode'].keys()) if 'mode' in self._ctrl_params else []) 135 | 136 | async def async_set_temperature(self, **kwargs): 137 | """Set new target temperatures.""" 138 | if kwargs.get(ATTR_TEMPERATURE) is not None: 139 | result = await self.set_property_new(self._did_prefix + "target_temperature", kwargs.get(ATTR_TEMPERATURE)) 140 | if result: 141 | self._target_temperature = kwargs.get(ATTR_TEMPERATURE) 142 | self.async_write_ha_state() 143 | 144 | async def async_set_operation_mode(self, operation_mode): 145 | """Set new operation mode.""" 146 | if operation_mode == 'on': 147 | await self.async_turn_on() 148 | if self._state == True: 149 | self._current_operation = 'on' 150 | elif operation_mode == 'off': 151 | await self.async_turn_off() 152 | if self._state == False: 153 | self._current_operation = 'off' 154 | else: 155 | result = await self.set_property_new(self._did_prefix + "mode", self._ctrl_params['mode'][operation_mode]) 156 | if result: 157 | self._current_operation = operation_mode 158 | self.async_write_ha_state() 159 | 160 | def _handle_platform_specific_attrs(self): 161 | super()._handle_platform_specific_attrs() 162 | try: 163 | self._target_temperature = self._state_attrs.get(self._did_prefix + 'target_temperature') 164 | except: 165 | pass 166 | try: 167 | self._current_temperature = self._state_attrs.get(self._did_prefix + 'temperature') 168 | except: 169 | pass 170 | try: 171 | o = self._state_attrs.get(self._did_prefix + 'mode') 172 | if o in ('on','off'): 173 | self._current_operation = o 174 | elif o is not None: 175 | self.get_key_by_value(self._ctrl_params['mode'], o) 176 | except: 177 | pass 178 | -------------------------------------------------------------------------------- /tools/xiaomi_cloud.py: -------------------------------------------------------------------------------- 1 | """ 2 | The base logic was taken from project https://github.com/squachen/micloud 3 | 4 | I had to rewrite the code to work asynchronously and handle timeouts for 5 | requests to the cloud. 6 | 7 | MIT License 8 | 9 | Copyright (c) 2020 Sammy Svensson 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | SOFTWARE. 28 | """ 29 | import asyncio 30 | import base64 31 | import hashlib 32 | import hmac 33 | import json 34 | import logging 35 | import os 36 | import random 37 | import string 38 | import time 39 | 40 | from aiohttp import ClientSession 41 | 42 | _LOGGER = logging.getLogger(__name__) 43 | 44 | SERVERS = ['cn', 'de', 'i2', 'ru', 'sg', 'us'] 45 | UA = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-%s APP/xiaomi.smarthome APPV/62830" 46 | 47 | 48 | class MiCloud: 49 | auth = None 50 | 51 | def __init__(self, session: ClientSession): 52 | self.session = session 53 | self.device_id = get_random_string(16) 54 | 55 | async def login(self, username: str, password: str): 56 | try: 57 | payload = await self._login_step1() 58 | data = await self._login_step2(username, password, payload) 59 | if not data['location']: 60 | return False 61 | 62 | token = await self._login_step3(data['location']) 63 | 64 | self.auth = { 65 | 'user_id': data['userId'], 66 | 'ssecurity': data['ssecurity'], 67 | 'service_token': token 68 | } 69 | 70 | return True 71 | 72 | except Exception as e: 73 | _LOGGER.exception(f"Can't login to Mi Cloud: {e}") 74 | return False 75 | 76 | async def _login_step1(self): 77 | r = await self.session.get( 78 | 'https://account.xiaomi.com/pass/serviceLogin', 79 | cookies={'sdkVersion': '3.8.6', 'deviceId': self.device_id}, 80 | headers={'User-Agent': UA % self.device_id}, 81 | params={'sid': 'xiaomiio', '_json': 'true'}) 82 | raw = await r.read() 83 | _LOGGER.debug(f"MiCloud step1") 84 | resp: dict = json.loads(raw[11:]) 85 | return {k: v for k, v in resp.items() 86 | if k in ('sid', 'qs', 'callback', '_sign')} 87 | 88 | async def _login_step2(self, username: str, password: str, payload: dict): 89 | payload['user'] = username 90 | payload['hash'] = hashlib.md5(password.encode()).hexdigest().upper() 91 | 92 | r = await self.session.post( 93 | 'https://account.xiaomi.com/pass/serviceLoginAuth2', 94 | cookies={'sdkVersion': '3.8.6', 'deviceId': self.device_id}, 95 | data=payload, 96 | headers={'User-Agent': UA % self.device_id}, 97 | params={'_json': 'true'}) 98 | raw = await r.read() 99 | _LOGGER.debug(f"MiCloud step2") 100 | resp = json.loads(raw[11:]) 101 | return resp 102 | 103 | async def _login_step3(self, location): 104 | r = await self.session.get(location, headers={'User-Agent': UA}) 105 | service_token = r.cookies['serviceToken'].value 106 | _LOGGER.debug(f"MiCloud step3") 107 | return service_token 108 | 109 | async def get_total_devices(self, servers: list): 110 | total = [] 111 | for server in servers: 112 | devices = await self.get_devices(server) 113 | if devices is None: 114 | return None 115 | total += devices 116 | return total 117 | 118 | async def get_devices(self, server: str): 119 | assert server in SERVERS, "Wrong server: " + server 120 | baseurl = 'https://api.io.mi.com/app' if server == 'cn' \ 121 | else f"https://{server}.api.io.mi.com/app" 122 | 123 | url = '/home/device_list' 124 | data = '{"getVirtualModel":false,"getHuamiDevices":0}' 125 | 126 | nonce = gen_nonce() 127 | signed_nonce = gen_signed_nonce(self.auth['ssecurity'], nonce) 128 | signature = gen_signature(url, signed_nonce, nonce, data) 129 | 130 | try: 131 | r = await self.session.post(baseurl + url, cookies={ 132 | 'userId': self.auth['user_id'], 133 | 'serviceToken': self.auth['service_token'], 134 | 'locale': 'en_US' 135 | }, headers={ 136 | 'User-Agent': UA, 137 | 'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2' 138 | }, data={ 139 | 'signature': signature, 140 | '_nonce': nonce, 141 | 'data': data 142 | }, timeout=10) 143 | 144 | resp = await r.json(content_type=None) 145 | assert resp['code'] == 0, resp 146 | return resp['result']['list'] 147 | 148 | except asyncio.TimeoutError: 149 | _LOGGER.error("Timeout while loading MiCloud device list") 150 | except: 151 | _LOGGER.exception(f"Can't load devices list") 152 | 153 | return None 154 | 155 | 156 | def get_random_string(length: int): 157 | seq = string.ascii_uppercase + string.digits 158 | return ''.join((random.choice(seq) for _ in range(length))) 159 | 160 | 161 | def gen_nonce() -> str: 162 | """Time based nonce.""" 163 | nonce = os.urandom(8) + int(time.time() / 60).to_bytes(4, 'big') 164 | return base64.b64encode(nonce).decode() 165 | 166 | 167 | def gen_signed_nonce(ssecret: str, nonce: str) -> str: 168 | """Nonce signed with ssecret.""" 169 | m = hashlib.sha256() 170 | m.update(base64.b64decode(ssecret)) 171 | m.update(base64.b64decode(nonce)) 172 | return base64.b64encode(m.digest()).decode() 173 | 174 | 175 | def gen_signature(url: str, signed_nonce: str, nonce: str, data: str) -> str: 176 | """Request signature based on url, signed_nonce, nonce and data.""" 177 | sign = '&'.join([url, signed_nonce, nonce, 'data=' + data]) 178 | signature = hmac.new(key=base64.b64decode(signed_nonce), 179 | msg=sign.encode(), 180 | digestmod=hashlib.sha256).digest() 181 | return base64.b64encode(signature).decode() 182 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/translations/zh-Hans.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "已经配置过这个设备了", 5 | "success": "登录成功!稍等片刻刷新页面,即可看到添加的设备。\n后续如果想添加更多设备,可以点击账号下的`选项`,**或者**点击`添加集成`,选择`Xiaomi MIoT`。" 6 | }, 7 | "create_entry": { 8 | "default": "" 9 | }, 10 | "error": { 11 | "unexpected_error": "发生错误,请查看日志", 12 | "cannot_connect": "无法连接", 13 | "connection_failed": "网络连接失败", 14 | "value_error": "输入错误", 15 | "bad_params": "参数有误", 16 | "no_local_access": "该设备不支持本地接入,已启用云端接入。请再次点击“提交”", 17 | "no_connect_warning": "连接设备失败。如果您添加的是小爱音箱,这是正常现象;否则,请检查信息是否有误。请在下方输入小爱音箱的 model。", 18 | "wrong_pwd": "密码错误", 19 | "need_auth": "本次登录要输验证码。请打开上面的链接,按照页面提示操作。提示 ok 后,重新登录。", 20 | "account_tips": "登录账号是为了方便您添加设备,而不会自动添加所有设备。\n登录后再次添加此集成,即可选择要添加的设备。" 21 | }, 22 | "step": { 23 | "user": { 24 | "data": { 25 | "name": "名称", 26 | "host": "IP", 27 | "token": "token" 28 | }, 29 | "description": "登录小米账号以后,可以更快捷地添加设备。", 30 | "title": "选择操作" 31 | }, 32 | "xiaomi_account": { 33 | "title": "小米账号登录", 34 | "description": "{hint}请输入小米账号和密码。", 35 | "data": { 36 | "username": "邮箱/小米 ID/手机号", 37 | "password": "密码", 38 | "server_location": "设备所在服务器" 39 | } 40 | }, 41 | "localinfo": { 42 | "data": { 43 | "name": "名称", 44 | "host": "IP", 45 | "token": "token" 46 | }, 47 | "description": "本地通信需要获取以下信息", 48 | "title": "配置" 49 | }, 50 | "devinfo": { 51 | "title": "设备信息", 52 | "data": { 53 | "devtype": "请选择设备类型", 54 | "mapping": "mapping", 55 | "params": "配置参数", 56 | "cloud_read": "从云端获取设备状态", 57 | "cloud_write": "通过云端控制设备" 58 | 59 | }, 60 | "description": "{device_info}" 61 | }, 62 | "cloudinfo": { 63 | "title": "账号信息", 64 | "data": { 65 | "did": "did", 66 | "userId": "userId", 67 | "serviceToken": "serviceToken", 68 | "ssecurity": "ssecurity" 69 | }, 70 | "description": "云端通信需要获取以下信息" 71 | }, 72 | "select_devices": { 73 | "data": { 74 | "devices": "设备列表" 75 | }, 76 | "description": "你有 {dev_count} 个设备!在下面选择要添加的设备吧!\n一次选不完没关系,之后还可以继续添加。", 77 | "title": "选择要添加的设备" 78 | } 79 | }, 80 | "title": "MIoT" 81 | }, 82 | "system_health": { 83 | "info": { 84 | "logged_in": "已登录小米账号", 85 | "can_reach_micloud_server": "可访问米家服务器", 86 | "account_devices_count": "账号米家设备数", 87 | "added_devices": "插件接入设备数", 88 | "accounts_count": "云端接入账号数" 89 | } 90 | }, 91 | "options": { 92 | "abort": { 93 | "no_configurable_options": "没有可配置的选项" 94 | }, 95 | "error": { 96 | "plz_agree": "请阅读说明", 97 | "cannot_connect": "无法连接", 98 | "connection_failed": "网络连接失败", 99 | "wrong_pwd": "密码错误", 100 | "need_auth": "本次登录要输验证码。请打开上面的链接,按照页面提示操作。提示 ok 后,重新登录。", 101 | "dev_readapt_failed": "设备适配失败,参数未作改动。", 102 | "invalid_json": "JSON 格式错误" 103 | }, 104 | "step": { 105 | "init": { 106 | "data": { 107 | "async_step_update_xiaomi_account": "更新小米账号的密码和服务器地区", 108 | "async_step_select_devices": "批量添加设备(不要和上一项一起选中)", 109 | "async_step_light_and_lock": "显示/隐藏指示灯或童锁开关", 110 | "async_step_climate": "为空调绑定外部温度传感器", 111 | "async_step_cover": "反转卷帘上下位置", 112 | "async_step_binary_sensor": "反转二元传感器状态", 113 | "async_step_re_adapt": "重新适配设备", 114 | "async_step_edit_mpprm": "修改 mapping 和配置参数", 115 | "async_step_edit_iptoken": "更新设备 IP 和 token" 116 | }, 117 | "description": "", 118 | "title": "您想要……" 119 | }, 120 | "update_xiaomi_account": { 121 | "title": "更新小米账号信息", 122 | "description": "{hint}为小米账号“{username}”更新密码和服务器地区。", 123 | "data": { 124 | "username": "邮箱/小米 ID/手机号", 125 | "password": "密码", 126 | "server_location": "设备所在服务器" 127 | } 128 | }, 129 | "sensor": { 130 | "data": { 131 | }, 132 | "description": "勾选后,每个属性都会作为一个传感器实体出现。", 133 | "title": "传感器设置" 134 | }, 135 | "cover": { 136 | "data": { 137 | "reverse_position_percentage": "反转位置百分比" 138 | }, 139 | "description": "Home Assistant 定义的卷帘位置百分比:0 为关闭(放下),100 为开启(升起)。\n如果厂商的定义与之相反,就会出现“下降到底时上升按钮变灰”的问题。\n勾选后,位置百分比反转,可解决此问题。", 140 | "title": "卷帘设置" 141 | }, 142 | "binary_sensor": { 143 | "data": { 144 | "reverse": "反转二元传感器状态" 145 | }, 146 | "description": "如果二元传感器的状态与实际状态相反,勾选此选项可反转该设备下所有二元传感器的状态。\n如果只需要反转部分传感器的状态,您可以手动修改 mapping 和配置参数。", 147 | "title": "二元传感器设置" 148 | }, 149 | "climate": { 150 | "data": { 151 | "current_temp_source": "环境温度传感器实体 ID" 152 | }, 153 | "description": "如果您的空调没有环境温度传感器,空调实体的环境温度会显示为和目标温度一致。\n您可以在此处输入其他温度传感器的实体 ID,这样就可以把它作为环境温度来源。", 154 | "title": "空调设置" 155 | }, 156 | "light_and_lock": { 157 | "data": { 158 | "show_indicator_light": "显示指示灯开关", 159 | "show_physical_controls_locked": "显示童锁开关" 160 | }, 161 | "description": "", 162 | "title": "指示灯和童锁设置" 163 | }, 164 | "select_devices": { 165 | "data": { 166 | "devices": "设备列表" 167 | }, 168 | "description": "请选择要添加的设备。\n\n点击完成后,设备将在后台添加,请刷新页面查看已添加的设备。", 169 | "title": "批量添加" 170 | }, 171 | "edit_mpprm": { 172 | "data": { 173 | "mapping": "mapping", 174 | "params": "配置参数" 175 | }, 176 | "description": "如果您不了解以下参数,请保持默认即可。\n如果刚刚进行过“重新适配”,那么下面显示的是最新的适配结果。", 177 | "title": "编辑 Mapping 和参数" 178 | }, 179 | "edit_iptoken": { 180 | "data": { 181 | "host": "IP", 182 | "token": "token" 183 | }, 184 | "title": "更新设备 IP 和 token" 185 | } 186 | } 187 | } 188 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/deps/const.py: -------------------------------------------------------------------------------- 1 | import homeassistant.helpers.config_validation as cv 2 | import voluptuous as vol 3 | from homeassistant.const import * 4 | 5 | CONF_UPDATE_INSTANT = "update_instant" 6 | CONF_MAPPING = 'mapping' 7 | CONF_CONTROL_PARAMS = 'params' 8 | CONF_CLOUD = 'update_from_cloud' 9 | CONF_MODEL = 'model' 10 | CONF_SENSOR_PROPERTY = "sensor_property" 11 | CONF_SENSOR_UNIT = "sensor_unit" 12 | CONF_DEFAULT_PROPERTIES = "default_properties" 13 | 14 | ATTR_STATE_VALUE = "state_value" 15 | ATTR_MODEL = "model" 16 | ATTR_FIRMWARE_VERSION = "firmware_version" 17 | ATTR_HARDWARE_VERSION = "hardware_version" 18 | 19 | DOMAIN = 'xiaomi_miot_raw' 20 | SUPPORTED_DOMAINS = [ 21 | "sensor", 22 | "switch", 23 | "light", 24 | "fan", 25 | "cover", 26 | "humidifier", 27 | "media_player", 28 | "climate", 29 | "lock", 30 | "water_heater", 31 | "vacuum", 32 | "binary_sensor", 33 | ] 34 | DEFAULT_NAME = "Xiaomi MIoT Device" 35 | 36 | DUMMY_IP = "255.255.255.255" 37 | DUMMY_TOKEN = "00000000000000000000000000000000" 38 | 39 | SCHEMA = { 40 | vol.Required(CONF_HOST): cv.string, 41 | vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), 42 | vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, 43 | vol.Optional(CONF_UPDATE_INSTANT, default=True): cv.boolean, 44 | vol.Optional(CONF_CLOUD): vol.All(), 45 | vol.Optional('cloud_write'):vol.All(), 46 | 47 | vol.Required(CONF_MAPPING):vol.All(), 48 | vol.Required(CONF_CONTROL_PARAMS):vol.All(), 49 | 50 | vol.Optional(CONF_SENSOR_PROPERTY): cv.string, 51 | vol.Optional(CONF_SENSOR_UNIT): cv.string, 52 | } 53 | 54 | SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) 55 | SERVICE_TO_METHOD = { 56 | 'speak_text': { 57 | "method": "async_speak_text", 58 | "schema": SERVICE_SCHEMA.extend({ 59 | vol.Required('text'): cv.string, 60 | }) 61 | }, 62 | 'execute_text': { 63 | "method": "async_execute_text", 64 | "schema": SERVICE_SCHEMA.extend({ 65 | vol.Required('text'): cv.string, 66 | vol.Optional('silent'): cv.boolean, 67 | }) 68 | }, 69 | 'call_action': { 70 | "method": "call_action_new", 71 | "schema": SERVICE_SCHEMA.extend({ 72 | vol.Required('siid'): vol.All(), 73 | vol.Required('aiid'): vol.All(), 74 | vol.Optional('inn'): vol.All(), 75 | }) 76 | }, 77 | 'set_miot_property': { 78 | "method": "set_property_for_service", 79 | "schema": SERVICE_SCHEMA.extend({ 80 | vol.Required('siid'): vol.All(), 81 | vol.Required('piid'): vol.All(), 82 | vol.Required('value'): vol.All(), 83 | }) 84 | }, 85 | } 86 | 87 | MAP = { 88 | "sensor": { 89 | "air_fryer", 90 | "air_monitor", 91 | "battery", 92 | "bed", 93 | "chair", 94 | "chair_customize", 95 | "coffee_machine", 96 | "cooker", 97 | "door", 98 | "doorbell", 99 | "fridge", 100 | "electricity", 101 | "environment", 102 | "filter", 103 | "filter_2", 104 | "filter_3", 105 | "filter_4", 106 | "filter_5", 107 | "filter_6", 108 | "filter_7", 109 | "filter_8", 110 | "gas_sensor", 111 | "health_pot", 112 | "illumination_sensor", 113 | "induction_cooker", 114 | "juicer", 115 | "magnet_sensor", 116 | "microwave_oven", 117 | "motion_detection", 118 | "motion_sensor", 119 | "multifunction_cooking_pot", 120 | "oven", 121 | "plant_monitor", 122 | "power_consumption", 123 | "power_10A_consumption", 124 | "pressure_cooker", 125 | "printer", 126 | "remain_clean_time", 127 | "repellent", 128 | "repellent_liquid", 129 | "router", 130 | "sleep_info", 131 | "sleep_monitor", 132 | "submersion_sensor", 133 | "tds_sensor", 134 | "temperature_humidity_sensor", 135 | "walking_pad", 136 | "water_purifier", 137 | }, 138 | "switch": { 139 | "switch", 140 | "outlet", 141 | "switch_2", 142 | "switch_3", 143 | "switch_4", 144 | "switch_5", 145 | "switch_6", 146 | "switch_7", 147 | "switch_8", 148 | "switch_usb", 149 | "coffee_machine", 150 | "fish_tank", 151 | }, 152 | "light": { 153 | "light", 154 | "light_2", 155 | "light_3", 156 | "light_4", 157 | "light_5", 158 | "light_6", 159 | "light_7", 160 | "light_8", 161 | "ambient_light", 162 | "ambient_light_custom", 163 | "germicidal_lamp", 164 | "plant_light", 165 | "indicator_light", 166 | "night_light", 167 | "screen", 168 | }, 169 | "fan": { 170 | "a_l", 171 | "fan", 172 | "ceiling_fan", 173 | "air_fresh", 174 | "air_purifier", 175 | "washer", 176 | "hood", 177 | "fan_control", 178 | "dryer", 179 | "toilet", 180 | "settings", 181 | "settings_2", 182 | "air_fresh_heater", 183 | "bed", 184 | "pet_drinking_fountain", 185 | "mosquito_dispeller", 186 | }, 187 | "cover": { 188 | "curtain", 189 | "airer", 190 | "window_opener", 191 | }, 192 | "humidifier": { 193 | "humidifier", 194 | "dehumidifier", 195 | }, 196 | "media_player": { 197 | "media_player", 198 | "speaker", 199 | "play_control", 200 | "television", 201 | }, 202 | "climate": { 203 | "air_conditioner", 204 | "air_condition_outlet", 205 | "heater", 206 | "ir_aircondition_control", 207 | "thermostat", 208 | }, 209 | "lock": { 210 | "physical_controls_locked", 211 | }, 212 | "water_heater": { 213 | "water_heater", 214 | "kettle", 215 | "dishwasher", 216 | "water_dispenser", 217 | }, 218 | "vacuum": { 219 | "vacuum", 220 | }, 221 | "binary_sensor": {}, 222 | "select": {"a_l"}, 223 | } 224 | 225 | UNIT_MAPPING = { 226 | "percentage" : PERCENTAGE , # 百分比 227 | "celsius" : TEMP_CELSIUS , # 摄氏度 228 | "seconds" : "秒" , # 秒 229 | "minutes" : "分钟" , # 分 230 | "hours" : "小时" , # 小时 231 | "days" : "天" , # 天 232 | "kelvin" : TEMP_KELVIN , # 开氏温标 233 | "pascal" : "Pa" , # 帕斯卡(大气压强单位) 234 | "arcdegrees" : "rad" , # 弧度(角度单位) 235 | "rgb" : "RGB" , # RGB(颜色) 236 | "watt" : POWER_WATT , # 瓦特(功率) 237 | "litre" : VOLUME_LITERS , # 升 238 | "ppm" : CONCENTRATION_PARTS_PER_MILLION , # ppm浓度 239 | "lux" : LIGHT_LUX , # 勒克斯(照度) 240 | "mg/m3" : CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER , # 毫克每立方米 241 | } 242 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/cover.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from datetime import timedelta 5 | from functools import partial 6 | from typing import Optional 7 | 8 | from collections import OrderedDict 9 | import async_timeout 10 | import homeassistant.helpers.config_validation as cv 11 | import voluptuous as vol 12 | from aiohttp import ClientSession 13 | from homeassistant.components.cover import ( 14 | DEVICE_CLASS_CURTAIN, DOMAIN, 15 | ENTITY_ID_FORMAT, PLATFORM_SCHEMA, 16 | SUPPORT_CLOSE, SUPPORT_OPEN, 17 | SUPPORT_SET_POSITION, SUPPORT_STOP, 18 | CoverEntity) 19 | from homeassistant.const import * 20 | from homeassistant.exceptions import PlatformNotReady 21 | from homeassistant.helpers import aiohttp_client 22 | from homeassistant.util import Throttle 23 | from miio.exceptions import DeviceException 24 | from .deps.miio_new import MiotDevice 25 | 26 | import copy 27 | from .basic_dev_class import ( 28 | GenericMiotDevice, 29 | ToggleableMiotDevice, 30 | MiotSubDevice, 31 | MiotSubToggleableDevice 32 | ) 33 | from . import async_generic_setup_platform 34 | from .deps.const import ( 35 | DOMAIN, 36 | CONF_UPDATE_INSTANT, 37 | CONF_MAPPING, 38 | CONF_CONTROL_PARAMS, 39 | CONF_CLOUD, 40 | CONF_MODEL, 41 | ATTR_STATE_VALUE, 42 | ATTR_MODEL, 43 | ATTR_FIRMWARE_VERSION, 44 | ATTR_HARDWARE_VERSION, 45 | SCHEMA, 46 | MAP, 47 | DUMMY_IP, 48 | DUMMY_TOKEN, 49 | ) 50 | TYPE = 'cover' 51 | _LOGGER = logging.getLogger(__name__) 52 | 53 | DEFAULT_NAME = "Generic MIoT " + TYPE 54 | DATA_KEY = TYPE + '.' + DOMAIN 55 | 56 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 57 | SCHEMA 58 | ) 59 | 60 | SCAN_INTERVAL = timedelta(seconds=2) 61 | # pylint: disable=unused-argument 62 | 63 | @asyncio.coroutine 64 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 65 | hass.data[DOMAIN]['add_handler'].setdefault(TYPE, {}) 66 | if 'config_entry' in config: 67 | id = config['config_entry'].entry_id 68 | hass.data[DOMAIN]['add_handler'][TYPE].setdefault(id, async_add_devices) 69 | 70 | await async_generic_setup_platform( 71 | hass, 72 | config, 73 | async_add_devices, 74 | discovery_info, 75 | TYPE, 76 | {'default': MiotCover}, 77 | ) 78 | 79 | async def async_setup_entry(hass, config_entry, async_add_entities): 80 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 81 | await async_setup_platform(hass, config, async_add_entities) 82 | 83 | class MiotCover(GenericMiotDevice, CoverEntity): 84 | def __init__(self, device, config, device_info, hass, main_mi_type): 85 | GenericMiotDevice.__init__(self, device, config, device_info, hass, main_mi_type) 86 | self._current_position = None 87 | self._target_position = None 88 | self._action = None 89 | self._throttle1 = Throttle(timedelta(seconds=1))(self._async_update) 90 | self._throttle10 = Throttle(timedelta(seconds=10))(self._async_update) 91 | self.async_update = self._throttle10 92 | 93 | @property 94 | def should_poll(self): 95 | """The cover should always be pulled.""" 96 | return True 97 | 98 | @property 99 | def available(self): 100 | """Return true when state is known.""" 101 | return True 102 | 103 | @property 104 | def supported_features(self): 105 | if self._did_prefix + 'target_position' in self._mapping: 106 | return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION 107 | else: 108 | return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP 109 | 110 | @property 111 | def current_cover_position(self): 112 | """Return the current position of the cover.""" 113 | if self._current_position is None: 114 | return 50 115 | elif self._ctrl_params.get('reverse_position_percentage', False): 116 | return self._ctrl_params['current_position']['value_range'][1] - self._current_position 117 | return self._current_position 118 | 119 | @property 120 | def is_closed(self): 121 | """ Most of Xiaomi covers does not report position as 0 when they are fully closed. 122 | It can be 0, 1, 2... So we consider it closed when it is <= 3. The _current_position 123 | has been converted so it is always percentage. (#227) """ 124 | return self.current_cover_position <= 3 125 | 126 | @property 127 | def is_closing(self): 128 | """Return if the cover is closing or not.""" 129 | if type(self._action) == str: 130 | return 'down' in self._action.lower() \ 131 | or 'dowm' in self._action.lower() \ 132 | or 'clos' in self._action.lower() 133 | elif type(self._action) == int: 134 | try: 135 | return self._action == self._ctrl_params['motor_status']['close'] 136 | except KeyError: 137 | return False 138 | return False 139 | 140 | @property 141 | def is_opening(self): 142 | """Return if the cover is opening or not.""" 143 | if type(self._action) == str: 144 | return 'up' in self._action.lower() \ 145 | or 'open' in self._action.lower() 146 | elif type(self._action) == int: 147 | try: 148 | return self._action == self._ctrl_params['motor_status']['open'] 149 | except KeyError: 150 | return False 151 | return False 152 | 153 | async def async_open_cover(self, **kwargs): 154 | """Open the cover.""" 155 | result = await self.set_property_new(self._did_prefix + "motor_control",self._ctrl_params['motor_control']['open']) 156 | if result: 157 | # self._skip_update = True 158 | try: 159 | self._action = self._ctrl_params['motor_status']['open'] 160 | except KeyError as ex: 161 | pass 162 | self.async_write_ha_state() 163 | self.async_update = self._throttle1 164 | self.schedule_update_ha_state(force_refresh=True) 165 | 166 | async def async_close_cover(self, **kwargs): 167 | """Close the cover.""" 168 | result = await self.set_property_new(self._did_prefix + "motor_control",self._ctrl_params['motor_control']['close']) 169 | if result: 170 | try: 171 | self._action = self._ctrl_params['motor_status']['close'] 172 | except KeyError: 173 | pass 174 | self.async_write_ha_state() 175 | self.async_update = self._throttle1 176 | self.schedule_update_ha_state(force_refresh=True) 177 | 178 | async def async_stop_cover(self, **kwargs): 179 | """Close the cover.""" 180 | result = await self.set_property_new(self._did_prefix + "motor_control",self._ctrl_params['motor_control']['stop']) 181 | if result: 182 | self.async_write_ha_state() 183 | 184 | async def async_set_cover_position(self, **kwargs): 185 | """Set the cover.""" 186 | if 'value_range' in self._ctrl_params['target_position']: 187 | result = await self.set_property_new(self._did_prefix + "target_position",self.convert_value(kwargs['position'],"current_position",True,self._ctrl_params['target_position']['value_range'])) 188 | else: 189 | result = await self.set_property_new(self._did_prefix + "target_position",kwargs['position']) 190 | 191 | if result: 192 | self._skip_update = True 193 | 194 | def _handle_platform_specific_attrs(self): 195 | super()._handle_platform_specific_attrs() 196 | self._current_position = self._state_attrs.get(self._did_prefix + 'current_position') 197 | if 'current_position' in self._ctrl_params: 198 | if 'value_range' in self._ctrl_params['current_position'] and self._current_position is not None: 199 | self._current_position = self.convert_value(self._current_position,"current_position",False,self._ctrl_params['current_position']['value_range']) 200 | if self.is_closing or self.is_opening: 201 | self.async_update = self._throttle1 202 | else: 203 | self.async_update = self._throttle10 204 | self._action = self._state_attrs.get(self._did_prefix + 'motor_status') or \ 205 | self._state_attrs.get(self._did_prefix + 'status') 206 | 207 | async def _async_update(self): 208 | if self._update_instant is False or self._skip_update: 209 | self._skip_update = False 210 | return 211 | await super().async_update() 212 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "abort": { 4 | "already_configured": "You have already configured this device!", 5 | "success": "Login succeed! Wait a moment and refresh to see devices added.\nTo add more, click `CONFIGURATION` under your account or `ADD INTEGRATION` - `Xiaomi MIoT`." 6 | }, 7 | "create_entry": { 8 | "default": "" 9 | }, 10 | "error": { 11 | "unexpected_error": "Unexpected error. See logs for detail", 12 | "cannot_connect": "Can not connect", 13 | "connection_failed": "Network unreachable", 14 | "value_error": "Wrong value", 15 | "bad_params": "Bad params", 16 | "no_local_access": "Local access is not supported on this device. Cloud Access has been enabled. Click 'SUBMIT' again.", 17 | "no_connect_warning": "Failed to discover your device. If it is a Xiaomi Speaker, it is normal and you need to enter its model manually above. If not, please check your input.", 18 | "wrong_pwd": "Wrong password", 19 | "need_auth": "Two-factor authentication required. Open the link above to finish authentication, and log in again.", 20 | "account_tips": "This is to facilitate adding devices. \nAfter logging in, come back here to select devices to add." 21 | }, 22 | "step": { 23 | "user": { 24 | "data": { 25 | "name": "Name", 26 | "host": "IP", 27 | "token": "token" 28 | }, 29 | "description": "", 30 | "title": "Select Operation" 31 | }, 32 | "xiaomi_account": { 33 | "title": "Log in Xiaomi Account", 34 | "description": "{hint}Enter your Xiaomi Credentials.", 35 | "data": { 36 | "username": "Email/Xiaomi ID", 37 | "password": "Password", 38 | "server_location": "Server Location" 39 | } 40 | }, 41 | "localinfo": { 42 | "data": { 43 | "name": "Name", 44 | "host": "IP", 45 | "token": "token" 46 | }, 47 | "description": "Local access requires information above", 48 | "title": "Configure" 49 | }, 50 | "devinfo": { 51 | "title": "Device info", 52 | "data": { 53 | "devtype": "Device type(s)", 54 | "mapping": "mapping", 55 | "params": "params", 56 | "cloud_read": "Read status from cloud", 57 | "cloud_write": "Control device from cloud" 58 | 59 | }, 60 | "description": "{device_info}" 61 | }, 62 | "cloudinfo": { 63 | "title": "Account Credentials", 64 | "data": { 65 | "did": "did", 66 | "userId": "userId", 67 | "serviceToken": "serviceToken", 68 | "ssecurity": "ssecurity" 69 | }, 70 | "description": "Cloud access requires information above" 71 | }, 72 | "select_devices": { 73 | "data": { 74 | "devices": "Devices list" 75 | }, 76 | "description": "You have {dev_count} devices! Select devices to add below.\nYou can still select more in the future.", 77 | "title": "Select Devices" 78 | } 79 | }, 80 | "title": "MIoT" 81 | }, 82 | "system_health": { 83 | "info": { 84 | "logged_in": "Logged in to Xiaomi Account", 85 | "can_reach_micloud_server": "Reach Xiaomi Cloud Server", 86 | "account_devices_count": "Devices in account", 87 | "added_devices": "Devices added", 88 | "accounts_count": "Xiaomi accounts" 89 | } 90 | }, 91 | "options": { 92 | "abort": { 93 | "no_configurable_options": "No options available." 94 | }, 95 | "error": { 96 | "plz_agree": "Please read carefully", 97 | "cannot_connect": "Cannot connect", 98 | "connection_failed": "Network connection failed", 99 | "wrong_pwd": "Wrong password", 100 | "need_auth": "Two-factor authentication required. Open the link above to finish authentication, and log in again.", 101 | "dev_readapt_failed": "Failed re-adapting device. Parameters were not changed.", 102 | "invalid_json": "Invalid JSON" 103 | }, 104 | "step": { 105 | "init": { 106 | "data": { 107 | "async_step_update_xiaomi_account": "Update Xiaomi Account Credential and Server Location", 108 | "async_step_select_devices": "Batch Adding Devices (Do not select with the one above)", 109 | "async_step_light_and_lock": "Show or Hide Switch for Indicator Light and Child Lock", 110 | "async_step_climate": "Attach a Sensor to Climate for Current Temperature", 111 | "async_step_cover": "Reverse Cover Position", 112 | "async_step_re_adapt": "Re-Adapt Device", 113 | "async_step_edit_mpprm": "Edit Mapping and Params", 114 | "async_step_edit_iptoken": "Update IP and Token" 115 | }, 116 | "description": "", 117 | "title": "Would you like to..." 118 | }, 119 | "update_xiaomi_account": { 120 | "title": "Update Xiaomi Account Credential", 121 | "description": "{hint}Update password for “{username}”.", 122 | "data": { 123 | "username": "Email/Xiaomi ID", 124 | "password": "Password", 125 | "server_location": "Server Location" 126 | } 127 | }, 128 | "sensor": { 129 | "data": { 130 | }, 131 | "description": "If checked, there will be a sensor entity for each attribule.", 132 | "title": "Sensor Options" 133 | }, 134 | "cover": { 135 | "data": { 136 | "reverse_position_percentage": "Reverse position percentage" 137 | }, 138 | "description": "Reversing position percentage can fix some problems.", 139 | "title": "Cover Options" 140 | }, 141 | "binary_sensor": { 142 | "data": { 143 | "reverse": "Reverse binary sensor state" 144 | }, 145 | "description": "Enable this feature to reverse states of all binary sensors that belong to this device.\nYou can also edit mapping and params to reverse only some of them.", 146 | "title": "Binary Sensor Options" 147 | }, 148 | "climate": { 149 | "data": { 150 | "current_temp_source": "Environment temperature sensor entity ID" 151 | }, 152 | "description": "Enter a entity ID of temperature sensor here to use it as the current temperature sensor.", 153 | "title": "Climate Options" 154 | }, 155 | "light_and_lock": { 156 | "data": { 157 | "show_indicator_light": "Enable switch for indicator light", 158 | "show_physical_controls_locked": "Enable switch for child lock" 159 | }, 160 | "description": "", 161 | "title": "Indicator Light / Child Lock Options" 162 | }, 163 | "select_devices": { 164 | "data": { 165 | "devices": "Devices list" 166 | }, 167 | "description": "Select devices to add.\n\nAfter submitting, devices will be added in background. Refresh to see them.", 168 | "title": "Batch Adding Devices" 169 | }, 170 | "edit_mpprm": { 171 | "data": { 172 | "mapping": "mapping", 173 | "params": "params" 174 | }, 175 | "description": "If you don’t know what they are, keep them as default.\nIf the device was re-adapted, the latest config is shown below.", 176 | "title": "Edit Mapping and Params" 177 | }, 178 | "edit_iptoken": { 179 | "data": { 180 | "host": "IP", 181 | "token": "token" 182 | }, 183 | "title": "Update IP and Token" 184 | } 185 | } 186 | } 187 | } -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/vacuum.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from collections import OrderedDict 5 | from datetime import timedelta 6 | from functools import partial 7 | from typing import Optional 8 | 9 | import async_timeout 10 | import homeassistant.helpers.config_validation as cv 11 | import voluptuous as vol 12 | from aiohttp import ClientSession 13 | from homeassistant.components import vacuum 14 | from homeassistant.components.vacuum import ( 15 | ATTR_CLEANED_AREA, 16 | STATE_CLEANING, 17 | STATE_DOCKED, 18 | STATE_IDLE, 19 | STATE_PAUSED, 20 | STATE_RETURNING, 21 | SUPPORT_BATTERY, 22 | SUPPORT_CLEAN_SPOT, 23 | SUPPORT_FAN_SPEED, 24 | SUPPORT_LOCATE, 25 | SUPPORT_PAUSE, 26 | SUPPORT_RETURN_HOME, 27 | SUPPORT_SEND_COMMAND, 28 | SUPPORT_START, 29 | SUPPORT_STATE, 30 | SUPPORT_STATUS, 31 | SUPPORT_STOP, 32 | SUPPORT_TURN_OFF, 33 | SUPPORT_TURN_ON, 34 | StateVacuumEntity, 35 | VacuumEntity, 36 | PLATFORM_SCHEMA 37 | ) 38 | from homeassistant.components.climate.const import * 39 | from homeassistant.const import * 40 | from homeassistant.exceptions import PlatformNotReady 41 | from homeassistant.helpers import aiohttp_client 42 | from miio.exceptions import DeviceException 43 | from .deps.miio_new import MiotDevice 44 | 45 | import copy 46 | from .basic_dev_class import ( 47 | GenericMiotDevice, 48 | ToggleableMiotDevice, 49 | MiotSubDevice, 50 | MiotSubToggleableDevice 51 | ) 52 | from . import async_generic_setup_platform 53 | from .deps.const import ( 54 | DOMAIN, 55 | CONF_UPDATE_INSTANT, 56 | CONF_MAPPING, 57 | CONF_CONTROL_PARAMS, 58 | CONF_CLOUD, 59 | CONF_MODEL, 60 | ATTR_STATE_VALUE, 61 | ATTR_MODEL, 62 | ATTR_FIRMWARE_VERSION, 63 | ATTR_HARDWARE_VERSION, 64 | SCHEMA, 65 | MAP, 66 | DUMMY_IP, 67 | DUMMY_TOKEN, 68 | ) 69 | 70 | TYPE = 'vacuum' 71 | _LOGGER = logging.getLogger(__name__) 72 | 73 | DEFAULT_NAME = "Generic MIoT " + TYPE 74 | DATA_KEY = TYPE + '.' + DOMAIN 75 | 76 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 77 | SCHEMA 78 | ) 79 | 80 | STATE_MAPPING = { 81 | STATE_CLEANING: ['Sweeping'], 82 | STATE_DOCKED: ['Charging'], 83 | STATE_IDLE: ['Idle'], 84 | STATE_PAUSED: ['Paused'], 85 | STATE_RETURNING: ['Go Charging'], 86 | } 87 | 88 | SCAN_INTERVAL = timedelta(seconds=10) 89 | # pylint: disable=unused-argument 90 | @asyncio.coroutine 91 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 92 | await async_generic_setup_platform( 93 | hass, 94 | config, 95 | async_add_devices, 96 | discovery_info, 97 | TYPE, 98 | {'default': MiotVacuum}, 99 | ) 100 | 101 | async def async_setup_entry(hass, config_entry, async_add_entities): 102 | config = hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data)) 103 | await async_setup_platform(hass, config, async_add_entities) 104 | 105 | async def async_unload_entry(hass, config_entry, async_add_entities): 106 | return True 107 | 108 | class MiotVacuum(GenericMiotDevice, StateVacuumEntity): 109 | def __init__(self, device, config, device_info, hass, main_mi_type): 110 | GenericMiotDevice.__init__(self, device, config, device_info, hass, main_mi_type) 111 | self._state = None 112 | self._battery_level = None 113 | self._status = "" 114 | self._fan_speed = None 115 | hass.async_add_job(self.create_sub_entities) 116 | 117 | @property 118 | def supported_features(self): 119 | """Flag supported features.""" 120 | s = 0 121 | if 'a_l_vacuum_start_sweep' in self._mapping: 122 | s |= SUPPORT_START 123 | if 'a_l_vacuum_pause_sweeping' in self._mapping: 124 | s |= SUPPORT_PAUSE 125 | if 'a_l_vacuum_stop_sweeping' in self._mapping: 126 | s |= SUPPORT_STOP 127 | if self._did_prefix + 'mode' in self._mapping: 128 | s |= SUPPORT_FAN_SPEED 129 | if self._did_prefix + 'battery_level' in self._mapping or \ 130 | 'battery_battery_level' in self._mapping: 131 | s |= SUPPORT_BATTERY 132 | if 'a_l_battery_start_charge' in self._mapping: 133 | s |= SUPPORT_RETURN_HOME 134 | if 'a_l_voice_find_device' in self._mapping or \ 135 | 'a_l_identify_identify' in self._mapping: 136 | s |= SUPPORT_LOCATE 137 | return s 138 | 139 | @property 140 | def state(self): 141 | """Return the current state of the vacuum.""" 142 | return self._state 143 | 144 | @property 145 | def battery_level(self): 146 | """Return the current battery level of the vacuum.""" 147 | return self._battery_level 148 | 149 | @property 150 | def fan_speed(self): 151 | """Return the current fan speed of the vacuum.""" 152 | return self._fan_speed 153 | 154 | @property 155 | def fan_speed_list(self): 156 | """Return the list of supported fan speeds.""" 157 | return list(self._ctrl_params['mode'].keys()) 158 | 159 | async def async_start(self): 160 | """Start or resume the cleaning task.""" 161 | if self.supported_features & SUPPORT_START == 0: 162 | return 163 | 164 | result = await self.call_action_new(*(self._mapping['a_l_vacuum_start_sweep'].values())) 165 | if result: 166 | self._state = STATE_CLEANING 167 | self.schedule_update_ha_state() 168 | 169 | 170 | async def async_pause(self): 171 | """Pause the cleaning task.""" 172 | if self.supported_features & SUPPORT_PAUSE == 0: 173 | return 174 | 175 | result = await self.call_action_new(*(self._mapping['a_l_vacuum_pause_sweeping'].values())) 176 | if result: 177 | self._state = STATE_PAUSED 178 | self.schedule_update_ha_state() 179 | 180 | async def async_stop(self, **kwargs): 181 | """Stop the cleaning task, do not return to dock.""" 182 | if self.supported_features & SUPPORT_STOP == 0: 183 | return 184 | 185 | result = await self.call_action_new(*(self._mapping['a_l_vacuum_stop_sweeping'].values())) 186 | if result: 187 | self._state = STATE_IDLE 188 | self.schedule_update_ha_state() 189 | 190 | async def async_return_to_base(self, **kwargs): 191 | """Return dock to charging base.""" 192 | if self.supported_features & SUPPORT_RETURN_HOME == 0: 193 | return 194 | 195 | result = await self.call_action_new(*(self._mapping['a_l_battery_start_charge'].values())) 196 | if result: 197 | self._state = STATE_RETURNING 198 | self.schedule_update_ha_state() 199 | 200 | async def async_clean_spot(self, **kwargs): 201 | """Perform a spot clean-up.""" 202 | raise NotImplementedError() 203 | 204 | async def async_set_fan_speed(self, fan_speed, **kwargs): 205 | """Set the vacuum's fan speed.""" 206 | if self.supported_features & SUPPORT_FAN_SPEED == 0: 207 | return 208 | 209 | result = await self.set_property_new(self._did_prefix + "mode", self._ctrl_params['mode'][fan_speed]) 210 | if result: 211 | self._fan_speed = fan_speed 212 | self.schedule_update_ha_state() 213 | 214 | async def async_locate(self, **kwargs): 215 | """Locate the vacuum (usually by playing a song).""" 216 | if 'a_l_voice_find_device' in self._mapping: 217 | result = await self.call_action_new(*(self._mapping['a_l_voice_find_device'].values())) 218 | elif 'a_l_identify_identify' in self._mapping: 219 | result = await self.call_action_new(*(self._mapping['a_l_identify_identify'].values())) 220 | else: 221 | return 222 | 223 | if result: 224 | self.schedule_update_ha_state() 225 | 226 | def _handle_platform_specific_attrs(self): 227 | super()._handle_platform_specific_attrs() 228 | try: 229 | for k,v in STATE_MAPPING.items(): 230 | if self._state_attrs.get(self._did_prefix + 'status') in v: 231 | self._state = k 232 | except: 233 | pass 234 | try: 235 | self._battery_level = self._state_attrs.get(self._did_prefix + 'battery_level') or \ 236 | self._state_attrs.get('battery_battery_level') 237 | except: 238 | pass 239 | try: 240 | self._fan_speed = self.get_key_by_value(self._ctrl_params['mode'],self._state_attrs.get(self._did_prefix + 'mode')) 241 | except KeyError: 242 | self._fan_speed = None 243 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/media_player.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from datetime import timedelta 3 | from functools import partial 4 | from dataclasses import dataclass 5 | 6 | import homeassistant.helpers.config_validation as cv 7 | import homeassistant.util.dt as dt_util 8 | import voluptuous as vol 9 | from homeassistant.components import media_player 10 | from homeassistant.components.media_player import * 11 | from homeassistant.components.media_player.const import * 12 | from homeassistant.const import * 13 | from homeassistant.exceptions import PlatformNotReady 14 | from miio.exceptions import DeviceException 15 | from .deps.miio_new import MiotDevice 16 | from homeassistant.components import persistent_notification 17 | 18 | from .basic_dev_class import ( 19 | GenericMiotDevice, 20 | MiotIRDevice, 21 | ToggleableMiotDevice, 22 | MiotSubDevice, 23 | MiotSubToggleableDevice 24 | ) 25 | from . import async_generic_setup_platform 26 | from .deps.const import (ATTR_FIRMWARE_VERSION, ATTR_HARDWARE_VERSION, 27 | ATTR_MODEL, ATTR_STATE_VALUE, CONF_CLOUD, 28 | CONF_CONTROL_PARAMS, CONF_MAPPING, CONF_MODEL, 29 | CONF_UPDATE_INSTANT, DOMAIN, MAP, SCHEMA, SERVICE_SCHEMA, SERVICE_TO_METHOD) 30 | import copy 31 | 32 | TYPE = 'media_player' 33 | _LOGGER = logging.getLogger(__name__) 34 | 35 | DEFAULT_NAME = "Generic MIoT " + TYPE 36 | DATA_KEY = TYPE + '.' + DOMAIN 37 | 38 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 39 | SCHEMA 40 | ) 41 | 42 | SCAN_INTERVAL = timedelta(seconds=10) 43 | 44 | 45 | @dataclass 46 | class dev_info: 47 | model : str 48 | mac_address : str 49 | firmware_version : str 50 | hardware_version : str 51 | 52 | @asyncio.coroutine 53 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 54 | await async_generic_setup_platform( 55 | hass, 56 | config, 57 | async_add_devices, 58 | discovery_info, 59 | TYPE, 60 | {'default': MiotMediaPlayer, '_ir_tv': MiotIRTV}, 61 | ) 62 | 63 | async def async_setup_entry(hass, config_entry, async_add_entities): 64 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 65 | await async_setup_platform(hass, config, async_add_entities) 66 | 67 | 68 | class MiotMediaPlayer(GenericMiotDevice, MediaPlayerEntity): 69 | def __init__(self, device, config, device_info, hass, main_mi_type): 70 | GenericMiotDevice.__init__(self, device, config, device_info, hass, main_mi_type) 71 | self._player_state = STATE_PAUSED 72 | self._sound_mode = "" 73 | self._device_class = DEVICE_CLASS_SPEAKER 74 | self._source = "" 75 | self._volume_level = 0.05 76 | 77 | @property 78 | def supported_features(self): 79 | """Return the supported features.""" 80 | s = 0 81 | if 'a_l_play_control_play' in self._mapping: 82 | s |= SUPPORT_PLAY 83 | if 'a_l_play_control_pause' in self._mapping: 84 | s |= SUPPORT_PAUSE 85 | if 'a_l_play_control_next' in self._mapping: 86 | s |= SUPPORT_NEXT_TRACK 87 | if 'a_l_play_control_previous' in self._mapping: 88 | s |= SUPPORT_PREVIOUS_TRACK 89 | if self._did_prefix + 'mp_sound_mode' in self._mapping: 90 | s |= SUPPORT_SELECT_SOUND_MODE 91 | if 'mp_source' in self._ctrl_params: 92 | s |= SUPPORT_SELECT_SOURCE 93 | if self._did_prefix + 'volume' in self._mapping: 94 | s |= SUPPORT_VOLUME_SET 95 | # s |= SUPPORT_PLAY_MEDIA 96 | return s 97 | 98 | @property 99 | def state(self): 100 | """Return the state of the player.""" 101 | return self._player_state 102 | 103 | @property 104 | def sound_mode(self): 105 | """Return the current sound mode.""" 106 | return self._sound_mode 107 | 108 | @property 109 | def sound_mode_list(self): 110 | """Return a list of available sound modes.""" 111 | if s := self._ctrl_params.get('mp_sound_mode'): 112 | return list(s) 113 | return [] 114 | 115 | @property 116 | def source(self): 117 | """Return the current input source.""" 118 | return self._source 119 | 120 | @property 121 | def source_list(self): 122 | """List of available sources.""" 123 | if s := self._ctrl_params.get('mp_source'): 124 | return list(s.keys()) 125 | return [] 126 | 127 | async def async_select_source(self, source): 128 | """Set the input source.""" 129 | result = await self.call_action_new(*(self._ctrl_params['mp_source'][source].values())) 130 | if result: 131 | self._source = source 132 | self.schedule_update_ha_state() 133 | 134 | @property 135 | def device_class(self): 136 | """Return the device class of the media player.""" 137 | return self._device_class 138 | 139 | async def async_media_play(self): 140 | """Send play command.""" 141 | result = await self.call_action_new(*(self._mapping['a_l_play_control_play'].values())) 142 | if result: 143 | self._player_state = STATE_PLAYING 144 | self.schedule_update_ha_state() 145 | 146 | async def async_media_pause(self): 147 | """Send pause command.""" 148 | result = await self.call_action_new(*(self._mapping['a_l_play_control_pause'].values())) 149 | if result: 150 | self._player_state = STATE_PAUSED 151 | self.schedule_update_ha_state() 152 | 153 | async def async_select_sound_mode(self, sound_mode): 154 | """Select sound mode.""" 155 | result = await self.call_action_new(*(self._mapping[self._did_prefix + 'mp_sound_mode'].values()), [sound_mode]) 156 | if result: 157 | # self._sound_mode = sound_mode 158 | self.schedule_update_ha_state() 159 | 160 | async def async_media_previous_track(self): 161 | """Send previous track command.""" 162 | result = await self.call_action_new(*(self._mapping['a_l_play_control_previous'].values())) 163 | 164 | async def async_media_next_track(self): 165 | """Send next track command.""" 166 | result = await self.call_action_new(*(self._mapping['a_l_play_control_next'].values())) 167 | 168 | async def async_set_volume_level(self, volume): 169 | """Set the volume level, range 0..1.""" 170 | result = await self.set_property_new(self._did_prefix + "volume", self.convert_value(volume, 'volume', True, self._ctrl_params['volume']['value_range'])) 171 | if result: 172 | self._volume_level = volume 173 | self.schedule_update_ha_state() 174 | 175 | async def async_speak_text(self, text): 176 | result = await self.call_action_new(*(self._mapping['a_l_intelligent_speaker_play_text'].values()), [text]) 177 | 178 | async def async_execute_text(self, text, silent = False): 179 | result = await self.call_action_new(*(self._mapping['a_l_intelligent_speaker_execute_text_directive'].values()), [text, (0 if silent else 1)]) 180 | 181 | @property 182 | def volume_level(self): 183 | """Return the volume level of the media player (0..1).""" 184 | return self._volume_level 185 | 186 | def _handle_platform_specific_attrs(self): 187 | super()._handle_platform_specific_attrs() 188 | # player_state = self._state_attrs.get(self._did_prefix + 'playing_state') 189 | # if player_state is not None and self._ctrl_params.get('playing_state'): 190 | # if player_state in (self._ctrl_params['playing_state'].get('pause'), 'pause'): 191 | # self._player_state = STATE_PAUSED 192 | # elif player_state in (self._ctrl_params['playing_state'].get('playing'), 'playing'): 193 | # self._player_state = STATE_PLAYING 194 | # else: 195 | # _LOGGER.warning(f"Unknown state for player {self._name}: {player_state}") 196 | 197 | self._volume_level = self.convert_value( 198 | self._state_attrs.get(self._did_prefix + 'volume') or 0, 199 | 'volume', False, self._ctrl_params['volume']['value_range'] 200 | ) 201 | 202 | class MiotIRTV(MiotIRDevice, MediaPlayerEntity): 203 | def __init__(self, config, hass, did_prefix): 204 | super().__init__(config, hass, did_prefix) 205 | self._player_state = STATE_PLAYING 206 | self._sound_mode = "" 207 | self._source = "" 208 | self._volume_level = 0.5 209 | 210 | @property 211 | def supported_features(self): 212 | """Return the supported features.""" 213 | return ( 214 | SUPPORT_VOLUME_SET 215 | | SUPPORT_VOLUME_MUTE 216 | | SUPPORT_TURN_ON 217 | | SUPPORT_TURN_OFF 218 | | SUPPORT_NEXT_TRACK 219 | | SUPPORT_PREVIOUS_TRACK 220 | ) 221 | 222 | @property 223 | def device_class(self): 224 | return DEVICE_CLASS_TV 225 | 226 | @property 227 | def state(self): 228 | """Return the state of the player.""" 229 | return self._player_state 230 | 231 | @property 232 | def volume_level(self): 233 | """Return the volume level of the media player (0..1).""" 234 | return self._volume_level 235 | 236 | async def async_turn_on(self): 237 | result = await self.async_send_ir_command('turn_on') 238 | if result: 239 | self._player_state = STATE_PLAYING 240 | self.async_write_ha_state() 241 | 242 | async def async_turn_off(self): 243 | result = await self.async_send_ir_command('turn_off') 244 | if result: 245 | self._player_state = STATE_OFF 246 | self.async_write_ha_state() 247 | 248 | async def async_volume_up(self): 249 | await self.async_send_ir_command('volume_up') 250 | 251 | async def async_volume_down(self): 252 | await self.async_send_ir_command('volume_down') 253 | 254 | async def async_set_volume_level(self, volume): 255 | if volume < 0.5: 256 | result = await self.async_volume_down() 257 | elif volume > 0.5: 258 | result = await self.async_volume_up() 259 | else: 260 | return 261 | if result: 262 | self._volume_level = 0.5 263 | self.async_write_ha_state() 264 | 265 | 266 | async def async_mute_volume(self): 267 | await self.async_send_ir_command('mute_on') 268 | 269 | async def async_media_next_track(self): 270 | await self.async_send_ir_command('channel_up') 271 | 272 | async def async_media_previous_track(self): 273 | await self.async_send_ir_command('channel_down') 274 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/deps/special_devices.py: -------------------------------------------------------------------------------- 1 | SPECIAL_DEVICES={ 2 | "chuangmi.plug.212a01":{ 3 | "device_type": ['switch','sensor'], 4 | "mapping": {"switch": {"switch_status": {"siid": 2, "piid": 1}, "temperature": {"siid": 2, "piid": 6}, "working_time": {"siid": 2, "piid": 7}}, "power_consumption": {"power_consumption": {"siid": 5, "piid": 1}, "electric_current": {"siid": 5, "piid": 2}, "voltage": {"siid": 5, "piid": 3}, "electric_power": {"siid": 5, "piid": 6}}}, 5 | "params": {"switch": {"switch_status": {"power_on": True, "power_off": False}, "main": True}, "power_consumption": {"electric_power":{"value_ratio": 0.01, "unit": "W"}}} 6 | }, 7 | "xiaomi.wifispeaker.x08c": { 8 | "device_type": ['media_player'], 9 | "mapping":{"speaker": {"volume": {"siid": 4, "piid": 1}, "mute": {"siid": 4, "piid": 2}, "playing_state": {"siid": 2, "piid": 1},"mp_sound_mode":{"siid":3,"aiid":1}}, "a_l": {"play_control_pause": {"siid": 2, "aiid": 1}, "play_control_play": {"siid": 2, "aiid": 2}, "play_control_next": {"siid": 2, "aiid": 3}, "play_control_previous": {"siid": 2, "aiid": 4}, "intelligent_speaker_play_text": {"siid": 3, "aiid": 1}, "intelligent_speaker_wake_up": {"siid": 3, "aiid": 2}, "intelligent_speaker_play_radio": {"siid": 3, "aiid": 3}, "intelligent_speaker_play_music": {"siid": 3, "aiid": 4}, "intelligent_speaker_execute_text_directive": {"siid": 3, "aiid": 5}}}, 10 | "params": {"speaker": {"volume": {"value_range": [5, 100, 5]}, "main": True, "mp_source":{"\u64ad\u653e\u79c1\u4eba\u7535\u53f0":{"siid":3,"aiid":3},"\u64ad\u653e\u97f3\u4e50":{"siid":3,"aiid":4},"\u505c\u6b62\u95f9\u949f":{"siid":6,"aiid":1}},"mp_sound_mode":{"\u4f60\u597d":0},"playing_state": {"pause": 0, "playing": 1}}} 11 | }, 12 | "lumi.sensor_motion.v2": { 13 | "device_type":['sensor'], 14 | "mapping":{"motion":{"key":"device_log","type":"prop"}}, 15 | "params":{"event_based":True} 16 | }, 17 | "lumi.motion.bmgl01": { 18 | "device_type":['sensor'], 19 | "mapping":{"motion":{"key":15, "type":"event"}}, 20 | "params":{"event_based":True} 21 | }, 22 | "lumi.sensor_motion.aq2": { 23 | "device_type":['sensor'], 24 | "mapping":{"motion":{"key":"device_log","type":"prop"}}, 25 | "params":{"event_based":True} 26 | }, 27 | "cuco.plug.cp2":{ 28 | "device_type": ['switch','sensor'], 29 | "mapping": {"switch":{"switch_status":{"siid":2,"piid":1}},"power_consumption":{"power_consumption":{"siid":2,"piid":2},"voltage":{"siid":2,"piid":3},"electric_current":{"siid":2,"piid":4},"countdown_time":{"siid":2,"piid":5}}}, 30 | "params": {"switch":{"switch_status":{"power_on":True,"power_off":False},"main":True},"power_consumption":{"power_consumption":{"access":5,"format":"uint16","unit":"kWh","value_range":[0,65535,1],"value_ratio": 0.01},"voltage":{"access":5,"format":"uint16","unit":"V","value_range":[0,3000,1],"value_ratio": 0.1},"electric_current":{"access":1,"format":"uint16","unit":"A","value_range":[0,65535,1],"value_ratio": 0.001},"countdown_time":{"access":7,"format":"uint16","unit":"minutes","value_range":[0,1440,1]}}, 'max_properties': 1} 31 | }, 32 | "cuco.plug.cp1m":{ 33 | "device_type": ['switch','sensor'], 34 | "mapping": {"switch":{"switch_status":{"siid":2,"piid":1}},"power_consumption":{"power_consumption":{"siid":2,"piid":2},"voltage":{"siid":2,"piid":3},"electric_current":{"siid":2,"piid":4}}}, 35 | "params": {"switch":{"switch_status":{"power_on":True,"power_off":False},"main":True},"power_consumption":{"power_consumption":{"access":5,"format":"uint16","unit":"kWh","value_range":[0,65535,1],"value_ratio": 0.01},"voltage":{"access":5,"format":"uint16","unit":"V","value_range":[0,3000,1],"value_ratio": 0.1},"electric_current":{"access":1,"format":"uint16","unit":"A","value_range":[0,65535,1],"value_ratio": 0.001}}, 'max_properties': 1} 36 | }, 37 | "degree.lunar.smh013": { 38 | "device_type": ['switch', 'sensor'], 39 | "mapping": {"sleep_monitor":{"sleep_state":{"siid":2,"piid":1},"realtime_heart_rate":{"siid":4,"piid":10},"realtime_breath_rate":{"siid":4,"piid":11},"realtime_sleepstage":{"siid":4,"piid":12}},"switch":{"switch_status":{"siid":4,"piid":15}}}, 40 | "params": {"sleep_monitor":{"sleep_state":{"access":5,"format":"uint8","unit":None,"value_list":{"Out of Bed":0,"Awake":1,"Light Sleep":3,"Deep Sleep":4,"Rapid Eye Movement":2}},"realtime_heart_rate":{"unit":"bpm"},"realtime_breath_rate":{"unit":"/min"},"main":True},"switch":{"switch_status":{"power_on":True,"power_off":False}}} 41 | }, 42 | "hhcc.plantmonitor.v1": { 43 | "device_type": ['sensor'], 44 | "mapping": {"plant_monitor":{"temperature":{"siid":3,"piid":2},"relative_humidity":{"siid":2,"piid":1},"soil_ec":{"siid":2,"piid":2},"illumination":{"siid":2,"piid":3}}}, 45 | "params": {"plant_monitor": {"temperature": {"access": 5, "format": "float", "unit": "celsius"}, "relative_humidity": {"access": 5, "format": "float", "unit": "percentage", "value_range": [0, 100, 1]}, "soil_ec": {"access": 5, "format": "uint16", "unit": "µS/cm", "value_range": [0, 5000, 1]}, "illumination": {"access": 5, "format": "float", "unit": "lux", "value_range": [0, 10000, 1]}, "main": True}} 46 | }, 47 | "zhimi.heater.na1": { 48 | "device_type": ['climate', 'switch', 'light', 'lock'], 49 | "mapping": {"heater": {"fault": {"siid": 2, "piid": 1}, "switch_status": {"siid": 2, "piid": 2}, "speed": {"siid": 2, "piid": 3}, "horizontal_swing": {"siid": 2, "piid": 4}}, "indicator_light": {"brightness": {"siid": 6, "piid": 1}}, "physical_controls_locked": {"physical_controls_locked": {"siid": 7, "piid": 1}}, "switch": {"switch_status":{"siid":3,"piid":1}}, "switch_2": {"switch_status":{"siid":8,"piid":3}}}, 50 | "params": {"heater": {"switch_status": {"power_on": True, "power_off": False}, "fault": {"No faults": 0}, "horizontal_swing":{"off":0,"on":1}, "speed": {"High": 1, "Low": 2}, "main": True},"switch":{"switch_status":{"power_on":True,"power_off":False}}, "indicator_light": {"brightness": {"value_range": [0, 2, 1]}}, "physical_controls_locked": {"enabled": False}, "switch_2":{"switch_status":{"power_on": True,"power_off": False}}} 51 | }, 52 | "lumi.gateway.mgl03": { 53 | "device_type": ['switch'], 54 | "mapping": {"switch": {"switch_status": {"siid": 3, "piid": 22}}}, 55 | "params": {"switch": {"switch_status": {"power_on": 2, "power_off": 0}, "main": True}} 56 | }, 57 | "zimi.plug.zncz01": { 58 | "device_type": ['switch', 'light', 'sensor'], 59 | "mapping": {"switch": {"switch_status": {"siid": 2, "piid": 1}, "working_time": {"siid": 2, "piid": 2}}, "power_consumption": {"electric_power": {"siid": 3, "piid": 1}, "power_consumption": {"siid": 3, "piid": 2}}, "indicator_light": {"switch_status": {"siid": 4, "piid": 1}}}, 60 | "params": {"switch": {"switch_status": {"power_on": True, "power_off": False}, "working_time": {"access": 5, "format": "uint32", "unit": "minutes", "value_range": [0, 30, 1]}, "main": True}, "power_consumption": {"electric_power":{"value_ratio": 0.01, "unit": "W"}}, "indicator_light": {"switch_status": {"power_on": True, "power_off": False}}} 61 | }, 62 | "lumi.acpartner.mcn04": { 63 | "device_type": ['climate', 'light', 'sensor'], 64 | "mapping": {"air_conditioner": {"switch_status": {"siid": 3, "piid": 1}, "mode": {"siid": 3, "piid": 2}, "fault": {"siid": 3, "piid": 3}, "target_temperature": {"siid": 3, "piid": 4}, "speed": {"siid": 4, "piid": 2}, "vertical_swing": {"siid": 4, "piid": 4}}, "power_consumption": {"power_consumption": {"siid": 7, "piid": 1}, "electric_power": {"siid": 7, "piid": 2}}, "power_10A_consumption": {"power_consumption": {"siid": 7, "piid": 3}, "electric_power": {"siid": 7, "piid": 4}}, "indicator_light": {"indicator_light": {"siid": 9, "piid": 1}, "effective_time": {"siid": 9, "piid": 2}}}, 65 | "params": {"air_conditioner": {"switch_status": {"power_on": True, "power_off": False}, "fault": {"No Faults": 0}, "mode": {"Cool": 0, "Heat": 1, "Auto": 2, "Fan": 3, "Dry": 4}, "target_temperature": {"value_range": [16, 30, 1]}, "speed": {"Auto": 0, "Low": 1, "Medium": 2, "High": 3}, "main": True}, "power_consumption": {"electric_power":{"unit": "W"}}, "power_10A_consumption": {"electric_power":{"unit": "W"}},"indicator_light": {"enabled": False, "effective_time": {"access": 7, "format": "uint32", "unit": None, "value_range": [1, 991378198, 1]}}} 66 | }, 67 | "lumi.airrtc.tcpecn01": { 68 | "device_type": ['climate', 'switch'], 69 | "mapping": {"air_conditioner": {"switch_status": {"siid": 2, "piid": 1}, "mode": {"siid": 2, "piid": 2}, "target_temperature": {"siid": 2, "piid": 3}, "speed": {"siid": 3, "piid": 1}}, "switch": {"switch_status": {"siid": 2, "piid": 1}}}, 70 | "params": {"air_conditioner": {"switch_status": {"power_on": True, "power_off": False}, "mode": {"Cool": 1, "Heat": 2}, "target_temperature": {"value_range": [17, 30, 1]}, "main": True, "speed": {"Auto": 0, "Low": 1, "Medium": 2, "High": 3}}, "switch": {"switch_status": {"power_on": True, "power_off": False}}} 71 | }, 72 | "dmaker.fan.1e": { 73 | "device_type": ['fan'], 74 | "mapping": {"fan": {"switch_status": {"siid": 2, "piid": 1}, "speed": {"siid": 2, "piid": 2}, "mode": {"siid": 2, "piid": 3}, "oscillate": {"siid": 2, "piid": 4}, "horizontal_angle": {"siid": 2, "piid": 5}, "stepless_speed": {"siid": 8, "piid": 1}, "motor_control": {"siid": 6, "piid": 1}}, "indicator_light": {"switch_status": {"siid": 4, "piid": 1}}, "physical_controls_locked": {"physical_controls_locked": {"siid": 7, "piid": 1}}, "a_l": {"off_delay_time_toggle": {"siid": 3, "aiid": 1}, "dm_service_toggle_mode": {"siid": 8, "aiid": 1}, "dm_service_loop_gear": {"siid": 8, "aiid": 2}}}, 75 | "params": {"fan": {"switch_status": {"power_on": True, "power_off": False}, "speed": {"Level1": 1, "Level2": 2, "Level3": 3, "Level4": 4}, "mode": {"Straight Wind": 0, "Natural Wind": 1}, "oscillate": {"True": True, "False": False}, "horizontal_angle": {"access": 7, "format": "uint16", "unit": None, "value_list": {"30": 30, "60": 60, "90": 90, "120": 120, "140": 140}}, "main": True, "stepless_speed": {"value_range": [1, 100, 1]}, "motor_control": {"left": 1, "right": 2}}, "indicator_light": {"switch_status": {"power_on": True, "power_off": False}}, "physical_controls_locked": {"enabled": False}} 76 | }, 77 | "cgllc.airm.cgdn1": { 78 | "device_type": ['sensor', 'fan'], 79 | "mapping": {"environment": {"relative_humidity": {"siid": 3, "piid": 1}, "pm2_5_density": {"siid": 3, "piid": 4}, "pm10_density": {"siid": 3, "piid": 5}, "temperature": {"siid": 3, "piid": 7}, "co2_density": {"siid": 3, "piid": 8}}, "settings": {"start_time": {"siid": 9, "piid": 2}, "end_time": {"siid": 9, "piid": 3}, "monitoring_frequency": {"siid": 9, "piid": 4}, "screen_off": {"siid": 9, "piid": 5}, "device_off": {"siid": 9, "piid": 6}, "tempature_unit": {"siid": 9, "piid": 7}}, "a_l": {"settings_set_start_time": {"siid": 9, "aiid": 2}, "settings_set_end_time": {"siid": 9, "aiid": 3}, "settings_set_frequency": {"siid": 9, "aiid": 4}, "settings_set_screen_off": {"siid": 9, "aiid": 5}, "settings_set_device_off": {"siid": 9, "aiid": 6}, "settings_set_temp_unit": {"siid": 9, "aiid": 7}}}, 80 | "params": {"environment": {"relative_humidity": {"access": 5, "format": "uint8", "unit": "percentage", "value_range": [0, 100, 1]}, "pm2_5_density": {"access": 5, "format": "uint16", "unit": "\u03bcg/m3", "value_range": [0, 1000, 1]}, "pm10_density": {"access": 5, "format": "uint16", "unit": "\u03bcg/m3", "value_range": [0, 1000, 1]}, "temperature": {"access": 5, "format": "float", "unit": "celsius", "value_range": [-30, 100, 1e-05]}, "co2_density": {"access": 5, "format": "uint16", "unit": "ppm", "value_range": [0, 9999, 1]}, "main": True}, "settings": {"start_time": {"access": 7, "format": "int32", "value_range": [0, 2147483647, 1]}, "end_time": {"access": 7, "format": "int32", "value_range": [0, 2147483647, 1]}, "monitoring_frequency": {"access": 7, "format": "uint16", "unit": "seconds", "value_list": {"Second": 600, "Null": 0}}, "screen_off": {"access": 7, "format": "uint16", "unit": "seconds", "value_list": {"Second": 300, "Null": 0}}, "device_off": {"access": 7, "format": "int8", "unit": "minutes", "value_list": {"Minute": 60, "Null": 0}}, "tempature_unit": {"access": 7, "format": "string"}}, 'max_properties': 8} 81 | }, 82 | "yeelink.remote.remote": { 83 | "device_type":['sensor'], 84 | "mapping":{"button":{"key":4097,"type":"event"}}, 85 | "params":{"event_based":True} 86 | }, 87 | "ateai.mosq.dakuo": { 88 | "device_type": ["sensor", "fan"], 89 | "mapping": {"mosquito_dispeller": {"switch_status": {"siid": 6, "piid": 1},"mode":{"siid": 6, "piid": 2}}, "repellent_liquid": {"liquid_left": {"siid": 5, "piid": 1}}, "a_l": {"repellent_liquid_reset_liquid": {"siid": 5, "aiid": 1}}}, 90 | "params": {"mosquito_dispeller":{"switch_status":{"power_on":1,"power_off":0},"main":True,"mode":{"Strong":0,"Baby":1}},"repellent_liquid":{"liquid_left":{"access":5,"format":"uint8","unit":"percentage","value_range":[0,100,1]}}} 91 | }, 92 | } 93 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/deps/xiaomi_cloud_new.py: -------------------------------------------------------------------------------- 1 | """ 2 | The base logic was taken from project https://github.com/squachen/micloud 3 | 4 | I had to rewrite the code to work asynchronously and handle timeouts for 5 | requests to the cloud. 6 | 7 | MIT License 8 | 9 | Copyright (c) 2020 Sammy Svensson 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in all 19 | copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | SOFTWARE. 28 | """ 29 | import asyncio 30 | import base64 31 | import hashlib 32 | import hmac 33 | import json 34 | import locale 35 | import logging 36 | import os 37 | import random 38 | import string 39 | import time 40 | 41 | from aiohttp import ClientSession, ClientConnectorError 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | SERVERS = ['cn', 'de', 'i2', 'ru', 'sg', 'us'] 46 | UA = "Android-7.1.1-1.0.0-ONEPLUS A3010-136-%s APP/xiaomi.smarthome APPV/62830" 47 | 48 | 49 | class MiCloud: 50 | auth = None 51 | svr = None 52 | _fail_count = 0 53 | 54 | def __init__(self, session: ClientSession): 55 | self.session = session 56 | self.device_id = get_random_string(16) 57 | 58 | async def login(self, username: str, password: str): 59 | try: 60 | payload = await self._login_step1() 61 | if isinstance(payload, Exception): 62 | return (-2, payload) 63 | 64 | data = await self._login_step2(username, password, payload) 65 | if isinstance(data, Exception): 66 | return (-2, data) 67 | if 'notificationUrl' in data: 68 | return (-1, data['notificationUrl']) 69 | elif not data['location']: 70 | return (-1, None) 71 | 72 | token = await self._login_step3(data['location']) 73 | if isinstance(token, Exception): 74 | return (-2, token) 75 | 76 | self.auth = { 77 | 'user_id': data['userId'], 78 | 'ssecurity': data['ssecurity'], 79 | 'service_token': token 80 | } 81 | return (0, None) 82 | 83 | except Exception as e: 84 | # There should be no exception here? 85 | _LOGGER.exception(f"Can't login to MiCloud: {e}") 86 | raise e from None 87 | 88 | def login_by_credientals(self, userId, serviceToken, ssecurity): 89 | self.auth = { 90 | 'user_id': userId, 91 | 'ssecurity': ssecurity, 92 | 'service_token': serviceToken 93 | } 94 | 95 | return True 96 | 97 | async def _login_step1(self): 98 | _LOGGER.debug(f"Logging in to Xiaomi Cloud (1/3)...") 99 | try: 100 | r = await self.session.get( 101 | 'https://account.xiaomi.com/pass/serviceLogin', 102 | cookies={'sdkVersion': '3.8.6', 'deviceId': self.device_id}, 103 | headers={'User-Agent': UA % self.device_id}, 104 | params={'sid': 'xiaomiio', '_json': 'true'}) 105 | raw = await r.read() 106 | resp: dict = json.loads(raw[11:]) 107 | return {k: v for k, v in resp.items() 108 | if k in ('sid', 'qs', 'callback', '_sign')} 109 | except ClientConnectorError as ex: 110 | return ex 111 | 112 | async def _login_step2(self, username: str, password: str, payload: dict): 113 | _LOGGER.debug(f"Logging in to Xiaomi Cloud (2/3)...") 114 | payload['user'] = username 115 | payload['hash'] = hashlib.md5(password.encode()).hexdigest().upper() 116 | try: 117 | r = await self.session.post( 118 | 'https://account.xiaomi.com/pass/serviceLoginAuth2', 119 | cookies={'sdkVersion': '3.8.6', 'deviceId': self.device_id}, 120 | data=payload, 121 | headers={'User-Agent': UA % self.device_id}, 122 | params={'_json': 'true'}) 123 | raw = await r.read() 124 | resp = json.loads(raw[11:]) 125 | return resp 126 | except ClientConnectorError as ex: 127 | return ex 128 | 129 | async def _login_step3(self, location): 130 | _LOGGER.debug(f"Logging in to Xiaomi Cloud (3/3)...") 131 | try: 132 | r = await self.session.get(location, headers={'User-Agent': UA}) 133 | service_token = r.cookies['serviceToken'].value 134 | return service_token 135 | except ClientConnectorError as ex: 136 | return ex 137 | 138 | async def get_total_devices(self, servers: list): 139 | total = [] 140 | for server in servers: 141 | devices = await self.get_devices(server) 142 | if devices is None: 143 | return None 144 | total += devices 145 | return total 146 | 147 | async def get_devices(self, server: str): 148 | assert server in SERVERS, "Wrong server: " + server 149 | baseurl = 'https://api.io.mi.com/app' if server == 'cn' \ 150 | else f"https://{server}.api.io.mi.com/app" 151 | 152 | url = '/home/device_list' 153 | data = '{"getVirtualModel":false,"getHuamiDevices":0}' 154 | 155 | nonce = gen_nonce() 156 | signed_nonce = gen_signed_nonce(self.auth['ssecurity'], nonce) 157 | signature = gen_signature(url, signed_nonce, nonce, data) 158 | try: 159 | loc = locale.getdefaultlocale()[0] or "en_US" 160 | except Exception: 161 | loc = "en_US" 162 | try: 163 | r = await self.session.post(baseurl + url, cookies={ 164 | 'userId': self.auth['user_id'], 165 | 'serviceToken': self.auth['service_token'], 166 | 'locale': loc 167 | }, headers={ 168 | 'User-Agent': UA, 169 | 'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2' 170 | }, data={ 171 | 'signature': signature, 172 | '_nonce': nonce, 173 | 'data': data 174 | }, timeout=5) 175 | 176 | resp = await r.json(content_type=None) 177 | assert resp['code'] == 0, resp 178 | return resp['result']['list'] 179 | 180 | except asyncio.TimeoutError: 181 | _LOGGER.error("Timeout while loading MiCloud device list") 182 | except ClientConnectorError: 183 | _LOGGER.error("Failed loading MiCloud device list") 184 | except: 185 | _LOGGER.exception(f"Can't load devices list") 186 | 187 | return None 188 | 189 | async def request_miot_api(self, api, data = None, server: str = None): 190 | server = server or self.svr or 'cn' 191 | api_base = 'https://api.io.mi.com/app' if server == 'cn' \ 192 | else f"https://{server}.api.io.mi.com/app" 193 | url = api_base+api 194 | 195 | nonce = gen_nonce() 196 | signed_nonce = gen_signed_nonce(self.auth['ssecurity'], nonce) 197 | signature = gen_signature(api, signed_nonce, nonce, data) 198 | headers = { 199 | 'content-type': "application/x-www-form-urlencoded", 200 | 'x-xiaomi-protocal-flag-cli': "PROTOCAL-HTTP2", 201 | 'connection': "Keep-Alive", 202 | 'accept-encoding': "gzip", 203 | 'cache-control': "no-cache", 204 | } 205 | try: 206 | r = await self.session.post(url, cookies={ 207 | 'userId': self.auth['user_id'], 208 | 'serviceToken': self.auth['service_token'], 209 | }, headers={ 210 | 'User-Agent': UA, 211 | 'x-xiaomi-protocal-flag-cli': 'PROTOCAL-HTTP2' 212 | }, data={ 213 | 'signature': signature, 214 | '_nonce': nonce, 215 | 'data': data 216 | }, timeout=5) 217 | 218 | self._fail_count = 0 219 | resp = await r.json(content_type=None) 220 | if resp.get('message') == 'auth err': 221 | _LOGGER.error("小米账号登录信息失效") 222 | return None 223 | elif resp.get('code') != 0: 224 | _LOGGER.error(f"Response of {api} from cloud: {resp}") 225 | return resp 226 | else: 227 | # 注意:此处成功只代表请求是成功的,但控制设备不一定成功, 228 | # 取决于 result 里的 code 229 | _LOGGER.info(f"Response of {api} from cloud: {resp}") 230 | return resp 231 | 232 | except (asyncio.TimeoutError, ClientConnectorError) as ex: 233 | if self._fail_count < 3 and api == "/miotspec/prop/get": 234 | self._fail_count += 1 235 | _LOGGER.info(f"Error while requesting MIoT api {api} : {ex} ({self._fail_count})") 236 | else: 237 | _LOGGER.error(f"Error while requesting MIoT api {api} : {ex}") 238 | except: 239 | _LOGGER.exception(f"Can't request MIoT api") 240 | 241 | async def request_rpc(self, did, method, params: str = "", server: str = None): 242 | data = json.dumps({ 243 | "id": 1, 244 | "method": method, 245 | "params": params, 246 | }, separators=(',', ':')) 247 | return await self.request_miot_api(f'/home/rpc/{did}', data, server) 248 | 249 | async def get_props(self, params: str = "", server: str = None, *, use_rpc = False): 250 | if not use_rpc: 251 | return await self.request_miot_api('/miotspec/prop/get', params, server) 252 | else: 253 | p = json.loads(params).get('params') 254 | if p: 255 | _LOGGER.warn(p) 256 | if 'did' in p[0]: 257 | did = p[0]['did'] 258 | return await self.request_rpc(did, "get_properties", p, server) 259 | _LOGGER.error("Need did!") 260 | return None 261 | 262 | async def set_props(self, params: str = "", server: str = None, *, use_rpc = False): 263 | if not use_rpc: 264 | return await self.request_miot_api('/miotspec/prop/set', params, server) 265 | else: 266 | p = json.loads(params).get('params') 267 | if p: 268 | if 'did' in p[0]: 269 | did = p[0]['did'] 270 | return await self.request_rpc(did, "set_properties", p, server) 271 | _LOGGER.error("Need did!") 272 | return None 273 | 274 | async def call_action(self, params: str = "", server: str = None, *, use_rpc = False): 275 | return await self.request_miot_api('/miotspec/action', params, server) 276 | 277 | async def get_user_device_data(self, did: str, key, type_, server: str = None, *, limit=5): 278 | data = { 279 | "uid": self.auth['user_id'], 280 | "did": did, 281 | "time_end": 9999999999, 282 | "time_start": 0, 283 | "limit": limit, 284 | "key": key, 285 | "type": type_, 286 | } 287 | params = json.dumps(data, separators=(',', ':')) 288 | return await self.request_miot_api('/user/get_user_device_data', params, server) 289 | 290 | 291 | def get_random_string(length: int): 292 | seq = string.ascii_uppercase + string.digits 293 | return ''.join((random.choice(seq) for _ in range(length))) 294 | 295 | 296 | def gen_nonce() -> str: 297 | """Time based nonce.""" 298 | nonce = os.urandom(8) + int(time.time() / 60).to_bytes(4, 'big') 299 | return base64.b64encode(nonce).decode() 300 | 301 | 302 | def gen_signed_nonce(ssecret: str, nonce: str) -> str: 303 | """Nonce signed with ssecret.""" 304 | m = hashlib.sha256() 305 | m.update(base64.b64decode(ssecret)) 306 | m.update(base64.b64decode(nonce)) 307 | return base64.b64encode(m.digest()).decode() 308 | 309 | 310 | def gen_signature(url: str, signed_nonce: str, nonce: str, data: str) -> str: 311 | """Request signature based on url, signed_nonce, nonce and data.""" 312 | sign = '&'.join([url, signed_nonce, nonce, 'data=' + data]) 313 | signature = hmac.new(key=base64.b64decode(signed_nonce), 314 | msg=sign.encode(), 315 | digestmod=hashlib.sha256).digest() 316 | return base64.b64encode(signature).decode() 317 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/light.py: -------------------------------------------------------------------------------- 1 | """Platform for light integration.""" 2 | import asyncio 3 | import logging 4 | from functools import partial 5 | 6 | from datetime import timedelta 7 | import json 8 | from collections import OrderedDict 9 | import homeassistant.helpers.config_validation as cv 10 | import voluptuous as vol 11 | from homeassistant.components.light import ( 12 | ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, 13 | ATTR_EFFECT, ATTR_HS_COLOR, 14 | PLATFORM_SCHEMA, 15 | SUPPORT_BRIGHTNESS, SUPPORT_COLOR, 16 | SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, 17 | LightEntity) 18 | from homeassistant.const import * 19 | from homeassistant.exceptions import PlatformNotReady 20 | from homeassistant.util import color 21 | from miio.exceptions import DeviceException 22 | from .deps.miio_new import MiotDevice 23 | 24 | from .basic_dev_class import ( 25 | GenericMiotDevice, 26 | ToggleableMiotDevice, 27 | MiotSubDevice, 28 | MiotSubToggleableDevice, 29 | MiotIRDevice, 30 | ) 31 | from . import async_generic_setup_platform 32 | from .deps.const import ( 33 | DOMAIN, 34 | CONF_UPDATE_INSTANT, 35 | CONF_MAPPING, 36 | CONF_CONTROL_PARAMS, 37 | CONF_CLOUD, 38 | CONF_MODEL, 39 | ATTR_STATE_VALUE, 40 | ATTR_MODEL, 41 | ATTR_FIRMWARE_VERSION, 42 | ATTR_HARDWARE_VERSION, 43 | SCHEMA, 44 | MAP, 45 | DUMMY_IP, 46 | DUMMY_TOKEN, 47 | ) 48 | import copy 49 | 50 | TYPE = 'light' 51 | 52 | _LOGGER = logging.getLogger(__name__) 53 | SCAN_INTERVAL = timedelta(seconds=10) 54 | DEFAULT_NAME = "Generic MIoT " + TYPE 55 | DATA_KEY = TYPE + '.' + DOMAIN 56 | 57 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 58 | SCHEMA 59 | ) 60 | 61 | # pylint: disable=unused-argument 62 | @asyncio.coroutine 63 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 64 | await async_generic_setup_platform( 65 | hass, 66 | config, 67 | async_add_devices, 68 | discovery_info, 69 | TYPE, 70 | {'default': MiotLight, '_ir_light': MiotIRLight}, 71 | {'default': MiotSubLight} 72 | ) 73 | 74 | async def async_setup_entry(hass, config_entry, async_add_entities): 75 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 76 | await async_setup_platform(hass, config, async_add_entities) 77 | 78 | 79 | class MiotLight(ToggleableMiotDevice, LightEntity): 80 | def __init__(self, device, config, device_info, hass, main_mi_type): 81 | ToggleableMiotDevice.__init__(self, device, config, device_info, hass, main_mi_type) 82 | self._brightness = None 83 | self._color = None 84 | self._color_temp = None 85 | self._effect = None 86 | hass.async_add_job(self.create_sub_entities) 87 | 88 | @property 89 | def supported_features(self): 90 | """Return the supported features.""" 91 | s = 0 92 | if self._did_prefix + 'brightness' in self._mapping: 93 | s |= SUPPORT_BRIGHTNESS 94 | if self._did_prefix + 'color_temperature' in self._mapping: 95 | s |= SUPPORT_COLOR_TEMP 96 | if self._did_prefix + 'mode' in self._mapping: 97 | s |= SUPPORT_EFFECT 98 | if self._did_prefix + 'color' in self._mapping: 99 | s |= SUPPORT_COLOR 100 | return s 101 | 102 | @property 103 | def brightness(self): 104 | """Return the brightness of the light. 105 | 106 | This method is optional. Removing it indicates to Home Assistant 107 | that brightness is not supported for this light. 108 | """ 109 | return self._brightness 110 | 111 | async def async_turn_on(self, **kwargs): 112 | """Turn on.""" 113 | parameters = [] 114 | if 'switch_status' in self._ctrl_params: 115 | parameters.append({**{'did': self._did_prefix + "switch_status", 'value': self._ctrl_params['switch_status']['power_on']},**(self._mapping[self._did_prefix + 'switch_status'])}) 116 | elif 'brightness' in self._ctrl_params and ATTR_BRIGHTNESS not in kwargs: 117 | # for some devices that control onoff by setting brightness to 0 118 | parameters.append({**{'did': self._did_prefix + "brightness", 'value': self._ctrl_params['brightness']['value_range'][-2]}, **(self._mapping[self._did_prefix + 'brightness'])}) 119 | if ATTR_EFFECT in kwargs: 120 | modes = self._ctrl_params['mode'] 121 | parameters.append({**{'did': self._did_prefix + "mode", 'value': self._ctrl_params['mode'].get(kwargs[ATTR_EFFECT])}, **(self._mapping[self._did_prefix + 'mode'])}) 122 | if ATTR_BRIGHTNESS in kwargs: 123 | self._effect = None 124 | parameters.append({**{'did': self._did_prefix + "brightness", 'value': self.convert_value(kwargs[ATTR_BRIGHTNESS],"brightness", True, self._ctrl_params['brightness']['value_range'])}, **(self._mapping[self._did_prefix + 'brightness'])}) 125 | if ATTR_COLOR_TEMP in kwargs: 126 | self._effect = None 127 | valuerange = self._ctrl_params['color_temperature']['value_range'] 128 | ct = self.convert_value(kwargs[ATTR_COLOR_TEMP], "color_temperature") 129 | ct = valuerange[0] if ct < valuerange[0] else valuerange[1] if ct > valuerange[1] else ct 130 | parameters.append({**{'did': self._did_prefix + "color_temperature", 'value': ct}, **(self._mapping[self._did_prefix + 'color_temperature'])}) 131 | if ATTR_HS_COLOR in kwargs: 132 | self._effect = None 133 | intcolor = self.convert_value(kwargs[ATTR_HS_COLOR],'color') 134 | parameters.append({**{'did': self._did_prefix + "color", 'value': intcolor}, **(self._mapping[self._did_prefix + 'color'])}) 135 | 136 | result = await self.set_property_new(multiparams = parameters) 137 | 138 | if result: 139 | self._state = True 140 | self.async_write_ha_state() 141 | 142 | async def async_turn_off(self, **kwargs): 143 | """Turn off.""" 144 | if 'switch_status' in self._ctrl_params: 145 | prm = self._ctrl_params['switch_status']['power_off'] 146 | result = await self.set_property_new(self._did_prefix + "switch_status",prm) 147 | elif 'brightness' in self._ctrl_params: 148 | prm = self._ctrl_params['brightness']['value_range'][0] 149 | result = await self.set_property_new(self._did_prefix + "brightness",prm) 150 | else: 151 | raise NotImplementedError() 152 | if result: 153 | self._state = False 154 | self.async_write_ha_state() 155 | 156 | @property 157 | def color_temp(self): 158 | """Return the color temperature in mired.""" 159 | return self._color_temp 160 | 161 | @property 162 | def min_mireds(self): 163 | """Return the coldest color_temp that this light supports.""" 164 | try: 165 | return self.convert_value(self._ctrl_params['color_temperature']['value_range'][1], "color_temperature") or 1 166 | except KeyError: 167 | return None 168 | @property 169 | def max_mireds(self): 170 | """Return the warmest color_temp that this light supports.""" 171 | try: 172 | return self.convert_value(self._ctrl_params['color_temperature']['value_range'][0], "color_temperature") or 100 173 | except KeyError: 174 | return None 175 | @property 176 | def effect_list(self): 177 | """Return the list of supported effects.""" 178 | return list(self._ctrl_params['mode'].keys()) #+ ['none'] 179 | 180 | @property 181 | def effect(self): 182 | """Return the current effect.""" 183 | return self._effect 184 | 185 | @property 186 | def hs_color(self): 187 | """Return the hs color value.""" 188 | return self._color 189 | 190 | def _handle_platform_specific_attrs(self): 191 | super()._handle_platform_specific_attrs() 192 | try: 193 | self._brightness = self.convert_value(self._state_attrs[self._did_prefix + 'brightness'],"brightness",False,self._ctrl_params['brightness']['value_range']) 194 | except KeyError: pass 195 | try: 196 | self._color = self.convert_value(self._state_attrs[self._did_prefix + 'color'],"color",False) 197 | except KeyError: pass 198 | try: 199 | self._color_temp = self.convert_value(self._state_attrs[self._did_prefix + 'color_temperature'], "color_temperature") or 100 200 | except KeyError: pass 201 | try: 202 | self._state_attrs.update({'color_temperature': self._state_attrs[self._did_prefix + 'color_temperature']}) 203 | except KeyError: pass 204 | try: 205 | self._state_attrs.update({'mode': self._state_attrs['mode']}) 206 | except KeyError: pass 207 | try: 208 | self._effect = self.get_key_by_value(self._ctrl_params['mode'],self._state_attrs[self._did_prefix + 'mode']) 209 | except KeyError: 210 | self._effect = None 211 | 212 | class MiotSubLight(MiotSubToggleableDevice, LightEntity): 213 | def __init__(self, parent_device, mapping, params, mitype): 214 | super().__init__(parent_device, mapping, params, mitype) 215 | self._brightness = None 216 | self._color = None 217 | self._color_temp = None 218 | self._effect = None 219 | 220 | @property 221 | def supported_features(self): 222 | """Return the supported features.""" 223 | s = 0 224 | if 'brightness' in self._mapping: 225 | s |= SUPPORT_BRIGHTNESS 226 | if 'color_temperature' in self._mapping: 227 | s |= SUPPORT_COLOR_TEMP 228 | if 'mode' in self._mapping: 229 | s |= SUPPORT_EFFECT 230 | if 'color' in self._mapping: 231 | s |= SUPPORT_COLOR 232 | return s 233 | 234 | @property 235 | def brightness(self): 236 | """Return the brightness of the light.""" 237 | try: 238 | return self.convert_value(self.extra_state_attributes[self._did_prefix + 'brightness'],"brightness",False,self._ctrl_params['brightness']['value_range']) 239 | except: 240 | return None 241 | 242 | async def async_turn_on(self, **kwargs): 243 | """Turn on.""" 244 | parameters = [] 245 | if 'switch_status' in self._ctrl_params: 246 | parameters.append({**{'did': self._did_prefix + "switch_status", 'value': self._ctrl_params['switch_status']['power_on']},**(self._mapping['switch_status'])}) 247 | elif 'brightness' in self._ctrl_params and ATTR_BRIGHTNESS not in kwargs: 248 | # for some devices that control onoff by setting brightness to 0 249 | parameters.append({**{'did': self._did_prefix + "brightness", 'value': self._ctrl_params['brightness']['value_range'][-2]}, **(self._mapping['brightness'])}) 250 | if ATTR_EFFECT in kwargs: 251 | modes = self._ctrl_params['mode'] 252 | parameters.append({**{'did': self._did_prefix + "mode", 'value': self._ctrl_params['mode'].get(kwargs[ATTR_EFFECT])}, **(self._mapping['mode'])}) 253 | if ATTR_BRIGHTNESS in kwargs: 254 | self._effect = None 255 | parameters.append({**{'did': self._did_prefix + "brightness", 'value': self.convert_value(kwargs[ATTR_BRIGHTNESS],"brightness", True, self._ctrl_params['brightness']['value_range'])}, **(self._mapping['brightness'])}) 256 | if ATTR_COLOR_TEMP in kwargs: 257 | self._effect = None 258 | valuerange = self._ctrl_params['color_temperature']['value_range'] 259 | ct = self.convert_value(kwargs[ATTR_COLOR_TEMP], "color_temperature") 260 | ct = valuerange[0] if ct < valuerange[0] else valuerange[1] if ct > valuerange[1] else ct 261 | parameters.append({**{'did': self._did_prefix + "color_temperature", 'value': ct}, **(self._mapping['color_temperature'])}) 262 | if ATTR_HS_COLOR in kwargs: 263 | self._effect = None 264 | intcolor = self.convert_value(kwargs[ATTR_HS_COLOR],'color') 265 | parameters.append({**{'did': self._did_prefix + "color", 'value': intcolor}, **(self._mapping['color'])}) 266 | 267 | result = await self._parent_device.set_property_new(multiparams = parameters) 268 | 269 | if result: 270 | self._state = True 271 | self._state_attrs[f"{self._did_prefix}switch_status"] = True 272 | self._parent_device.schedule_update_ha_state(force_refresh=True) 273 | 274 | async def async_turn_off(self, **kwargs): 275 | """Turn off.""" 276 | if 'switch_status' in self._ctrl_params: 277 | prm = self._ctrl_params['switch_status']['power_off'] 278 | result = await self._parent_device.set_property_new(self._did_prefix + "switch_status",prm) 279 | elif 'brightness' in self._ctrl_params: 280 | prm = self._ctrl_params['brightness']['value_range'][0] 281 | result = await self._parent_device.set_property_new(self._did_prefix + "brightness",prm) 282 | else: 283 | raise NotImplementedError() 284 | if result: 285 | self._state = False 286 | # self._state_attrs[f"{self._did_prefix}switch_status"] = False 287 | self._parent_device.schedule_update_ha_state(force_refresh=True) 288 | self._skip_update = True 289 | 290 | @property 291 | def color_temp(self): 292 | """Return the color temperature in mired.""" 293 | try: 294 | self._color_temp = self.convert_value(self.extra_state_attributes[self._did_prefix + 'color_temperature'], "color_temperature") or 100 295 | except KeyError: pass 296 | return self._color_temp 297 | 298 | @property 299 | def min_mireds(self): 300 | """Return the coldest color_temp that this light supports.""" 301 | try: 302 | return self.convert_value(self._ctrl_params['color_temperature']['value_range'][1], "color_temperature") or 1 303 | except KeyError: 304 | return None 305 | @property 306 | def max_mireds(self): 307 | """Return the warmest color_temp that this light supports.""" 308 | try: 309 | return self.convert_value(self._ctrl_params['color_temperature']['value_range'][0], "color_temperature") or 100 310 | except KeyError: 311 | return None 312 | @property 313 | def effect_list(self): 314 | """Return the list of supported effects.""" 315 | return list(self._ctrl_params['mode'].keys()) #+ ['none'] 316 | 317 | @property 318 | def effect(self): 319 | """Return the current effect.""" 320 | try: 321 | self._effect = self.get_key_by_value(self._ctrl_params['mode'],self.extra_state_attributes[self._did_prefix + 'mode']) 322 | except KeyError: 323 | self._effect = None 324 | return self._effect 325 | 326 | @property 327 | def hs_color(self): 328 | """Return the hs color value.""" 329 | try: 330 | self._color = self.convert_value(self.extra_state_attributes[self._did_prefix + 'color'],"color",False) 331 | except KeyError: 332 | self._color = None 333 | return self._color 334 | 335 | class MiotIRLight(MiotIRDevice, LightEntity): 336 | @property 337 | def supported_features(self): 338 | """Return the supported features.""" 339 | return SUPPORT_BRIGHTNESS 340 | 341 | @property 342 | def brightness(self): 343 | return 128 344 | 345 | @property 346 | def is_on(self): 347 | return self._state 348 | 349 | async def async_turn_on(self, **kwargs): 350 | result = False 351 | if ATTR_BRIGHTNESS in kwargs: 352 | if kwargs[ATTR_BRIGHTNESS] > 128: 353 | result = await self.async_send_ir_command('brightness_up') 354 | elif kwargs[ATTR_BRIGHTNESS] < 128: 355 | result = await self.async_send_ir_command('brightness_down') 356 | else: 357 | return 358 | else: 359 | result = await self.async_send_ir_command('turn_on') 360 | if result: 361 | self._state = True 362 | self.async_write_ha_state() 363 | 364 | async def async_turn_off(self, **kwargs): 365 | result = await self.async_send_ir_command('turn_off') 366 | if result: 367 | self._state = False 368 | self.async_write_ha_state() 369 | -------------------------------------------------------------------------------- /custom_components/xiaomi_miot_raw/fan.py: -------------------------------------------------------------------------------- 1 | """Platform for light integration.""" 2 | import asyncio 3 | import logging 4 | from functools import partial 5 | 6 | from collections import OrderedDict 7 | from datetime import timedelta 8 | from homeassistant.const import __version__ as current_version 9 | from distutils.version import StrictVersion 10 | import homeassistant.helpers.config_validation as cv 11 | import voluptuous as vol 12 | from homeassistant.components import fan 13 | from homeassistant.components.fan import ( 14 | PLATFORM_SCHEMA, 15 | SUPPORT_OSCILLATE, 16 | SUPPORT_SET_SPEED, 17 | SUPPORT_DIRECTION, 18 | FanEntity) 19 | from homeassistant.const import * 20 | from homeassistant.exceptions import PlatformNotReady 21 | from homeassistant.util import color 22 | from miio.exceptions import DeviceException 23 | from .deps.miio_new import MiotDevice 24 | 25 | from .basic_dev_class import ( 26 | GenericMiotDevice, 27 | ToggleableMiotDevice, 28 | MiotSubDevice, 29 | MiotSubToggleableDevice 30 | ) 31 | from . import async_generic_setup_platform 32 | from .switch import BinarySelectorEntity 33 | from .deps.const import ( 34 | DOMAIN, 35 | CONF_UPDATE_INSTANT, 36 | CONF_MAPPING, 37 | CONF_CONTROL_PARAMS, 38 | CONF_CLOUD, 39 | CONF_MODEL, 40 | ATTR_STATE_VALUE, 41 | ATTR_MODEL, 42 | ATTR_FIRMWARE_VERSION, 43 | ATTR_HARDWARE_VERSION, 44 | SCHEMA, 45 | MAP, 46 | DUMMY_IP, 47 | DUMMY_TOKEN, 48 | ) 49 | import copy 50 | 51 | TYPE = 'fan' 52 | _LOGGER = logging.getLogger(__name__) 53 | 54 | DEFAULT_NAME = "Generic MIoT " + TYPE 55 | DATA_KEY = TYPE + '.' + DOMAIN 56 | 57 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 58 | SCHEMA 59 | ) 60 | 61 | SCAN_INTERVAL = timedelta(seconds=10) 62 | 63 | NEW_FAN = True if StrictVersion(current_version.replace(".dev","a")) >= StrictVersion("2021.2.9") else False 64 | SUPPORT_PRESET_MODE = 8 65 | 66 | # pylint: disable=unused-argument 67 | @asyncio.coroutine 68 | async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 69 | hass.data[DOMAIN]['add_handler'].setdefault(TYPE, {}) 70 | if 'config_entry' in config: 71 | id = config['config_entry'].entry_id 72 | hass.data[DOMAIN]['add_handler'][TYPE].setdefault(id, async_add_devices) 73 | 74 | await async_generic_setup_platform( 75 | hass, 76 | config, 77 | async_add_devices, 78 | discovery_info, 79 | TYPE, 80 | {'default': MiotFan}, 81 | {'default': MiotSubFan, 'a_l': MiotActionList} 82 | ) 83 | 84 | async def async_setup_entry(hass, config_entry, async_add_entities): 85 | config = copy.copy(hass.data[DOMAIN]['configs'].get(config_entry.entry_id, dict(config_entry.data))) 86 | await async_setup_platform(hass, config, async_add_entities) 87 | 88 | class MiotFan(ToggleableMiotDevice, FanEntity): 89 | def __init__(self, device, config, device_info, hass, main_mi_type): 90 | ToggleableMiotDevice.__init__(self, device, config, device_info, hass, main_mi_type) 91 | self._speed = None 92 | self._mode = None 93 | self._oscillation = None 94 | hass.async_add_job(self.create_sub_entities) 95 | 96 | @property 97 | def supported_features(self): 98 | """Return the supported features.""" 99 | s = 0 100 | if self._did_prefix + 'oscillate' in self._mapping: 101 | s |= SUPPORT_OSCILLATE 102 | if self._did_prefix + 'motor_control' in self._mapping: 103 | s |= SUPPORT_DIRECTION 104 | if self._did_prefix + 'speed' in self._mapping: 105 | s |= (SUPPORT_SET_SPEED if not NEW_FAN else SUPPORT_SET_SPEED) 106 | if self._did_prefix + 'mode' in self._mapping: 107 | s |= (SUPPORT_SET_SPEED if not NEW_FAN else SUPPORT_PRESET_MODE) 108 | return s 109 | 110 | @property 111 | def speed_list(self) -> list: 112 | """Get the list of available speeds.""" 113 | if NEW_FAN: 114 | # 这个是假的!! 115 | return None 116 | else: 117 | if 'speed' in self._ctrl_params: 118 | return list(self._ctrl_params['speed'].keys()) 119 | elif 'mode' in self._ctrl_params: 120 | return list(self._ctrl_params['mode'].keys()) 121 | 122 | @property 123 | def _speed_list_without_preset_modes(self) -> list: 124 | if 'stepless_speed' not in self._ctrl_params: 125 | return list(self._ctrl_params['speed'].keys()) 126 | else: 127 | return list(range( 128 | self._ctrl_params['stepless_speed']['value_range'][0], 129 | self._ctrl_params['stepless_speed']['value_range'][1] + 1, 130 | self._ctrl_params['stepless_speed']['value_range'][2], 131 | )) 132 | 133 | @property 134 | def speed(self): 135 | """Return the current speed.""" 136 | return (self._speed or self._mode) if not NEW_FAN else self._speed 137 | 138 | @property 139 | def preset_modes(self) -> list: 140 | """Get the list of available preset_modes.""" 141 | try: 142 | return list(self._ctrl_params['mode'].keys()) 143 | except KeyError: 144 | return [] 145 | 146 | @property 147 | def preset_mode(self): 148 | """Return the current speed.""" 149 | return self._mode 150 | 151 | # @property 152 | # def percentage(self): 153 | # return None 154 | 155 | @property 156 | def speed_count(self): 157 | return len(self._speed_list_without_preset_modes) 158 | 159 | @property 160 | def oscillating(self): 161 | """Return the oscillation state.""" 162 | return self._oscillation 163 | 164 | async def async_oscillate(self, oscillating: bool) -> None: 165 | """Set oscillation.""" 166 | # result = await self.set_property_new(self._did_prefix + "oscillate",self._ctrl_params['oscillate'][oscillating]) 167 | result = await self.set_property_new(self._did_prefix + "oscillate", oscillating) 168 | 169 | if result: 170 | self._oscillation = True 171 | self._skip_update = True 172 | 173 | async def async_turn_on(self, speed: str = None, **kwargs) -> None: 174 | """旧版HA前端调风速是这个""" 175 | result = True 176 | if not self.is_on: 177 | result &= await self.set_property_new(self._did_prefix + "switch_status", self._ctrl_params['switch_status']['power_on']) 178 | 179 | parameters = [] 180 | if 'from_stepless_speed' in kwargs: 181 | parameters.append({**{'did': self._did_prefix + "stepless_speed", 'value': speed}, **(self._mapping[self._did_prefix + 'stepless_speed'])}) 182 | 183 | elif speed: 184 | if 'speed' in self._ctrl_params: 185 | parameters.append({**{'did': self._did_prefix + "speed", 'value': self._ctrl_params['speed'][speed]}, **(self._mapping[self._did_prefix + 'speed'])}) 186 | elif 'mode' in self._ctrl_params: 187 | parameters.append({**{'did': self._did_prefix + "mode", 'value': self._ctrl_params['mode'][speed]}, **(self._mapping[self._did_prefix + 'mode'])}) 188 | 189 | if parameters: 190 | result &= await self.set_property_new(multiparams = parameters) 191 | if result: 192 | self._state = True 193 | if speed is not None: 194 | self._speed = speed 195 | self._skip_update = True 196 | 197 | async def async_set_speed(self, speed: str) -> None: 198 | """HomeKit调风速是这个,旧版speed形如“Level1”,新版是百分比""" 199 | if 'stepless_speed' not in self._ctrl_params or not NEW_FAN: 200 | await self.async_turn_on(speed) 201 | else: 202 | await self.async_turn_on(speed, from_stepless_speed = True) 203 | 204 | async def async_set_preset_mode(self, preset_mode: str) -> None: 205 | """Set new preset mode.""" 206 | result = await self.set_property_new(self._did_prefix + "mode", self._ctrl_params['mode'][preset_mode]) 207 | if result: 208 | self._state = True 209 | self._mode = preset_mode 210 | self._skip_update = True 211 | 212 | @property 213 | def current_direction(self) -> str: 214 | """Fan direction.""" 215 | return None 216 | 217 | async def async_set_direction(self, direction: str) -> None: 218 | """Set the direction of the fan.""" 219 | if direction == 'forward': 220 | d = 'left' 221 | elif direction == 'reverse': 222 | d = 'right' 223 | else: 224 | d = direction 225 | if d not in self._ctrl_params['motor_control']: 226 | raise TypeError(f"Your fan does not support {direction}.") 227 | await self.set_property_new(self._did_prefix + "motor_control", self._ctrl_params['motor_control'][d]) 228 | 229 | # async def async_set_percentage(self, percentage: int) -> None: 230 | # """Set the speed percentage of the fan.""" 231 | # pass 232 | 233 | def _handle_platform_specific_attrs(self): 234 | super()._handle_platform_specific_attrs() 235 | try: 236 | self._speed = self.get_key_by_value(self._ctrl_params['speed'],self._state_attrs.get(self._did_prefix + 'speed')) \ 237 | if 'stepless_speed' not in self._ctrl_params or not NEW_FAN \ 238 | else self._state_attrs.get(self._did_prefix + 'stepless_speed') 239 | except KeyError: 240 | self._speed = None 241 | try: 242 | self._mode = self.get_key_by_value(self._ctrl_params['mode'],self._state_attrs.get(self._did_prefix + 'mode')) 243 | except KeyError: 244 | self._mode = None 245 | self._oscillation = self._state_attrs.get(self._did_prefix + 'oscillate') 246 | 247 | class MiotSubFan(MiotSubToggleableDevice, FanEntity): 248 | def __init__(self, parent_device, mapping, params, mitype): 249 | super().__init__(parent_device, mapping, params, mitype) 250 | self._speed = None 251 | self._oscillation = None 252 | 253 | @property 254 | def supported_features(self): 255 | """Return the supported features.""" 256 | s = 0 257 | if 'oscillate' in self._mapping: 258 | s |= SUPPORT_OSCILLATE 259 | if 'speed' in self._mapping: 260 | s |= (SUPPORT_SET_SPEED if not NEW_FAN else SUPPORT_PRESET_MODE) 261 | return s 262 | 263 | @property 264 | def speed_list(self) -> list: 265 | """Get the list of available speeds.""" 266 | if NEW_FAN: 267 | return None 268 | else: 269 | return list(self._ctrl_params['speed'].keys()) 270 | 271 | @property 272 | def preset_modes(self) -> list: 273 | """Get the list of available preset_modes.""" 274 | return list(self._ctrl_params['speed'].keys()) 275 | 276 | @property 277 | def speed(self): 278 | """Return the current speed.""" 279 | if not NEW_FAN: 280 | try: 281 | self._speed = self.get_key_by_value(self._ctrl_params['speed'],self.extra_state_attributes[self._did_prefix + 'speed']) 282 | except KeyError: 283 | self._speed = None 284 | return self._speed 285 | else: 286 | return None 287 | 288 | @property 289 | def preset_mode(self): 290 | """Return the current speed.""" 291 | try: 292 | self._speed = self.get_key_by_value(self._ctrl_params['speed'],self.extra_state_attributes[self._did_prefix + 'speed']) 293 | except KeyError: 294 | self._speed = None 295 | return self._speed 296 | 297 | @property 298 | def percentage(self): 299 | return 0 300 | 301 | @property 302 | def speed_count(self): 303 | return 1 304 | 305 | @property 306 | def oscillating(self): 307 | """Return the oscillation state.""" 308 | return self.extra_state_attributes.get(self._did_prefix + 'oscillate') 309 | 310 | async def async_oscillate(self, oscillating: bool) -> None: 311 | """Set oscillation.""" 312 | # result = await self.set_property_new(self._did_prefix + "oscillate",self._ctrl_params['oscillate'][oscillating]) 313 | result = await self._parent_device.set_property_new(self._did_prefix + "oscillate", oscillating) 314 | 315 | if result: 316 | self._oscillation = True 317 | self._skip_update = True 318 | 319 | async def async_set_preset_mode(self, preset_mode: str) -> None: 320 | """Set new preset mode.""" 321 | result = await self._parent_device.set_property_new(self._did_prefix + "speed", self._ctrl_params['speed'][preset_mode]) 322 | if result: 323 | self._state = True 324 | self._speed = preset_mode 325 | self._skip_update = True 326 | 327 | async def async_set_percentage(self, percentage: int) -> None: 328 | """Set the speed percentage of the fan.""" 329 | pass 330 | 331 | 332 | async def async_turn_on(self, speed: str = None, **kwargs) -> None: 333 | """Turn on the entity.""" 334 | parameters = [{**{'did': self._did_prefix + "switch_status", 'value': self._ctrl_params['switch_status']['power_on']},**(self._mapping['switch_status'])}] 335 | 336 | if speed: 337 | parameters.append({**{'did': self._did_prefix + "speed", 'value': self._ctrl_params['speed'][speed]}, **(self._mapping['speed'])}) 338 | 339 | result = await self._parent_device.set_property_new(multiparams = parameters) 340 | if result: 341 | self._state = True 342 | self._speed = speed 343 | self._skip_update = True 344 | 345 | class MiotActionList(MiotSubDevice, FanEntity): 346 | def __init__(self, parent_device, mapping, params, mitype): 347 | """params is not needed. We keep it here to make the ctor same.""" 348 | super().__init__(parent_device, mapping, {}, mitype) 349 | self._name_suffix = '动作列表' 350 | self._action_list = [] 351 | for k, v in mapping.items(): 352 | if 'aiid' in v: 353 | self._action_list.append(k) 354 | self._state2 = STATE_ON 355 | 356 | @property 357 | def supported_features(self): 358 | """Return the supported features.""" 359 | return SUPPORT_SET_SPEED if not NEW_FAN else SUPPORT_PRESET_MODE 360 | 361 | @property 362 | def speed_list(self) -> list: 363 | """Get the list of available speeds.""" 364 | return self._action_list 365 | 366 | @property 367 | def speed(self): 368 | """Return the current speed.""" 369 | return None 370 | 371 | @property 372 | def percentage(self) -> str: 373 | """Return the current speed.""" 374 | return None 375 | 376 | preset_modes = speed_list 377 | preset_mode = speed 378 | 379 | @property 380 | def speed_count(self) -> int: 381 | """Return the number of speeds the fan supports.""" 382 | return len(self._action_list) 383 | 384 | # @property 385 | # def _speed_list_without_preset_modes(self) -> list: 386 | # return [] 387 | 388 | async def async_turn_on(self, speed: str = None, **kwargs) -> None: 389 | result = await self._parent_device.call_action_new(*self._mapping[speed].values()) 390 | if result: 391 | self._state2 = STATE_OFF 392 | self.schedule_update_ha_state() 393 | 394 | async def async_turn_off(self): 395 | pass 396 | 397 | async def async_set_preset_mode(self, preset_mode: str) -> None: 398 | """Set new preset mode.""" 399 | await self.async_turn_on(speed=preset_mode) 400 | 401 | async def async_set_percentage(self, percentage: int) -> None: 402 | """Set the speed percentage of the fan.""" 403 | pass 404 | 405 | @property 406 | def is_on(self): 407 | return self._state2 == STATE_ON 408 | 409 | @property 410 | def state(self): 411 | return self._state2 412 | 413 | @property 414 | def extra_state_attributes(self): 415 | return {ATTR_ATTRIBUTION: "在上方列表选择动作。选择后会立即执行。\n操作成功后,开关会短暂回弹。"} 416 | 417 | async def async_update(self): 418 | await asyncio.sleep(1) 419 | self._state2 = STATE_ON 420 | 421 | class SelectorEntity(MiotSubDevice, FanEntity): 422 | def __init__(self, parent_device, **kwargs): 423 | self._parent_device = parent_device 424 | self._did_prefix = f"{kwargs.get('did_prefix')[:10]}_" if kwargs.get('did_prefix') else "" 425 | self._field = kwargs.get('field') 426 | self._value_list = kwargs.get('value_list') 427 | self._name_suffix = kwargs.get('name') or self._field.replace("_", " ").title() 428 | self._name = f'{parent_device.name} {self._name_suffix}' 429 | self._unique_id = f"{parent_device.unique_id}-{kwargs.get('field')}" 430 | self._entity_id = f"{parent_device._entity_id}-{kwargs.get('field')}" 431 | self.entity_id = f"{DOMAIN}.{self._entity_id}" 432 | self._available = True 433 | self._icon = "mdi:tune" 434 | 435 | @property 436 | def supported_features(self): 437 | """Return the supported features.""" 438 | return SUPPORT_SET_SPEED if not NEW_FAN else SUPPORT_PRESET_MODE 439 | 440 | @property 441 | def speed_list(self) -> list: 442 | """Get the list of available speeds.""" 443 | return list(self._value_list) 444 | 445 | @property 446 | def speed(self): 447 | """Return the current speed.""" 448 | return self._parent_device.get_key_by_value(self._value_list, self._parent_device.extra_state_attributes.get(self._did_prefix + self._field)) 449 | 450 | @property 451 | def percentage(self) -> str: 452 | """Return the current speed.""" 453 | return None 454 | 455 | preset_modes = speed_list 456 | preset_mode = speed 457 | 458 | @property 459 | def speed_count(self) -> int: 460 | """Return the number of speeds the fan supports.""" 461 | return len(self._action_list) 462 | 463 | async def async_turn_on(self, speed = None, **kwargs) -> None: 464 | result = await self._parent_device.set_property_new(self._did_prefix + self._field, self._value_list[speed]) 465 | if result: 466 | self._state2 = STATE_OFF 467 | self.schedule_update_ha_state() 468 | 469 | async def async_turn_off(self): 470 | pass 471 | 472 | async def async_set_preset_mode(self, preset_mode: str) -> None: 473 | """Set new preset mode.""" 474 | await self.async_turn_on(speed=preset_mode) 475 | 476 | async def async_set_percentage(self, percentage: int) -> None: 477 | """Set the speed percentage of the fan.""" 478 | pass 479 | 480 | @property 481 | def is_on(self): 482 | return self._state2 == STATE_ON 483 | 484 | @property 485 | def state(self): 486 | return self._state2 487 | 488 | @property 489 | def extra_state_attributes(self): 490 | return {ATTR_ATTRIBUTION: f"可在此设置“{self._parent_device.name}”的 {self._field}。开关仅用于反馈操作是否成功,无控制功能。"} 491 | 492 | async def async_update(self): 493 | await asyncio.sleep(1) 494 | self._state2 = STATE_ON 495 | --------------------------------------------------------------------------------