From caae39292c12b4c0e3ef34d3439118023cad3ad1 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:04:01 -0400 Subject: [PATCH 01/12] Add PTX-F1-Display (090615.remote.x1swd) support and debug logs 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. --- .../ble_monitor/ble_parser/xiaomi.py | 32 +++++++++++++++++-- custom_components/ble_monitor/const.py | 2 ++ 2 files changed, 31 insertions(+), 3 deletions(-) mode change 100755 => 100644 custom_components/ble_monitor/ble_parser/xiaomi.py mode change 100755 => 100644 custom_components/ble_monitor/const.py diff --git a/custom_components/ble_monitor/ble_parser/xiaomi.py b/custom_components/ble_monitor/ble_parser/xiaomi.py old mode 100755 new mode 100644 index 1eeb49d2..4f6d15c0 --- a/custom_components/ble_monitor/ble_parser/xiaomi.py +++ b/custom_components/ble_monitor/ble_parser/xiaomi.py @@ -86,7 +86,8 @@ 0x3E17: "KS1BP", 0x3BD5: "MJTZC01YM", 0x50FB: "ES3", - 0x5DB1: "MBS17" + 0x5DB1: "MBS17", + 0x64C5: "PTX-F1-Display" } # Structured objects for data conversions @@ -730,6 +731,18 @@ def obj4803(xobj): batt = xobj[0] return {"battery": batt} +def obj605d(xobj): + """Temperature""" + temp = xobj[0] + return {"temperature": temp} + +def obj6012(xobj): + """Humidity""" + return obj4802(xobj) + +def obj6003(xobj): + """Battery""" + return obj4803(xobj) def obj4804(xobj): """Opening status""" @@ -1041,6 +1054,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 @@ -1071,6 +1086,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 @@ -1101,6 +1118,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 @@ -1385,7 +1404,10 @@ def obj6e16(xobj): 0x560d: obj560d, 0x560e: obj560e, 0x5a16: obj5a16, - 0x6E16: obj6e16, + 0x605d: obj605d, + 0x6012: obj6012, + 0x6003: obj6003, + 0x6E16: obj6e16 } @@ -1489,6 +1511,7 @@ 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: + _LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex()) return None else: pass @@ -1498,11 +1521,13 @@ 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 + _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 + _LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex()) return None self.lpacket_ids[mac] = packet_id @@ -1585,7 +1610,7 @@ def parse_xiaomi(self, data: bytes, mac: bytes): "0x4e0e", "0x560c", "0x560d", - "0x560e" + "0x560e", ]: result.update(resfunc(dobject, device_type)) else: @@ -1614,6 +1639,7 @@ 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) + _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]]) diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py old mode 100755 new mode 100644 index fff0cd4e..8db19efb --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -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", "battery", "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"], []], @@ -2279,6 +2280,7 @@ class BLEMonitorBinarySensorEntityDescription( 'XMWXKG01YL' : 'Xiaomi', 'XMWXKG01LM' : 'Xiaomi', 'PTX' : 'Xiaomi', + "PTX-F1-Display" : 'Xiaomi', 'SV40' : 'Lockin', 'SU001-T' : 'Petoneer', 'ATC' : 'ATC', From c2799253178cef5bc8062512335a68e942a2889f Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:12:01 -0400 Subject: [PATCH 02/12] Revert "Add PTX-F1-Display (090615.remote.x1swd) support and debug logs" This reverts commit caae39292c12b4c0e3ef34d3439118023cad3ad1. --- custom_components/ble_monitor/ble_parser/xiaomi.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/custom_components/ble_monitor/ble_parser/xiaomi.py b/custom_components/ble_monitor/ble_parser/xiaomi.py index 4f6d15c0..1d8932cf 100644 --- a/custom_components/ble_monitor/ble_parser/xiaomi.py +++ b/custom_components/ble_monitor/ble_parser/xiaomi.py @@ -740,10 +740,6 @@ def obj6012(xobj): """Humidity""" return obj4802(xobj) -def obj6003(xobj): - """Battery""" - return obj4803(xobj) - def obj4804(xobj): """Opening status""" opening_state = xobj[0] @@ -1406,7 +1402,6 @@ def obj6e16(xobj): 0x5a16: obj5a16, 0x605d: obj605d, 0x6012: obj6012, - 0x6003: obj6003, 0x6E16: obj6e16 } From c7462dcb11c09b06546c92ccaaca7378b8c2b512 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:26:43 -0400 Subject: [PATCH 03/12] Add Xiaomi PTX F1 (Display) device docs and image Add doc file for the Xiaomi PTX F1 4-Button Wireless Switch (Display Version). --- docs/_devices/Xiaomi_PTX_F1_Display.md | 31 +++++++++++++++++++++++++ docs/assets/images/PTX_F1_Display.webp | Bin 0 -> 4502 bytes 2 files changed, 31 insertions(+) create mode 100644 docs/_devices/Xiaomi_PTX_F1_Display.md create mode 100644 docs/assets/images/PTX_F1_Display.webp diff --git a/docs/_devices/Xiaomi_PTX_F1_Display.md b/docs/_devices/Xiaomi_PTX_F1_Display.md new file mode 100644 index 00000000..d80adfc9 --- /dev/null +++ b/docs/_devices/Xiaomi_PTX_F1_Display.md @@ -0,0 +1,31 @@ +--- +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 + - rssi +broadcasted_property_notes: + - property: four btn switch 1 + 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' +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). +--- \ No newline at end of file diff --git a/docs/assets/images/PTX_F1_Display.webp b/docs/assets/images/PTX_F1_Display.webp new file mode 100644 index 0000000000000000000000000000000000000000..99d9408a62fc527ceb77b039e668ba7343c9403a GIT binary patch literal 4502 zcma)8S2!Dv+YM@OwW;|tQZrRGqST62HDa$&d(|q6*sEeyQJW~yqDHCJ+M^V)DT1KV z)Qqk6_xnH3ck#dbzW2Q6;@rGf=Y7sG)`39M7y$rtO*KO^LwO4-006-7FR?`bS=Fb8 z+7J=|fap!!ZVA6ac4i=Yw%HrAwKw1FjduZ8<`sIC6pfmAf#v4jSEt|NZ?RRi2ny}j zUWwknqn}h|MeqvcZHx_Z;Z^<{)Co&0i)YltD*KIholb~V9!i-cSwULgf6`#tNc2pt z<^Sr|WCp98Nfy0tC}zr|lVlbAQVI2@`%FNWMvX-bn9uw?^@3Hgu6?PIx-LXKOi${yW^F&uGIV%e0AoabXld&+;W9LCIn`}wW zW>MPF>miP^NbI+u(BcAAQJ1mw5}0co|9u3qyV0d*M-Fo`k(v!KS?_MoykeTGjOHI00^DOLqfR!ire zFl1@fc<|W+`!8~M$wnBrV+B1aZ|UON$;qsh+gAAAhsO7OSpbzC}*a%!{gh zV0cJZ8o?H$35@d?jnZ*_$=ThlBjUvQ9iY3+n`<+f9i8=FZvStywjY_P%k@C%KpVRa z@H1m<9m2$=9<}oc{PE~jgfp3aehM~_K;L`Caw*dMd?WR!K0@d?lY022X# zBBJ}NM5{!~8EQpOQDwpcrH1F)-At73FAkNz_L6+R@!PTbeBSxZR~dD&c3syDJ1Amb znLC|U5LtrFg^}*yXA?iN)keoZP-vKO^0v zUSeyN^0iy-=n0GY|I6~RZXl^HDX`?xRFoKZz^#=kc&hl_IW$V#NzeuuY* z{Sx3dui5q?pD~I-DB@SPNy))u4feBF^w1iQ>w}85Yj2KdnCXum<+>2TtSsHh);y(@ zs(#kI*nvhkk}5``^G@qQm*zWeE`DDqkpfh4^=8c%@g&?iS;<;17_P~CQd)`5#wJI* z$AUhJX4W;$vPIycdeq?)WMN#;Rn1Z+mFdW$xqrA?rQGrJ`!?vM`$NX2ZJK=Fbr~)> z%4Y`tF-dMSdt=$(q0!j62?IeI0$wK@$jNUwC-{U!UFXShbe7c=6LRcIW>#ls?j)%bP+DCgo1YDL(>Y_ zpxSQ$sbfo$4V9-g)u;u%u5&g58STnNQrkvn30nCN8>7Wk@D0>NDIk1p zBZHB7Ck@l9A3oouIN5oSmn)|Q?C_R6y@)dp4#xbw9NvJR;ba_2EBR|z)!Fb?a*LBP ztIc|}LlP)ViV!-FnY(HC#k&AVE4Oo@lj)&NVebg2nk0Fc^;pPaJwR5$)hfDEqCIfg zkiT@6_J`%KiL3lwLu(yijE|LZlDFYcgHIC7m!f-qR>5;(e@pn#sMRa+GctjrsN2M_ zNXav-N>M!neIv~yW5P|~$9Jy=LjO;X0{}PIp;7-vA%Gl^_)Yja(Fv8#siNpiP9l+BXxql zEQy5mDLI1>RT*PWCD*o4XgEQYaPsHjVy4|cFVMPd6a7yyZ-`{;RdPA*(zj9n%5i+u zN{rn!z%p6#9IgIdokh9C?_r_ZK1SZp>TZj2704bx#>NccA!h?AK*N#YV`Q8SZ}W<#jrs%YS_e`j&mJWg zbrf+)`&25}|0r9MJ3>=VKyYt8xZWlG&cJ|L#?;WTiG!S5vtvrkKE1inr6fq2n2yv^f-l@cTxPodSd(3J`5e}x^)Rq!=8{)HL?7Yx<%PbJa??6R-f+u?$B91_j-KI^ zsdc-r@Y15cA7=6clfD`ReP{wjt-tc=81j0h^2-u_kMavb#`uc!q6JC>>|ly*>+M?D zJPTd)12LJZ%2HYsexjHoH-#+~9LN(9vRmw47hmcHE`Nl3R>WCWIBrw1o!=j*fHIi~ zQ=g79~^|!b>Y%|tQrRPlE-UOpGUfg^h3n)+%axi!2*M8j4B0ohqEfWh4*PM zeLSL|-id2ZUvZ?HM2+$bAi|-qSZ=(K^)s!&b8=P8aYV=js-8Av+4p$m~_NQIm#-oAe%PrpI#8IQA18{NS zGO=l7Dx-VH!J7}SKy81)f~PWDG-N@p|laxX_sD>=|OT9uBEIuih$tJC~Qoldhha(s8D ztrx4!K}3kvFbbO8=8Fq_CAwx};Xf`gRyjQ+nS&Pqg>j2LY?|$|Rh0?Lp~DPouvy@R zgZbwm7Lp5lN==SV(L^==@0DBx=9eepSW8p3%pB6R`Z4I>O)55MhxN1QtZtXz+}!Cd zh;vLh6&UHNR73A8Thg>zV9qbQvbTISe91j=u=$e^0)NKhsaLU>m$Qosy?2%hydQfa z(#P9_+9q4c@wI0uXEAIs5*ftLRo}r3Q6uiLWiRHhp6B-VUHdNsy_bl(llZjl9C=9N zP{#}W7BZ(hlGYIoXX98-?tV$I#35$%^v(<%0o zC%z}8A9ZiWslV_$-mCMhF-W9l(ntT4RI;XIDmS1r3XGBYa@gr<%CcMP@p2y8tgFL! zSEW9#nw%nKg1%2b*a;X~$~cJLvbjSt&6_Vdy7tP(0b^|0aa4;l5YwF@k*52=Wd7hr zOgv6~tYx+i-K!&A#_#><;j@CN5MyMWfnoz+aXm&ia)%sAIuTH4H>YBFoq%)+kX9ZV z$W2j@vpP;P_?^nI%iIyFcX3jtS49w^P54sR33^MQ%@o@ZF1F=DoAiiyX{u`kYE`7V zPT|T;KieFL8m?^~X+6~^>|Z)!f33kqurD1gEm{;`=HkF#_bPZ_p5U!v%%VDxVD6$(|Py7v5*w!*MDEw{2hS{>U?d6EpileMRWQXd$8eW_vhz3z9OSn z(=7PF9y^)1O)y8HuyQ!UHda-svtdLHh1u}z9Q-J0_}>s5W< z>ujFqfJF}%K=1rto3z?@h22YbuOr>=iQg1ef(@yi(Py;s)h>Va2eXoH1vb*W?SJgW zDN68)rBAiGo4b_%brL((^tk%Bdgir0vH^|a;V;)*ngX9C;(zSmrCLymziN-Wi>QGc zEYF2JHmq5;SR+=R_;2+aogl{ipDf<^;v@yD?y~#pjH@oY(lQuuJLx{`{E$b>TeuW? zM7741nW#+QJ83hS;GW!9@%n_=F4&Q{P@}bPY9CxMS#@pW#z!7FZZ{V`0}|6Lidjf6 zO`f~}eU(Y_L~b8GYCrjqy<#Oh?ua(|WqkfE_~*qelaM-cvs#1wx+=67*h-hM;x6kd zx86hzrDklKOX-TGnz^VFrCKVT|JI@7J;0M0U~{ekbtyhaz#jmmD3B0#)P01Q-~3sM zh7reHG33u@=gGiyBG0OuZQGhx3f1D8DZqT@EiKH{V^qG&y&*LmG_z{hljYBSs%LWg z`62jjffiZXbwudXx>{!}Scm28SJuW~#Q_?d!Of8l4K_36Kh$_LjIV>W1!>wJ!5@9! z5Cwh8+5pnj#Ru6SEIt~vBREfTnpq6vHW)??<+vQ%YQZdjl%)^qjt^KiB++T{4XJo- z&!fAKzNMOVu5+HonYD|zVBZ`;=EDP?vtO=JbO(tLs-VUo>*x7|>w9)`voN8Wk36`s z>&ByIC)A56wu5+GPAKvF42)&gV?W=hL39erAo6ppS@Q`hjoWKsCQ{k*r%gDwMjoiK z+R(c8>zG7fEyym@*!ZT?DY!U*Fr{j~sLS3A6F1X?Os2ZTN6*#Ecrg#>Zf&Y1|DqM@ zzh+IZw*?bZd+CgTS_euXxzgspn$ruu4J`!@SGP{?s{YROHoG?NGjuez=gu$i2D(uU zYf7w3C9GUxLc@ldI>YhiF?_|XT|2$v>u2Z()=KNCf;!eS@&08=b~E&pN#S2I;C~dc zv%hd@NRco#n2A@2*W}TyP8@{OhGA8={=?#bN|Jf9)g?c4 zF92Z+)Xtn@5jDkqtC*tGLcfv@l*DR)Gu2pyg2c?07h>KHv<3uwZrcS-SGVs4#eZT1 zmZ-HsDjl`x?+yef2r&c{)5JH2t_~&no5@e@4#jDf@zEbnp58xb#FtW+m@ Date: Sat, 21 Mar 2026 04:54:54 -0400 Subject: [PATCH 04/12] Update test_xiaomi_parser.py --- .../ble_monitor/test/test_xiaomi_parser.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/custom_components/ble_monitor/test/test_xiaomi_parser.py b/custom_components/ble_monitor/test/test_xiaomi_parser.py index fbfd4692..be6044f8 100644 --- a/custom_components/ble_monitor/test/test_xiaomi_parser.py +++ b/custom_components/ble_monitor/test/test_xiaomi_parser.py @@ -1196,6 +1196,61 @@ 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_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"] == "" + def test_Xiaomi_XMPIRO2SXS(self): """Test Xiaomi parser for XMPIRO2SXS.""" self.aeskeys = {} From 15847a5ae28f83bb30df82db7b7e9b12fdbb36e4 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Sat, 21 Mar 2026 04:55:35 -0400 Subject: [PATCH 05/12] Add Xiaomi MiBeacon V4/V5 anonymizer Add tools/anonymize_mibeacon_v5.py: a CLI script to anonymize Xiaomi MiBeacon V4/V5 encrypted BLE advertisements. --- tools/anonymize_mibeacon_v5.py | 139 +++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tools/anonymize_mibeacon_v5.py diff --git a/tools/anonymize_mibeacon_v5.py b/tools/anonymize_mibeacon_v5.py new file mode 100644 index 00000000..1f3d1aba --- /dev/null +++ b/tools/anonymize_mibeacon_v5.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Anonymize Xiaomi MiBeacon V4/V5 encrypted raw advertisements.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass + +from Cryptodome.Cipher import AES + + +@dataclass +class AdStructure: + start: int + size: int + value: bytes + + +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] = [] + cursor = adpayload_start + remaining = adpayload_size + while remaining > 1: + adstruct_size = raw[cursor] + 1 + if adstruct_size <= 1 or adstruct_size > remaining: + break + chunk = raw[cursor:cursor + adstruct_size] + structures.append(AdStructure(cursor, adstruct_size, chunk)) + cursor += adstruct_size + remaining -= adstruct_size + return structures, adpayload_start, adpayload_size, is_ext_packet + + +def _extract_mac(raw: bytes, is_ext_packet: bool) -> bytes: + return (raw[8:14] if is_ext_packet else raw[7:13])[::-1] + + +def _calc_payload_start(service_data: bytes, mac: bytes) -> int: + i = 9 + frame_control = service_data[4] + (service_data[5] << 8) + mac_include = (frame_control >> 4) & 1 + capability_include = (frame_control >> 5) & 1 + if mac_include: + i += 6 + embedded_mac = service_data[9:15][::-1] + if embedded_mac != mac: + raise ValueError("MAC in Xiaomi payload does not match advertisement MAC") + if capability_include: + i += 1 + capability_types = service_data[i - 1] + if capability_types & 0x20: + i += 1 + return i + + +def _decrypt_payload(service_data: bytes, mac: bytes, key: bytes) -> tuple[bytes, int]: + payload_start = _calc_payload_start(service_data, mac) + nonce = b"".join([mac[::-1], service_data[6:9], service_data[-7:-4]]) + cipher = AES.new(key, AES.MODE_CCM, nonce=nonce, mac_len=4) + cipher.update(b"\x11") + plaintext = cipher.decrypt_and_verify(service_data[payload_start:-7], service_data[-4:]) + return plaintext, payload_start + + +def _encrypt_payload(service_data: bytearray, new_mac: bytes, new_key: bytes, payload_start: int, plaintext: bytes) -> bytes: + frame_control = service_data[4] + (service_data[5] << 8) + mac_include = (frame_control >> 4) & 1 + if mac_include: + service_data[9:15] = new_mac[::-1] + nonce = b"".join([new_mac[::-1], bytes(service_data[6:9]), bytes(service_data[-7:-4])]) + cipher = AES.new(new_key, AES.MODE_CCM, nonce=nonce, mac_len=4) + cipher.update(b"\x11") + encrypted = cipher.encrypt(plaintext) + token = cipher.digest() + rebuilt = bytearray(service_data) + rebuilt[payload_start:-7] = encrypted + rebuilt[-4:] = token + return bytes(rebuilt) + + +def anonymize_xiaomi_mibeacon_v5(raw_hex: str, real_key_hex: str, new_mac_hex: str, new_key_hex: str) -> dict[str, str]: + raw = bytearray.fromhex(raw_hex) + real_key = bytes.fromhex(real_key_hex) + new_key = bytes.fromhex(new_key_hex) + new_mac = bytes.fromhex(new_mac_hex) + structures, _, _, is_ext_packet = _parse_ad_structures(raw) + old_mac = _extract_mac(raw, is_ext_packet) + service_ad = next( + ( + item + for item in structures + if item.size > 4 and item.value[1] == 0x16 and item.value[2] == 0x95 and item.value[3] == 0xFE + ), + None, + ) + if service_ad is None: + raise ValueError("No Xiaomi FE95 service data found in raw advertisement") + service_data = service_ad.value + plaintext, payload_start = _decrypt_payload(service_data, old_mac, real_key) + encrypted_service_data = _encrypt_payload(bytearray(service_data), new_mac, new_key, payload_start, plaintext) + raw[service_ad.start:service_ad.start + service_ad.size] = encrypted_service_data + if is_ext_packet: + raw[8:14] = new_mac[::-1] + else: + raw[7:13] = new_mac[::-1] + verify_plaintext, _ = _decrypt_payload(bytes(encrypted_service_data), new_mac, new_key) + if verify_plaintext != plaintext: + raise ValueError("Verification failed: payload mismatch after re-encryption") + return { + "old_mac": old_mac.hex().upper(), + "new_mac": new_mac.hex().upper(), + "new_key": new_key.hex(), + "raw": bytes(raw).hex().upper(), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Anonymize Xiaomi MiBeacon V4/V5 encrypted advertisements") + parser.add_argument("--raw", required=True, help="Raw BLE HCI event hex string") + parser.add_argument("--key", required=True, help="Original 16-byte AES key hex") + parser.add_argument("--new-mac", default="112233445566", help="Anonymized MAC hex, default: 112233445566") + parser.add_argument( + "--new-key", + default="00112233445566778899aabbccddeeff", + help="Anonymized 16-byte AES key hex, default: 00112233445566778899aabbccddeeff", + ) + args = parser.parse_args() + result = anonymize_xiaomi_mibeacon_v5(args.raw, args.key, args.new_mac, args.new_key) + print(f"old_mac={result['old_mac']}") + print(f"new_mac={result['new_mac']}") + print(f"new_key={result['new_key']}") + print(f"raw={result['raw']}") + + +if __name__ == "__main__": + main() From ef4bb7e0a8514c95668238bfd38ed1698825c409 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Sat, 21 Mar 2026 05:08:29 -0400 Subject: [PATCH 06/12] Update Xiaomi_PTX_F1_Display.md --- docs/_devices/Xiaomi_PTX_F1_Display.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_devices/Xiaomi_PTX_F1_Display.md b/docs/_devices/Xiaomi_PTX_F1_Display.md index d80adfc9..7b2120c9 100644 --- a/docs/_devices/Xiaomi_PTX_F1_Display.md +++ b/docs/_devices/Xiaomi_PTX_F1_Display.md @@ -28,4 +28,4 @@ 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). ---- \ No newline at end of file +--- From 9f9e498e0a851e301dcb51687cfff1b32ff6e771 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Sat, 21 Mar 2026 05:12:06 -0400 Subject: [PATCH 07/12] Revert "Add Xiaomi MiBeacon V4/V5 anonymizer" This reverts commit 15847a5ae28f83bb30df82db7b7e9b12fdbb36e4. --- tools/anonymize_mibeacon_v5.py | 139 --------------------------------- 1 file changed, 139 deletions(-) delete mode 100644 tools/anonymize_mibeacon_v5.py diff --git a/tools/anonymize_mibeacon_v5.py b/tools/anonymize_mibeacon_v5.py deleted file mode 100644 index 1f3d1aba..00000000 --- a/tools/anonymize_mibeacon_v5.py +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env python3 -"""Anonymize Xiaomi MiBeacon V4/V5 encrypted raw advertisements.""" - -from __future__ import annotations - -import argparse -from dataclasses import dataclass - -from Cryptodome.Cipher import AES - - -@dataclass -class AdStructure: - start: int - size: int - value: bytes - - -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] = [] - cursor = adpayload_start - remaining = adpayload_size - while remaining > 1: - adstruct_size = raw[cursor] + 1 - if adstruct_size <= 1 or adstruct_size > remaining: - break - chunk = raw[cursor:cursor + adstruct_size] - structures.append(AdStructure(cursor, adstruct_size, chunk)) - cursor += adstruct_size - remaining -= adstruct_size - return structures, adpayload_start, adpayload_size, is_ext_packet - - -def _extract_mac(raw: bytes, is_ext_packet: bool) -> bytes: - return (raw[8:14] if is_ext_packet else raw[7:13])[::-1] - - -def _calc_payload_start(service_data: bytes, mac: bytes) -> int: - i = 9 - frame_control = service_data[4] + (service_data[5] << 8) - mac_include = (frame_control >> 4) & 1 - capability_include = (frame_control >> 5) & 1 - if mac_include: - i += 6 - embedded_mac = service_data[9:15][::-1] - if embedded_mac != mac: - raise ValueError("MAC in Xiaomi payload does not match advertisement MAC") - if capability_include: - i += 1 - capability_types = service_data[i - 1] - if capability_types & 0x20: - i += 1 - return i - - -def _decrypt_payload(service_data: bytes, mac: bytes, key: bytes) -> tuple[bytes, int]: - payload_start = _calc_payload_start(service_data, mac) - nonce = b"".join([mac[::-1], service_data[6:9], service_data[-7:-4]]) - cipher = AES.new(key, AES.MODE_CCM, nonce=nonce, mac_len=4) - cipher.update(b"\x11") - plaintext = cipher.decrypt_and_verify(service_data[payload_start:-7], service_data[-4:]) - return plaintext, payload_start - - -def _encrypt_payload(service_data: bytearray, new_mac: bytes, new_key: bytes, payload_start: int, plaintext: bytes) -> bytes: - frame_control = service_data[4] + (service_data[5] << 8) - mac_include = (frame_control >> 4) & 1 - if mac_include: - service_data[9:15] = new_mac[::-1] - nonce = b"".join([new_mac[::-1], bytes(service_data[6:9]), bytes(service_data[-7:-4])]) - cipher = AES.new(new_key, AES.MODE_CCM, nonce=nonce, mac_len=4) - cipher.update(b"\x11") - encrypted = cipher.encrypt(plaintext) - token = cipher.digest() - rebuilt = bytearray(service_data) - rebuilt[payload_start:-7] = encrypted - rebuilt[-4:] = token - return bytes(rebuilt) - - -def anonymize_xiaomi_mibeacon_v5(raw_hex: str, real_key_hex: str, new_mac_hex: str, new_key_hex: str) -> dict[str, str]: - raw = bytearray.fromhex(raw_hex) - real_key = bytes.fromhex(real_key_hex) - new_key = bytes.fromhex(new_key_hex) - new_mac = bytes.fromhex(new_mac_hex) - structures, _, _, is_ext_packet = _parse_ad_structures(raw) - old_mac = _extract_mac(raw, is_ext_packet) - service_ad = next( - ( - item - for item in structures - if item.size > 4 and item.value[1] == 0x16 and item.value[2] == 0x95 and item.value[3] == 0xFE - ), - None, - ) - if service_ad is None: - raise ValueError("No Xiaomi FE95 service data found in raw advertisement") - service_data = service_ad.value - plaintext, payload_start = _decrypt_payload(service_data, old_mac, real_key) - encrypted_service_data = _encrypt_payload(bytearray(service_data), new_mac, new_key, payload_start, plaintext) - raw[service_ad.start:service_ad.start + service_ad.size] = encrypted_service_data - if is_ext_packet: - raw[8:14] = new_mac[::-1] - else: - raw[7:13] = new_mac[::-1] - verify_plaintext, _ = _decrypt_payload(bytes(encrypted_service_data), new_mac, new_key) - if verify_plaintext != plaintext: - raise ValueError("Verification failed: payload mismatch after re-encryption") - return { - "old_mac": old_mac.hex().upper(), - "new_mac": new_mac.hex().upper(), - "new_key": new_key.hex(), - "raw": bytes(raw).hex().upper(), - } - - -def main() -> None: - parser = argparse.ArgumentParser(description="Anonymize Xiaomi MiBeacon V4/V5 encrypted advertisements") - parser.add_argument("--raw", required=True, help="Raw BLE HCI event hex string") - parser.add_argument("--key", required=True, help="Original 16-byte AES key hex") - parser.add_argument("--new-mac", default="112233445566", help="Anonymized MAC hex, default: 112233445566") - parser.add_argument( - "--new-key", - default="00112233445566778899aabbccddeeff", - help="Anonymized 16-byte AES key hex, default: 00112233445566778899aabbccddeeff", - ) - args = parser.parse_args() - result = anonymize_xiaomi_mibeacon_v5(args.raw, args.key, args.new_mac, args.new_key) - print(f"old_mac={result['old_mac']}") - print(f"new_mac={result['new_mac']}") - print(f"new_key={result['new_key']}") - print(f"raw={result['raw']}") - - -if __name__ == "__main__": - main() From 8c2f12f3fdf022f4dbe21fda75510bb2eb57e99c Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Sat, 21 Mar 2026 05:19:42 -0400 Subject: [PATCH 08/12] Reapply "Add Xiaomi MiBeacon V4/V5 anonymizer" This reverts commit 9f9e498e0a851e301dcb51687cfff1b32ff6e771. --- tools/anonymize_mibeacon_v5.py | 139 +++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 tools/anonymize_mibeacon_v5.py diff --git a/tools/anonymize_mibeacon_v5.py b/tools/anonymize_mibeacon_v5.py new file mode 100644 index 00000000..1f3d1aba --- /dev/null +++ b/tools/anonymize_mibeacon_v5.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Anonymize Xiaomi MiBeacon V4/V5 encrypted raw advertisements.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass + +from Cryptodome.Cipher import AES + + +@dataclass +class AdStructure: + start: int + size: int + value: bytes + + +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] = [] + cursor = adpayload_start + remaining = adpayload_size + while remaining > 1: + adstruct_size = raw[cursor] + 1 + if adstruct_size <= 1 or adstruct_size > remaining: + break + chunk = raw[cursor:cursor + adstruct_size] + structures.append(AdStructure(cursor, adstruct_size, chunk)) + cursor += adstruct_size + remaining -= adstruct_size + return structures, adpayload_start, adpayload_size, is_ext_packet + + +def _extract_mac(raw: bytes, is_ext_packet: bool) -> bytes: + return (raw[8:14] if is_ext_packet else raw[7:13])[::-1] + + +def _calc_payload_start(service_data: bytes, mac: bytes) -> int: + i = 9 + frame_control = service_data[4] + (service_data[5] << 8) + mac_include = (frame_control >> 4) & 1 + capability_include = (frame_control >> 5) & 1 + if mac_include: + i += 6 + embedded_mac = service_data[9:15][::-1] + if embedded_mac != mac: + raise ValueError("MAC in Xiaomi payload does not match advertisement MAC") + if capability_include: + i += 1 + capability_types = service_data[i - 1] + if capability_types & 0x20: + i += 1 + return i + + +def _decrypt_payload(service_data: bytes, mac: bytes, key: bytes) -> tuple[bytes, int]: + payload_start = _calc_payload_start(service_data, mac) + nonce = b"".join([mac[::-1], service_data[6:9], service_data[-7:-4]]) + cipher = AES.new(key, AES.MODE_CCM, nonce=nonce, mac_len=4) + cipher.update(b"\x11") + plaintext = cipher.decrypt_and_verify(service_data[payload_start:-7], service_data[-4:]) + return plaintext, payload_start + + +def _encrypt_payload(service_data: bytearray, new_mac: bytes, new_key: bytes, payload_start: int, plaintext: bytes) -> bytes: + frame_control = service_data[4] + (service_data[5] << 8) + mac_include = (frame_control >> 4) & 1 + if mac_include: + service_data[9:15] = new_mac[::-1] + nonce = b"".join([new_mac[::-1], bytes(service_data[6:9]), bytes(service_data[-7:-4])]) + cipher = AES.new(new_key, AES.MODE_CCM, nonce=nonce, mac_len=4) + cipher.update(b"\x11") + encrypted = cipher.encrypt(plaintext) + token = cipher.digest() + rebuilt = bytearray(service_data) + rebuilt[payload_start:-7] = encrypted + rebuilt[-4:] = token + return bytes(rebuilt) + + +def anonymize_xiaomi_mibeacon_v5(raw_hex: str, real_key_hex: str, new_mac_hex: str, new_key_hex: str) -> dict[str, str]: + raw = bytearray.fromhex(raw_hex) + real_key = bytes.fromhex(real_key_hex) + new_key = bytes.fromhex(new_key_hex) + new_mac = bytes.fromhex(new_mac_hex) + structures, _, _, is_ext_packet = _parse_ad_structures(raw) + old_mac = _extract_mac(raw, is_ext_packet) + service_ad = next( + ( + item + for item in structures + if item.size > 4 and item.value[1] == 0x16 and item.value[2] == 0x95 and item.value[3] == 0xFE + ), + None, + ) + if service_ad is None: + raise ValueError("No Xiaomi FE95 service data found in raw advertisement") + service_data = service_ad.value + plaintext, payload_start = _decrypt_payload(service_data, old_mac, real_key) + encrypted_service_data = _encrypt_payload(bytearray(service_data), new_mac, new_key, payload_start, plaintext) + raw[service_ad.start:service_ad.start + service_ad.size] = encrypted_service_data + if is_ext_packet: + raw[8:14] = new_mac[::-1] + else: + raw[7:13] = new_mac[::-1] + verify_plaintext, _ = _decrypt_payload(bytes(encrypted_service_data), new_mac, new_key) + if verify_plaintext != plaintext: + raise ValueError("Verification failed: payload mismatch after re-encryption") + return { + "old_mac": old_mac.hex().upper(), + "new_mac": new_mac.hex().upper(), + "new_key": new_key.hex(), + "raw": bytes(raw).hex().upper(), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Anonymize Xiaomi MiBeacon V4/V5 encrypted advertisements") + parser.add_argument("--raw", required=True, help="Raw BLE HCI event hex string") + parser.add_argument("--key", required=True, help="Original 16-byte AES key hex") + parser.add_argument("--new-mac", default="112233445566", help="Anonymized MAC hex, default: 112233445566") + parser.add_argument( + "--new-key", + default="00112233445566778899aabbccddeeff", + help="Anonymized 16-byte AES key hex, default: 00112233445566778899aabbccddeeff", + ) + args = parser.parse_args() + result = anonymize_xiaomi_mibeacon_v5(args.raw, args.key, args.new_mac, args.new_key) + print(f"old_mac={result['old_mac']}") + print(f"new_mac={result['new_mac']}") + print(f"new_key={result['new_key']}") + print(f"raw={result['raw']}") + + +if __name__ == "__main__": + main() From ed1efe88531898f7bcb9da4975d897af67c23622 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 14:15:47 +0000 Subject: [PATCH 09/12] Address PTX-F1-Display review feedback Agent-Logs-Url: https://github.com/M1k0t0/ble_monitor/sessions/a064e650-6199-4a84-9509-3a1212cd4ffb Co-authored-by: M1k0t0 <58372301+M1k0t0@users.noreply.github.com> --- .../ble_monitor/ble_parser/xiaomi.py | 18 ++++++++---- custom_components/ble_monitor/const.py | 4 +-- .../ble_monitor/test/test_xiaomi_parser.py | 29 +++++++++++++++++++ docs/_devices/Xiaomi_PTX_F1_Display.md | 8 ++--- tools/anonymize_mibeacon_v5.py | 23 +++++++++++++++ 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/custom_components/ble_monitor/ble_parser/xiaomi.py b/custom_components/ble_monitor/ble_parser/xiaomi.py index 0155b7d2..afe0dcaf 100644 --- a/custom_components/ble_monitor/ble_parser/xiaomi.py +++ b/custom_components/ble_monitor/ble_parser/xiaomi.py @@ -731,10 +731,15 @@ def obj4803(xobj): batt = xobj[0] return {"battery": batt} + def obj605d(xobj): """Temperature""" - temp = xobj[0] - return {"temperature": temp} + if len(xobj) == 1: + temp = xobj[0] + return {"temperature": temp} + else: + return {} + def obj6012(xobj): """Humidity""" @@ -1512,7 +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: - _LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex()) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex()) return None else: pass @@ -1522,13 +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 - _LOGGER.debug("Lower priority advertisement received, not processing. Data: %s", data.hex()) + 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 - _LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex()) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Duplicate packet received, not processing. Data: %s", data.hex()) return None self.lpacket_ids[mac] = packet_id diff --git a/custom_components/ble_monitor/const.py b/custom_components/ble_monitor/const.py index 509f3c44..cf8e9793 100644 --- a/custom_components/ble_monitor/const.py +++ b/custom_components/ble_monitor/const.py @@ -2120,7 +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", "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"], []], 'YLAI003' : [["rssi", "battery"], ["button"], []], 'YLYK01YL' : [["rssi"], ["remote"], ["remote single press", "remote long press"]], 'YLYK01YL-FANCL' : [["rssi"], ["fan remote"], []], @@ -2281,7 +2281,7 @@ class BLEMonitorBinarySensorEntityDescription( 'XMWXKG01YL' : 'Xiaomi', 'XMWXKG01LM' : 'Xiaomi', 'PTX' : 'Xiaomi', - "PTX-F1-Display" : 'Xiaomi', + 'PTX-F1-Display' : 'Xiaomi', 'SV40' : 'Lockin', 'SU001-T' : 'Petoneer', 'ATC' : 'ATC', diff --git a/custom_components/ble_monitor/test/test_xiaomi_parser.py b/custom_components/ble_monitor/test/test_xiaomi_parser.py index 921ca12f..d0393b02 100644 --- a/custom_components/ble_monitor/test/test_xiaomi_parser.py +++ b/custom_components/ble_monitor/test/test_xiaomi_parser.py @@ -1243,6 +1243,35 @@ def test_Xiaomi_PTX_F1_Display_single_press(self): 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 = {} diff --git a/docs/_devices/Xiaomi_PTX_F1_Display.md b/docs/_devices/Xiaomi_PTX_F1_Display.md index 7b2120c9..e128ba86 100644 --- a/docs/_devices/Xiaomi_PTX_F1_Display.md +++ b/docs/_devices/Xiaomi_PTX_F1_Display.md @@ -14,13 +14,13 @@ broadcasted_properties: - rssi broadcasted_property_notes: - property: four btn switch 1 - 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: 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 3 - 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 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 broadcast_rate: active_scan: encryption_key: true diff --git a/tools/anonymize_mibeacon_v5.py b/tools/anonymize_mibeacon_v5.py index 1f3d1aba..3ee5b349 100644 --- a/tools/anonymize_mibeacon_v5.py +++ b/tools/anonymize_mibeacon_v5.py @@ -17,9 +17,18 @@ class AdStructure: def _parse_ad_structures(raw: bytes) -> tuple[list[AdStructure], int, int, bool]: + if len(raw) < 4: + raise ValueError("Raw BLE HCI event is too short to determine advertisement packet type") is_ext_packet = raw[3] == 0x0D adpayload_start = 29 if is_ext_packet else 14 + if len(raw) < adpayload_start: + packet_type = "extended" if is_ext_packet else "legacy" + raise ValueError(f"Raw {packet_type} BLE HCI event is too short; expected at least {adpayload_start} bytes") adpayload_size = raw[adpayload_start - 1] + if len(raw) < adpayload_start + adpayload_size: + raise ValueError( + f"Raw BLE HCI event is shorter than the advertised payload length ({adpayload_size} bytes)" + ) structures: list[AdStructure] = [] cursor = adpayload_start remaining = adpayload_size @@ -35,29 +44,43 @@ def _parse_ad_structures(raw: bytes) -> tuple[list[AdStructure], int, int, bool] def _extract_mac(raw: bytes, is_ext_packet: bool) -> bytes: + min_length = 14 if is_ext_packet else 13 + if len(raw) < min_length: + packet_type = "extended" if is_ext_packet else "legacy" + raise ValueError(f"Raw {packet_type} BLE HCI event is too short to contain a MAC address") return (raw[8:14] if is_ext_packet else raw[7:13])[::-1] def _calc_payload_start(service_data: bytes, mac: bytes) -> int: + if len(service_data) < 9: + raise ValueError("Xiaomi FE95 service data is too short; expected at least 9 bytes") i = 9 frame_control = service_data[4] + (service_data[5] << 8) mac_include = (frame_control >> 4) & 1 capability_include = (frame_control >> 5) & 1 if mac_include: i += 6 + if len(service_data) < i: + raise ValueError("Xiaomi FE95 service data is too short to contain the embedded MAC address") embedded_mac = service_data[9:15][::-1] if embedded_mac != mac: raise ValueError("MAC in Xiaomi payload does not match advertisement MAC") if capability_include: i += 1 + if len(service_data) < i: + raise ValueError("Xiaomi FE95 service data is too short to contain the capability byte") capability_types = service_data[i - 1] if capability_types & 0x20: i += 1 + if len(service_data) < i: + raise ValueError("Xiaomi FE95 service data is too short to contain the capability IO byte") return i def _decrypt_payload(service_data: bytes, mac: bytes, key: bytes) -> tuple[bytes, int]: payload_start = _calc_payload_start(service_data, mac) + if len(service_data) < payload_start + 7: + raise ValueError("Xiaomi FE95 service data is too short to contain an encrypted payload and authentication tag") nonce = b"".join([mac[::-1], service_data[6:9], service_data[-7:-4]]) cipher = AES.new(key, AES.MODE_CCM, nonce=nonce, mac_len=4) cipher.update(b"\x11") From dc98cfc3deb7569c6d901bd738bdded4a1138d39 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Fri, 1 May 2026 10:34:58 -0400 Subject: [PATCH 10/12] Update custom_components/ble_monitor/ble_parser/xiaomi.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- custom_components/ble_monitor/ble_parser/xiaomi.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/ble_monitor/ble_parser/xiaomi.py b/custom_components/ble_monitor/ble_parser/xiaomi.py index afe0dcaf..04577b47 100644 --- a/custom_components/ble_monitor/ble_parser/xiaomi.py +++ b/custom_components/ble_monitor/ble_parser/xiaomi.py @@ -1648,7 +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) - _LOGGER.debug("Key error for device with MAC %s, cannot decrypt data. Data: %s", to_mac(mac), data.hex()) + 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]]) From a4a053614148aadc91f86786010240905af4dcb4 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Fri, 1 May 2026 10:38:02 -0400 Subject: [PATCH 11/12] Update docs/_devices/Xiaomi_PTX_F1_Display.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/_devices/Xiaomi_PTX_F1_Display.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_devices/Xiaomi_PTX_F1_Display.md b/docs/_devices/Xiaomi_PTX_F1_Display.md index e128ba86..9ea63b47 100644 --- a/docs/_devices/Xiaomi_PTX_F1_Display.md +++ b/docs/_devices/Xiaomi_PTX_F1_Display.md @@ -11,6 +11,7 @@ broadcasted_properties: - four btn switch 2 - four btn switch 3 - four btn switch 4 + - button switch - rssi broadcasted_property_notes: - property: four btn switch 1 From cf2d36be10c459b55c6902f069223a1b0a1bfe59 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:40:53 -0400 Subject: [PATCH 12/12] fix: Address review comments Move the new Xiaomi data object converters into numerical order and keep the data object dispatch table sorted consistently. Inline the PTX-F1-Display button handling instead of reusing the KS1BP converter path, so future device-specific behavior can diverge cleanly. --- .../ble_monitor/ble_parser/xiaomi.py | 103 +++++++++++++++--- 1 file changed, 85 insertions(+), 18 deletions(-) diff --git a/custom_components/ble_monitor/ble_parser/xiaomi.py b/custom_components/ble_monitor/ble_parser/xiaomi.py index 04577b47..a4c002d7 100644 --- a/custom_components/ble_monitor/ble_parser/xiaomi.py +++ b/custom_components/ble_monitor/ble_parser/xiaomi.py @@ -732,19 +732,6 @@ 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] @@ -1062,7 +1049,29 @@ def obj4e0c(xobj, device_type): "button switch": "single press", } elif device_type == "PTX-F1-Display": - return obj560c(xobj, "KS1BP") + click = xobj[0] + if click == 1: + result = { + "four btn switch 1": "toggle", + "button switch": "single press", + } + elif click == 2: + result = { + "four btn switch 2": "toggle", + "button switch": "single press", + } + elif click == 3: + result = { + "four btn switch 3": "toggle", + "button switch": "single press", + } + elif click == 4: + result = { + "four btn switch 4": "toggle", + "button switch": "single press", + } + else: + result = None else: result = {} return result @@ -1094,7 +1103,29 @@ def obj4e0d(xobj, device_type): "button switch": "double press", } elif device_type == "PTX-F1-Display": - return obj560d(xobj, "KS1BP") + click = xobj[0] + if click == 1: + result = { + "four btn switch 1": "toggle", + "button switch": "double press", + } + elif click == 2: + result = { + "four btn switch 2": "toggle", + "button switch": "double press", + } + elif click == 3: + result = { + "four btn switch 3": "toggle", + "button switch": "double press", + } + elif click == 4: + result = { + "four btn switch 4": "toggle", + "button switch": "double press", + } + else: + result = None else: result = {} return result @@ -1126,7 +1157,29 @@ def obj4e0e(xobj, device_type): "button switch": "long press", } elif device_type == "PTX-F1-Display": - return obj560e(xobj, "KS1BP") + click = xobj[0] + if click == 1: + result = { + "four btn switch 1": "toggle", + "button switch": "long press", + } + elif click == 2: + result = { + "four btn switch 2": "toggle", + "button switch": "long press", + } + elif click == 3: + result = { + "four btn switch 3": "toggle", + "button switch": "long press", + } + elif click == 4: + result = { + "four btn switch 4": "toggle", + "button switch": "long press", + } + else: + result = None else: result = {} return result @@ -1302,6 +1355,20 @@ def obj5a16(xobj): return None +def obj6012(xobj): + """Humidity""" + return obj4802(xobj) + + +def obj605d(xobj): + """Temperature""" + if len(xobj) == 1: + temp = xobj[0] + return {"temperature": temp} + else: + return {} + + def obj6e16(xobj): """Body Composition Scale""" (profile_id, data, _) = struct.unpack("