casambi-bt-revamped 0.3.4__py3-none-any.whl → 0.3.6__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 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):
@@ -415,23 +421,35 @@ class CasambiClient:
415
421
  # TODO: Check incoming counter and direction flag
416
422
  self._inPacketCount += 1
417
423
 
418
- # Store raw encrypted packet for reference
419
- raw_packet = data[:]
424
+ # Store raw encrypted packet for Android parser analysis
425
+ raw_encrypted_packet = data[:]
426
+
427
+ # Android parser comparison - disabled as protocols are incompatible
428
+ android_switch_event = None
429
+ # The Android parser expects a completely different packet format than what
430
+ # this implementation uses. Logging disabled to reduce noise.
420
431
 
421
432
  try:
422
- data = self._encryptor.decryptAndVerify(data, data[:4] + self._nonce[4:])
433
+ decrypted_data = self._encryptor.decryptAndVerify(data, data[:4] + self._nonce[4:])
423
434
  except InvalidSignature:
424
435
  # We only drop packets with invalid signature here instead of going into an error state
425
436
  self._logger.error(f"Invalid signature for packet {b2a(data)}!")
426
437
  return
438
+
439
+ # Protocol analysis: Current implementation vs Android
440
+ # These are fundamentally different protocols:
441
+ # - Current: Type 0x07 + message-based protocol (0x10/0x08 messages)
442
+ # - Android: Complex header with command types 29-36 for buttons
443
+ # No conversion possible - they're incompatible formats
444
+ android_switch_event = None
427
445
 
428
- packetType = data[0]
429
- self._logger.debug(f"Incoming data of type {packetType}: {b2a(data)}")
446
+ packetType = decrypted_data[0]
447
+ self._logger.debug(f"Incoming data of type {packetType}: {b2a(decrypted_data)}")
430
448
 
431
449
  if packetType == IncommingPacketType.UnitState:
432
- self._parseUnitStates(data[1:])
450
+ self._parseUnitStates(decrypted_data[1:])
433
451
  elif packetType == IncommingPacketType.SwitchEvent:
434
- self._parseSwitchEvent(data[1:], self._inPacketCount, raw_packet)
452
+ self._parseSwitchEvent(decrypted_data[1:], self._inPacketCount, raw_encrypted_packet, android_switch_event)
435
453
  elif packetType == IncommingPacketType.NetworkConfig:
436
454
  # We don't care about the config the network thinks it has.
437
455
  # We assume that cloud config and local config match.
@@ -485,12 +503,19 @@ class CasambiClient:
485
503
  f"Ran out of data while parsing unit state! Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
486
504
  )
487
505
 
488
- def _parseSwitchEvent(self, data: bytes, packet_seq: int = None, raw_packet: bytes = None) -> None:
506
+ def _parseSwitchEvent(self, data: bytes, packet_seq: int = None, raw_packet: bytes = None, android_switch_event: dict = None) -> None:
489
507
  """Parse switch event packet which contains multiple message types"""
490
508
  self._logger.info(f"Parsing incoming switch event packet... Data: {b2a(data)}")
509
+
510
+ # Special handling for message type 0x29 - not a switch event
511
+ if len(data) >= 1 and data[0] == 0x29:
512
+ self._logger.debug(f"Ignoring message type 0x29 (not a switch event): {b2a(data)}")
513
+ return
491
514
 
492
515
  pos = 0
493
516
  oldPos = 0
517
+ switch_events_found = 0
518
+
494
519
  try:
495
520
  while pos <= len(data) - 3:
496
521
  oldPos = pos
@@ -502,6 +527,15 @@ class CasambiClient:
502
527
  parameter = data[pos + 2] & 15
503
528
  pos += 3
504
529
 
530
+ # Sanity check: message type should be reasonable
531
+ if message_type > 0x80:
532
+ self._logger.debug(
533
+ f"Skipping invalid message type 0x{message_type:02x} at position {oldPos}"
534
+ )
535
+ # Try to resync by looking for next valid message
536
+ pos = oldPos + 1
537
+ continue
538
+
505
539
  # Check if we have enough data for the payload
506
540
  if pos + length > len(data):
507
541
  self._logger.debug(
@@ -516,11 +550,21 @@ class CasambiClient:
516
550
 
517
551
  # Process based on message type
518
552
  if message_type == 0x08 or message_type == 0x10: # Switch/button events
519
- self._processSwitchMessage(message_type, flags, parameter, payload, data, oldPos, packet_seq, raw_packet)
520
- else:
521
- # Log other message types for now
553
+ switch_events_found += 1
554
+ self._processSwitchMessage(message_type, flags, parameter, payload, data, oldPos, packet_seq, raw_packet, android_switch_event)
555
+ elif message_type == 0x29:
556
+ # This shouldn't happen due to check above, but just in case
557
+ self._logger.debug(f"Ignoring embedded type 0x29 message")
558
+ elif message_type in [0x00, 0x06, 0x09, 0x1f, 0x2a]:
559
+ # Known non-switch message types - log at debug level
522
560
  self._logger.debug(
523
- f"Message type 0x{message_type:02x}: flags=0x{flags:02x}, "
561
+ f"Non-switch message type 0x{message_type:02x}: flags=0x{flags:02x}, "
562
+ f"param={parameter}, payload={b2a(payload)}"
563
+ )
564
+ else:
565
+ # Unknown message types - log at info level
566
+ self._logger.info(
567
+ f"Unknown message type 0x{message_type:02x}: flags=0x{flags:02x}, "
524
568
  f"param={parameter}, payload={b2a(payload)}"
525
569
  )
526
570
 
@@ -531,8 +575,11 @@ class CasambiClient:
531
575
  f"Ran out of data while parsing switch event packet! "
532
576
  f"Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
533
577
  )
