casambi-bt-revamped 0.3.4__py3-none-any.whl → 0.3.6.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- CasambiBt/_client.py +47 -0
- CasambiBt/_client_android_parser.py +215 -0
- {casambi_bt_revamped-0.3.4.dist-info → casambi_bt_revamped-0.3.6.dev1.dist-info}/METADATA +1 -1
- {casambi_bt_revamped-0.3.4.dist-info → casambi_bt_revamped-0.3.6.dev1.dist-info}/RECORD +7 -6
- {casambi_bt_revamped-0.3.4.dist-info → casambi_bt_revamped-0.3.6.dev1.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.4.dist-info → casambi_bt_revamped-0.3.6.dev1.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.4.dist-info → casambi_bt_revamped-0.3.6.dev1.dist-info}/top_level.txt +0 -0
CasambiBt/_client.py
CHANGED
|
@@ -33,6 +33,12 @@ from .errors import ( # noqa: E402
|
|
|
33
33
|
UnsupportedProtocolVersion,
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
+
# Import Android parser for comparison
|
|
37
|
+
try:
|
|
38
|
+
from ._client_android_parser import AndroidPacketParser
|
|
39
|
+
except ImportError:
|
|
40
|
+
AndroidPacketParser = None
|
|
41
|
+
|
|
36
42
|
|
|
37
43
|
@unique
|
|
38
44
|
class IncommingPacketType(IntEnum):
|
|
@@ -488,6 +494,20 @@ class CasambiClient:
|
|
|
488
494
|
def _parseSwitchEvent(self, data: bytes, packet_seq: int = None, raw_packet: bytes = None) -> None:
|
|
489
495
|
"""Parse switch event packet which contains multiple message types"""
|
|
490
496
|
self._logger.info(f"Parsing incoming switch event packet... Data: {b2a(data)}")
|
|
497
|
+
|
|
498
|
+
# Try Android parser for comparison if available
|
|
499
|
+
if AndroidPacketParser and raw_packet:
|
|
500
|
+
try:
|
|
501
|
+
# The raw_packet includes encryption, we need the decrypted version
|
|
502
|
+
# which starts after the type byte (data includes content after type byte)
|
|
503
|
+
# Reconstruct the full decrypted packet for Android parser
|
|
504
|
+
full_packet = bytes([IncommingPacketType.SwitchEvent]) + data
|
|
505
|
+
android_result = AndroidPacketParser.parse_complete_packet(full_packet)
|
|
506
|
+
if android_result.get('switch_event'):
|
|
507
|
+
self._logger.info(f"Android parser result: {android_result['switch_event']['android_log']}")
|
|
508
|
+
self._logger.debug(f"Android parser details: {android_result}")
|
|
509
|
+
except Exception as e:
|
|
510
|
+
self._logger.debug(f"Android parser comparison failed: {e}")
|
|
491
511
|
|
|
492
512
|
pos = 0
|
|
493
513
|
oldPos = 0
|
|
@@ -581,6 +601,32 @@ class CasambiClient:
|
|
|
581
601
|
f"Switch event (type 0x{message_type:02x}): button={button}, unit_id={unit_id}, "
|
|
582
602
|
f"action={action_display} ({event_string}), flags=0x{flags:02x}"
|
|
583
603
|
)
|
|
604
|
+
|
|
605
|
+
# Also try to parse with Android method if this is the full packet
|
|
606
|
+
android_comparison = None
|
|
607
|
+
if AndroidPacketParser and message_type == 0x07 and len(payload) >= 9:
|
|
608
|
+
try:
|
|
609
|
+
# Try to parse as Android packet structure
|
|
610
|
+
android_parsed = AndroidPacketParser.parse_complete_packet(payload)
|
|
611
|
+
if android_parsed.get('switch_event'):
|
|
612
|
+
android_evt = android_parsed['switch_event']
|
|
613
|
+
android_comparison = {
|
|
614
|
+
'unit_id': android_evt['unit_id'],
|
|
615
|
+
'button': android_evt['button'],
|
|
616
|
+
'state': android_evt['state'],
|
|
617
|
+
'param_p': android_evt['param_p'],
|
|
618
|
+
'param_s': android_evt['param_s'],
|
|
619
|
+
'android_log': android_evt['android_log']
|
|
620
|
+
}
|
|
621
|
+
self._logger.info(f"Android parser comparison: {android_evt['android_log']}")
|
|
622
|
+
|
|
623
|
+
# Log differences
|
|
624
|
+
if android_evt['unit_id'] != unit_id:
|
|
625
|
+
self._logger.warning(f"Unit ID mismatch: current={unit_id}, android={android_evt['unit_id']}")
|
|
626
|
+
if android_evt['button'] != button:
|
|
627
|
+
self._logger.warning(f"Button mismatch: current={button}, android={android_evt['button']}")
|
|
628
|
+
except Exception as e:
|
|
629
|
+
self._logger.debug(f"Android parser comparison failed: {e}")
|
|
584
630
|
|
|
585
631
|
self._dataCallback(
|
|
586
632
|
IncommingPacketType.SwitchEvent,
|
|
@@ -597,6 +643,7 @@ class CasambiClient:
|
|
|
597
643
|
"decrypted_data": b2a(full_data),
|
|
598
644
|
"message_position": start_pos,
|
|
599
645
|
"payload_hex": b2a(payload),
|
|
646
|
+
"android_comparison": android_comparison,
|
|
600
647
|
},
|
|
601
648
|
)
|
|
602
649
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Android App Compatible Parser for Casambi Switch Events
|
|
3
|
+
|
|
4
|
+
This module implements the exact parsing logic from the decompiled Android app
|
|
5
|
+
to compare with our current implementation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import struct
|
|
9
|
+
from typing import Dict, Any, Optional, Tuple
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AndroidPacketParser:
|
|
16
|
+
"""Parser that follows the exact Android app implementation"""
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def parse_packet_header(data: bytes, pos: int = 0) -> Tuple[Dict[str, Any], int]:
|
|
20
|
+
"""
|
|
21
|
+
Parse packet header according to Android app C1775b.Q method (lines 230-243)
|
|
22
|
+
|
|
23
|
+
Returns: (header_dict, new_position)
|
|
24
|
+
"""
|
|
25
|
+
if len(data) - pos < 9:
|
|
26
|
+
raise ValueError("Insufficient data for packet header")
|
|
27
|
+
|
|
28
|
+
# Read header (2 bytes)
|
|
29
|
+
unsigned_short = struct.unpack_from('>H', data, pos)[0]
|
|
30
|
+
pos += 2
|
|
31
|
+
|
|
32
|
+
# Extract flags from header
|
|
33
|
+
has_origin_handle = (unsigned_short & 64) != 0 # bit 6
|
|
34
|
+
is_unique = (unsigned_short & 128) != 0 # bit 7
|
|
35
|
+
has_session = (unsigned_short & 256) != 0 # bit 8
|
|
36
|
+
has_origin_handle_alt = (unsigned_short & 512) != 0 # bit 9
|
|
37
|
+
flag_1024 = (unsigned_short & 1024) != 0 # bit 10
|
|
38
|
+
|
|
39
|
+
# Read command type (1 byte) - this is the EnumC1777d ordinal
|
|
40
|
+
command_type = data[pos] & 0xFF
|
|
41
|
+
pos += 1
|
|
42
|
+
|
|
43
|
+
# Read origin (2 bytes)
|
|
44
|
+
origin = struct.unpack_from('>H', data, pos)[0]
|
|
45
|
+
pos += 2
|
|
46
|
+
|
|
47
|
+
# Read target (2 bytes)
|
|
48
|
+
target = struct.unpack_from('>H', data, pos)[0]
|
|
49
|
+
pos += 2
|
|
50
|
+
|
|
51
|
+
# Extract lifetime from header bits 11-14
|
|
52
|
+
lifetime = (unsigned_short >> 11) & 15
|
|
53
|
+
|
|
54
|
+
# Read age (2 bytes)
|
|
55
|
+
age = struct.unpack_from('>H', data, pos)[0]
|
|
56
|
+
pos += 2
|
|
57
|
+
|
|
58
|
+
# Read optional origin handle if flag is set
|
|
59
|
+
origin_handle = None
|
|
60
|
+
if has_origin_handle_alt:
|
|
61
|
+
origin_handle = data[pos] & 0xFF
|
|
62
|
+
pos += 1
|
|
63
|
+
|
|
64
|
+
# Extract payload length from header bits 0-5
|
|
65
|
+
payload_length = unsigned_short & 63
|
|
66
|
+
|
|
67
|
+
header = {
|
|
68
|
+
'header_raw': unsigned_short,
|
|
69
|
+
'command_type': command_type,
|
|
70
|
+
'origin': origin,
|
|
71
|
+
'target': target,
|
|
72
|
+
'lifetime': lifetime,
|
|
73
|
+
'age': age,
|
|
74
|
+
'origin_handle': origin_handle,
|
|
75
|
+
'payload_length': payload_length,
|
|
76
|
+
'flags': {
|
|
77
|
+
'has_origin_handle': has_origin_handle,
|
|
78
|
+
'is_unique': is_unique,
|
|
79
|
+
'has_session': has_session,
|
|
80
|
+
'has_origin_handle_alt': has_origin_handle_alt,
|
|
81
|
+
'flag_1024': flag_1024
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return header, pos
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def parse_switch_event(header: Dict[str, Any], payload: bytes) -> Optional[Dict[str, Any]]:
|
|
89
|
+
"""
|
|
90
|
+
Parse switch event according to Android app logic (lines 271-280)
|
|
91
|
+
|
|
92
|
+
The Android app checks:
|
|
93
|
+
- target & 0xFF == 6 (lower byte of target must be 6)
|
|
94
|
+
- command_type ordinal must be between 29-36 (FunctionButtonEvent0-7)
|
|
95
|
+
- payload must have at least 3 bytes
|
|
96
|
+
"""
|
|
97
|
+
target_type = header['target'] & 0xFF
|
|
98
|
+
target_unit_id = header['target'] >> 8
|
|
99
|
+
command_type = header['command_type']
|
|
100
|
+
|
|
101
|
+
# Check if this is a switch event
|
|
102
|
+
if target_type != 6:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# Check if command type is in range for button events (29-36)
|
|
106
|
+
if command_type < 29 or command_type > 36:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
# Check payload length
|
|
110
|
+
if len(payload) < 3:
|
|
111
|
+
logger.warning(f"Switch event payload too short: {len(payload)} bytes")
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# Parse according to Android logic
|
|
115
|
+
first_byte = payload[0]
|
|
116
|
+
button_index = command_type - 29 # 0-7
|
|
117
|
+
|
|
118
|
+
# Extract parameters from first byte
|
|
119
|
+
param_p = (first_byte >> 3) & 15 # bits 3-6
|
|
120
|
+
param_s = first_byte & 7 # bits 0-2
|
|
121
|
+
state = 1 if (first_byte & 128) else 0 # bit 7
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
'unit_id': target_unit_id,
|
|
125
|
+
'button': button_index,
|
|
126
|
+
'state': state, # 1 = pressed, 0 = released
|
|
127
|
+
'param_p': param_p,
|
|
128
|
+
'param_s': param_s,
|
|
129
|
+
'target_type': target_type,
|
|
130
|
+
'command_type': command_type,
|
|
131
|
+
'payload_hex': payload.hex(),
|
|
132
|
+
'android_log': f"Unit {target_unit_id} Switch event: #{button_index} (P{param_p} S{param_s}) = {state}"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def parse_complete_packet(data: bytes) -> Dict[str, Any]:
|
|
137
|
+
"""Parse a complete packet and extract switch events if present"""
|
|
138
|
+
try:
|
|
139
|
+
header, payload_start = AndroidPacketParser.parse_packet_header(data)
|
|
140
|
+
|
|
141
|
+
# Read payload
|
|
142
|
+
payload_length = header['payload_length']
|
|
143
|
+
if len(data) - payload_start < payload_length:
|
|
144
|
+
raise ValueError(f"Insufficient data for payload: need {payload_length}, have {len(data) - payload_start}")
|
|
145
|
+
|
|
146
|
+
payload = data[payload_start:payload_start + payload_length]
|
|
147
|
+
|
|
148
|
+
# Try to parse as switch event
|
|
149
|
+
switch_event = AndroidPacketParser.parse_switch_event(header, payload)
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
'header': header,
|
|
153
|
+
'payload': payload.hex(),
|
|
154
|
+
'switch_event': switch_event
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(f"Failed to parse packet: {e}")
|
|
159
|
+
return {'error': str(e), 'data': data.hex()}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Command type constants from EnumC1777d
|
|
163
|
+
class FunctionType:
|
|
164
|
+
"""Function type constants from Android app"""
|
|
165
|
+
BUTTON_EVENT_0 = 29
|
|
166
|
+
BUTTON_EVENT_1 = 30
|
|
167
|
+
BUTTON_EVENT_2 = 31
|
|
168
|
+
BUTTON_EVENT_3 = 32
|
|
169
|
+
BUTTON_EVENT_4 = 33
|
|
170
|
+
BUTTON_EVENT_5 = 34
|
|
171
|
+
BUTTON_EVENT_6 = 35
|
|
172
|
+
BUTTON_EVENT_7 = 36
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def compare_with_current_parser(data: bytes, current_parser_result: Dict[str, Any]) -> Dict[str, Any]:
|
|
176
|
+
"""Compare Android parser results with current implementation"""
|
|
177
|
+
android_result = AndroidPacketParser.parse_complete_packet(data)
|
|
178
|
+
|
|
179
|
+
comparison = {
|
|
180
|
+
'android_parser': android_result,
|
|
181
|
+
'current_parser': current_parser_result,
|
|
182
|
+
'differences': []
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Compare results
|
|
186
|
+
if android_result.get('switch_event') and current_parser_result:
|
|
187
|
+
android_evt = android_result['switch_event']
|
|
188
|
+
|
|
189
|
+
# Check unit_id
|
|
190
|
+
if android_evt['unit_id'] != current_parser_result.get('unit_id'):
|
|
191
|
+
comparison['differences'].append({
|
|
192
|
+
'field': 'unit_id',
|
|
193
|
+
'android': android_evt['unit_id'],
|
|
194
|
+
'current': current_parser_result.get('unit_id')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
# Check button
|
|
198
|
+
if android_evt['button'] != current_parser_result.get('button'):
|
|
199
|
+
comparison['differences'].append({
|
|
200
|
+
'field': 'button',
|
|
201
|
+
'android': android_evt['button'],
|
|
202
|
+
'current': current_parser_result.get('button')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
# Map Android state to current event names
|
|
206
|
+
android_event = 'button_press' if android_evt['state'] == 1 else 'button_release'
|
|
207
|
+
if android_event != current_parser_result.get('event'):
|
|
208
|
+
comparison['differences'].append({
|
|
209
|
+
'field': 'event',
|
|
210
|
+
'android': android_event,
|
|
211
|
+
'current': current_parser_result.get('event'),
|
|
212
|
+
'note': 'Android only has press/release, no hold detection'
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return comparison
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
|
|
2
2
|
CasambiBt/_cache.py,sha256=KZ2xbiHAHXUPa8Gw_75Nw9NL4QSY_sTWHbyYXYUDaB0,3865
|
|
3
3
|
CasambiBt/_casambi.py,sha256=gLLkhEcObgapqTx5Mk7WRClyG29UyfZYZCCIhhOg4H4,23101
|
|
4
|
-
CasambiBt/_client.py,sha256=
|
|
4
|
+
CasambiBt/_client.py,sha256=7pdSgYYs5dW1e2XiqMycToqui3HaY4Wj-wr66-7iCMQ,26447
|
|
5
|
+
CasambiBt/_client_android_parser.py,sha256=1lVkVYMO0Nhh9_nkNwgb68hlCS_uD8WxYQDir5hOdHs,7744
|
|
5
6
|
CasambiBt/_constants.py,sha256=_AxkG7Btxl4VeS6mO7GJW5Kc9dFs3s9sDmtJ83ZEKNw,359
|
|
6
7
|
CasambiBt/_discover.py,sha256=H7HpiFYIy9ELvmPXXd_ck-5O5invJf15dDIRk-vO5IE,1696
|
|
7
8
|
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
@@ -11,8 +12,8 @@ CasambiBt/_operation.py,sha256=-BuC1Bvtg-G-zSN_b_0JMvXdHZaR6LbTw0S425jg96c,842
|
|
|
11
12
|
CasambiBt/_unit.py,sha256=YiQWoHmMDWHHo4XAjtW8rHsBqIqpmp9MVdv1Mbu2xw4,17043
|
|
12
13
|
CasambiBt/errors.py,sha256=0JgDjaKlAKDes0poWzA8nrTUYQ8qdNfBb8dfaqqzCRA,1664
|
|
13
14
|
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
casambi_bt_revamped-0.3.
|
|
15
|
-
casambi_bt_revamped-0.3.
|
|
16
|
-
casambi_bt_revamped-0.3.
|
|
17
|
-
casambi_bt_revamped-0.3.
|
|
18
|
-
casambi_bt_revamped-0.3.
|
|
15
|
+
casambi_bt_revamped-0.3.6.dev1.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
16
|
+
casambi_bt_revamped-0.3.6.dev1.dist-info/METADATA,sha256=lOUhjWLxLPDUhpuiQX2weU0U3RP-69ha26bCNQ-5wvg,3049
|
|
17
|
+
casambi_bt_revamped-0.3.6.dev1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
18
|
+
casambi_bt_revamped-0.3.6.dev1.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
19
|
+
casambi_bt_revamped-0.3.6.dev1.dist-info/RECORD,,
|
|
File without changes
|
{casambi_bt_revamped-0.3.4.dist-info → casambi_bt_revamped-0.3.6.dev1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.4.dist-info → casambi_bt_revamped-0.3.6.dev1.dist-info}/top_level.txt
RENAMED
|
File without changes
|