casambi-bt-revamped 0.3.5__tar.gz → 0.3.6.dev2__tar.gz

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.
Files changed (24) hide show
  1. {casambi_bt_revamped-0.3.5/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.6.dev2}/PKG-INFO +1 -1
  2. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/setup.cfg +1 -1
  3. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_client.py +63 -11
  4. casambi_bt_revamped-0.3.6.dev2/src/CasambiBt/_client_android_parser.py +215 -0
  5. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
  6. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/casambi_bt_revamped.egg-info/SOURCES.txt +1 -0
  7. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/LICENSE +0 -0
  8. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/README.md +0 -0
  9. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/pyproject.toml +0 -0
  10. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/__init__.py +0 -0
  11. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_cache.py +0 -0
  12. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_casambi.py +0 -0
  13. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_constants.py +0 -0
  14. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_discover.py +0 -0
  15. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_encryption.py +0 -0
  16. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_keystore.py +0 -0
  17. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_network.py +0 -0
  18. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_operation.py +0 -0
  19. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/_unit.py +0 -0
  20. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/errors.py +0 -0
  21. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/CasambiBt/py.typed +0 -0
  22. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  23. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  24. {casambi_bt_revamped-0.3.5 → casambi_bt_revamped-0.3.6.dev2}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.5
3
+ Version: 0.3.6.dev2
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,6 +1,6 @@
1
1
  [metadata]
2
2
  name = casambi-bt-revamped
3
- version = 0.3.5
3
+ version = 0.3.6.dev2
4
4
  author = rankjie
5
5
  author_email = rankjie@gmail.com
6
6
  description = Enhanced Casambi Bluetooth client library with switch event support
@@ -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,47 @@ 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
+ # Try Android parser on the raw packet BEFORE decryption
428
+ android_switch_event = None
429
+ if AndroidPacketParser:
430
+ try:
431
+ # The Android parser expects unencrypted packets, but let's try anyway
432
+ # to see the structure
433
+ self._logger.debug(f"Attempting Android parser on raw packet: {b2a(raw_encrypted_packet)}")
434
+ android_result = AndroidPacketParser.parse_complete_packet(raw_encrypted_packet)
435
+ self._logger.debug(f"Android parser raw result: {android_result}")
436
+ except Exception as e:
437
+ self._logger.debug(f"Android parser on raw packet failed (expected): {e}")
420
438
 
421
439
  try:
422
- data = self._encryptor.decryptAndVerify(data, data[:4] + self._nonce[4:])
440
+ decrypted_data = self._encryptor.decryptAndVerify(data, data[:4] + self._nonce[4:])
423
441
  except InvalidSignature:
424
442
  # We only drop packets with invalid signature here instead of going into an error state
425
443
  self._logger.error(f"Invalid signature for packet {b2a(data)}!")
426
444
  return
427
-
428
- packetType = data[0]
429
- self._logger.debug(f"Incoming data of type {packetType}: {b2a(data)}")
445
+
446
+ # Now try Android parser on decrypted data
447
+ if AndroidPacketParser:
448
+ try:
449
+ self._logger.debug(f"Attempting Android parser on decrypted packet: {b2a(decrypted_data)}")
450
+ android_result = AndroidPacketParser.parse_complete_packet(decrypted_data)
451
+ if android_result.get('switch_event'):
452
+ android_switch_event = android_result['switch_event']
453
+ self._logger.info(f"Android parser found switch event: {android_switch_event['android_log']}")
454
+ self._logger.debug(f"Android parser decrypted result: {android_result}")
455
+ except Exception as e:
456
+ self._logger.debug(f"Android parser on decrypted packet failed: {e}")
457
+
458
+ packetType = decrypted_data[0]
459
+ self._logger.debug(f"Incoming data of type {packetType}: {b2a(decrypted_data)}")
430
460
 
431
461
  if packetType == IncommingPacketType.UnitState:
432
- self._parseUnitStates(data[1:])
462
+ self._parseUnitStates(decrypted_data[1:])
433
463
  elif packetType == IncommingPacketType.SwitchEvent:
