├── 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 | [](https://github.com/ha0y/xiaomi_miot_raw/releases/latest) [](https://github.com/ha0y/xiaomi_miot_raw/stargazers) [](https://github.com/ha0y/xiaomi_miot_raw/issues) [](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 | 
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | **登录账号后,即可立即选择要添加的设备。**
79 |
80 | **后续如需添加更多设备,再次通过`途径 1`进入插件,即可选择要添加的设备。**
81 |
82 | 图片步骤说明
83 |
84 | 
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 |
--------------------------------------------------------------------------------