casambi-bt-revamped 0.3.7.dev3__py3-none-any.whl → 0.3.7.dev5__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/_casambi.py +6 -10
- CasambiBt/_client.py +228 -69
- CasambiBt/_client_android_parser.py +215 -0
- CasambiBt/_unit.py +20 -1
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.7.dev5.dist-info}/METADATA +2 -2
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.7.dev5.dist-info}/RECORD +9 -8
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.7.dev5.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.7.dev5.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.7.dev5.dist-info}/top_level.txt +0 -0
CasambiBt/_casambi.py
CHANGED
|
@@ -423,14 +423,14 @@ class Casambi:
|
|
|
423
423
|
f"Handling switch event: unit_id={data.get('unit_id')}, "
|
|
424
424
|
f"button={data.get('button')}, event={data.get('event')}"
|
|
425
425
|
)
|
|
426
|
-
|
|
426
|
+
|
|
427
427
|
# Notify listeners
|
|
428
|
-
for
|
|
428
|
+
for h in self._switchEventCallbacks:
|
|
429
429
|
try:
|
|
430
|
-
|
|
430
|
+
h(data)
|
|
431
431
|
except Exception:
|
|
432
432
|
self._logger.error(
|
|
433
|
-
f"Exception occurred in switchEventCallback {
|
|
433
|
+
f"Exception occurred in switchEventCallback {h}.",
|
|
434
434
|
exc_info=True,
|
|
435
435
|
)
|
|
436
436
|
else:
|
|
@@ -457,9 +457,7 @@ class Casambi:
|
|
|
457
457
|
self._unitChangedCallbacks.remove(handler)
|
|
458
458
|
self._logger.debug(f"Removed unit changed handler {handler}")
|
|
459
459
|
|
|
460
|
-
def registerSwitchEventHandler(
|
|
461
|
-
self, handler: Callable[[dict[str, Any]], None]
|
|
462
|
-
) -> None:
|
|
460
|
+
def registerSwitchEventHandler(self, handler: Callable[[dict[str, Any]], None]) -> None:
|
|
463
461
|
"""Register a new handler for switch events.
|
|
464
462
|
|
|
465
463
|
This handler is called whenever a switch event is received.
|
|
@@ -476,9 +474,7 @@ class Casambi:
|
|
|
476
474
|
self._switchEventCallbacks.append(handler)
|
|
477
475
|
self._logger.debug(f"Registered switch event handler {handler}")
|
|
478
476
|
|
|
479
|
-
def unregisterSwitchEventHandler(
|
|
480
|
-
self, handler: Callable[[dict[str, Any]], None]
|
|
481
|
-
) -> None:
|
|
477
|
+
def unregisterSwitchEventHandler(self, handler: Callable[[dict[str, Any]], None]) -> None:
|
|
482
478
|
"""Unregister an existing switch event handler.
|
|
483
479
|
|
|
484
480
|
:param handler: The handler to unregister.
|
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):
|
|
@@ -414,18 +420,28 @@ class CasambiClient:
|
|
|
414
420
|
) -> None:
|
|
415
421
|
# TODO: Check incoming counter and direction flag
|
|
416
422
|
self._inPacketCount += 1
|
|
417
|
-
|
|
418
|
-
# Store raw encrypted packet for
|
|
423
|
+
|
|
424
|
+
# Store raw encrypted packet for Android parser analysis
|
|
419
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
|
-
decrypted_data = self._encryptor.decryptAndVerify(
|
|
423
|
-
data, data[:4] + self._nonce[4:]
|
|
424
|
-
)
|
|
433
|
+
decrypted_data = self._encryptor.decryptAndVerify(data, data[:4] + self._nonce[4:])
|
|
425
434
|
except InvalidSignature:
|
|
426
435
|
# We only drop packets with invalid signature here instead of going into an error state
|
|
427
436
|
self._logger.error(f"Invalid signature for packet {b2a(data)}!")
|
|
428
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
|
|
429
445
|
|
|
430
446
|
packetType = decrypted_data[0]
|
|
431
447
|
self._logger.debug(f"Incoming data of type {packetType}: {b2a(decrypted_data)}")
|
|
@@ -433,9 +449,7 @@ class CasambiClient:
|
|
|
433
449
|
if packetType == IncommingPacketType.UnitState:
|
|
434
450
|
self._parseUnitStates(decrypted_data[1:])
|
|
435
451
|
elif packetType == IncommingPacketType.SwitchEvent:
|
|
436
|
-
self._parseSwitchEvent(
|
|
437
|
-
decrypted_data[1:], self._inPacketCount, raw_encrypted_packet
|
|
438
|
-
)
|
|
452
|
+
self._parseSwitchEvent(decrypted_data[1:], self._inPacketCount, raw_encrypted_packet, android_switch_event)
|
|
439
453
|
elif packetType == IncommingPacketType.NetworkConfig:
|
|
440
454
|
# We don't care about the config the network thinks it has.
|
|
441
455
|
# We assume that cloud config and local config match.
|
|
@@ -489,29 +503,26 @@ class CasambiClient:
|
|
|
489
503
|
f"Ran out of data while parsing unit state! Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
|
|
490
504
|
)
|
|
491
505
|
|
|
492
|
-
def _parseSwitchEvent(
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
self._logger.info(
|
|
497
|
-
f"Parsing incoming switch event packet #{packet_seq}... Data: {b2a(data)}"
|
|
498
|
-
)
|
|
499
|
-
|
|
506
|
+
def _parseSwitchEvent(self, data: bytes, packet_seq: int = None, raw_packet: bytes = None, android_switch_event: dict = None) -> None:
|
|
507
|
+
"""Parse switch event packet which contains multiple message types"""
|
|
508
|
+
self._logger.info(f"Parsing incoming switch event packet #{packet_seq}... Data: {b2a(data)}")
|
|
509
|
+
|
|
500
510
|
# Special handling for message type 0x29 - not a switch event
|
|
501
511
|
if len(data) >= 1 and data[0] == 0x29:
|
|
502
|
-
self._logger.debug(
|
|
503
|
-
f"Ignoring message type 0x29 (not a switch event): {b2a(data)}"
|
|
504
|
-
)
|
|
512
|
+
self._logger.debug(f"Ignoring message type 0x29 (not a switch event): {b2a(data)}")
|
|
505
513
|
return
|
|
506
514
|
|
|
515
|
+
# NEW PARSING: Android-style protocol parsing (for comparison only)
|
|
516
|
+
self._parseAndroidProtocol(data, packet_seq, raw_packet)
|
|
517
|
+
|
|
507
518
|
pos = 0
|
|
508
519
|
oldPos = 0
|
|
509
520
|
switch_events_found = 0
|
|
510
|
-
|
|
521
|
+
|
|
511
522
|
try:
|
|
512
523
|
while pos <= len(data) - 3:
|
|
513
524
|
oldPos = pos
|
|
514
|
-
|
|
525
|
+
|
|
515
526
|
# Parse message header
|
|
516
527
|
message_type = data[pos]
|
|
517
528
|
flags = data[pos + 1]
|
|
@@ -543,22 +554,41 @@ class CasambiClient:
|
|
|
543
554
|
# Process based on message type
|
|
544
555
|
if message_type == 0x08 or message_type == 0x10: # Switch/button events
|
|
545
556
|
switch_events_found += 1
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
557
|
+
|
|
558
|
+
# Enhanced button extraction for battery-powered switches
|
|
559
|
+
# For type 0x08, the parameter byte encodes the button differently
|
|
560
|
+
if message_type == 0x08:
|
|
561
|
+
# Type 0x08: Check if this might be using Android-style encoding
|
|
562
|
+
# where button is in upper nibble when lower nibble is 0
|
|
563
|
+
button_lower = parameter & 0x0F
|
|
564
|
+
button_upper = (parameter >> 4) & 0x0F
|
|
565
|
+
|
|
566
|
+
# Log both interpretations for comparison
|
|
553
567
|
self._logger.debug(
|
|
554
|
-
f"
|
|
568
|
+
f"[TYPE 0x08 ANALYSIS] parameter=0x{parameter:02x}, "
|
|
569
|
+
f"lower_nibble={button_lower}, upper_nibble={button_upper}"
|
|
555
570
|
)
|
|
571
|
+
|
|
572
|
+
# If lower nibble is 0 and upper nibble is not, use upper
|
|
573
|
+
# This handles cases like 0x20 (button 2), 0x30 (button 3), etc.
|
|
574
|
+
if button_lower == 0 and button_upper != 0:
|
|
575
|
+
button = button_upper
|
|
576
|
+
self._logger.info(
|
|
577
|
+
f"[TYPE 0x08 FIX] Using upper nibble for button: {button} "
|
|
578
|
+
f"(parameter=0x{parameter:02x})"
|
|
579
|
+
)
|
|
580
|
+
else:
|
|
581
|
+
button = button_lower
|
|
582
|
+
self._logger.debug(
|
|
583
|
+
f"[TYPE 0x08] Using lower nibble for button: {button} "
|
|
584
|
+
f"(parameter=0x{parameter:02x})"
|
|
585
|
+
)
|
|
556
586
|
else:
|
|
557
|
-
|
|
587
|
+
# Type 0x10: Standard extraction from lower nibble
|
|
588
|
+
button = parameter & 0x0F
|
|
558
589
|
self._logger.debug(
|
|
559
|
-
f"
|
|
590
|
+
f"[TYPE 0x10] Button extraction: parameter=0x{parameter:02x}, button={button}"
|
|
560
591
|
)
|
|
561
|
-
|
|
562
592
|
# For type 0x10 messages, we need to pass additional data beyond the declared payload
|
|
563
593
|
if message_type == 0x10:
|
|
564
594
|
# Extend to include at least 10 bytes from message start for state byte
|
|
@@ -566,20 +596,11 @@ class CasambiClient:
|
|
|
566
596
|
full_message_data = data[oldPos:extended_end]
|
|
567
597
|
else:
|
|
568
598
|
full_message_data = data
|
|
569
|
-
self._processSwitchMessage(
|
|
570
|
-
message_type,
|
|
571
|
-
flags,
|
|
572
|
-
button,
|
|
573
|
-
payload,
|
|
574
|
-
full_message_data,
|
|
575
|
-
oldPos,
|
|
576
|
-
packet_seq,
|
|
577
|
-
raw_packet,
|
|
578
|
-
)
|
|
599
|
+
self._processSwitchMessage(message_type, flags, button, payload, full_message_data, oldPos, packet_seq, raw_packet, android_switch_event)
|
|
579
600
|
elif message_type == 0x29:
|
|
580
601
|
# This shouldn't happen due to check above, but just in case
|
|
581
|
-
self._logger.debug("Ignoring embedded type 0x29 message")
|
|
582
|
-
elif message_type in [0x00, 0x06, 0x09,
|
|
602
|
+
self._logger.debug(f"Ignoring embedded type 0x29 message")
|
|
603
|
+
elif message_type in [0x00, 0x06, 0x09, 0x1f, 0x2a]:
|
|
583
604
|
# Known non-switch message types - log at debug level
|
|
584
605
|
self._logger.debug(
|
|
585
606
|
f"Non-switch message type 0x{message_type:02x}: flags=0x{flags:02x}, "
|
|
@@ -599,10 +620,83 @@ class CasambiClient:
|
|
|
599
620
|
f"Ran out of data while parsing switch event packet! "
|
|
600
621
|
f"Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
|
|
601
622
|
)
|
|
602
|
-
|
|
623
|
+
|
|
603
624
|
if switch_events_found == 0:
|
|
604
625
|
self._logger.debug(f"No switch events found in packet: {b2a(data)}")
|
|
605
626
|
|
|
627
|
+
def _parseAndroidProtocol(
|
|
628
|
+
self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
|
|
629
|
+
) -> None:
|
|
630
|
+
"""
|
|
631
|
+
Parse using Android app's protocol structure (for comparison only).
|
|
632
|
+
This doesn't generate events, just logs what it would detect.
|
|
633
|
+
|
|
634
|
+
Android protocol structure:
|
|
635
|
+
- 2 bytes: Header/flags
|
|
636
|
+
- 1 byte: Command type (29-36 for ButtonEvent0-7)
|
|
637
|
+
- 2 bytes: Origin
|
|
638
|
+
- 2 bytes: Target (lower byte = type, upper byte = unit ID)
|
|
639
|
+
- 2 bytes: Age
|
|
640
|
+
- Variable: Payload
|
|
641
|
+
"""
|
|
642
|
+
try:
|
|
643
|
+
# Check if data looks like Android protocol format
|
|
644
|
+
if len(data) < 9:
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
# Try to parse as Android protocol
|
|
648
|
+
pos = 0
|
|
649
|
+
if data[0] in [0x08, 0x10]: # This is BLE protocol, not Android protocol
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
# Attempt Android protocol parsing
|
|
653
|
+
try:
|
|
654
|
+
import struct
|
|
655
|
+
# Read header
|
|
656
|
+
header = struct.unpack_from(">H", data, 0)[0]
|
|
657
|
+
command_type = data[2] if len(data) > 2 else 0
|
|
658
|
+
origin = struct.unpack_from(">H", data, 3)[0] if len(data) > 4 else 0
|
|
659
|
+
target = struct.unpack_from(">H", data, 5)[0] if len(data) > 6 else 0
|
|
660
|
+
age = struct.unpack_from(">H", data, 7)[0] if len(data) > 8 else 0
|
|
661
|
+
|
|
662
|
+
payload_length = (header & 0x3F) # Lower 6 bits
|
|
663
|
+
lifetime = (header >> 11) & 0x0F # Bits 11-14
|
|
664
|
+
|
|
665
|
+
target_type = target & 0xFF # Lower byte
|
|
666
|
+
unit_id = target >> 8 # Upper byte
|
|
667
|
+
|
|
668
|
+
# Check if this is a switch event (target type = 6)
|
|
669
|
+
if target_type == 6 and 29 <= command_type <= 36:
|
|
670
|
+
button_index = command_type - 29
|
|
671
|
+
|
|
672
|
+
if len(data) >= 10:
|
|
673
|
+
payload_start = 9
|
|
674
|
+
if len(data) >= payload_start + 1:
|
|
675
|
+
first_payload_byte = data[payload_start]
|
|
676
|
+
param_p = (first_payload_byte >> 3) & 0x0F
|
|
677
|
+
param_s = first_payload_byte & 0x07
|
|
678
|
+
state = (first_payload_byte & 0x80) >> 7
|
|
679
|
+
|
|
680
|
+
self._logger.info(
|
|
681
|
+
f"[ANDROID PROTOCOL] Unit {unit_id} Switch event: "
|
|
682
|
+
f"button #{button_index} (P{param_p} S{param_s}) = {state} "
|
|
683
|
+
f"(1=pressed, 0=released)"
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
# Compare with what current parsing would detect
|
|
687
|
+
self._logger.debug(
|
|
688
|
+
f"[ANDROID PROTOCOL] Full parse: header=0x{header:04x}, "
|
|
689
|
+
f"cmd={command_type}, origin={origin}, target=0x{target:04x}, "
|
|
690
|
+
f"lifetime={lifetime}, age={age}, payload_len={payload_length}"
|
|
691
|
+
)
|
|
692
|
+
except:
|
|
693
|
+
# Not Android protocol format, skip
|
|
694
|
+
pass
|
|
695
|
+
|
|
696
|
+
except Exception as e:
|
|
697
|
+
# Silently ignore - this is just for comparison
|
|
698
|
+
pass
|
|
699
|
+
|
|
606
700
|
def _processSwitchMessage(
|
|
607
701
|
self,
|
|
608
702
|
message_type: int,
|
|
@@ -623,14 +717,14 @@ class CasambiClient:
|
|
|
623
717
|
if message_type == 0x10 and len(payload) >= 3:
|
|
624
718
|
# Type 0x10: unit_id is at payload[2]
|
|
625
719
|
unit_id = payload[2]
|
|
626
|
-
extra_data = payload[3:] if len(payload) > 3 else b
|
|
720
|
+
extra_data = payload[3:] if len(payload) > 3 else b''
|
|
627
721
|
else:
|
|
628
722
|
# Standard parsing for other message types
|
|
629
723
|
unit_id = payload[0]
|
|
630
|
-
extra_data = b
|
|
724
|
+
extra_data = b''
|
|
631
725
|
if len(payload) > 2:
|
|
632
726
|
extra_data = payload[2:]
|
|
633
|
-
|
|
727
|
+
|
|
634
728
|
# Extract action based on message type (action SHOULD be different for press vs release)
|
|
635
729
|
if message_type == 0x10 and len(payload) > 1:
|
|
636
730
|
# Type 0x10: action is at payload[1]
|
|
@@ -642,50 +736,91 @@ class CasambiClient:
|
|
|
642
736
|
action = None
|
|
643
737
|
|
|
644
738
|
event_string = "unknown"
|
|
645
|
-
|
|
739
|
+
|
|
646
740
|
# Different interpretation based on message type
|
|
647
741
|
if message_type == 0x08:
|
|
648
742
|
# Type 0x08: Use bit 1 of action for press/release
|
|
649
743
|
if action is not None:
|
|
650
744
|
is_release = (action >> 1) & 1
|
|
651
745
|
event_string = "button_release" if is_release else "button_press"
|
|
746
|
+
|
|
747
|
+
# NEW: Enhanced logging for battery-powered switches
|
|
748
|
+
self._logger.debug(
|
|
749
|
+
f"[TYPE 0x08 STATE] action=0x{action:02x}, release_bit={is_release}, "
|
|
750
|
+
f"event={event_string}, unit_id={unit_id}, button={button}"
|
|
751
|
+
)
|
|
652
752
|
elif message_type == 0x10:
|
|
653
753
|
# Type 0x10: The state byte is at position 9 (0-indexed) from message start
|
|
654
|
-
# This applies to all units,
|
|
754
|
+
# This applies to all units, including battery-powered switches
|
|
655
755
|
# full_data for type 0x10 is the message data starting from position 0
|
|
656
756
|
state_pos = 9
|
|
657
757
|
if len(full_data) > state_pos:
|
|
658
758
|
state_byte = full_data[state_pos]
|
|
759
|
+
|
|
760
|
+
# NEW: Enhanced state detection with logging
|
|
761
|
+
self._logger.debug(
|
|
762
|
+
f"[TYPE 0x10 STATE] State byte at pos {state_pos}: 0x{state_byte:02x}"
|
|
763
|
+
)
|
|
764
|
+
|
|
659
765
|
if state_byte == 0x01:
|
|
660
766
|
event_string = "button_press"
|
|
661
767
|
elif state_byte == 0x02:
|
|
662
768
|
event_string = "button_release"
|
|
663
769
|
elif state_byte == 0x09:
|
|
664
770
|
event_string = "button_hold"
|
|
665
|
-
elif state_byte ==
|
|
771
|
+
elif state_byte == 0x0c:
|
|
666
772
|
event_string = "button_release_after_hold"
|
|
667
773
|
else:
|
|
668
|
-
|
|
669
|
-
|
|
774
|
+
# More detailed logging for unknown states
|
|
775
|
+
self._logger.info(
|
|
776
|
+
f"[TYPE 0x10 STATE] Unknown state byte 0x{state_byte:02x} at pos {state_pos}, "
|
|
777
|
+
f"unit_id={unit_id}, button={button}, full_data={b2a(full_data)}"
|
|
670
778
|
)
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
779
|
+
|
|
780
|
+
# Enhanced fallback logic for battery-powered switches
|
|
781
|
+
# Check multiple patterns that indicate release
|
|
782
|
+
if len(extra_data) >= 1:
|
|
783
|
+
first_extra = extra_data[0]
|
|
784
|
+
if first_extra == 0x12:
|
|
785
|
+
event_string = "button_release"
|
|
786
|
+
self._logger.debug("[TYPE 0x10 STATE] Detected release via 0x12 pattern")
|
|
787
|
+
elif first_extra in [0x00, 0x10]:
|
|
788
|
+
event_string = "button_press"
|
|
789
|
+
self._logger.debug(f"[TYPE 0x10 STATE] Detected press via 0x{first_extra:02x} pattern")
|
|
790
|
+
else:
|
|
791
|
+
# Check action byte patterns
|
|
792
|
+
if action is not None:
|
|
793
|
+
# Common patterns: 0xf8/0xfb = press, 0xfa/0xfc = release
|
|
794
|
+
if action in [0xf8, 0xfb, 0xf7, 0xf9]:
|
|
795
|
+
event_string = "button_press"
|
|
796
|
+
self._logger.debug(f"[TYPE 0x10 STATE] Detected press via action 0x{action:02x}")
|
|
797
|
+
elif action in [0xfa, 0xfc, 0xfd, 0xfe]:
|
|
798
|
+
event_string = "button_release"
|
|
799
|
+
self._logger.debug(f"[TYPE 0x10 STATE] Detected release via action 0x{action:02x}")
|
|
800
|
+
else:
|
|
801
|
+
event_string = "button_press" # Default to press
|
|
802
|
+
self._logger.debug(f"[TYPE 0x10 STATE] Defaulting to press for action 0x{action:02x}")
|
|
803
|
+
else:
|
|
804
|
+
event_string = "button_press" # Default to press
|
|
674
805
|
else:
|
|
675
|
-
event_string = "button_press"
|
|
806
|
+
event_string = "button_press" # Default to press when no extra data
|
|
676
807
|
else:
|
|
677
808
|
# Fallback when message is too short
|
|
809
|
+
self._logger.warning(
|
|
810
|
+
f"[TYPE 0x10 STATE] Message too short for state byte (len={len(full_data)}), "
|
|
811
|
+
f"unit_id={unit_id}, button={button}, payload={b2a(payload)}"
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
# Use enhanced fallback detection
|
|
678
815
|
if len(extra_data) >= 1 and extra_data[0] == 0x12:
|
|
679
816
|
event_string = "button_release"
|
|
680
|
-
self._logger.debug(
|
|
681
|
-
|
|
682
|
-
|
|
817
|
+
self._logger.debug("[TYPE 0x10 STATE] Using 0x12 pattern for release (short message)")
|
|
818
|
+
elif action is not None and action in [0xfa, 0xfc, 0xfd, 0xfe]:
|
|
819
|
+
event_string = "button_release"
|
|
820
|
+
self._logger.debug(f"[TYPE 0x10 STATE] Using action pattern 0x{action:02x} for release (short message)")
|
|
683
821
|
else:
|
|
684
|
-
#
|
|
685
|
-
self._logger.
|
|
686
|
-
f"Type 0x10 message missing state info, unit_id={unit_id}, payload={b2a(payload)}"
|
|
687
|
-
)
|
|
688
|
-
event_string = "unknown"
|
|
822
|
+
event_string = "button_press" # Default to press
|
|
823
|
+
self._logger.debug("[TYPE 0x10 STATE] Defaulting to press (short message)")
|
|
689
824
|
|
|
690
825
|
action_display = f"{action:#04x}" if action is not None else "N/A"
|
|
691
826
|
|
|
@@ -693,11 +828,33 @@ class CasambiClient:
|
|
|
693
828
|
f"Switch event (type 0x{message_type:02x}): button={button}, unit_id={unit_id}, "
|
|
694
829
|
f"action={action_display} ({event_string}), flags=0x{flags:02x}"
|
|
695
830
|
)
|
|
696
|
-
|
|
697
|
-
#
|
|
698
|
-
|
|
831
|
+
|
|
832
|
+
# Include Android parser comparison if available
|
|
833
|
+
android_comparison = None
|
|
834
|
+
if android_switch_event:
|
|
835
|
+
android_comparison = {
|
|
836
|
+
'unit_id': android_switch_event['unit_id'],
|
|
837
|
+
'button': android_switch_event['button'],
|
|
838
|
+
'state': android_switch_event['state'],
|
|
839
|
+
'param_p': android_switch_event['param_p'],
|
|
840
|
+
'param_s': android_switch_event['param_s'],
|
|
841
|
+
'android_log': android_switch_event['android_log']
|
|
842
|
+
}
|
|
843
|
+
# Log differences
|
|
844
|
+
if android_switch_event['unit_id'] != unit_id:
|
|
845
|
+
self._logger.warning(f"Unit ID mismatch: current={unit_id}, android={android_switch_event['unit_id']}")
|
|
846
|
+
if android_switch_event['button'] != button:
|
|
847
|
+
self._logger.warning(f"Button mismatch: current={button}, android={android_switch_event['button']}")
|
|
848
|
+
|
|
849
|
+
# Extract controlling unit if present
|
|
850
|
+
controlling_unit = None
|
|
851
|
+
# This is redundant since we already return early if unit_id_echo != unit_id
|
|
852
|
+
# Removing to avoid confusion
|
|
853
|
+
|
|
854
|
+
# Filter out all type 0x08 messages
|
|
855
|
+
if message_type == 0x08:
|
|
699
856
|
self._logger.debug(
|
|
700
|
-
f"Filtering out type 0x08
|
|
857
|
+
f"Filtering out type 0x08 event: button={button}, unit_id={unit_id}, "
|
|
701
858
|
f"action={action_display}, flags=0x{flags:02x}"
|
|
702
859
|
)
|
|
703
860
|
return
|
|
@@ -712,11 +869,13 @@ class CasambiClient:
|
|
|
712
869
|
"event": event_string,
|
|
713
870
|
"flags": flags,
|
|
714
871
|
"extra_data": extra_data,
|
|
872
|
+
"controlling_unit": controlling_unit,
|
|
715
873
|
"packet_sequence": packet_seq,
|
|
716
874
|
"raw_packet": b2a(raw_packet) if raw_packet else None,
|
|
717
875
|
"decrypted_data": b2a(full_data),
|
|
718
876
|
"message_position": start_pos,
|
|
719
877
|
"payload_hex": b2a(payload),
|
|
878
|
+
"android_comparison": android_comparison,
|
|
720
879
|
},
|
|
721
880
|
)
|
|
722
881
|
|
|
@@ -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
|
CasambiBt/_unit.py
CHANGED
|
@@ -111,6 +111,7 @@ class UnitState:
|
|
|
111
111
|
self._colorsource: ColorSource | None = None
|
|
112
112
|
self._xy: tuple[float, float] | None = None
|
|
113
113
|
self._slider: int | None = None
|
|
114
|
+
self._onoff: bool | None = None
|
|
114
115
|
|
|
115
116
|
def _check_range(
|
|
116
117
|
self, value: int | float, min: int | float, max: int | float
|
|
@@ -269,8 +270,20 @@ class UnitState:
|
|
|
269
270
|
def slider(self) -> None:
|
|
270
271
|
self.slider = None
|
|
271
272
|
|
|
273
|
+
@property
|
|
274
|
+
def onoff(self) -> bool | None:
|
|
275
|
+
return self._onoff
|
|
276
|
+
|
|
277
|
+
@onoff.setter
|
|
278
|
+
def onoff(self, value: bool) -> None:
|
|
279
|
+
self._onoff = value
|
|
280
|
+
|
|
281
|
+
@onoff.deleter
|
|
282
|
+
def onoff(self) -> None:
|
|
283
|
+
self._onoff = None
|
|
284
|
+
|
|
272
285
|
def __repr__(self) -> str:
|
|
273
|
-
return f"UnitState(dimmer={self.dimmer}, vertical={self._vertical}, rgb={self.rgb.__repr__()}, white={self.white}, temperature={self.temperature}, colorsource={self.colorsource}, xy={self.xy}, slider={self.slider})"
|
|
286
|
+
return f"UnitState(dimmer={self.dimmer}, vertical={self._vertical}, rgb={self.rgb.__repr__()}, white={self.white}, temperature={self.temperature}, colorsource={self.colorsource}, xy={self.xy}, slider={self.slider}, onoff={self.onoff})"
|
|
274
287
|
|
|
275
288
|
|
|
276
289
|
# TODO: Make unit immutable (refactor state, on, online out of it)
|
|
@@ -308,6 +321,8 @@ class Unit:
|
|
|
308
321
|
@property
|
|
309
322
|
def is_on(self) -> bool:
|
|
310
323
|
"""Determine whether the unit is turned on."""
|
|
324
|
+
if self.unitType.get_control(UnitControlType.ONOFF) and self._state:
|
|
325
|
+
return self._on and self._state.onoff is True
|
|
311
326
|
if self.unitType.get_control(UnitControlType.DIMMER) and self._state:
|
|
312
327
|
return (
|
|
313
328
|
self._on and self._state.dimmer is not None and self._state.dimmer > 0
|
|
@@ -385,6 +400,8 @@ class Unit:
|
|
|
385
400
|
elif c.type == UnitControlType.SLIDER and state.slider is not None:
|
|
386
401
|
scale = UnitState.SLIDER_RESOLUTION - c.length
|
|
387
402
|
scaledValue = state.slider >> scale
|
|
403
|
+
elif c.type == UnitControlType.ONOFF and state.onoff is not None:
|
|
404
|
+
scaledValue = 1 if state.onoff else 0
|
|
388
405
|
|
|
389
406
|
# Use default if unsupported type or unset value in state
|
|
390
407
|
else:
|
|
@@ -477,6 +494,8 @@ class Unit:
|
|
|
477
494
|
elif c.type == UnitControlType.SLIDER:
|
|
478
495
|
scale = UnitState.SLIDER_RESOLUTION - c.length
|
|
479
496
|
self._state.slider = cInt << scale
|
|
497
|
+
elif c.type == UnitControlType.ONOFF:
|
|
498
|
+
self._state.onoff = cInt != 0
|
|
480
499
|
elif c.type == UnitControlType.UNKOWN:
|
|
481
500
|
# Might be useful for implementing more state types
|
|
482
501
|
_LOGGER.debug(
|
{casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.7.dev5.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.7.
|
|
3
|
+
Version: 0.3.7.dev5
|
|
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
|
|
@@ -46,7 +46,7 @@ Have a look at `demo.py` for a small example.
|
|
|
46
46
|
|
|
47
47
|
### Switch Event Support
|
|
48
48
|
|
|
49
|
-
This
|
|
49
|
+
This fork adds support for receiving switch button events:
|
|
50
50
|
|
|
51
51
|
```python
|
|
52
52
|
from CasambiBt import Casambi
|
{casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.7.dev5.dist-info}/RECORD
RENAMED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
|
|
2
2
|
CasambiBt/_cache.py,sha256=KZ2xbiHAHXUPa8Gw_75Nw9NL4QSY_sTWHbyYXYUDaB0,3865
|
|
3
|
-
CasambiBt/_casambi.py,sha256=
|
|
4
|
-
CasambiBt/_client.py,sha256=
|
|
3
|
+
CasambiBt/_casambi.py,sha256=gLLkhEcObgapqTx5Mk7WRClyG29UyfZYZCCIhhOg4H4,23101
|
|
4
|
+
CasambiBt/_client.py,sha256=zjenWQE6EuedFJS87HunDvFLYZnnIO-BmM-zqqXfX1Q,38235
|
|
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
|
|
8
9
|
CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
|
|
9
10
|
CasambiBt/_network.py,sha256=qcsWn_EsBexzXCv14JcpSIymhuR6Eaf479lZdzpfYBM,14417
|
|
10
11
|
CasambiBt/_operation.py,sha256=-BuC1Bvtg-G-zSN_b_0JMvXdHZaR6LbTw0S425jg96c,842
|
|
11
|
-
CasambiBt/_unit.py,sha256=
|
|
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.7.
|
|
15
|
-
casambi_bt_revamped-0.3.7.
|
|
16
|
-
casambi_bt_revamped-0.3.7.
|
|
17
|
-
casambi_bt_revamped-0.3.7.
|
|
18
|
-
casambi_bt_revamped-0.3.7.
|
|
15
|
+
casambi_bt_revamped-0.3.7.dev5.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
16
|
+
casambi_bt_revamped-0.3.7.dev5.dist-info/METADATA,sha256=bVPa_6JqEvAHMjmUZbdjpb2T0bElv2uA69OIQQvZ7Aw,3049
|
|
17
|
+
casambi_bt_revamped-0.3.7.dev5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
18
|
+
casambi_bt_revamped-0.3.7.dev5.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
19
|
+
casambi_bt_revamped-0.3.7.dev5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.7.dev5.dist-info}/top_level.txt
RENAMED
|
File without changes
|