Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions custom_components/ble_monitor/ble_parser/xiaomi.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@
0x3E17: "KS1BP",
0x3BD5: "MJTZC01YM",
0x50FB: "ES3",
0x5DB1: "MBS17"
0x5DB1: "MBS17",
0x64C5: "PTX-F1-Display"
}

# Structured objects for data conversions
Expand Down Expand Up @@ -731,6 +732,19 @@ def obj4803(xobj):
return {"battery": batt}


def obj605d(xobj):
"""Temperature"""
if len(xobj) == 1:
temp = xobj[0]
return {"temperature": temp}
else:
return {}


def obj6012(xobj):
"""Humidity"""
return obj4802(xobj)

def obj4804(xobj):
"""Opening status"""
opening_state = xobj[0]
Expand Down Expand Up @@ -1047,6 +1061,8 @@ def obj4e0c(xobj, device_type):
"one btn switch": "toggle",
"button switch": "single press",
}
elif device_type == "PTX-F1-Display":
return obj560c(xobj, "KS1BP")
else:
result = {}
return result
Expand Down Expand Up @@ -1077,6 +1093,8 @@ def obj4e0d(xobj, device_type):
"one btn switch": "toggle",
"button switch": "double press",
}
elif device_type == "PTX-F1-Display":
return obj560d(xobj, "KS1BP")
else:
result = {}
return result
Expand Down Expand Up @@ -1107,6 +1125,8 @@ def obj4e0e(xobj, device_type):
"one btn switch": "toggle",
"button switch": "long press",
}
elif device_type == "PTX-F1-Display":
return obj560e(xobj, "KS1BP")
else:
result = {}
return result
Expand Down Expand Up @@ -1391,7 +1411,9 @@ def obj6e16(xobj):
0x560d: obj560d,
0x560e: obj560e,
0x5a16: obj5a16,
0x6E16: obj6e16,
0x605d: obj605d,
0x6012: obj6012,
0x6E16: obj6e16
}


Expand Down Expand Up @@ -1495,6 +1517,8 @@ def parse_xiaomi(self, data: bytes, mac: bytes):
# only process messages with same priority that have a unique packet id
if prev_packet == packet_id:
if self.filter_duplicates is True:
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex())
return None
Comment on lines 1519 to 1522
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
else:
pass
Expand All @@ -1504,11 +1528,15 @@ def parse_xiaomi(self, data: bytes, mac: bytes):
# do not process advertisements with lower priority (ATC advertisements will be used instead)
prev_adv_priority -= 1
self.adv_priority[mac] = prev_adv_priority
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Lower priority advertisement received, not processing. Data: %s", data.hex())
return None
else:
if prev_packet == packet_id:
if self.filter_duplicates is True:
# only process messages with highest priority and messages with unique packet id
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex())
return None
self.lpacket_ids[mac] = packet_id

Expand Down Expand Up @@ -1591,7 +1619,7 @@ def parse_xiaomi(self, data: bytes, mac: bytes):
"0x4e0e",
"0x560c",
"0x560d",
"0x560e"
"0x560e",
]:
result.update(resfunc(dobject, device_type))
else:
Expand Down Expand Up @@ -1620,6 +1648,8 @@ def decrypt_mibeacon_v4_v5(self, data, i, mac):
if mac not in self.no_key_message:
_LOGGER.error("No encryption key found for device with MAC %s", to_mac(mac))
self.no_key_message.append(mac)
if _LOGGER.isEnabledFor(logging.DEBUG):
_LOGGER.debug("Key error for device with MAC %s, cannot decrypt data. Data: %s", to_mac(mac), data.hex())
return None

nonce = b"".join([mac[::-1], data[6:9], data[-7:-4]])
Expand Down
2 changes: 2 additions & 0 deletions custom_components/ble_monitor/const.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -2120,6 +2120,7 @@ class BLEMonitorBinarySensorEntityDescription(
'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", "rssi"], ["four btn switch 1", "four btn switch 2", "four btn switch 3", "four btn switch 4"], []],
'YLAI003' : [["rssi", "battery"], ["button"], []],
'YLYK01YL' : [["rssi"], ["remote"], ["remote single press", "remote long press"]],
'YLYK01YL-FANCL' : [["rssi"], ["fan remote"], []],
Expand Down Expand Up @@ -2280,6 +2281,7 @@ class BLEMonitorBinarySensorEntityDescription(
'XMWXKG01YL' : 'Xiaomi',
'XMWXKG01LM' : 'Xiaomi',
'PTX' : 'Xiaomi',
'PTX-F1-Display' : 'Xiaomi',
'SV40' : 'Lockin',
'SU001-T' : 'Petoneer',
'ATC' : 'ATC',
Expand Down
84 changes: 84 additions & 0 deletions custom_components/ble_monitor/test/test_xiaomi_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,90 @@ def test_Xiaomi_PTX(self):
assert sensor_msg["button switch"] == "single press"
assert sensor_msg["rssi"] == -52

def test_Xiaomi_PTX_F1_Display_single_press(self):
"""Test Xiaomi parser for PTX-F1-Display single press on switch 4."""
self.aeskeys = {}
data_string = "043E3E0201000066554433221132020106191695FE5859C5642D6655443322112D9475BB270100AB9CBFA914093039303631352E72656D6F74652E7831737764C6".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"] == 45
assert sensor_msg["data"]
assert sensor_msg["four btn switch 4"] == "toggle"
assert sensor_msg["button switch"] == "single press"
assert sensor_msg["rssi"] == -58
assert sensor_msg["local_name"] == "090615.remote.x1swd"

def test_Xiaomi_PTX_F1_Display_temperature(self):
"""Test Xiaomi parser for PTX-F1-Display temperature."""
self.aeskeys = {}
data_string = (
"043E29020100006655443322111D020106191695FE5859C56433665544332211997B546B0C01009089C35AC4"
).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"]
assert sensor_msg["temperature"] == 25
assert sensor_msg["rssi"] == -60
assert sensor_msg["local_name"] == ""

def test_Xiaomi_PTX_F1_Display_humidity(self):
"""Test Xiaomi parser for PTX-F1-Display humidity."""
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"]
assert sensor_msg["humidity"] == 39
assert sensor_msg["rssi"] == -60
assert sensor_msg["local_name"] == ""

Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"] == ""

Copilot uses AI. Check for mistakes.
def test_Xiaomi_XMPIRO2SXS(self):
"""Test Xiaomi parser for XMPIRO2SXS."""
self.aeskeys = {}
Expand Down
32 changes: 32 additions & 0 deletions docs/_devices/Xiaomi_PTX_F1_Display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
manufacturer: Xiaomi
name: PTX F1 4-Button Wireless Switch (Display Version)
model: F1
image: PTX_F1_Display.webp
physical_description:
broadcasted_properties:
- temperature
- humidity
- four btn switch 1
- four btn switch 2
- four btn switch 3
- four btn switch 4
Comment thread
M1k0t0 marked this conversation as resolved.
- button switch
- rssi
broadcasted_property_notes:
- property: four btn switch 1
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
broadcast_rate:
active_scan:
encryption_key: true
custom_firmware:
notes:
- Unable to retrieve the battery percentage right now. (need help!)
- The switch sensor state will return to `no press` after the time set with the [reset_timer](configuration_params#reset_timer) option. It is advised to change the reset time to 1 second (default = 35 seconds).
---
Binary file added docs/assets/images/PTX_F1_Display.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading