Add support for PTX-F1-Display and enhance debugging tools#1533
Add support for PTX-F1-Display and enhance debugging tools#1533M1k0t0 wants to merge 8 commits intocustom-components:masterfrom
Conversation
Add device mapping for PTX-F1-Display (https://home.miot-spec.com/spec/090615.remote.x1swd) Internal type: 0x64C5. Unable to retrieve the battery percentage right now since the packet I dumped does not include it. Add debug logging for duplicate packets, lower-priority advertisements, and missing decryption keys to aid troubleshooting.
This reverts commit caae392.
Add doc file for the Xiaomi PTX F1 4-Button Wireless Switch (Display Version).
Add tools/anonymize_mibeacon_v5.py: a CLI script to anonymize Xiaomi MiBeacon V4/V5 encrypted BLE advertisements.
This reverts commit 15847a5.
This reverts commit 9f9e498.
There was a problem hiding this comment.
Pull request overview
Adds recognition and parsing for the Xiaomi PTX-F1-Display (MiBeacon V5 encrypted) device type, plus supporting docs/assets and a helper tool intended to aid debugging/trace generation.
Changes:
- Add new Xiaomi device type ID mapping and decode paths for PTX-F1-Display (including button events and new data objects).
- Extend constants and tests to cover PTX-F1-Display parsing behavior.
- Add device documentation + image asset, and a new MiBeacon anonymization tool script.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
custom_components/ble_monitor/ble_parser/xiaomi.py |
Adds PTX-F1-Display type mapping, new object converters, and extra debug logging in duplicate/priority/key-error paths. |
custom_components/ble_monitor/const.py |
Registers PTX-F1-Display in measurement/manufacturer dictionaries for entity creation. |
custom_components/ble_monitor/test/test_xiaomi_parser.py |
Adds PTX-F1-Display tests for a button press payload and a humidity payload. |
docs/_devices/Xiaomi_PTX_F1_Display.md |
Adds a new device documentation entry for PTX F1 Display. |
docs/assets/images/PTX_F1_Display.webp |
Adds the device image used by the docs page. |
tools/anonymize_mibeacon_v5.py |
Adds a standalone script to anonymize MiBeacon V4/V5 encrypted raw advertisements (MAC/key replacement + re-encryption). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if self.filter_duplicates is True: | ||
| _LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex()) | ||
| return None |
There was a problem hiding this comment.
The new debug log computes data.hex() unconditionally; .hex() is evaluated even when debug logging is disabled, which can add measurable overhead in a hot path (duplicate filtering). Consider guarding with _LOGGER.isEnabledFor(logging.DEBUG) (or equivalent) before building the hex string; same applies to the other newly added debug statements below.
| temp = xobj[0] | ||
| return {"temperature": temp} |
There was a problem hiding this comment.
obj605d assumes the temperature payload is at least 1 byte and silently ignores any extra bytes. To avoid mis-parsing (and to prevent surprises if the object definition changes), add an explicit length check (and return {} / None on unexpected lengths), similar to other obj* converters in this file.
| temp = xobj[0] | |
| return {"temperature": temp} | |
| if len(xobj) == 1: | |
| temp = xobj[0] | |
| return {"temperature": temp} | |
| else: | |
| return {} |
| 'XMWXKG01YL' : [["rssi"], ["two btn switch left", "two btn switch right"], []], | ||
| 'XMWXKG01LM' : [["battery", "rssi"], ["one btn switch"], []], | ||
| 'PTX' : [["battery", "rssi"], ["one btn switch"], []], | ||
| 'PTX-F1-Display' : [["temperature", "humidity", "battery", "rssi"], ["four btn switch 1", "four btn switch 2", "four btn switch 3", "four btn switch 4"], []], |
There was a problem hiding this comment.
This new device entry advertises a battery measurement, but the accompanying device documentation in this PR says battery percentage can't be retrieved and doesn't list battery as a broadcasted property. Either remove battery here or update the docs/parser so the battery value is actually produced, to avoid creating a permanent 'unknown' battery entity.
| 'PTX-F1-Display' : [["temperature", "humidity", "battery", "rssi"], ["four btn switch 1", "four btn switch 2", "four btn switch 3", "four btn switch 4"], []], | |
| 'PTX-F1-Display' : [["temperature", "humidity", "rssi"], ["four btn switch 1", "four btn switch 2", "four btn switch 3", "four btn switch 4"], []], |
| 'XMWXKG01YL' : 'Xiaomi', | ||
| 'XMWXKG01LM' : 'Xiaomi', | ||
| 'PTX' : 'Xiaomi', | ||
| "PTX-F1-Display" : 'Xiaomi', |
There was a problem hiding this comment.
The device key uses double quotes while the surrounding entries consistently use single quotes. For consistency (and to minimize noisy diffs in the future), switch this key to single quotes.
| "PTX-F1-Display" : 'Xiaomi', | |
| 'PTX-F1-Display' : 'Xiaomi', |
| note: returns 'short press', 'double press' or 'long press' | ||
| - property: four btn switch 2 | ||
| note: returns 'short press', 'double press' or 'long press' | ||
| - property: four btn switch 3 | ||
| note: returns 'short press', 'double press' or 'long press' | ||
| - property: four btn switch 4 | ||
| note: returns 'short press', 'double press' or 'long press' |
There was a problem hiding this comment.
The notes for four btn switch 1 imply the property returns press types (short/double/long), but the parser emits four btn switch X: "toggle" and puts the press type in button switch (see the added tests in this PR). Please update these property notes (and the other 3 button notes below) to match the actual emitted fields/values.
| note: returns 'short press', 'double press' or 'long press' | |
| - property: four btn switch 2 | |
| note: returns 'short press', 'double press' or 'long press' | |
| - property: four btn switch 3 | |
| note: returns 'short press', 'double press' or 'long press' | |
| - property: four btn switch 4 | |
| note: returns 'short press', 'double press' or 'long press' | |
| note: always "toggle"; actual press type ('short press', 'double press', 'long press') is reported via the 'button switch' property | |
| - property: four btn switch 2 | |
| note: always "toggle"; actual press type ('short press', 'double press', 'long press') is reported via the 'button switch' property | |
| - property: four btn switch 3 | |
| note: always "toggle"; actual press type ('short press', 'double press', 'long press') is reported via the 'button switch' property | |
| - property: four btn switch 4 | |
| note: always "toggle"; actual press type ('short press', 'double press', 'long press') is reported via the 'button switch' property |
| assert sensor_msg["humidity"] == 39 | ||
| assert sensor_msg["rssi"] == -60 | ||
| assert sensor_msg["local_name"] == "" | ||
|
|
There was a problem hiding this comment.
This PR adds parsing support for the new temperature data object (0x605d / obj605d), but there isn't a corresponding test covering temperature decoding for PTX-F1-Display. Adding a temperature sample test would help prevent regressions and validate the expected units/format.
| def test_Xiaomi_PTX_F1_Display_temperature(self): | |
| """Test Xiaomi parser for PTX-F1-Display temperature.""" | |
| self.aeskeys = {} | |
| data_string = "043E29020100006655443322111D020106191695FE5859C56433665544332211D67B54550C01001D8F98BBC4".replace(" ", "") | |
| data = bytes(bytearray.fromhex(data_string)) | |
| aeskey = "00112233445566778899aabbccddeeff" | |
| is_ext_packet = True if data[3] == 0x0D else False | |
| mac = (data[8 if is_ext_packet else 7:14 if is_ext_packet else 13])[::-1] | |
| mac_address = mac.hex() | |
| p_mac = bytes.fromhex(mac_address.replace(":", "").lower()) | |
| p_key = bytes.fromhex(aeskey.lower()) | |
| self.aeskeys[p_mac] = p_key | |
| # pylint: disable=unused-variable | |
| ble_parser = BleParser(aeskeys=self.aeskeys) | |
| sensor_msg, tracker_msg = ble_parser.parse_raw_data(data) | |
| assert sensor_msg["firmware"] == "Xiaomi (MiBeacon V5 encrypted)" | |
| assert sensor_msg["type"] == "PTX-F1-Display" | |
| assert sensor_msg["mac"] == "112233445566" | |
| assert sensor_msg["packet"] == 51 | |
| assert sensor_msg["data"] | |
| # Validate that temperature is decoded and numeric; exact value depends on obj605d payload. | |
| assert "temperature" in sensor_msg | |
| assert isinstance(sensor_msg["temperature"], (int, float)) | |
| assert sensor_msg["rssi"] == -60 | |
| assert sensor_msg["local_name"] == "" |
| def _parse_ad_structures(raw: bytes) -> tuple[list[AdStructure], int, int, bool]: | ||
| is_ext_packet = raw[3] == 0x0D | ||
| adpayload_start = 29 if is_ext_packet else 14 | ||
| adpayload_size = raw[adpayload_start - 1] | ||
| structures: list[AdStructure] = [] |
There was a problem hiding this comment.
The script directly indexes into raw/service_data (e.g., raw[3], raw[adpayload_start-1], service_data[4]) without validating the minimum lengths first. For malformed/short --raw inputs this will raise IndexError and produce an unhelpful traceback; consider adding explicit length checks up front and raising a clear ValueError describing the expected format/length.
No description provided.