casambi-bt-revamped 0.3.7.dev3__tar.gz → 0.3.7.dev4__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.
- {casambi_bt_revamped-0.3.7.dev3/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.7.dev4}/PKG-INFO +2 -2
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/README.md +1 -1
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/setup.cfg +1 -1
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_casambi.py +6 -10
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_client.py +75 -72
- casambi_bt_revamped-0.3.7.dev4/src/CasambiBt/_client_android_parser.py +215 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_network.py +1 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_unit.py +22 -1
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4/src/casambi_bt_revamped.egg-info}/PKG-INFO +2 -2
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/casambi_bt_revamped.egg-info/SOURCES.txt +1 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/LICENSE +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/pyproject.toml +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/__init__.py +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_cache.py +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_constants.py +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_discover.py +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_encryption.py +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_keystore.py +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_operation.py +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/errors.py +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/py.typed +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
- {casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/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.7.
|
|
3
|
+
Version: 0.3.7.dev4
|
|
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
|
|
@@ -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.
|
|
@@ -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,23 @@ 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
|
|
|
507
515
|
pos = 0
|
|
508
516
|
oldPos = 0
|
|
509
517
|
switch_events_found = 0
|
|
510
|
-
|
|
518
|
+
|
|
511
519
|
try:
|
|
512
520
|
while pos <= len(data) - 3:
|
|
513
521
|
oldPos = pos
|
|
514
|
-
|
|
522
|
+
|
|
515
523
|
# Parse message header
|
|
516
524
|
message_type = data[pos]
|
|
517
525
|
flags = data[pos + 1]
|
|
@@ -546,19 +554,15 @@ class CasambiClient:
|
|
|
546
554
|
# Extract button ID - try both upper and lower nibbles
|
|
547
555
|
button_lower = parameter & 0x0F
|
|
548
556
|
button_upper = (parameter >> 4) & 0x0F
|
|
549
|
-
|
|
557
|
+
|
|
550
558
|
# Use upper 4 bits if lower 4 bits are 0, otherwise use lower 4 bits
|
|
551
559
|
if button_lower == 0 and button_upper != 0:
|
|
552
560
|
button = button_upper
|
|
553
|
-
self._logger.debug(
|
|
554
|
-
f"EVO button extraction: parameter=0x{parameter:02x}, using upper nibble, button={button}"
|
|
555
|
-
)
|
|
561
|
+
self._logger.debug(f"EVO button extraction: parameter=0x{parameter:02x}, using upper nibble, button={button}")
|
|
556
562
|
else:
|
|
557
563
|
button = button_lower
|
|
558
|
-
self._logger.debug(
|
|
559
|
-
|
|
560
|
-
)
|
|
561
|
-
|
|
564
|
+
self._logger.debug(f"EVO button extraction: parameter=0x{parameter:02x}, using lower nibble, button={button}")
|
|
565
|
+
|
|
562
566
|
# For type 0x10 messages, we need to pass additional data beyond the declared payload
|
|
563
567
|
if message_type == 0x10:
|
|
564
568
|
# Extend to include at least 10 bytes from message start for state byte
|
|
@@ -566,20 +570,11 @@ class CasambiClient:
|
|
|
566
570
|
full_message_data = data[oldPos:extended_end]
|
|
567
571
|
else:
|
|
568
572
|
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
|
-
)
|
|
573
|
+
self._processSwitchMessage(message_type, flags, button, payload, full_message_data, oldPos, packet_seq, raw_packet, android_switch_event)
|
|
579
574
|
elif message_type == 0x29:
|
|
580
575
|
# 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,
|
|
576
|
+
self._logger.debug(f"Ignoring embedded type 0x29 message")
|
|
577
|
+
elif message_type in [0x00, 0x06, 0x09, 0x1f, 0x2a]:
|
|
583
578
|
# Known non-switch message types - log at debug level
|
|
584
579
|
self._logger.debug(
|
|
585
580
|
f"Non-switch message type 0x{message_type:02x}: flags=0x{flags:02x}, "
|
|
@@ -599,22 +594,12 @@ class CasambiClient:
|
|
|
599
594
|
f"Ran out of data while parsing switch event packet! "
|
|
600
595
|
f"Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
|
|
601
596
|
)
|
|
602
|
-
|
|
597
|
+
|
|
603
598
|
if switch_events_found == 0:
|
|
604
599
|
self._logger.debug(f"No switch events found in packet: {b2a(data)}")
|
|
605
600
|
|
|
606
|
-
def _processSwitchMessage(
|
|
607
|
-
|
|
608
|
-
message_type: int,
|
|
609
|
-
flags: int,
|
|
610
|
-
button: int,
|
|
611
|
-
payload: bytes,
|
|
612
|
-
full_data: bytes,
|
|
613
|
-
start_pos: int,
|
|
614
|
-
packet_seq: int = None,
|
|
615
|
-
raw_packet: bytes = None,
|
|
616
|
-
) -> None:
|
|
617
|
-
"""Process a switch/button message (types 0x08 or 0x10)."""
|
|
601
|
+
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:
|
|
602
|
+
"""Process a switch/button message (types 0x08 or 0x10)"""
|
|
618
603
|
if not payload:
|
|
619
604
|
self._logger.error("Switch message has empty payload")
|
|
620
605
|
return
|
|
@@ -623,14 +608,14 @@ class CasambiClient:
|
|
|
623
608
|
if message_type == 0x10 and len(payload) >= 3:
|
|
624
609
|
# Type 0x10: unit_id is at payload[2]
|
|
625
610
|
unit_id = payload[2]
|
|
626
|
-
extra_data = payload[3:] if len(payload) > 3 else b
|
|
611
|
+
extra_data = payload[3:] if len(payload) > 3 else b''
|
|
627
612
|
else:
|
|
628
613
|
# Standard parsing for other message types
|
|
629
614
|
unit_id = payload[0]
|
|
630
|
-
extra_data = b
|
|
615
|
+
extra_data = b''
|
|
631
616
|
if len(payload) > 2:
|
|
632
617
|
extra_data = payload[2:]
|
|
633
|
-
|
|
618
|
+
|
|
634
619
|
# Extract action based on message type (action SHOULD be different for press vs release)
|
|
635
620
|
if message_type == 0x10 and len(payload) > 1:
|
|
636
621
|
# Type 0x10: action is at payload[1]
|
|
@@ -642,7 +627,7 @@ class CasambiClient:
|
|
|
642
627
|
action = None
|
|
643
628
|
|
|
644
629
|
event_string = "unknown"
|
|
645
|
-
|
|
630
|
+
|
|
646
631
|
# Different interpretation based on message type
|
|
647
632
|
if message_type == 0x08:
|
|
648
633
|
# Type 0x08: Use bit 1 of action for press/release
|
|
@@ -662,12 +647,10 @@ class CasambiClient:
|
|
|
662
647
|
event_string = "button_release"
|
|
663
648
|
elif state_byte == 0x09:
|
|
664
649
|
event_string = "button_hold"
|
|
665
|
-
elif state_byte ==
|
|
650
|
+
elif state_byte == 0x0c:
|
|
666
651
|
event_string = "button_release_after_hold"
|
|
667
652
|
else:
|
|
668
|
-
self._logger.debug(
|
|
669
|
-
f"Type 0x10: Unknown state byte 0x{state_byte:02x} at message pos {state_pos}"
|
|
670
|
-
)
|
|
653
|
+
self._logger.debug(f"Type 0x10: Unknown state byte 0x{state_byte:02x} at message pos {state_pos}")
|
|
671
654
|
# Fallback: check if extra_data starts with 0x12 (indicates release)
|
|
672
655
|
if len(extra_data) >= 1 and extra_data[0] == 0x12:
|
|
673
656
|
event_string = "button_release"
|
|
@@ -677,14 +660,10 @@ class CasambiClient:
|
|
|
677
660
|
# Fallback when message is too short
|
|
678
661
|
if len(extra_data) >= 1 and extra_data[0] == 0x12:
|
|
679
662
|
event_string = "button_release"
|
|
680
|
-
self._logger.debug(
|
|
681
|
-
"Type 0x10: Using extra_data pattern for release detection"
|
|
682
|
-
)
|
|
663
|
+
self._logger.debug(f"Type 0x10: Using extra_data pattern for release detection")
|
|
683
664
|
else:
|
|
684
665
|
# Cannot determine state
|
|
685
|
-
self._logger.warning(
|
|
686
|
-
f"Type 0x10 message missing state info, unit_id={unit_id}, payload={b2a(payload)}"
|
|
687
|
-
)
|
|
666
|
+
self._logger.warning(f"Type 0x10 message missing state info, unit_id={unit_id}, payload={b2a(payload)}")
|
|
688
667
|
event_string = "unknown"
|
|
689
668
|
|
|
690
669
|
action_display = f"{action:#04x}" if action is not None else "N/A"
|
|
@@ -693,11 +672,33 @@ class CasambiClient:
|
|
|
693
672
|
f"Switch event (type 0x{message_type:02x}): button={button}, unit_id={unit_id}, "
|
|
694
673
|
f"action={action_display} ({event_string}), flags=0x{flags:02x}"
|
|
695
674
|
)
|
|
696
|
-
|
|
697
|
-
#
|
|
698
|
-
|
|
675
|
+
|
|
676
|
+
# Include Android parser comparison if available
|
|
677
|
+
android_comparison = None
|
|
678
|
+
if android_switch_event:
|
|
679
|
+
android_comparison = {
|
|
680
|
+
'unit_id': android_switch_event['unit_id'],
|
|
681
|
+
'button': android_switch_event['button'],
|
|
682
|
+
'state': android_switch_event['state'],
|
|
683
|
+
'param_p': android_switch_event['param_p'],
|
|
684
|
+
'param_s': android_switch_event['param_s'],
|
|
685
|
+
'android_log': android_switch_event['android_log']
|
|
686
|
+
}
|
|
687
|
+
# Log differences
|
|
688
|
+
if android_switch_event['unit_id'] != unit_id:
|
|
689
|
+
self._logger.warning(f"Unit ID mismatch: current={unit_id}, android={android_switch_event['unit_id']}")
|
|
690
|
+
if android_switch_event['button'] != button:
|
|
691
|
+
self._logger.warning(f"Button mismatch: current={button}, android={android_switch_event['button']}")
|
|
692
|
+
|
|
693
|
+
# Extract controlling unit if present
|
|
694
|
+
controlling_unit = None
|
|
695
|
+
# This is redundant since we already return early if unit_id_echo != unit_id
|
|
696
|
+
# Removing to avoid confusion
|
|
697
|
+
|
|
698
|
+
# Filter out all type 0x08 messages
|
|
699
|
+
if message_type == 0x08:
|
|
699
700
|
self._logger.debug(
|
|
700
|
-
f"Filtering out type 0x08
|
|
701
|
+
f"Filtering out type 0x08 event: button={button}, unit_id={unit_id}, "
|
|
701
702
|
f"action={action_display}, flags=0x{flags:02x}"
|
|
702
703
|
)
|
|
703
704
|
return
|
|
@@ -712,11 +713,13 @@ class CasambiClient:
|
|
|
712
713
|
"event": event_string,
|
|
713
714
|
"flags": flags,
|
|
714
715
|
"extra_data": extra_data,
|
|
716
|
+
"controlling_unit": controlling_unit,
|
|
715
717
|
"packet_sequence": packet_seq,
|
|
716
718
|
"raw_packet": b2a(raw_packet) if raw_packet else None,
|
|
717
719
|
"decrypted_data": b2a(full_data),
|
|
718
720
|
"message_position": start_pos,
|
|
719
721
|
"payload_hex": b2a(payload),
|
|
722
|
+
"android_comparison": android_comparison,
|
|
720
723
|
},
|
|
721
724
|
)
|
|
722
725
|
|
|
@@ -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
|
|
@@ -75,6 +75,7 @@ class UnitType:
|
|
|
75
75
|
:ivar model: The model name of this unit type.
|
|
76
76
|
:ivar manufacturer: The manufacturer of this unit type.
|
|
77
77
|
:ivar controls: The different types of controls this unit type is capable of.
|
|
78
|
+
:ivar pushButtonCount: The number of push buttons this unit type has (optional).
|
|
78
79
|
"""
|
|
79
80
|
|
|
80
81
|
id: int
|
|
@@ -83,6 +84,7 @@ class UnitType:
|
|
|
83
84
|
mode: str
|
|
84
85
|
stateLength: int
|
|
85
86
|
controls: list[UnitControl]
|
|
87
|
+
pushButtonCount: int | None = None
|
|
86
88
|
|
|
87
89
|
def get_control(self, controlType: UnitControlType) -> UnitControl | None:
|
|
88
90
|
"""Return the control description if the unit type supports the given type of control.
|
|
@@ -111,6 +113,7 @@ class UnitState:
|
|
|
111
113
|
self._colorsource: ColorSource | None = None
|
|
112
114
|
self._xy: tuple[float, float] | None = None
|
|
113
115
|
self._slider: int | None = None
|
|
116
|
+
self._onoff: bool | None = None
|
|
114
117
|
|
|
115
118
|
def _check_range(
|
|
116
119
|
self, value: int | float, min: int | float, max: int | float
|
|
@@ -269,8 +272,20 @@ class UnitState:
|
|
|
269
272
|
def slider(self) -> None:
|
|
270
273
|
self.slider = None
|
|
271
274
|
|
|
275
|
+
@property
|
|
276
|
+
def onoff(self) -> bool | None:
|
|
277
|
+
return self._onoff
|
|
278
|
+
|
|
279
|
+
@onoff.setter
|
|
280
|
+
def onoff(self, value: bool) -> None:
|
|
281
|
+
self._onoff = value
|
|
282
|
+
|
|
283
|
+
@onoff.deleter
|
|
284
|
+
def onoff(self) -> None:
|
|
285
|
+
self._onoff = None
|
|
286
|
+
|
|
272
287
|
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})"
|
|
288
|
+
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
289
|
|
|
275
290
|
|
|
276
291
|
# TODO: Make unit immutable (refactor state, on, online out of it)
|
|
@@ -308,6 +323,8 @@ class Unit:
|
|
|
308
323
|
@property
|
|
309
324
|
def is_on(self) -> bool:
|
|
310
325
|
"""Determine whether the unit is turned on."""
|
|
326
|
+
if self.unitType.get_control(UnitControlType.ONOFF) and self._state:
|
|
327
|
+
return self._on and self._state.onoff is True
|
|
311
328
|
if self.unitType.get_control(UnitControlType.DIMMER) and self._state:
|
|
312
329
|
return (
|
|
313
330
|
self._on and self._state.dimmer is not None and self._state.dimmer > 0
|
|
@@ -385,6 +402,8 @@ class Unit:
|
|
|
385
402
|
elif c.type == UnitControlType.SLIDER and state.slider is not None:
|
|
386
403
|
scale = UnitState.SLIDER_RESOLUTION - c.length
|
|
387
404
|
scaledValue = state.slider >> scale
|
|
405
|
+
elif c.type == UnitControlType.ONOFF and state.onoff is not None:
|
|
406
|
+
scaledValue = 1 if state.onoff else 0
|
|
388
407
|
|
|
389
408
|
# Use default if unsupported type or unset value in state
|
|
390
409
|
else:
|
|
@@ -477,6 +496,8 @@ class Unit:
|
|
|
477
496
|
elif c.type == UnitControlType.SLIDER:
|
|
478
497
|
scale = UnitState.SLIDER_RESOLUTION - c.length
|
|
479
498
|
self._state.slider = cInt << scale
|
|
499
|
+
elif c.type == UnitControlType.ONOFF:
|
|
500
|
+
self._state.onoff = cInt != 0
|
|
480
501
|
elif c.type == UnitControlType.UNKOWN:
|
|
481
502
|
# Might be useful for implementing more state types
|
|
482
503
|
_LOGGER.debug(
|
|
@@ -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.dev4
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_constants.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_discover.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_encryption.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_keystore.py
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.7.dev3 → casambi_bt_revamped-0.3.7.dev4}/src/CasambiBt/_operation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|