434
- self._parseSwitchEvent(data[1:], self._inPacketCount, raw_packet)
464
+ self._parseSwitchEvent(decrypted_data[1:], self._inPacketCount, raw_encrypted_packet, android_switch_event)
435
465
  elif packetType == IncommingPacketType.NetworkConfig:
436
466
  # We don't care about the config the network thinks it has.
437
467
  # We assume that cloud config and local config match.
@@ -485,9 +515,13 @@ class CasambiClient:
485
515
  f"Ran out of data while parsing unit state! Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
486
516
  )
487
517
 
488
- def _parseSwitchEvent(self, data: bytes, packet_seq: int = None, raw_packet: bytes = None) -> None:
518
+ def _parseSwitchEvent(self, data: bytes, packet_seq: int = None, raw_packet: bytes = None, android_switch_event: dict = None) -> None:
489
519
  """Parse switch event packet which contains multiple message types"""
490
520
  self._logger.info(f"Parsing incoming switch event packet... Data: {b2a(data)}")
521
+
522
+ # Log Android parser result if available
523
+ if android_switch_event:
524
+ self._logger.info(f"Android parser comparison: {android_switch_event['android_log']}")
491
525
 
492
526
  pos = 0
493
527
  oldPos = 0
@@ -516,7 +550,7 @@ 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)
553
+ self._processSwitchMessage(message_type, flags, parameter, payload, data, oldPos, packet_seq, raw_packet, android_switch_event)
520
554
  else:
521
555
  # Log other message types for now
522
556
  self._logger.debug(
@@ -532,7 +566,7 @@ class CasambiClient:
532
566
  f"Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
533
567
  )
534
568
 
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:
569
+ 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
570
  """Process a switch/button message (types 0x08 or 0x10)"""
537
571
  if not payload:
538
572
  self._logger.error("Switch message has empty payload")
@@ -581,6 +615,23 @@ class CasambiClient:
581
615
  f"Switch event (type 0x{message_type:02x}): button={button}, unit_id={unit_id}, "
582
616
  f"action={action_display} ({event_string}), flags=0x{flags:02x}"
583
617
  )
618
+
619
+ # Include Android parser comparison if available
620
+ android_comparison = None
621
+ if android_switch_event:
622
+ android_comparison = {
623
+ 'unit_id': android_switch_event['unit_id'],
624
+ 'button': android_switch_event['button'],
625
+ 'state': android_switch_event['state'],
626
+ 'param_p': android_switch_event['param_p'],
627
+ 'param_s': android_switch_event['param_s'],
628
+ 'android_log': android_switch_event['android_log']
629
+ }
630
+ # Log differences
631
+ if android_switch_event['unit_id'] != unit_id:
632
+ self._logger.warning(f"Unit ID mismatch: current={unit_id}, android={android_switch_event['unit_id']}")
633
+ if android_switch_event['button'] != button:
634
+ self._logger.warning(f"Button mismatch: current={button}, android={android_switch_event['button']}")
584
635
 
585
636
  self._dataCallback(
586
637
  IncommingPacketType.SwitchEvent,
@@ -597,6 +648,7 @@ class CasambiClient:
597
648
  "decrypted_data": b2a(full_data),
598
649
  "message_position": start_pos,
599
650
  "payload_hex": b2a(payload),
651
+ "android_comparison": android_comparison,
600
652
  },
601
653
  )
602
654
 
@@ -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.5
3
+ Version: 0.3.6.dev2
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
@@ -6,6 +6,7 @@ src/CasambiBt/__init__.py
6
6
  src/CasambiBt/_cache.py
7
7
  src/CasambiBt/_casambi.py
8
8
  src/CasambiBt/_client.py
9
+ src/CasambiBt/_client_android_parser.py
9
10
  src/CasambiBt/_constants.py
10
11
  src/CasambiBt/_discover.py
11
12
  src/CasambiBt/_encryption.py