pax_global_header 0000666 0000000 0000000 00000000064 14650172074 0014520 g ustar 00root root 0000000 0000000 52 comment=dfb1f84064041564b84ca2d0ce759b7e0b94bd43
python-xiaomi-ble-0.30.2/ 0000775 0000000 0000000 00000000000 14650172074 0015147 5 ustar 00root root 0000000 0000000 python-xiaomi-ble-0.30.2/LICENSE 0000664 0000000 0000000 00000026121 14650172074 0016156 0 ustar 00root root 0000000 0000000
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 J. Nick Koston
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
python-xiaomi-ble-0.30.2/PKG-INFO 0000664 0000000 0000000 00000012124 14650172074 0016244 0 ustar 00root root 0000000 0000000 Metadata-Version: 2.1
Name: xiaomi-ble
Version: 0.30.2
Summary: Manage Xiaomi BLE devices
Home-page: https://github.com/bluetooth-devices/xiaomi-ble
License: Apache-2.0
Author: John Carr
Author-email: john.carr@unrouted.co.uk
Requires-Python: >=3.9,<4.0
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Natural Language :: English
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries
Provides-Extra: docs
Requires-Dist: Sphinx (>=5.0,<6.0) ; extra == "docs"
Requires-Dist: bleak (>=0.19.5)
Requires-Dist: bleak-retry-connector (>=2.13.0)
Requires-Dist: bluetooth-data-tools (>=0.3.1)
Requires-Dist: bluetooth-sensor-state-data (>=1.6.0)
Requires-Dist: cryptography (>=40.0.0)
Requires-Dist: home-assistant-bluetooth (>=1.9.2)
Requires-Dist: myst-parser (>=0.18,<0.19) ; extra == "docs"
Requires-Dist: pycryptodomex (>=3.19.1)
Requires-Dist: sensor-state-data (>=2.17.1)
Requires-Dist: sphinx-rtd-theme (>=1.0,<2.0) ; extra == "docs"
Project-URL: Bug Tracker, https://github.com/bluetooth-devices/xiaomi-ble/issues
Project-URL: Changelog, https://github.com/bluetooth-devices/xiaomi-ble/blob/main/CHANGELOG.md
Project-URL: Documentation, https://xiaomi-ble.readthedocs.io
Project-URL: Repository, https://github.com/bluetooth-devices/xiaomi-ble
Description-Content-Type: text/markdown
# Xiaomi BLE
Manage Xiaomi BLE devices
## Installation
Install this via pip (or your favourite package manager).
`pip install xiaomi-ble`
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
## Credits
This package was created with
[Cookiecutter](https://github.com/audreyr/cookiecutter) and the
[browniebroke/cookiecutter-pypackage](https://github.com/browniebroke/cookiecutter-pypackage)
project template.
python-xiaomi-ble-0.30.2/README.md 0000664 0000000 0000000 00000006654 14650172074 0016441 0 ustar 00root root 0000000 0000000 # Xiaomi BLE
Manage Xiaomi BLE devices
## Installation
Install this via pip (or your favourite package manager).
`pip install xiaomi-ble`
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
## Credits
This package was created with
[Cookiecutter](https://github.com/audreyr/cookiecutter) and the
[browniebroke/cookiecutter-pypackage](https://github.com/browniebroke/cookiecutter-pypackage)
project template.
python-xiaomi-ble-0.30.2/pyproject.toml 0000664 0000000 0000000 00000005057 14650172074 0020072 0 ustar 00root root 0000000 0000000 [tool.poetry]
name = "xiaomi-ble"
version = "0.30.2"
description = "Manage Xiaomi BLE devices"
authors = ["John Carr "]
license = "Apache-2.0"
readme = "README.md"
repository = "https://github.com/bluetooth-devices/xiaomi-ble"
documentation = "https://xiaomi-ble.readthedocs.io"
classifiers = [
"Development Status :: 2 - Pre-Alpha",
"Intended Audience :: Developers",
"Natural Language :: English",
"Operating System :: OS Independent",
"Topic :: Software Development :: Libraries",
]
packages = [
{ include = "xiaomi_ble", from = "src" },
]
[tool.poetry.urls]
"Bug Tracker" = "https://github.com/bluetooth-devices/xiaomi-ble/issues"
"Changelog" = "https://github.com/bluetooth-devices/xiaomi-ble/blob/main/CHANGELOG.md"
[tool.poetry.dependencies]
python = "^3.9"
# Documentation Dependencies
Sphinx = {version = "^5.0", optional = true}
sphinx-rtd-theme = {version = "^1.0", optional = true}
myst-parser = {version = "^0.18", optional = true}
home-assistant-bluetooth = ">=1.9.2"
sensor-state-data = ">=2.17.1"
bluetooth-sensor-state-data = ">=1.6.0"
bleak-retry-connector = ">=2.13.0"
bluetooth-data-tools = ">=0.3.1"
bleak = ">=0.19.5"
cryptography = ">=40.0.0"
pycryptodomex = ">=3.19.1"
[tool.poetry.extras]
docs = [
"myst-parser",
"sphinx",
"sphinx-rtd-theme",
]
[tool.poetry.dev-dependencies]
pytest = "^7.4"
pytest-cov = "^4.1"
black = "^23.12.1"
isort = "^5.13.2"
flake8 = "^7.0.0"
mypy = "^1.8.0"
pyupgrade = "^3.15.0"
[tool.semantic_release]
branch = "main"
version_toml = "pyproject.toml:tool.poetry.version"
version_variable = "src/xiaomi_ble/__init__.py:__version__"
build_command = "pip install poetry && poetry build"
[tool.pytest.ini_options]
addopts = "-v -Wdefault --cov=xiaomi_ble --cov-report=term-missing:skip-covered"
pythonpath = ["src"]
[tool.coverage.run]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"@overload",
"if TYPE_CHECKING",
"raise NotImplementedError",
]
[tool.isort]
profile = "black"
known_first_party = ["xiaomi_ble", "tests"]
[tool.mypy]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
mypy_path = "src/"
no_implicit_optional = true
show_error_codes = true
warn_unreachable = true
warn_unused_ignores = true
exclude = [
'docs/.*',
'setup.py',
]
[[tool.mypy.overrides]]
module = "tests.*"
allow_untyped_defs = true
[[tool.mypy.overrides]]
module = "docs.*"
ignore_errors = true
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
python-xiaomi-ble-0.30.2/src/ 0000775 0000000 0000000 00000000000 14650172074 0015736 5 ustar 00root root 0000000 0000000 python-xiaomi-ble-0.30.2/src/xiaomi_ble/ 0000775 0000000 0000000 00000000000 14650172074 0020046 5 ustar 00root root 0000000 0000000 python-xiaomi-ble-0.30.2/src/xiaomi_ble/__init__.py 0000664 0000000 0000000 00000001473 14650172074 0022164 0 ustar 00root root 0000000 0000000 """Parser for Xiaomi BLE advertisements.
This file is shamlessly copied from the following repository:
https://github.com/Ernst79/bleparser/blob/c42ae922e1abed2720c7fac993777e1bd59c0c93/package/bleparser/Xiaomi.py
MIT License applies.
"""
from __future__ import annotations
from sensor_state_data import (
DeviceClass,
DeviceKey,
SensorDescription,
SensorDeviceInfo,
SensorUpdate,
SensorValue,
Units,
)
from .devices import SLEEPY_DEVICE_MODELS
from .parser import EncryptionScheme, XiaomiBluetoothDeviceData
__version__ = "0.30.2"
__all__ = [
"SLEEPY_DEVICE_MODELS",
"EncryptionScheme",
"XiaomiBluetoothDeviceData",
"SensorDescription",
"SensorDeviceInfo",
"DeviceClass",
"DeviceKey",
"SensorUpdate",
"SensorDeviceInfo",
"SensorValue",
"Units",
]
python-xiaomi-ble-0.30.2/src/xiaomi_ble/const.py 0000664 0000000 0000000 00000004327 14650172074 0021554 0 ustar 00root root 0000000 0000000 """Constants for Xiaomi BLE advertisements."""
from enum import Enum
from sensor_state_data import BaseDeviceClass
TIMEOUT_1DAY = 86400
SERVICE_MIBEACON = "0000fe95-0000-1000-8000-00805f9b34fb"
SERVICE_HHCCJCY10 = "0000fd50-0000-1000-8000-00805f9b34fb"
SERVICE_SCALE1 = "0000181d-0000-1000-8000-00805f9b34fb"
SERVICE_SCALE2 = "0000181b-0000-1000-8000-00805f9b34fb"
# This characteristic contains the current battery level for a HHCCJCY01
# as well as the firmware version
CHARACTERISTIC_BATTERY = "00001a02-0000-1000-8000-00805f9b34fb"
class EncryptionScheme(Enum):
"""Encryption Schemes for Xiaomi MiBeacon."""
# No encryption is needed to use this device
NONE = "none"
# 12 byte encryption key expected
MIBEACON_LEGACY = "mibeacon_legacy"
# 16 byte encryption key expected
MIBEACON_4_5 = "mibeacon_4_5"
class ExtendedBinarySensorDeviceClass(BaseDeviceClass):
"""Device class for additional binary sensors (compared to sensor-state-data)."""
# On means armed (away), Off means disarmed
ARMED = "armed"
# On means door left open, Off means door closed
DEVICE_FORCIBLY_REMOVED = "device_forcibly_removed"
# On means door left open, Off means door closed
DOOR_LEFT_OPEN = "door_left_open"
# On means door stuck, Off means clear
DOOR_STUCK = "door_stuck"
# On means fingerprint Ok, Off means fingerprint Not Ok
FINGERPRINT = "fingerprint"
# On means door someone knocking on the door, Off means no knocking
KNOCK_ON_THE_DOOR = "knock_on_the_door"
# On means door pried, Off means door not pried
PRY_THE_DOOR = "pry_the_door"
# On means toothbrush On, Off means toothbrush Off
TOOTHBRUSH = "toothbrush"
# On means antilock turned On, Off means antilOck turned Off
ANTILOCK = "antilock"
# On means childlock Turned On, Off means childlock turned Off
CHILDLOCK = "childlock"
class ExtendedSensorDeviceClass(BaseDeviceClass):
"""Device class for additional sensors (compared to sensor-state-data)."""
# Consumable
CONSUMABLE = "consumable"
# Toothbrush counter
COUNTER = "counter"
# Key id
KEY_ID = "key_id"
# Lock method
LOCK_METHOD = "lock_method"
# Toothbrush score
SCORE = "score"
python-xiaomi-ble-0.30.2/src/xiaomi_ble/devices.py 0000664 0000000 0000000 00000013572 14650172074 0022052 0 ustar 00root root 0000000 0000000 import dataclasses
@dataclasses.dataclass
class DeviceEntry:
name: str
model: str
manufacturer: str = "Xiaomi"
DEVICE_TYPES: dict[int, DeviceEntry] = {
0x0C3C: DeviceEntry(
name="Alarm Clock",
model="CGC1",
),
0x0576: DeviceEntry(
name="3-in-1 Alarm Clock",
model="CGD1",
),
0x066F: DeviceEntry(
name="Temperature/Humidity Sensor",
model="CGDK2",
),
0x0347: DeviceEntry(
name="Temperature/Humidity Sensor",
model="CGG1",
),
0x0B48: DeviceEntry(
name="Temperature/Humidity Sensor",
model="CGG1-ENCRYPTED",
),
0x03D6: DeviceEntry(
name="Door/Window Sensor",
model="CGH1",
),
0x0A83: DeviceEntry(
name="Motion/Light Sensor",
model="CGPR1",
),
0x03BC: DeviceEntry(
name="Grow Care Garden",
model="GCLS002",
),
0x0098: DeviceEntry(
name="Plant Sensor",
model="HHCCJCY01",
),
0x015D: DeviceEntry(
name="Smart Flower Pot",
model="HHCCPOT002",
),
0x02DF: DeviceEntry(
name="Formaldehyde Sensor",
model="JQJCY01YM",
),
0x0997: DeviceEntry(
name="Smoke Detector",
model="JTYJGD03MI",
),
0x1568: DeviceEntry(
name="Switch (single button)",
model="K9B-1BTN",
),
0x1569: DeviceEntry(
name="Switch (double button)",
model="K9B-2BTN",
),
0x0DFD: DeviceEntry(
name="Switch (triple button)",
model="K9B-3BTN",
),
0x1C10: DeviceEntry(
name="Switch (single button)",
model="K9BB-1BTN",
),
0x1889: DeviceEntry(
name="Door/Window Sensor",
model="MS1BB(MI)",
),
0x2AEB: DeviceEntry(
name="Motion Sensor",
model="HS1BB(MI)",
),
0x3F0F: DeviceEntry(name="Flood and Rain Sensor", model="RS1BB(MI)"),
0x01AA: DeviceEntry(
name="Temperature/Humidity Sensor",
model="LYWSDCGQ",
),
0x045B: DeviceEntry(
name="Temperature/Humidity Sensor",
model="LYWSD02",
),
0x16E4: DeviceEntry(
name="Temperature/Humidity Sensor",
model="LYWSD02MMC",
),
0x2542: DeviceEntry(
name="Temperature/Humidity Sensor",
model="LYWSD02MMC",
),
0x055B: DeviceEntry(
name="Temperature/Humidity Sensor",
model="LYWSD03MMC",
),
0x2832: DeviceEntry(
name="Temperature/Humidity Sensor",
model="MJWSD05MMC",
),
0x098B: DeviceEntry(
name="Door/Window Sensor",
model="MCCGQ02HL",
),
0x06D3: DeviceEntry(
name="Alarm Clock",
model="MHO-C303",
),
0x0387: DeviceEntry(
name="Temperature/Humidity Sensor",
model="MHO-C401",
),
0x07F6: DeviceEntry(
name="Nightlight",
model="MJYD02YL",
),
0x04E9: DeviceEntry(
name="Door Lock",
model="MJZNMSQ01YD",
),
0x00DB: DeviceEntry(
name="Baby Thermometer",
model="MMC-T201-1",
),
0x0391: DeviceEntry(
name="Body Thermometer",
model="MMC-W505",
),
0x03DD: DeviceEntry(
name="Nightlight",
model="MUE4094RT",
),
0x0489: DeviceEntry(
name="Smart Toothbrush",
model="M1S-T500",
),
0x0806: DeviceEntry(
name="Smart Toothbrush",
model="T700",
),
0x1790: DeviceEntry(
name="Smart Toothbrush",
model="T700",
),
0x0A8D: DeviceEntry(
name="Motion Sensor",
model="RTCGQ02LM",
),
0x3531: DeviceEntry(
name="Motion Sensor",
model="XMPIRO2SXS",
),
0x0863: DeviceEntry(
name="Flood Detector",
model="SJWS01LM",
),
0x045C: DeviceEntry(
name="Smart Kettle",
model="V-SK152",
),
0x040A: DeviceEntry(
name="Mosquito Repellent",
model="WX08ZM",
),
0x04E1: DeviceEntry(
name="Magic Cube",
model="XMMF01JQD",
),
0x1203: DeviceEntry(
name="Thermometer",
model="XMWSDJ04MMC",
),
0x1949: DeviceEntry(
name="Switch (double button)",
model="XMWXKG01YL",
),
0x2387: DeviceEntry(
name="Button",
model="XMWXKG01LM",
),
0x098C: DeviceEntry(
name="Door Lock",
model="XMZNMST02YD",
),
0x0784: DeviceEntry(
name="Door Lock",
model="XMZNMS04LM",
),
0x0E39: DeviceEntry(
name="Door Lock",
model="XMZNMS08LM",
),
0x07BF: DeviceEntry(
name="Wireless Switch",
model="YLAI003",
),
0x38BB: DeviceEntry(
name="Wireless Switch",
model="PTX_YK1_QMIMB",
),
0x0153: DeviceEntry(
name="Remote Control",
model="YLYK01YL",
),
0x068E: DeviceEntry(
name="Fan Remote Control",
model="YLYK01YL-FANCL",
),
0x04E6: DeviceEntry(
name="Ventilator Fan Remote Control",
model="YLYK01YL-VENFAN",
),
0x03BF: DeviceEntry(
name="Bathroom Heater Remote",
model="YLYB01YL-BHFRC",
),
0x03B6: DeviceEntry(
name="Dimmer Switch",
model="YLKG07YL/YLKG08YL",
),
0x0083: DeviceEntry(
name="Smart Kettle",
model="YM-K1501",
),
0x0113: DeviceEntry(
name="Smart Kettle",
model="YM-K1501EU",
),
0x069E: DeviceEntry(
name="Door Lock",
model="ZNMS16LM",
),
0x069F: DeviceEntry(
name="Door Lock",
model="ZNMS17LM",
),
0x0380: DeviceEntry(
name="Door Lock",
model="DSL-C08",
),
0x11C2: DeviceEntry(
name="Door Lock",
model="Lockin-SV40",
),
0x0DE7: DeviceEntry(
name="Odor Eliminator",
model="SU001-T",
),
}
SLEEPY_DEVICE_MODELS = {
"CGH1",
"JTYJGD03MI",
"MCCGQ02HL",
"RTCGQ02LM",
"MMC-W505",
"RS1BB(MI)",
}
python-xiaomi-ble-0.30.2/src/xiaomi_ble/events.py 0000664 0000000 0000000 00000000701 14650172074 0021722 0 ustar 00root root 0000000 0000000 """Event constants for xiaomi-ble."""
from __future__ import annotations
from sensor_state_data.enum import StrEnum
class EventDeviceKeys(StrEnum):
"""Keys for devices that send events."""
# Button
BUTTON = "button"
# Cube
CUBE = "cube"
# Dimmer
DIMMER = "dimmer"
# Error
ERROR = "error"
# Fingerprint
FINGERPRINT = "fingerprint"
# Motion
MOTION = "motion"
# Lock
LOCK = "lock"
python-xiaomi-ble-0.30.2/src/xiaomi_ble/locks.py 0000775 0000000 0000000 00000005671 14650172074 0021547 0 ustar 00root root 0000000 0000000 """Constants for Xiaomi Locks."""
from enum import Enum
class BleLockMethod(Enum):
"""Methods for opening and closing locks."""
BLUETOOTH = "bluetooth"
PASSWORD = "password" # nosec bandit B105
BIOMETRICS = "biometrics"
KEY_METHOD = "key_method"
TURNTABLE = "turntable_method"
NFC = "nfc_method"
ONE_TIME_PASSWORD = "one_time_password" # nosec bandit B105
TWO_STEP_VERIFICATION = "two_step_verification"
HOMEKIT = "homekit"
COERCION = "coercion_method"
MANUAL = "manual"
AUTOMATIC = "automatic"
ABNORMAL = "abnormal"
# Definition of lock messages
BLE_LOCK_ERROR = {
0xC0DE0000: "frequent_unlocking_with_incorrect_password",
0xC0DE0001: "frequent_unlocking_with_wrong_fingerprints",
0xC0DE0002: "operation_timeout_password_input_timeout",
0xC0DE0003: "lock_picking",
0xC0DE0004: "reset_button_is_pressed",
0xC0DE0005: "the_wrong_key_is_frequently_unlocked",
0xC0DE0006: "foreign_body_in_the_keyhole",
0xC0DE0007: "the_key_has_not_been_taken_out",
0xC0DE0008: "error_nfc_frequently_unlocks",
0xC0DE0009: "timeout_is_not_locked_as_required",
0xC0DE000A: "failure_to_unlock_frequently_in_multiple_ways",
0xC0DE000B: "unlocking_the_face_frequently_fails",
0xC0DE000C: "failure_to_unlock_the_vein_frequently",
0xC0DE000D: "hijacking_alarm",
0xC0DE000E: "unlock_inside_the_door_after_arming",
0xC0DE000F: "palmprints_frequently_fail_to_unlock",
0xC0DE0010: "the_safe_was_moved",
0xC0DE1000: "the_battery_level_is_less_than_10_percent",
0xC0DE1001: "the_battery_level_is_less_than_5_percent",
0xC0DE1002: "the_fingerprint_sensor_is_abnormal",
0xC0DE1003: "the_accessory_battery_is_low",
0xC0DE1004: "mechanical_failure",
0xC0DE1005: "the_lock_sensor_is_faulty",
}
BLE_LOCK_ACTION: dict[int, tuple[bool, str, str]] = {
0b0000: (True, "lock", "unlock_outside_the_door"),
0b0001: (False, "lock", "locked"),
0b0010: (False, "antilock", "turn_on_antilock"),
0b0011: (True, "antilock", "release_the_antilock"),
0b0100: (True, "lock", "unlock_inside_the_door"),
0b0101: (False, "lock", "lock_inside_the_door"),
0b0110: (False, "childlock", "turn_on_child_lock"),
0b0111: (True, "childlock", "turn_off_child_lock"),
0b1000: (False, "lock", "lock_outside_the_door"),
0b1111: (True, "lock", "abnormal"),
}
BLE_LOCK_METHOD: dict[int, BleLockMethod] = {
0b0000: BleLockMethod.BLUETOOTH,
0b0001: BleLockMethod.PASSWORD,
0b0010: BleLockMethod.BIOMETRICS,
0b0011: BleLockMethod.KEY_METHOD,
0b0100: BleLockMethod.TURNTABLE,
0b0101: BleLockMethod.NFC,
0b0110: BleLockMethod.ONE_TIME_PASSWORD,
0b0111: BleLockMethod.TWO_STEP_VERIFICATION,
0b1001: BleLockMethod.HOMEKIT,
0b1000: BleLockMethod.COERCION,
0b1010: BleLockMethod.MANUAL,
0b1011: BleLockMethod.AUTOMATIC,
0b1111: BleLockMethod.ABNORMAL,
}
python-xiaomi-ble-0.30.2/src/xiaomi_ble/parser.py 0000664 0000000 0000000 00000215742 14650172074 0021727 0 ustar 00root root 0000000 0000000 """Parser for Xiaomi BLE advertisements.
This file is shamlessly copied from the following repository:
https://github.com/Ernst79/bleparser/blob/c42ae922e1abed2720c7fac993777e1bd59c0c93/package/bleparser/xiaomi.py
MIT License applies.
"""
from __future__ import annotations
import datetime
import logging
import math
import struct
from typing import Any
from bleak import BleakClient
from bleak.backends.device import BLEDevice
from bleak_retry_connector import establish_connection
from bluetooth_data_tools import short_address
from bluetooth_sensor_state_data import BluetoothData
from Cryptodome.Cipher import AES
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
from home_assistant_bluetooth import BluetoothServiceInfo
from sensor_state_data import (
BinarySensorDeviceClass,
SensorLibrary,
SensorUpdate,
Units,
)
from .const import (
CHARACTERISTIC_BATTERY,
SERVICE_HHCCJCY10,
SERVICE_MIBEACON,
SERVICE_SCALE1,
SERVICE_SCALE2,
TIMEOUT_1DAY,
EncryptionScheme,
ExtendedBinarySensorDeviceClass,
ExtendedSensorDeviceClass,
)
from .devices import DEVICE_TYPES, SLEEPY_DEVICE_MODELS
from .events import EventDeviceKeys
from .locks import BLE_LOCK_ACTION, BLE_LOCK_ERROR, BLE_LOCK_METHOD
_LOGGER = logging.getLogger(__name__)
def to_mac(addr: bytes) -> str:
"""Return formatted MAC address"""
return ":".join(f"{i:02X}" for i in addr)
def to_unformatted_mac(addr: str) -> str:
"""Return unformatted MAC address"""
return "".join(f"{i:02X}" for i in addr[:])
def parse_event_properties(
event_property: str | None, value: int
) -> dict[str, int | None] | None:
"""Convert event property and data to event properties."""
if event_property:
return {event_property: value}
return None
# Structured objects for data conversions
TH_STRUCT = struct.Struct(" dict[str, Any]:
"""Motion"""
# 0x0003 is only used by MUE4094RT, which does not send motion clear.
# This object is therefore added as event (motion detected).
device.fire_event(
key=EventDeviceKeys.MOTION,
event_type="motion_detected",
event_properties=None,
)
return {}
def obj0006(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Fingerprint"""
if len(xobj) == 5:
key_id_bytes = xobj[0:4]
match_byte = xobj[4]
if key_id_bytes == b"\x00\x00\x00\x00":
key_type = "administrator"
elif key_id_bytes == b"\xff\xff\xff\xff":
key_type = "unknown operator"
elif key_id_bytes == b"\xde\xad\xbe\xef":
key_type = "invalid operator"
else:
key_type = str(int.from_bytes(key_id_bytes, "little"))
if match_byte == 0x00:
result = "match_successful"
elif match_byte == 0x01:
result = "match_failed"
elif match_byte == 0x02:
result = "timeout"
elif match_byte == 0x033:
result = "low_quality_too_light_fuzzy"
elif match_byte == 0x04:
result = "insufficient_area"
elif match_byte == 0x05:
result = "skin_is_too_dry"
elif match_byte == 0x06:
result = "skin_is_too_wet"
else:
result = None
fingerprint = True if match_byte == 0x00 else False
# Update fingerprint binary sensor
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.FINGERPRINT,
native_value=fingerprint,
device_class=ExtendedBinarySensorDeviceClass.FINGERPRINT,
name="Fingerprint",
)
# Update key_id sensor
device.update_sensor(
key=ExtendedSensorDeviceClass.KEY_ID,
name="Key id",
device_class=ExtendedSensorDeviceClass.KEY_ID,
native_value=key_type,
native_unit_of_measurement=None,
)
# Fire Fingerprint action event
if result:
device.fire_event(
key=EventDeviceKeys.FINGERPRINT,
event_type=result,
event_properties=None,
)
return {}
def obj0007(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Door"""
door_byte = xobj[0]
if door_byte == 0x00:
# open the door
device.update_predefined_binary_sensor(BinarySensorDeviceClass.DOOR, True)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_STUCK,
native_value=False, # reset door stuck
device_class=ExtendedBinarySensorDeviceClass.DOOR_STUCK,
name="Door stuck",
)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.KNOCK_ON_THE_DOOR,
native_value=False, # reset knock on the door
device_class=ExtendedBinarySensorDeviceClass.KNOCK_ON_THE_DOOR,
name="Knock on the door",
)
elif door_byte == 0x01:
# close the door
device.update_predefined_binary_sensor(BinarySensorDeviceClass.DOOR, False)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
native_value=False, # reset door left open
device_class=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
name="Door left open",
)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.PRY_THE_DOOR,
native_value=False, # reset pry the door
device_class=ExtendedBinarySensorDeviceClass.PRY_THE_DOOR,
name="Pry the door",
)
elif door_byte == 0x02:
# timeout, not closed
device.update_predefined_binary_sensor(BinarySensorDeviceClass.DOOR, True)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
native_value=True,
device_class=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
name="Door left open",
)
elif door_byte == 0x03:
# knock on the door
device.update_predefined_binary_sensor(BinarySensorDeviceClass.DOOR, False)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.KNOCK_ON_THE_DOOR,
native_value=True,
device_class=ExtendedBinarySensorDeviceClass.KNOCK_ON_THE_DOOR,
name="Knock on the door",
)
elif door_byte == 0x04:
# pry the door
device.update_predefined_binary_sensor(BinarySensorDeviceClass.DOOR, True)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.PRY_THE_DOOR,
native_value=True,
device_class=ExtendedBinarySensorDeviceClass.PRY_THE_DOOR,
name="Pry the door",
)
elif door_byte == 0x05:
# door stuck
device.update_predefined_binary_sensor(BinarySensorDeviceClass.DOOR, False)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_STUCK,
native_value=True,
device_class=ExtendedBinarySensorDeviceClass.DOOR_STUCK,
name="Door stuck",
)
return {}
def obj0008(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""armed away"""
value = xobj[0] ^ 1
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.ARMED,
native_value=bool(value), # Armed away
device_class=ExtendedBinarySensorDeviceClass.ARMED,
name="Armed",
)
# Lift up door handle outside the door sends this event from DSL-C08.
if device_type == "DSL-C08":
device.update_predefined_binary_sensor(
BinarySensorDeviceClass.LOCK, bool(value)
)
# Fire Lock action event
device.fire_event(
key=EventDeviceKeys.LOCK,
event_type="lock_outside_the_door",
event_properties=None,
)
# # Update method sensor
device.update_sensor(
key=ExtendedSensorDeviceClass.LOCK_METHOD,
name="Lock method",
device_class=ExtendedSensorDeviceClass.LOCK_METHOD,
native_value="manual",
native_unit_of_measurement=None,
)
return {}
def obj0010(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Toothbrush"""
if xobj[0] == 0:
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.TOOTHBRUSH,
native_value=True, # Toothbrush On
device_class=ExtendedBinarySensorDeviceClass.TOOTHBRUSH,
name="Toothbrush",
)
else:
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.TOOTHBRUSH,
native_value=False, # Toothbrush Off
device_class=ExtendedBinarySensorDeviceClass.TOOTHBRUSH,
name="Toothbrush",
)
if len(xobj) > 1:
device.update_sensor(
key=ExtendedSensorDeviceClass.COUNTER,
name="Counter",
native_unit_of_measurement=Units.TIME_SECONDS,
device_class=ExtendedSensorDeviceClass.COUNTER,
native_value=xobj[1],
)
return {}
def obj000a(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Body Temperature"""
if len(xobj) == 2:
(temp,) = T_STRUCT.unpack(xobj)
if temp:
device.update_predefined_sensor(
SensorLibrary.TEMPERATURE__CELSIUS, temp / 100
)
return {}
def obj000b(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Lock"""
if len(xobj) == 9:
lock_action_int = xobj[0] & 0x0F
lock_method_int = xobj[0] >> 4
key_id = int.from_bytes(xobj[1:5], "little")
short_key_id = key_id & 0xFFFF
# Lock action (event) and lock method (sensor)
if (
lock_action_int not in BLE_LOCK_ACTION
or lock_method_int not in BLE_LOCK_METHOD
):
return {}
lock_action = BLE_LOCK_ACTION[lock_action_int][2]
lock_method = BLE_LOCK_METHOD[lock_method_int]
# Some specific key_ids represent an error
error = BLE_LOCK_ERROR.get(key_id)
if not error:
if key_id == 0x00000000:
key_type = "administrator"
elif key_id == 0xFFFFFFFF:
key_type = "unknown operator"
elif key_id == 0xDEADBEEF:
key_type = "invalid operator"
elif key_id <= 0x7FFFFFF:
# Bluetooth (up to 2147483647)
key_type = f"Bluetooth key {key_id}"
else:
# All other key methods have only key ids up to 65536
if key_id <= 0x8001FFFF:
key_type = f"Fingerprint key id {short_key_id}"
elif key_id <= 0x8002FFFF:
key_type = f"Password key id {short_key_id}"
elif key_id <= 0x8003FFFF:
key_type = f"Keys key id {short_key_id}"
elif key_id <= 0x8004FFFF:
key_type = f"NFC key id {short_key_id}"
elif key_id <= 0x8005FFFF:
key_type = f"Two-step verification key id {short_key_id}"
elif key_id <= 0x8006FFFF:
key_type = f"Human face key id {short_key_id}"
elif key_id <= 0x8007FFFF:
key_type = f"Finger veins key id {short_key_id}"
elif key_id <= 0x8008FFFF:
key_type = f"Palm print key id {short_key_id}"
else:
key_type = f"key id {short_key_id}"
# Lock type and state
# Lock type can be `lock` or for ZNMS17LM `lock`, `childlock` or `antilock`
if device_type == "ZNMS17LM":
# Lock type can be `lock`, `childlock` or `antilock`
lock_type = BLE_LOCK_ACTION[lock_action_int][1]
else:
# Lock type can only be `lock` for other locks
lock_type = "lock"
lock_state = BLE_LOCK_ACTION[lock_action_int][0]
# Update lock state
if lock_type == "lock":
device.update_predefined_binary_sensor(
BinarySensorDeviceClass.LOCK, lock_state
)
elif lock_type == "childlock":
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.CHILDLOCK,
native_value=lock_state,
device_class=ExtendedBinarySensorDeviceClass.CHILDLOCK,
name="Childlock",
)
elif lock_type == "antilock":
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.ANTILOCK,
native_value=lock_state,
device_class=ExtendedBinarySensorDeviceClass.ANTILOCK,
name="Antilock",
)
else:
return {}
# Update key_id sensor
device.update_sensor(
key=ExtendedSensorDeviceClass.KEY_ID,
name="Key id",
device_class=ExtendedSensorDeviceClass.KEY_ID,
native_value=key_type,
native_unit_of_measurement=None,
)
# Fire Lock action event: see BLE_LOCK_ACTTION
device.fire_event(
key=EventDeviceKeys.LOCK,
event_type=lock_action,
event_properties=None,
)
# # Update method sensor: see BLE_LOCK_METHOD
device.update_sensor(
key=ExtendedSensorDeviceClass.LOCK_METHOD,
name="Lock method",
device_class=ExtendedSensorDeviceClass.LOCK_METHOD,
native_value=lock_method.value,
native_unit_of_measurement=None,
)
if error:
# Fire event with the error: see BLE_LOCK_ERROR
device.fire_event(
key=EventDeviceKeys.ERROR,
event_type=error,
event_properties=None,
)
return {}
def obj000f(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Moving with light"""
if len(xobj) == 3:
(illum,) = LIGHT_STRUCT.unpack(xobj + b"\x00")
device.update_predefined_binary_sensor(BinarySensorDeviceClass.MOTION, True)
if device_type in ["MJYD02YL", "RTCGQ02LM"]:
# MJYD02YL: 1 - moving no light, 100 - moving with light
# RTCGQ02LM: 0 - moving no light, 256 - moving with light
device.update_predefined_binary_sensor(
BinarySensorDeviceClass.LIGHT, bool(illum >= 100)
)
elif device_type == "CGPR1":
# CGPR1: moving, value is illumination in lux
device.update_predefined_sensor(SensorLibrary.LIGHT__LIGHT_LUX, illum)
return {}
def obj1001(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""button"""
if len(xobj) != 3:
return {}
(button_type, value, press_type) = BUTTON_STRUCT.unpack(xobj)
# button_type represents the pressed button or rubiks cube rotation direction
remote_command = None
fan_remote_command = None
ven_fan_remote_command = None
bathroom_remote_command = None
cube_rotation = None
one_btn_switch = False
two_btn_switch_left = False
two_btn_switch_right = False
three_btn_switch_left = False
three_btn_switch_middle = False
three_btn_switch_right = False
if button_type == 0:
remote_command = "on"
fan_remote_command = "fan"
ven_fan_remote_command = "swing"
bathroom_remote_command = "stop"
one_btn_switch = True
two_btn_switch_left = True
three_btn_switch_left = True
cube_rotation = "rotate_right"
elif button_type == 1:
remote_command = "off"
fan_remote_command = "light"
ven_fan_remote_command = "power"
bathroom_remote_command = "air_exchange"
two_btn_switch_right = True
three_btn_switch_middle = True
cube_rotation = "rotate_left"
elif button_type == 2:
remote_command = "brightness"
fan_remote_command = "wind_speed"
ven_fan_remote_command = "timer_60_minutes"
bathroom_remote_command = "fan"
two_btn_switch_left = True
two_btn_switch_right = True
three_btn_switch_right = True
elif button_type == 3:
remote_command = "plus"
fan_remote_command = "color_temperature"
ven_fan_remote_command = "increase_wind_speed"
bathroom_remote_command = "increase_speed"
three_btn_switch_left = True
three_btn_switch_middle = True
elif button_type == 4:
remote_command = "M"
fan_remote_command = "wind_mode"
ven_fan_remote_command = "timer_30_minutes"
bathroom_remote_command = "decrease_speed"
three_btn_switch_middle = True
three_btn_switch_right = True
elif button_type == 5:
remote_command = "min"
fan_remote_command = "brightness"
ven_fan_remote_command = "decrease_wind_speed"
bathroom_remote_command = "dry"
three_btn_switch_left = True
three_btn_switch_right = True
elif button_type == 6:
bathroom_remote_command = "light"
three_btn_switch_left = True
three_btn_switch_middle = True
three_btn_switch_right = True
elif button_type == 7:
bathroom_remote_command = "swing"
elif button_type == 8:
bathroom_remote_command = "heat"
# press_type represents the type of press or rotate
# for dimmers, buton_type is used to represent the type of press
# for dimmers, value or button_type is used to represent the direction and number
# of steps, number of presses or duration of long press
button_press_type = "no_press"
btn_switch_press_type = None
dimmer_value: int = 0
if press_type == 0:
button_press_type = "press"
btn_switch_press_type = "press"
elif press_type == 1:
button_press_type = "double_press"
btn_switch_press_type = "long_press"
elif press_type == 2:
button_press_type = "long_press"
btn_switch_press_type = "double_press"
elif press_type == 3:
if button_type == 0:
button_press_type = "press"
dimmer_value = value
if button_type == 1:
button_press_type = "long_press"
dimmer_value = value
elif press_type == 4:
if button_type == 0:
if value <= 127:
button_press_type = "rotate_right"
dimmer_value = value
else:
button_press_type = "rotate_left"
dimmer_value = 256 - value
elif button_type <= 127:
button_press_type = "rotate_right_pressed"
dimmer_value = button_type
else:
button_press_type = "rotate_left_pressed"
dimmer_value = 256 - button_type
elif press_type == 5:
button_press_type = "press"
elif press_type == 6:
button_press_type = "long_press"
# return device specific output
if device_type in ["RTCGQ02LM", "YLAI003", "JTYJGD03MI", "SJWS01LM"]:
# RTCGQ02LM, JTYJGD03MI, SJWS01LM: press
# YLAI003: press, double_press or long_press
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type=button_press_type,
event_properties=None,
)
elif device_type == "XMMF01JQD":
# cube_rotation: rotate_left or rotate_right
device.fire_event(
key=EventDeviceKeys.CUBE,
event_type=cube_rotation,
event_properties=None,
)
elif device_type == "YLYK01YL":
# Buttons: on, off, brightness, plus, min, M
# Press types: press and long_press
if remote_command == "on":
device.update_predefined_binary_sensor(BinarySensorDeviceClass.POWER, True)
elif remote_command == "off":
device.update_predefined_binary_sensor(BinarySensorDeviceClass.POWER, False)
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_{remote_command}",
event_type=button_press_type,
event_properties=None,
)
elif device_type == "YLYK01YL-FANRC":
# Buttons: fan, light, wind_speed, wind_mode, brightness, color_temperature
# Press types: press and long_press
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_{fan_remote_command}",
event_type=button_press_type,
event_properties=None,
)
elif device_type == "YLYK01YL-VENFAN":
# Buttons: swing, power, timer_30_minutes, timer_60_minutes,
# increase_wind_speed, decrease_wind_speed
# Press types: press and long_press
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_{ven_fan_remote_command}",
event_type=button_press_type,
event_properties=None,
)
elif device_type == "YLYB01YL-BHFRC":
# Buttons: heat, air_exchange, dry, fan, swing, decrease_speed, increase_speed,
# stop or light
# Press types: press and long_press
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_{bathroom_remote_command}",
event_type=button_press_type,
event_properties=None,
)
elif device_type == "YLKG07YL/YLKG08YL":
# Dimmer reports: press, long_press, rotate_left, rotate_right,
# rotate_left_pressed or rotate_right_pressed
if button_press_type == "press":
# it also reports how many times you pressed the dimmer.
event_property = "number_of_presses"
elif button_press_type == "long_press":
# it also reports the duration (in seconds) you pressed the dimmer
event_property = "duration"
elif button_press_type in [
"rotate_right",
"rotate_left",
"rotate_right_pressed",
"rotate_left_pressed",
]:
# it reports how far you rotate, measured in number of `steps`.
event_property = "steps"
else:
event_property = None
event_properties = parse_event_properties(
event_property=event_property, value=dimmer_value
)
device.fire_event(
key=EventDeviceKeys.DIMMER,
event_type=button_press_type,
event_properties=event_properties,
)
elif device_type == "K9B-1BTN":
# Press types: press, double_press, long_press
if one_btn_switch:
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type=btn_switch_press_type,
event_properties=None,
)
elif device_type == "K9B-2BTN":
# Buttons: left and/or right
# Press types: press, double_press, long_press
# device can send button press of multiple buttons in one message
if two_btn_switch_left:
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_left",
event_type=btn_switch_press_type,
event_properties=None,
)
if two_btn_switch_right:
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_right",
event_type=btn_switch_press_type,
event_properties=None,
)
elif device_type == "K9B-3BTN":
# Buttons: left, middle and/or right
# result can be press, double_press or long_press
# device can send button press of multiple buttons in one message
if three_btn_switch_left:
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_left",
event_type=btn_switch_press_type,
event_properties=None,
)
if three_btn_switch_middle:
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_middle",
event_type=btn_switch_press_type,
event_properties=None,
)
if three_btn_switch_right:
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_right",
event_type=btn_switch_press_type,
event_properties=None,
)
return {}
def obj1004(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Temperature"""
if len(xobj) == 2:
(temp,) = T_STRUCT.unpack(xobj)
device.update_predefined_sensor(SensorLibrary.TEMPERATURE__CELSIUS, temp / 10)
return {}
def obj1005(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Power on/off and Temperature"""
device.update_predefined_binary_sensor(BinarySensorDeviceClass.POWER, xobj[0])
device.update_predefined_sensor(SensorLibrary.TEMPERATURE__CELSIUS, xobj[1])
return {}
def obj1006(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Humidity"""
if len(xobj) == 2:
(humi,) = H_STRUCT.unpack(xobj)
if device_type in ["LYWSD03MMC", "MHO-C401"]:
# To handle jagged stair stepping readings from these sensors.
# https://github.com/custom-components/ble_monitor/blob/ef2e3944b9c1a635208390b8563710d0eec2a945/custom_components/ble_monitor/sensor.py#L752
# https://github.com/esphome/esphome/blob/c39f6d0738d97ecc11238220b493731ec70c701c/esphome/components/xiaomi_lywsd03mmc/xiaomi_lywsd03mmc.cpp#L44C14-L44C99
# https://github.com/custom-components/ble_monitor/issues/7#issuecomment-595948254
device.update_predefined_sensor(
SensorLibrary.HUMIDITY__PERCENTAGE, int(humi / 10)
)
else:
device.update_predefined_sensor(
SensorLibrary.HUMIDITY__PERCENTAGE, humi / 10
)
return {}
def obj1007(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Illuminance"""
if len(xobj) == 3:
(illum,) = ILL_STRUCT.unpack(xobj + b"\x00")
if device_type in ["MJYD02YL", "MCCGQ02HL"]:
# 100 means light, else dark (0 or 1)
# MCCGQ02HL might use obj1018 for light sensor, just added here to be sure.
device.update_predefined_binary_sensor(
BinarySensorDeviceClass.LIGHT, illum == 100
)
elif device_type in ["HHCCJCY01", "GCLS002"]:
# illumination in lux
device.update_predefined_sensor(SensorLibrary.LIGHT__LIGHT_LUX, illum)
return {}
def obj1008(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Moisture"""
device.update_predefined_sensor(SensorLibrary.MOISTURE__PERCENTAGE, xobj[0])
return {}
def obj1009(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Conductivity"""
if len(xobj) == 2:
(cond,) = CND_STRUCT.unpack(xobj)
device.update_predefined_sensor(SensorLibrary.CONDUCTIVITY__CONDUCTIVITY, cond)
return {}
def obj1010(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Formaldehyde"""
if len(xobj) == 2:
(fmdh,) = FMDH_STRUCT.unpack(xobj)
device.update_predefined_sensor(
SensorLibrary.FORMALDEHYDE__CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
fmdh / 100,
)
return {}
def obj1012(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Power on/off"""
device.update_predefined_binary_sensor(BinarySensorDeviceClass.POWER, xobj[0])
return {}
def obj1013(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Consumable (in percent)"""
device.update_sensor(
key=ExtendedSensorDeviceClass.CONSUMABLE,
name="Consumable",
native_unit_of_measurement=Units.PERCENTAGE,
device_class=ExtendedSensorDeviceClass.CONSUMABLE,
native_value=xobj[0],
)
return {}
def obj1014(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Moisture"""
device.update_predefined_binary_sensor(
BinarySensorDeviceClass.MOISTURE, xobj[0] > 0
)
return {}
def obj1015(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Smoke"""
device.update_predefined_binary_sensor(BinarySensorDeviceClass.SMOKE, xobj[0] > 0)
return {}
def obj1017(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Time in seconds without motion"""
if len(xobj) == 4:
(no_motion_time,) = M_STRUCT.unpack(xobj)
# seconds since last motion detected message
# 0x1017 is send 3 seconds after 0x000f, 5 seconds arter 0x1007
# and at 60, 120, 300, 600, 1200 and 1800 seconds after last motion.
# Anything <= 30 seconds is regarded motion detected in the MiHome app.
if no_motion_time <= 30:
device.update_predefined_binary_sensor(BinarySensorDeviceClass.MOTION, True)
else:
device.update_predefined_binary_sensor(
BinarySensorDeviceClass.MOTION, False
)
return {}
def obj1018(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Light intensity"""
device.update_predefined_binary_sensor(BinarySensorDeviceClass.LIGHT, bool(xobj[0]))
return {}
def obj1019(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Door/Window sensor"""
open_obj = xobj[0]
if open_obj == 0:
# opened
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, True)
elif open_obj == 1:
# closed
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, False)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
native_value=False, # reset door left open
device_class=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
name="Door left open",
)
elif open_obj == 2:
# closing timeout
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, True)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
native_value=True,
device_class=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
name="Door left open",
)
elif open_obj == 3:
# device reset (not implemented)
return {}
else:
return {}
return {}
def obj100a(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Battery"""
batt = xobj[0]
volt = 2.2 + (3.1 - 2.2) * (batt / 100)
device.update_predefined_sensor(SensorLibrary.BATTERY__PERCENTAGE, batt)
device.update_predefined_sensor(
SensorLibrary.VOLTAGE__ELECTRIC_POTENTIAL_VOLT, volt
)
return {}
def obj100d(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Temperature and humidity"""
if len(xobj) == 4:
(temp, humi) = TH_STRUCT.unpack(xobj)
device.update_predefined_sensor(SensorLibrary.TEMPERATURE__CELSIUS, temp / 10)
device.update_predefined_sensor(SensorLibrary.HUMIDITY__PERCENTAGE, humi / 10)
return {}
def obj100e(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Lock common attribute"""
# https://iot.mi.com/new/doc/accesses/direct-access/embedded-development/ble/object-definition#%E9%94%81%E5%B1%9E%E6%80%A7
if len(xobj) == 1:
# Unlock by type on some devices
if device_type == "DSL-C08":
lock_attribute = int.from_bytes(xobj, "little")
device.update_predefined_binary_sensor(
BinarySensorDeviceClass.LOCK, bool(lock_attribute & 0x01 ^ 1)
)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.CHILDLOCK,
native_value=bool(lock_attribute >> 3 ^ 1),
device_class=ExtendedBinarySensorDeviceClass.CHILDLOCK,
name="Childlock",
)
return {}
def obj101b(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Timeout no movement"""
# https://iot.mi.com/new/doc/accesses/direct-access/embedded-development/ble/object-definition#%E9%80%9A%E7%94%A8%E5%B1%9E%E6%80%A7
device.update_predefined_binary_sensor(BinarySensorDeviceClass.MOTION, False)
return {}
def obj2000(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Body temperature"""
if len(xobj) == 5:
(temp1, temp2, bat) = TTB_STRUCT.unpack(xobj)
# Body temperature is calculated from the two measured temperatures.
# Formula is based on approximation based on values in the app in
# the range 36.5 - 37.8.
body_temp = (
3.71934 * pow(10, -11) * math.exp(0.69314 * temp1 / 100)
- (1.02801 * pow(10, -8) * math.exp(0.53871 * temp2 / 100))
+ 36.413
)
device.update_predefined_sensor(SensorLibrary.TEMPERATURE__CELSIUS, body_temp)
device.update_predefined_sensor(SensorLibrary.BATTERY__PERCENTAGE, bat)
return {}
def obj3003(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Brushing"""
result = {}
start_obj = xobj[0]
if start_obj == 0:
# Start of brushing
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.TOOTHBRUSH,
native_value=True, # Toothbrush On
device_class=ExtendedBinarySensorDeviceClass.TOOTHBRUSH,
name="Toothbrush",
)
# Start time has not been implemented
start_time = struct.unpack(" dict[str, Any]:
"""Battery"""
device.update_predefined_sensor(SensorLibrary.BATTERY__PERCENTAGE, xobj[0])
return {}
def obj4804(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Opening (state)"""
opening_state = xobj[0]
# State of the door/window, used in combination with obj4a12
if opening_state == 1:
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, True)
elif opening_state == 2:
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, False)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
native_value=False, # reset door left open
device_class=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
name="Door left open",
)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED,
native_value=False, # reset device forcibly removed
device_class=ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED,
name="Device forcibly removed",
)
return {}
def obj4805(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Illuminance in lux"""
(illum,) = struct.unpack("f", xobj)
device.update_predefined_sensor(SensorLibrary.LIGHT__LIGHT_LUX, illum)
return {}
def obj4806(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Moisture"""
device.update_predefined_binary_sensor(
BinarySensorDeviceClass.MOISTURE, xobj[0] > 0
)
return {}
def obj4818(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Time in seconds of no motion"""
if len(xobj) == 2:
(no_motion_time,) = struct.unpack(" dict[str, Any]:
"""Low Battery"""
device.update_predefined_binary_sensor(BinarySensorDeviceClass.BATTERY, xobj[0])
return {}
def obj4a08(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Motion detected with Illuminance in lux"""
(illum,) = struct.unpack("f", xobj)
device.update_predefined_binary_sensor(BinarySensorDeviceClass.MOTION, True)
device.update_predefined_sensor(SensorLibrary.LIGHT__LIGHT_LUX, illum)
return {}
def obj4a0c(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Single press"""
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="press",
event_properties=None,
)
return {}
def obj4a0d(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Double press"""
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="double_press",
event_properties=None,
)
return {}
def obj4a0e(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Long press"""
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="long_press",
event_properties=None,
)
return {}
def obj4a0f(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Device forcibly removed"""
dev_forced = xobj[0]
if dev_forced == 1:
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, True)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED,
native_value=True,
device_class=ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED,
name="Device forcibly removed",
)
return {}
def obj4a12(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Opening (event)"""
opening_state = xobj[0]
# Opening event, used in combination with obj4804
if opening_state == 1:
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, True)
elif opening_state == 2:
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, False)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
native_value=False,
device_class=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
name="Door left open",
)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED,
native_value=False, # reset device forcibly removed
device_class=ExtendedBinarySensorDeviceClass.DEVICE_FORCIBLY_REMOVED,
name="Device forcibly removed",
)
return {}
def obj4a13(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Button press (MS1BB(MI))"""
press = xobj[0]
if press == 1:
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="press",
event_properties=None,
)
return {}
def obj4a1a(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Door left open"""
if xobj[0] == 1:
device.update_predefined_binary_sensor(BinarySensorDeviceClass.OPENING, True)
device.update_binary_sensor(
key=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
native_value=False,
device_class=ExtendedBinarySensorDeviceClass.DOOR_LEFT_OPEN,
name="Door left open",
)
return {}
def obj4c01(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Temperature"""
if len(xobj) == 4:
temp = FLOAT_STRUCT.unpack(xobj)[0]
device.update_predefined_sensor(
SensorLibrary.TEMPERATURE__CELSIUS, round(temp, 2)
)
return {}
def obj4c02(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Humidity"""
if len(xobj) == 1:
humi = xobj[0]
device.update_predefined_sensor(SensorLibrary.HUMIDITY__PERCENTAGE, humi)
return {}
def obj4c03(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Battery"""
device.update_predefined_sensor(SensorLibrary.BATTERY__PERCENTAGE, xobj[0])
return {}
def obj4c08(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Humidity"""
if len(xobj) == 4:
humi = FLOAT_STRUCT.unpack(xobj)[0]
device.update_predefined_sensor(SensorLibrary.HUMIDITY__PERCENTAGE, humi)
return {}
def obj4c14(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Mode"""
mode = xobj[0]
return {"mode": mode}
def obj4e01(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Low Battery"""
device.update_predefined_binary_sensor(BinarySensorDeviceClass.BATTERY, xobj[0])
return {}
def obj4e0c(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Button press"""
if device_type == "XMWXKG01YL":
press = xobj[0]
if press == 1:
# left button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_left",
event_type="press",
event_properties=None,
)
elif press == 2:
# right button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_right",
event_type="press",
event_properties=None,
)
elif press == 3:
# both left and right button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_left",
event_type="press",
event_properties=None,
)
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_right",
event_type="press",
event_properties=None,
)
elif device_type == "K9BB-1BTN":
press = xobj[0]
if press == 1:
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="press",
event_properties=None,
)
elif press == 8:
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="long_press",
event_properties=None,
)
elif press == 15:
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="double_press",
event_properties=None,
)
elif device_type == "XMWXKG01LM":
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="press",
event_properties=None,
)
return {}
def obj4e0d(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Double Press"""
if device_type == "XMWXKG01YL":
press = xobj[0]
if press == 1:
# left button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_left",
event_type="double_press",
event_properties=None,
)
elif press == 2:
# right button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_right",
event_type="double_press",
event_properties=None,
)
elif press == 3:
# both left and right button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_left",
event_type="double_press",
event_properties=None,
)
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_right",
event_type="double_press",
event_properties=None,
)
elif device_type == "XMWXKG01LM":
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="double_press",
event_properties=None,
)
return {}
def obj4e0e(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Long Press"""
if device_type == "XMWXKG01YL":
press = xobj[0]
if press == 1:
# left button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_left",
event_type="long_press",
event_properties=None,
)
elif press == 2:
# right button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_right",
event_type="long_press",
event_properties=None,
)
elif press == 3:
# both left and right button
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_left",
event_type="long_press",
event_properties=None,
)
device.fire_event(
key=f"{str(EventDeviceKeys.BUTTON)}_right",
event_type="long_press",
event_properties=None,
)
elif device_type == "XMWXKG01LM":
device.fire_event(
key=EventDeviceKeys.BUTTON,
event_type="long_press",
event_properties=None,
)
return {}
def obj4e1c(
xobj: bytes, device: XiaomiBluetoothDeviceData, device_type: str
) -> dict[str, Any]:
"""Device reset"""
return {"device reset": True}
# Dataobject dictionary
# {dataObject_id: (converter}
xiaomi_dataobject_dict = {
0x0003: obj0003,
0x0006: obj0006,
0x0007: obj0007,
0x0008: obj0008,
0x0010: obj0010,
0x000A: obj000a,
0x000B: obj000b,
0x000F: obj000f,
0x1001: obj1001,
0x1004: obj1004,
0x1005: obj1005,
0x1006: obj1006,
0x1007: obj1007,
0x1008: obj1008,
0x1009: obj1009,
0x1010: obj1010,
0x1012: obj1012,
0x1013: obj1013,
0x1014: obj1014,
0x1015: obj1015,
0x1017: obj1017,
0x1018: obj1018,
0x1019: obj1019,
0x100A: obj100a,
0x100D: obj100d,
0x100E: obj100e,
0x101B: obj101b,
0x2000: obj2000,
0x3003: obj3003,
0x4803: obj4803,
0x4804: obj4804,
0x4805: obj4805,
0x4806: obj4806,
0x4818: obj4818,
0x4A01: obj4a01,
0x4A08: obj4a08,
0x4A0C: obj4a0c,
0x4A0D: obj4a0d,
0x4A0E: obj4a0e,
0x4A0F: obj4a0f,
0x4A12: obj4a12,
0x4A13: obj4a13,
0x4A1A: obj4a1a,
0x4C01: obj4c01,
0x4C02: obj4c02,
0x4C03: obj4c03,
0x4C08: obj4c08,
0x4C14: obj4c14,
0x4E01: obj4e01,
0x4E1C: obj4e1c,
0x4E0C: obj4e0c,
0x4E0D: obj4e0d,
0x4E0E: obj4e0e,
}
def decode_temps(packet_value: int) -> float:
"""Decode potential negative temperatures."""
# https://github.com/Thrilleratplay/XiaomiWatcher/issues/2
if packet_value & 0x800000:
return float((packet_value ^ 0x800000) / -10000)
return float(packet_value / 10000)
def decode_temps_probes(packet_value: int) -> float:
"""Filter potential negative temperatures."""
if packet_value < 0:
return 0.0
return float(packet_value / 100)
class XiaomiBluetoothDeviceData(BluetoothData):
"""Data for Xiaomi BLE sensors."""
def __init__(self, bindkey: bytes | None = None) -> None:
super().__init__()
self.set_bindkey(bindkey)
# Data that we know how to parse but don't yet map to the SensorData model.
self.unhandled: dict[str, Any] = {}
# The type of encryption to expect, based on flags in the bluetooth
# frame.
self.encryption_scheme = EncryptionScheme.NONE
# If true then we have used the provided encryption key to decrypt at least
# one payload.
# If false then we have either not seen an encrypted payload, the key is wrong
# or encryption is not in use
self.bindkey_verified = False
# If True then the decryption has failed or has not been verified yet.
# If False then the decryption has succeeded.
self.decryption_failed = True
# If this is True, then we have not seen an advertisement with a payload
# Until we see a payload, we can't tell if this device is encrypted or not
self.pending = True
# The last service_info we saw that had a payload
# We keep this to help in reauth flows where we want to reprocess and old
# value with a new bindkey.
self.last_service_info: BluetoothServiceInfo | None = None
# If this is True, the device is not sending advertisements
# in a regular interval
self.sleepy_device = False
def set_bindkey(self, bindkey: bytes | None) -> None:
"""Set the bindkey."""
if bindkey:
if len(bindkey) == 12:
# MiBeacon v2/v3 bindkey (requires 4 additional (fixed) bytes)
bindkey = b"".join(
[bindkey[0:6], bytes.fromhex("8d3d3c97"), bindkey[6:]]
)
elif len(bindkey) == 16:
self.cipher: AESCCM | None = AESCCM(bindkey, tag_length=4)
else:
self.cipher = None
self.bindkey = bindkey
def supported(self, data: BluetoothServiceInfo) -> bool:
if not super().supported(data):
return False
return True
def _start_update(self, service_info: BluetoothServiceInfo) -> None:
"""Update from BLE advertisement data."""
_LOGGER.debug("Parsing Xiaomi BLE advertisement data: %s", service_info)
for uuid, data in service_info.service_data.items():
if uuid == SERVICE_MIBEACON:
if self._parse_xiaomi(service_info, service_info.name, data):
self.last_service_info = service_info
elif uuid == SERVICE_HHCCJCY10:
if self._parse_hhcc(service_info, data):
self.last_service_info = service_info
elif uuid == SERVICE_SCALE1:
if self._parse_scale_v1(service_info, data):
self.last_service_info = service_info
elif uuid == SERVICE_SCALE2:
if self._parse_scale_v2(service_info, data):
self.last_service_info = service_info
def _parse_hhcc(self, service_info: BluetoothServiceInfo, data: bytes) -> bool:
"""Parser for Pink version of HHCCJCY10."""
if len(data) != 9:
return False
identifier = short_address(service_info.address)
self.set_title(f"Plant Sensor {identifier} (HHCCJCY10)")
self.set_device_name(f"Plant Sensor {identifier}")
self.set_device_type("HHCCJCY10")
self.set_device_manufacturer("HHCC Plant Technology Co. Ltd")
xvalue_1 = data[0:3]
(moist, temp) = struct.unpack(">BH", xvalue_1)
self.update_predefined_sensor(SensorLibrary.TEMPERATURE__CELSIUS, temp / 10)
self.update_predefined_sensor(SensorLibrary.MOISTURE__PERCENTAGE, moist)
xvalue_2 = data[3:6]
(illu,) = struct.unpack(">i", b"\x00" + xvalue_2)
self.update_predefined_sensor(SensorLibrary.LIGHT__LIGHT_LUX, illu)
xvalue_3 = data[6:9]
(batt, cond) = struct.unpack(">BH", xvalue_3)
self.update_predefined_sensor(SensorLibrary.BATTERY__PERCENTAGE, batt)
self.update_predefined_sensor(SensorLibrary.CONDUCTIVITY__CONDUCTIVITY, cond)
return True
def _parse_xiaomi(
self, service_info: BluetoothServiceInfo, name: str, data: bytes
) -> bool:
"""Parser for Xiaomi sensors"""
# check for adstruc length
i = 5 # till Frame Counter
msg_length = len(data)
if msg_length < i:
_LOGGER.debug("Invalid data length (initial check), adv: %s", data.hex())
return False
mac_readable = service_info.address
source_mac = bytes.fromhex(mac_readable.replace(":", ""))
# extract frame control bits
frctrl = data[0] + (data[1] << 8)
frctrl_mesh = (frctrl >> 7) & 1 # mesh device
frctrl_version = frctrl >> 12 # version
frctrl_auth_mode = (frctrl >> 10) & 3
frctrl_solicited = (frctrl >> 9) & 1
frctrl_registered = (frctrl >> 8) & 1
frctrl_object_include = (frctrl >> 6) & 1
frctrl_capability_include = (frctrl >> 5) & 1
frctrl_mac_include = (frctrl >> 4) & 1 # check for MAC address in data
frctrl_is_encrypted = (frctrl >> 3) & 1 # check for encryption being used
frctrl_request_timing = frctrl & 1 # old version
# Check that device is not of mesh type
if frctrl_mesh != 0:
_LOGGER.debug(
"Device is a mesh type device, which is not supported. Data: %s",
data.hex(),
)
return False
# Check that version is 2 or higher
if frctrl_version < 2:
_LOGGER.debug(
"Device is using old data format, which is not supported. Data: %s",
data.hex(),
)
return False
# Check that MAC in data is the same as the source MAC
if frctrl_mac_include != 0:
i += 6
if msg_length < i:
_LOGGER.debug("Invalid data length (in MAC check), adv: %s", data.hex())
return False
xiaomi_mac_reversed = data[5:11]
xiaomi_mac = xiaomi_mac_reversed[::-1]
if xiaomi_mac != source_mac:
_LOGGER.debug(
"MAC address doesn't match data frame. Expected: %s, Got: %s",
to_mac(xiaomi_mac),
to_mac(source_mac),
)
return False
else:
xiaomi_mac = source_mac
# determine the device type
device_id = data[2] + (data[3] << 8)
try:
device = DEVICE_TYPES[device_id]
except KeyError:
_LOGGER.info(
"BLE ADV from UNKNOWN Xiaomi device: MAC: %s, ADV: %s",
source_mac,
data.hex(),
)
_LOGGER.debug("Unknown Xiaomi device found. Data: %s", data.hex())
return False
device_type = device.model
self.device_id = device_id
self.device_type = device_type
# set to True if the device is not sending regular BLE advertisements
self.sleepy_device = device_type in SLEEPY_DEVICE_MODELS
packet_id = data[4]
sinfo = "MiVer: " + str(frctrl_version)
sinfo += ", DevID: " + hex(device_id) + " : " + device_type
sinfo += ", FnCnt: " + str(packet_id)
if frctrl_request_timing != 0:
sinfo += ", Request timing"
if frctrl_registered != 0:
sinfo += ", Registered and bound"
else:
sinfo += ", Not bound"
if frctrl_solicited != 0:
sinfo += ", Request APP to register and bind"
if frctrl_auth_mode == 0:
sinfo += ", Old version certification"
elif frctrl_auth_mode == 1:
sinfo += ", Safety certification"
elif frctrl_auth_mode == 2:
sinfo += ", Standard certification"
# check for capability byte present
if frctrl_capability_include != 0:
i += 1
if msg_length < i:
_LOGGER.debug(
"Invalid data length (in capability check), adv: %s", data.hex()
)
return False
capability_types = data[i - 1]
sinfo += ", Capability: " + hex(capability_types)
if (capability_types & 0x20) != 0:
i += 1
if msg_length < i:
_LOGGER.debug(
"Invalid data length (in capability type check), adv: %s",
data.hex(),
)
return False
capability_io = data[i - 1]
sinfo += ", IO: " + hex(capability_io)
identifier = short_address(service_info.address)
self.set_title(f"{device.name} {identifier} ({device.model})")
self.set_device_name(f"{device.name} {identifier}")
self.set_device_type(device.model)
self.set_device_manufacturer(device.manufacturer)
# check that data contains object
if frctrl_object_include == 0:
# data does not contain Object
_LOGGER.debug("Advertisement doesn't contain payload, adv: %s", data.hex())
return False
self.pending = False
# check for encryption
if frctrl_is_encrypted != 0:
sinfo += ", Encryption"
firmware = "Xiaomi (MiBeacon V" + str(frctrl_version) + " encrypted)"
if frctrl_version <= 3:
self.encryption_scheme = EncryptionScheme.MIBEACON_LEGACY
payload = self._decrypt_mibeacon_legacy(data, i, xiaomi_mac)
else:
self.encryption_scheme = EncryptionScheme.MIBEACON_4_5
payload = self._decrypt_mibeacon_v4_v5(data, i, xiaomi_mac)
else: # No encryption
# check minimum advertisement length with data
firmware = "Xiaomi (MiBeacon V" + str(frctrl_version) + ")"
sinfo += ", No encryption"
if msg_length < i + 3:
_LOGGER.debug(
"Invalid data length (in non-encrypted data), adv: %s",
data.hex(),
)
return False
payload = data[i:]
self.set_device_sw_version(firmware)
if payload is not None:
sinfo += ", Object data: " + payload.hex()
# loop through parse_xiaomi payload
payload_start = 0
payload_length = len(payload)
# assume that the data may have several values of different types
while payload_length >= payload_start + 3:
obj_typecode = payload[payload_start] + (
payload[payload_start + 1] << 8
)
obj_length = payload[payload_start + 2]
next_start = payload_start + 3 + obj_length
if payload_length < next_start:
# The payload segments are corrupted - if this is legacy encryption
# then the key is probably just wrong
# V4/V5 encryption has an authentication tag, so we don't apply the
# same restriction there.
if self.encryption_scheme == EncryptionScheme.MIBEACON_LEGACY:
if self.decryption_failed is True:
# we only ask for reautentification
# till the decryption has failed twice.
self.bindkey_verified = False
else:
self.decryption_failed = True
_LOGGER.debug(
"Invalid payload data length, payload: %s", payload.hex()
)
break
this_start = payload_start + 3
dobject = payload[this_start:next_start]
if (
dobject
and obj_length != 0
or hex(obj_typecode)
in ["0x4a0c", "0x4a0d", "0x4a0e", "0x4e0c", "0x4e0d", "0x4e0e"]
):
resfunc = xiaomi_dataobject_dict.get(obj_typecode, None)
if resfunc:
self.unhandled.update(resfunc(dobject, self, device_type))
else:
_LOGGER.info(
"%s, UNKNOWN dataobject in payload! Adv: %s",
sinfo,
data.hex(),
)
payload_start = next_start
return True
def _parse_scale_v1(self, service_info: BluetoothServiceInfo, data: bytes) -> bool:
if len(data) != 10:
return False
uuid16 = (data[3] << 8) | data[2]
identifier = short_address(service_info.address)
self.device_id = uuid16
self.set_title(f"Mi Smart Scale ({identifier})")
self.set_device_name(f"Mi Smart Scale ({identifier})")
self.set_device_type("XMTZC01HM/XMTZC04HM")
self.set_device_manufacturer("Xiaomi")
self.pending = False
self.sleepy_device = True
control_byte = data[0]
mass = float(int.from_bytes(data[1:3], byteorder="little"))
mass_in_pounds = bool(int(control_byte & (1 << 0)))
mass_in_catty = bool(int(control_byte & (1 << 4)))
mass_in_kilograms = not mass_in_catty and not mass_in_pounds
mass_stabilized = bool(int(control_byte & (1 << 5)))
mass_removed = bool(int(control_byte & (1 << 7)))
if mass_in_kilograms:
# sensor advertises kg * 200
mass /= 200
elif mass_in_pounds:
# sensor advertises lbs * 100, conversion to kg (1 lbs = 0.45359237 kg)
mass *= 0.0045359237
else:
# sensor advertises catty * 100, conversion to kg (1 catty = 0.5 kg)
mass *= 0.005
self.update_predefined_sensor(
SensorLibrary.MASS_NON_STABILIZED__MASS_KILOGRAMS, mass
)
if mass_stabilized and not mass_removed:
self.update_predefined_sensor(SensorLibrary.MASS__MASS_KILOGRAMS, mass)
return True
def _parse_scale_v2(self, service_info: BluetoothServiceInfo, data: bytes) -> bool:
if len(data) != 13:
return False
uuid16 = (data[3] << 8) | data[2]
identifier = short_address(service_info.address)
self.device_id = uuid16
self.set_title(f"Mi Body Composition Scale ({identifier})")
self.set_device_name(f"Mi Body Composition Scale ({identifier})")
self.set_device_type("XMTZC02HM/XMTZC05HM/NUN4049CN")
self.set_device_manufacturer("Xiaomi")
self.pending = False
self.sleepy_device = True
control_bytes = data[:2]
# skip bytes containing date and time
impedance = int.from_bytes(data[9:11], byteorder="little")
mass = float(int.from_bytes(data[11:13], byteorder="little"))
# Decode control bytes
control_flags = "".join([bin(byte)[2:].zfill(8) for byte in control_bytes])
mass_in_pounds = bool(int(control_flags[7]))
mass_in_catty = bool(int(control_flags[9]))
mass_in_kilograms = not mass_in_catty and not mass_in_pounds
mass_stabilized = bool(int(control_flags[10]))
mass_removed = bool(int(control_flags[8]))
impedance_stabilized = bool(int(control_flags[14]))
if mass_in_kilograms:
# sensor advertises kg * 200
mass /= 200
elif mass_in_pounds:
# sensor advertises lbs * 100, conversion to kg (1 lbs = 0.45359237 kg)
mass *= 0.0045359237
else:
# sensor advertises catty * 100, conversion to kg (1 catty = 0.5 kg)
mass *= 0.005
self.update_predefined_sensor(
SensorLibrary.MASS_NON_STABILIZED__MASS_KILOGRAMS, mass
)
if mass_stabilized and not mass_removed:
self.update_predefined_sensor(SensorLibrary.MASS__MASS_KILOGRAMS, mass)
if impedance_stabilized:
self.update_predefined_sensor(SensorLibrary.IMPEDANCE__OHM, impedance)
return True
def _decrypt_mibeacon_v4_v5(
self, data: bytes, i: int, xiaomi_mac: bytes
) -> bytes | None:
"""decrypt MiBeacon v4/v5 encrypted advertisements"""
# check for minimum length of encrypted advertisement
if len(data) < i + 9:
_LOGGER.debug("Invalid data length (for decryption), adv: %s", data.hex())
return None
if not self.bindkey:
self.bindkey_verified = False
_LOGGER.debug("Encryption key not set and adv is encrypted")
return None
if not self.bindkey or len(self.bindkey) != 16:
self.bindkey_verified = False
_LOGGER.error("Encryption key should be 16 bytes (32 characters) long")
return None
nonce = b"".join([xiaomi_mac[::-1], data[2:5], data[-7:-4]])
associated_data = b"\x11"
mic = data[-4:]
encrypted_payload = data[i:-7]
assert self.cipher is not None # nosec
# decrypt the data
try:
decrypted_payload = self.cipher.decrypt(
nonce, encrypted_payload + mic, associated_data
)
except InvalidTag as error:
if self.decryption_failed is True:
# we only ask for reautentification till
# the decryption has failed twice.
self.bindkey_verified = False
else:
self.decryption_failed = True
_LOGGER.warning("Decryption failed: %s", error)
_LOGGER.debug("mic: %s", mic.hex())
_LOGGER.debug("nonce: %s", nonce.hex())
_LOGGER.debug("encrypted payload: %s", encrypted_payload.hex())
return None
if decrypted_payload is None:
self.bindkey_verified = False
_LOGGER.error(
"Decryption failed for %s, decrypted payload is None",
to_mac(xiaomi_mac),
)
return None
self.decryption_failed = False
self.bindkey_verified = True
return decrypted_payload
def _decrypt_mibeacon_legacy(
self, data: bytes, i: int, xiaomi_mac: bytes
) -> bytes | None:
"""decrypt MiBeacon v2/v3 encrypted advertisements"""
# check for minimum length of encrypted advertisement
if len(data) < i + 7:
_LOGGER.debug("Invalid data length (for decryption), adv: %s", data.hex())
return None
if not self.bindkey:
self.bindkey_verified = False
_LOGGER.debug("Encryption key not set and adv is encrypted")
return None
if len(self.bindkey) != 16:
self.bindkey_verified = False
_LOGGER.error("Encryption key should be 12 bytes (24 characters) long")
return None
nonce = b"".join([data[0:5], data[-4:-1], xiaomi_mac[::-1][:-1]])
encrypted_payload = data[i:-4]
# cryptography can't decrypt a message without authentication
# so we have to use Cryptodome
associated_data = b"\x11"
cipher = AES.new(self.bindkey, AES.MODE_CCM, nonce=nonce, mac_len=4)
cipher.update(associated_data)
assert cipher is not None # nosec
# decrypt the data
# note that V2/V3 encryption will often pass the decryption process with a
# wrong encryption key, resulting in useless data, and we won't be able
# to verify this, as V2/V3 encryption does not use a tag to verify
# the decrypted data. This will be filtered as wrong data length
# during the conversion of the payload to sensor data.
try:
decrypted_payload = cipher.decrypt(encrypted_payload)
except ValueError as error:
self.bindkey_verified = False
_LOGGER.warning("Decryption failed: %s", error)
_LOGGER.debug("nonce: %s", nonce.hex())
_LOGGER.debug("encrypted payload: %s", encrypted_payload.hex())
return None
if decrypted_payload is None:
self.bindkey_verified = False
_LOGGER.warning(
"Decryption failed for %s, decrypted payload is None",
to_mac(xiaomi_mac),
)
return None
self.bindkey_verified = True
return decrypted_payload
def poll_needed(
self, service_info: BluetoothServiceInfo, last_poll: float | None
) -> bool:
"""
This is called every time we get a service_info for a device. It means the
device is working and online. If 24 hours has passed, it may be a good
time to poll the device.
"""
if self.pending:
# Never need to poll if we are pending as we don't even know what
# kind of device we are
return False
if self.device_id not in [0x03BC, 0x0098]:
return False
return not last_poll or last_poll > TIMEOUT_1DAY
async def async_poll(self, ble_device: BLEDevice) -> SensorUpdate:
"""
Poll the device to retrieve any values we can't get from passive listening.
"""
if self.device_id in [0x03BC, 0x0098]:
client = await establish_connection(
BleakClient, ble_device, ble_device.address
)
try:
battery_char = client.services.get_characteristic(
CHARACTERISTIC_BATTERY
)
payload = await client.read_gatt_char(battery_char)
finally:
await client.disconnect()
self.set_device_sw_version(payload[2:].decode("utf-8"))
self.update_predefined_sensor(SensorLibrary.BATTERY__PERCENTAGE, payload[0])
return self._finish_update()
python-xiaomi-ble-0.30.2/src/xiaomi_ble/py.typed 0000664 0000000 0000000 00000000000 14650172074 0021533 0 ustar 00root root 0000000 0000000