578
+
579
+ if switch_events_found == 0:
580
+ self._logger.debug(f"No switch events found in packet: {b2a(data)}")
534
581
 
535
- def _processSwitchMessage(self, message_type: int, flags: int, button: int, payload: bytes, full_data: bytes, start_pos: int, packet_seq: int = None, raw_packet: bytes = None) -> None:
582
+ def _processSwitchMessage(self, message_type: int, flags: int, button: int, payload: bytes, full_data: bytes, start_pos: int, packet_seq: int = None, raw_packet: bytes = None, android_switch_event: dict = None) -> None:
536
583
  """Process a switch/button message (types 0x08 or 0x10)"""
537
584
  if not payload:
538
585
  self._logger.error("Switch message has empty payload")
@@ -547,6 +594,14 @@ class CasambiClient:
547
594
  extra_data = b''
548
595
  if len(payload) > 2:
549
596
  extra_data = payload[2:]
597
+
598
+ # Validate extra data for type 0x10
599
+ if message_type == 0x10 and len(extra_data) >= 3:
600
+ # Expected pattern: [unit_id_echo][0x12 or similar][0x00]
601
+ if extra_data[0] != unit_id:
602
+ self._logger.warning(
603
+ f"Extra data validation failed: unit_id_echo {extra_data[0]} != unit_id {unit_id}"
604
+ )
550
605
 
551
606
  event_string = "unknown"
552
607
 
@@ -581,6 +636,23 @@ class CasambiClient:
581
636
  f"Switch event (type 0x{message_type:02x}): button={button}, unit_id={unit_id}, "
582
637
  f"action={action_display} ({event_string}), flags=0x{flags:02x}"
583
638
  )
639
+
640
+ # Include Android parser comparison if available
641
+ android_comparison = None
642
+ if android_switch_event:
643
+ android_comparison = {
644
+ 'unit_id': android_switch_event['unit_id'],
645
+ 'button': android_switch_event['button'],
646
+ 'state': android_switch_event['state'],
647
+ 'param_p': android_switch_event['param_p'],
648
+ 'param_s': android_switch_event['param_s'],
649
+ 'android_log': android_switch_event['android_log']
650
+ }
651
+ # Log differences
652
+ if android_switch_event['unit_id'] != unit_id:
653
+ self._logger.warning(f"Unit ID mismatch: current={unit_id}, android={android_switch_event['unit_id']}")
654
+ if android_switch_event['button'] != button:
655
+ self._logger.warning(f"Button mismatch: current={button}, android={android_switch_event['button']}")
584
656
 
585
657
  self._dataCallback(
586
658
  IncommingPacketType.SwitchEvent,
@@ -597,6 +669,7 @@ class CasambiClient:
597
669
  "decrypted_data": b2a(full_data),
598
670
  "message_position": start_pos,
599
671
  "payload_hex": b2a(payload),
672
+ "android_comparison": android_comparison,
600
673
  },
601
674
  )
602
675
 
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: Enhanced Casambi Bluetooth client library with switch event support
5
5
  Home-page: https://github.com/rankjie/casambi-bt
6
6
  Author: rankjie
@@ -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=UxxOaITSt_I-sK-nL8AE0YNstieY5iPwd9oc5DGtWRE,23808
4
+ CasambiBt/_client.py,sha256=sYK0PpgzhqRc164Q6E2miPK0pNtQmPLoXiZ69-Wxgxk,27667
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.4.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
15
- casambi_bt_revamped-0.3.4.dist-info/METADATA,sha256=WLJVZXoFt18CY3wV2eRn-07ZEXQQKFvc6YGRy1OxdWA,3044
16
- casambi_bt_revamped-0.3.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
- casambi_bt_revamped-0.3.4.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
18
- casambi_bt_revamped-0.3.4.dist-info/RECORD,,
15
+ casambi_bt_revamped-0.3.6.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
16
+ casambi_bt_revamped-0.3.6.dist-info/METADATA,sha256=iUl6SlcgXC16rJxAiI5mFBfmlQohx-so3y5e_0pa5JQ,3044
17
+ casambi_bt_revamped-0.3.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
18
+ casambi_bt_revamped-0.3.6.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
19
+ casambi_bt_revamped-0.3.6.dist-info/RECORD,,