casambi-bt-revamped 0.3.11__py3-none-any.whl → 0.3.12.dev2__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 CHANGED
@@ -400,7 +400,7 @@ class Casambi:
400
400
  def _dataCallback(
401
401
  self, packetType: IncommingPacketType, data: dict[str, Any]
402
402
  ) -> None:
403
- self._logger.info(f"Incomming data callback of type {packetType}")
403
+ self._logger.debug("Incomming data callback of type %s", packetType)
404
404
  if packetType == IncommingPacketType.UnitState:
405
405
  self._logger.debug(
406
406
  f"Handling changed state {b2a(data['state'])} for unit {data['id']}"
@@ -473,13 +473,17 @@ class Casambi:
473
473
  """Register a new handler for switch events.
474
474
 
475
475
  This handler is called whenever a switch event is received.
476
- The handler is supplied with a dictionary containing:
477
- - unit_id: The ID of the switch unit
478
- - button: The button number that was pressed/released
479
- - event: Either "button_press" or "button_release"
480
- - message_type: The raw message type (0x08 or 0x10)
481
- - flags: Additional flags from the message
482
- - extra_data: Any additional data from the message
476
+ The handler is supplied with a dictionary containing (at minimum):
477
+ - unit_id: target unit id (from INVOCATION target high byte)
478
+ - button: best-effort "label" (typically 1..4 for 4-gang switches)
479
+ - event: "button_press" | "button_release" | "input_event"
480
+
481
+ Switch events are parsed from decrypted packet type=7 (INVOCATION stream),
482
+ matching casambi-android `v1.C1775b.Q(Q2.h)`. Extra diagnostic keys include:
483
+ - invocation_flags, opcode, origin, target, target_type, age, origin_handle
484
+ - button_event_index (0..7), param_p, param_s
485
+ - input_index (0..7), input_code, input_b1, input_channel, input_value16, input_mapped_event
486
+ - packet_sequence, arrival_sequence, raw_packet, decrypted_data, payload_hex, frame_offset, event_id
483
487
 
484
488
  :param handler: The method to call when a switch event is received.
485
489
  """
CasambiBt/_client.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import inspect
3
3
  import logging
4
+ import platform
4
5
  import struct
5
6
  from binascii import b2a_hex as b2a
6
7
  from collections.abc import Callable
@@ -24,6 +25,7 @@ from cryptography.hazmat.primitives.asymmetric import ec
24
25
  from ._constants import CASA_AUTH_CHAR_UUID, ConnectionState
25
26
  from ._encryption import Encryptor
26
27
  from ._network import Network
28
+ from ._switch_events import SwitchEventStreamDecoder
27
29
 
28
30
  # We need to move these imports here to prevent a cycle.
29
31
  from .errors import ( # noqa: E402
@@ -43,7 +45,7 @@ class IncommingPacketType(IntEnum):
43
45
 
44
46
 
45
47
  MIN_VERSION: Final[int] = 10
46
- MAX_VERSION: Final[int] = 10
48
+ MAX_VERSION: Final[int] = 11
47
49
 
48
50
 
49
51
  class CasambiClient:
@@ -79,6 +81,7 @@ class CasambiClient:
79
81
  else address_or_device
80
82
  )
81
83
  self._logger = logging.getLogger(__name__)
84
+ self._switchDecoder = SwitchEventStreamDecoder(self._logger)
82
85
  self._connectionState: ConnectionState = ConnectionState.NONE
83
86
  self._dataCallback = dataCallback
84
87
  self._disconnectedCallback = disonnectedCallback
@@ -122,6 +125,33 @@ class CasambiClient:
122
125
  else await get_device(self.address)
123
126
  )
124
127
 
128
+ if not device and isinstance(self._address_or_devive, str) and platform.system() == "Darwin":
129
+ # macOS CoreBluetooth typically reports random per-device identifiers as addresses
130
+ # unless `use_bdaddr` is enabled. Our `discover()` uses that flag so try it here.
131
+ try:
132
+ from ._discover import discover as discover_networks # local import to avoid cycles
133
+
134
+ networks = await discover_networks()
135
+ wanted = self.address.replace(":", "").lower()
136
+ for d in networks:
137
+ if d.address.replace(":", "").lower() == wanted:
138
+ device = d
139
+ break
140
+
141
+ if not device:
142
+ self._logger.warning(
143
+ "macOS BLE lookup by address failed. Discovered %d Casambi networks, but none match %s. Discovered=%s",
144
+ len(networks),
145
+ self.address,
146
+ [d.address for d in networks[:10]],
147
+ )
148
+ except Exception:
149
+ self._logger.debug(
150
+ "macOS fallback discovery failed while trying to find %s.",
151
+ self.address,
152
+ exc_info=True,
153
+ )
154
+
125
155
  if not device:
126
156
  self._logger.error("Failed to discover client.")
127
157
  raise NetworkNotFoundError
@@ -448,11 +478,28 @@ class CasambiClient:
448
478
  return
449
479
 
450
480
  packetType = decrypted_data[0]
451
- self._logger.debug(f"Incoming data of type {packetType}: {b2a(decrypted_data)}")
481
+ if self._logger.isEnabledFor(logging.DEBUG):
482
+ self._logger.debug(
483
+ "Incoming data of type %d: %s", packetType, b2a(decrypted_data)
484
+ )
452
485
 
453
486
  if packetType == IncommingPacketType.UnitState:
454
487
  self._parseUnitStates(decrypted_data[1:])
455
488
  elif packetType == IncommingPacketType.SwitchEvent:
489
+ # Stable logs for offline analysis: packet seq + encrypted + decrypted.
490
+ # (Decrypted data includes the leading packet type byte.)
491
+ if self._logger.isEnabledFor(logging.DEBUG):
492
+ self._logger.debug(
493
+ "[CASAMBI_RAW_PACKET] Encrypted #%s: %s",
494
+ device_sequence,
495
+ b2a(raw_encrypted_packet),
496
+ )
497
+ self._logger.debug(
498
+ "[CASAMBI_DECRYPTED] Type=%d #%s: %s",
499
+ packetType,
500
+ device_sequence,
501
+ b2a(decrypted_data),
502
+ )
456
503
  # Pass the device sequence as the packet sequence for consumers,
457
504
  # and still include the raw encrypted packet for diagnostics.
458
505
  seq_for_consumer = device_sequence if device_sequence is not None else self._inPacketCount
@@ -466,187 +513,145 @@ class CasambiClient:
466
513
  # In the future we might want to parse the revision and issue a warning if there is a mismatch.
467
514
  pass
468
515
  else:
469
- self._logger.info(f"Packet type {packetType} not implemented. Ignoring!")
516
+ self._logger.debug("Packet type %d not implemented. Ignoring!", packetType)
470
517
 
471
518
  def _parseUnitStates(self, data: bytes) -> None:
472
- self._logger.info("Parsing incoming unit states...")
473
- self._logger.debug(f"Incoming unit state: {b2a(data)}")
519
+ # Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
520
+ # as a stream of unit state records. Records have optional bytes depending on flags.
521
+ self._logger.debug("Parsing incoming unit states...")
522
+ if self._logger.isEnabledFor(logging.DEBUG):
523
+ self._logger.debug("Incoming unit state: %s", b2a(data))
474
524
 
475
525
  pos = 0
476
526
  oldPos = 0
477
527
  try:
528
+ # Android uses `while (available() >= 4)` as the loop condition.
478
529
  while pos <= len(data) - 4:
479
- id = data[pos]
530
+ unit_id = data[pos]
480
531
  flags = data[pos + 1]
481
- stateLen = ((data[pos + 2] >> 4) & 15) + 1
482
- prio = data[pos + 2] & 15
532
+ b8 = data[pos + 2]
533
+ state_len = ((b8 >> 4) & 0x0F) + 1
534
+ prio = b8 & 0x0F
483
535
  pos += 3
484
536
 
485
- online = flags & 2 != 0
486
- on = flags & 1 != 0
487
-
488
- if flags & 4:
489
- pos += 1 # TODO: con?
490
- if flags & 8:
491
- pos += 1 # TODO: sid?
492
- if flags & 16:
493
- pos += 1 # Unkown value
537
+ online = (flags & 0x02) != 0
538
+ on = (flags & 0x01) != 0
539
+
540
+ con: int | None = None
541
+ sid: int | None = None
542
+
543
+ # Optional bytes, matching Android:
544
+ # - flags&0x04: con (1 byte)
545
+ # - flags&0x08: sid (1 byte)
546
+ # - flags&0x10: extra byte; if missing Android uses 0xFF
547
+ if flags & 0x04:
548
+ con = data[pos]
549
+ pos += 1
550
+ if flags & 0x08:
551
+ sid = data[pos]
552
+ pos += 1
553
+
554
+ if flags & 0x10:
555
+ extra_byte = data[pos]
556
+ pos += 1
557
+ else:
558
+ extra_byte = 0xFF
494
559
 
495
- state = data[pos : pos + stateLen]
496
- pos += stateLen
560
+ state = data[pos : pos + state_len]
561
+ pos += state_len
497
562
 
498
- pos += (flags >> 6) & 3 # Padding?
563
+ padding_len = (flags >> 6) & 0x03
564
+ padding = data[pos : pos + padding_len] if padding_len else b""
565
+ pos += padding_len
499
566
 
500
- self._logger.debug(
501
- f"Parsed state: Id {id}, prio {prio}, online {online}, on {on}, state {b2a(state)}1"
502
- )
567
+ if self._logger.isEnabledFor(logging.DEBUG):
568
+ self._logger.debug(
569
+ "[CASAMBI_UNITSTATE_PARSED] unit=%d flags=0x%02x prio=%d online=%s on=%s con=%s sid=%s extra_byte=%d state=%s padding=%s",
570
+ unit_id,
571
+ flags,
572
+ prio,
573
+ online,
574
+ on,
575
+ con,
576
+ sid,
577
+ extra_byte,
578
+ b2a(state),
579
+ b2a(padding),
580
+ )
503
581
 
504
582
  self._dataCallback(
505
583
  IncommingPacketType.UnitState,
506
- {"id": id, "online": online, "on": on, "state": state},
584
+ {
585
+ "id": unit_id,
586
+ "online": online,
587
+ "on": on,
588
+ "state": state,
589
+ # Additional fields for diagnostics/analysis
590
+ "flags": flags,
591
+ "prio": prio,
592
+ "state_len": state_len,
593
+ "padding_len": padding_len,
594
+ "con": con,
595
+ "sid": sid,
596
+ "extra_byte": extra_byte,
597
+ "extra_float": extra_byte / 255.0,
598
+ },
507
599
  )
508
600
 
509
601
  oldPos = pos
510
602
  except IndexError:
511
603
  self._logger.error(
512
- f"Ran out of data while parsing unit state! Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
604
+ "Ran out of data while parsing unit state! Remaining data %s in %s.",
605
+ b2a(data[oldPos:]),
606
+ b2a(data),
513
607
  )
514
608
 
515
609
  def _parseSwitchEvent(
516
610
  self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
517
611
  ) -> None:
518
- """Parse switch event packet which contains multiple message types."""
519
- self._logger.info(
520
- f"Parsing incoming switch event packet #{packet_seq}... Data: {b2a(data)}"
521
- )
612
+ """Parse decrypted packet type=7 payload (INVOCATION stream).
522
613
 
523
- # Special handling for message type 0x29 - likely an extended/aux message
524
- if len(data) >= 1 and data[0] == 0x29:
525
- # Log details so we can correlate with outgoing ExtPacketSend trials
526
- if len(data) >= 3:
527
- length = ((data[2] >> 4) & 15) + 1
528
- parameter = data[2] & 15
529
- payload = data[3 : 3 + min(length, max(0, len(data) - 3))]
530
- self._logger.info(
531
- f"Ext-like message at packet head: flags=0x{data[1]:02x}, param={parameter}, payload={b2a(payload)}"
532
- )
533
- else:
534
- self._logger.info(
535
- f"Ext-like message 0x29 at packet head with insufficient length: {b2a(data)}"
536
- )
537
- return
538
-
539
- pos = 0
540
- oldPos = 0
541
- switch_events_found = 0
614
+ Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses decrypted packet type=7
615
+ as a stream of INVOCATION frames. Switch button events are INVOCATIONs.
616
+ """
542
617
 
543
- try:
544
- while pos <= len(data) - 3:
545
- oldPos = pos
546
-
547
- # Parse message header
548
- message_type = data[pos]
549
- flags = data[pos + 1]
550
- length = ((data[pos + 2] >> 4) & 15) + 1
551
- parameter = data[pos + 2] # Full byte, not just lower 4 bits
552
- pos += 3
553
-
554
- # Sanity check: message type should be reasonable
555
- if message_type > 0x80:
556
- self._logger.debug(
557
- f"Skipping invalid message type 0x{message_type:02x} at position {oldPos}"
558
- )
559
- # Try to resync by looking for next valid message
560
- pos = oldPos + 1
561
- continue
562
-
563
- # Check if we have enough data for the payload
564
- if pos + length > len(data):
565
- self._logger.debug(
566
- f"Incomplete message at position {oldPos}. "
567
- f"Type: 0x{message_type:02x}, declared length: {length}, available: {len(data) - pos}"
568
- )
569
- break
570
-
571
- # Extract the payload
572
- payload = data[pos : pos + length]
573
- pos += length
574
-
575
- # Process based on message type
576
- if message_type == 0x08 or message_type == 0x10: # Switch/button events
577
- switch_events_found += 1
578
-
579
- # Button extraction differs between type 0x08 and type 0x10
580
- if message_type == 0x08:
581
- # For type 0x08, the lower nibble is a code that maps to physical button id
582
- # Using formula: ((code + 2) % 4) + 1 based on reverse engineering findings
583
- code_nibble = parameter & 0x0F
584
- button = ((code_nibble + 2) % 4) + 1
585
- self._logger.debug(
586
- f"Type 0x08 button extraction: parameter=0x{parameter:02x}, code={code_nibble}, button={button}"
587
- )
588
- else:
589
- # For type 0x10, use existing logic
590
- button_lower = parameter & 0x0F
591
- button_upper = (parameter >> 4) & 0x0F
592
-
593
- # Use upper 4 bits if lower 4 bits are 0, otherwise use lower 4 bits
594
- if button_lower == 0 and button_upper != 0:
595
- button = button_upper
596
- self._logger.debug(
597
- f"Type 0x10 button extraction: parameter=0x{parameter:02x}, using upper nibble, button={button}"
598
- )
599
- else:
600
- button = button_lower
601
- self._logger.debug(
602
- f"Type 0x10 button extraction: parameter=0x{parameter:02x}, using lower nibble, button={button}"
603
- )
604
-
605
- # For type 0x10 messages, we need to pass additional data beyond the declared payload
606
- if message_type == 0x10:
607
- # Extend to include at least 10 bytes from message start for state byte
608
- extended_end = min(oldPos + 11, len(data))
609
- full_message_data = data[oldPos:extended_end]
610
- else:
611
- full_message_data = data
612
- self._processSwitchMessage(
613
- message_type,
614
- flags,
615
- button,
616
- payload,
617
- full_message_data,
618
- oldPos,
619
- packet_seq,
620
- raw_packet,
621
- )
622
- elif message_type == 0x29:
623
- # Extended/aux message embedded in switch event packet
624
- self._logger.info(
625
- f"Embedded 0x29 ext-like msg: flags=0x{flags:02x}, param=0x{parameter & 0x0F:01x}, payload={b2a(payload)}"
626
- )
627
- elif message_type in [0x00, 0x06, 0x09, 0x1F, 0x2A]:
628
- # Known non-switch message types - log at debug level
629
- self._logger.debug(
630
- f"Non-switch message type 0x{message_type:02x}: flags=0x{flags:02x}, "
631
- f"param={parameter}, payload={b2a(payload)}"
632
- )
633
- else:
634
- # Unknown message types - log at info level
635
- self._logger.info(
636
- f"Unknown message type 0x{message_type:02x}: flags=0x{flags:02x}, "
637
- f"param={parameter}, payload={b2a(payload)}"
638
- )
618
+ if self._logger.isEnabledFor(logging.DEBUG):
619
+ data_hex = b2a(data)
620
+ self._logger.debug(
621
+ "Parsing incoming switch event packet #%s... Data: %s",
622
+ packet_seq,
623
+ data_hex,
624
+ )
625
+ self._logger.debug(
626
+ "[CASAMBI_SWITCH_PACKET] Full data #%s: hex=%s len=%d",
627
+ packet_seq,
628
+ data_hex,
629
+ len(data),
630
+ )
639
631
 
640
- oldPos = pos
632
+ events, stats = self._switchDecoder.decode(
633
+ data,
634
+ packet_seq=packet_seq,
635
+ raw_packet=raw_packet,
636
+ arrival_sequence=self._inPacketCount,
637
+ )
641
638
 
642
- except IndexError:
643
- self._logger.error(
644
- f"Ran out of data while parsing switch event packet! "
645
- f"Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
646
- )
639
+ self._logger.debug(
640
+ "[CASAMBI_SWITCH_SUMMARY] packet=%s frames=%d button_frames=%d input_frames=%d ignored=%d emitted=%d suppressed_same_state=%d",
641
+ packet_seq,
642
+ stats.frames_total,
643
+ stats.frames_button,
644
+ stats.frames_input,
645
+ stats.frames_ignored,
646
+ stats.events_emitted,
647
+ stats.events_suppressed_same_state,
648
+ )
647
649
 
648
- if switch_events_found == 0:
649
- self._logger.debug(f"No switch events found in packet: {b2a(data)}")
650
+ for ev in events:
651
+ # Back-compat alias: older consumers looked for 'flags'
652
+ if "flags" not in ev:
653
+ ev["flags"] = ev.get("invocation_flags")
654
+ self._dataCallback(IncommingPacketType.SwitchEvent, ev)
650
655
 
651
656
  def _processSwitchMessage(
652
657
  self,
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Final
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class InvocationFrame:
10
+ """One INVOCATION frame.
11
+
12
+ Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses:
13
+ - flags:u16 (big-endian)
14
+ - opcode:u8
15
+ - origin:u16
16
+ - target:u16
17
+ - age:u16
18
+ - origin_handle?:u8 (if flags & 0x0200)
19
+ - payload: flags & 0x3f bytes
20
+ """
21
+
22
+ flags: int
23
+ opcode: int
24
+ origin: int
25
+ target: int
26
+ age: int
27
+ origin_handle: int | None
28
+ payload: bytes
29
+ offset: int # start offset of this frame in the decrypted type=7 payload
30
+
31
+ @property
32
+ def payload_len(self) -> int:
33
+ return self.flags & 0x3F
34
+
35
+
36
+ _FLAG_HAS_ORIGIN_HANDLE: Final[int] = 0x0200
37
+
38
+
39
+ def parse_invocation_stream(
40
+ data: bytes, *, logger: logging.Logger | None = None
41
+ ) -> list[InvocationFrame]:
42
+ """Parse decrypted packet type=7 payload into INVOCATION frames."""
43
+
44
+ frames: list[InvocationFrame] = []
45
+ pos = 0
46
+
47
+ # Android bails out if < 9 bytes remain.
48
+ while len(data) - pos >= 9:
49
+ frame_offset = pos
50
+
51
+ flags = int.from_bytes(data[pos : pos + 2], "big")
52
+ pos += 2
53
+
54
+ opcode = data[pos]
55
+ pos += 1
56
+
57
+ origin = int.from_bytes(data[pos : pos + 2], "big")
58
+ pos += 2
59
+
60
+ target = int.from_bytes(data[pos : pos + 2], "big")
61
+ pos += 2
62
+
63
+ age = int.from_bytes(data[pos : pos + 2], "big")
64
+ pos += 2
65
+
66
+ origin_handle: int | None = None
67
+ if flags & _FLAG_HAS_ORIGIN_HANDLE:
68
+ if pos >= len(data):
69
+ if logger:
70
+ logger.debug(
71
+ "INVOCATION frame truncated at origin_handle (offset=%d flags=0x%04x).",
72
+ frame_offset,
73
+ flags,
74
+ )
75
+ break
76
+ origin_handle = data[pos]
77
+ pos += 1
78
+
79
+ payload_len = flags & 0x3F
80
+ if pos + payload_len > len(data):
81
+ if logger:
82
+ logger.debug(
83
+ "INVOCATION frame truncated at payload (offset=%d flags=0x%04x payload_len=%d remaining=%d).",
84
+ frame_offset,
85
+ flags,
86
+ payload_len,
87
+ len(data) - pos,
88
+ )
89
+ break
90
+
91
+ payload = data[pos : pos + payload_len]
92
+ pos += payload_len
93
+
94
+ frames.append(
95
+ InvocationFrame(
96
+ flags=flags,
97
+ opcode=opcode,
98
+ origin=origin,
99
+ target=target,
100
+ age=age,
101
+ origin_handle=origin_handle,
102
+ payload=payload,
103
+ offset=frame_offset,
104
+ )
105
+ )
106
+
107
+ if logger and pos != len(data):
108
+ logger.debug(
109
+ "INVOCATION stream has %d trailing bytes (parsed=%d total=%d).",
110
+ len(data) - pos,
111
+ pos,
112
+ len(data),
113
+ )
114
+
115
+ return frames
116
+
@@ -0,0 +1,329 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from binascii import b2a_hex as b2a
6
+ from dataclasses import dataclass
7
+ from typing import Any, Final
8
+
9
+ from ._invocation import InvocationFrame, parse_invocation_stream
10
+
11
+
12
+ _BUTTON_EVENT_MIN: Final[int] = 29 # FunctionButtonEvent0
13
+ _BUTTON_EVENT_MAX: Final[int] = 36 # FunctionButtonEvent7
14
+ _INPUT_EVENT_MIN: Final[int] = 64 # FunctionNotifyInput0
15
+ _INPUT_EVENT_MAX: Final[int] = 71 # FunctionNotifyInput7
16
+
17
+ _TARGET_TYPE_BUTTON: Final[int] = 0x06
18
+ _TARGET_TYPE_INPUT: Final[int] = 0x12
19
+
20
+
21
+ def _guess_button_label_4gang(button_event_index: int) -> int:
22
+ """Casambi app labels a typical 4-button switch as 1..4.
23
+
24
+ Observed mapping for 4-gang switches in provided logs:
25
+ - ButtonEvent0 -> label 4
26
+ - ButtonEvent1 -> label 1
27
+ - ButtonEvent2 -> label 2
28
+ - ButtonEvent3 -> label 3
29
+ """
30
+
31
+ if 0 <= button_event_index <= 3:
32
+ return ((button_event_index + 3) % 4) + 1
33
+ return button_event_index
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class SwitchDecoderStats:
38
+ frames_total: int = 0
39
+ frames_button: int = 0
40
+ frames_input: int = 0
41
+ frames_ignored: int = 0
42
+ events_emitted: int = 0
43
+ events_suppressed_same_state: int = 0
44
+
45
+
46
+ class SwitchEventStreamDecoder:
47
+ """Decode decrypted packet type=7 payload into high-level switch events."""
48
+
49
+ def __init__(self, logger: logging.Logger | None = None) -> None:
50
+ self._logger = logger or logging.getLogger(__name__)
51
+ # (unit_id, button_event_index) -> pressed(bool)
52
+ self._last_pressed: dict[tuple[int, int], bool] = {}
53
+ # (unit_id, input_index) -> last input code (payload[0]) we emitted as a semantic event.
54
+ self._last_input_code: dict[tuple[int, int], int] = {}
55
+ # (unit_id, button_label) -> observed real button stream (target_type=0x06) for that button.
56
+ # If present, we avoid creating synthetic press/release events from input frames.
57
+ self._button_stream_seen: set[tuple[int, int]] = set()
58
+
59
+ def reset(self) -> None:
60
+ self._last_pressed.clear()
61
+ self._last_input_code.clear()
62
+ self._button_stream_seen.clear()
63
+
64
+ def decode(
65
+ self,
66
+ data: bytes,
67
+ *,
68
+ packet_seq: int | None = None,
69
+ raw_packet: bytes | None = None,
70
+ arrival_sequence: int | None = None,
71
+ ) -> tuple[list[dict[str, Any]], SwitchDecoderStats]:
72
+ """Decode one decrypted switch packet payload."""
73
+
74
+ frames = parse_invocation_stream(data, logger=self._logger)
75
+ stats = SwitchDecoderStats(frames_total=len(frames))
76
+ events: list[dict[str, Any]] = []
77
+
78
+ for frame in frames:
79
+ ev = self._decode_frame(
80
+ frame,
81
+ data=data,
82
+ packet_seq=packet_seq,
83
+ raw_packet=raw_packet,
84
+ arrival_sequence=arrival_sequence,
85
+ stats=stats,
86
+ )
87
+ if ev is None:
88
+ continue
89
+ events.append(ev)
90
+ stats.events_emitted += 1
91
+
92
+ return events, stats
93
+
94
+ def _decode_frame(
95
+ self,
96
+ frame: InvocationFrame,
97
+ *,
98
+ data: bytes,
99
+ packet_seq: int | None,
100
+ raw_packet: bytes | None,
101
+ arrival_sequence: int | None,
102
+ stats: SwitchDecoderStats,
103
+ ) -> dict[str, Any] | None:
104
+ unit_id = (frame.target >> 8) & 0xFF
105
+ target_type = frame.target & 0xFF
106
+
107
+ origin_unit_id = (frame.origin >> 8) & 0xFF
108
+ origin_type = frame.origin & 0xFF
109
+
110
+ # Button events (press/release) are INVOCATIONs targeted at type 0x06.
111
+ if (
112
+ target_type == _TARGET_TYPE_BUTTON
113
+ and _BUTTON_EVENT_MIN <= frame.opcode <= _BUTTON_EVENT_MAX
114
+ ):
115
+ stats.frames_button += 1
116
+
117
+ button_event_index = frame.opcode - _BUTTON_EVENT_MIN
118
+ button = _guess_button_label_4gang(button_event_index)
119
+ self._button_stream_seen.add((unit_id, button))
120
+
121
+ pressed = bool(frame.payload and (frame.payload[0] & 0x80))
122
+ state_key = (unit_id, button_event_index)
123
+ last_pressed = self._last_pressed.get(state_key)
124
+
125
+ # Wireless switches retransmit; drop repeated same-state frames to avoid duplicate events.
126
+ if last_pressed is not None and last_pressed == pressed:
127
+ stats.events_suppressed_same_state += 1
128
+ self._logger.debug(
129
+ "[CASAMBI_EVENT_SUPPRESS] unit=%d button_index=%d button=%d pressed=%s opcode=0x%02x origin=0x%04x age=0x%04x",
130
+ unit_id,
131
+ button_event_index,
132
+ button,
133
+ pressed,
134
+ frame.opcode,
135
+ frame.origin,
136
+ frame.age,
137
+ )
138
+ return None
139
+ self._last_pressed[state_key] = pressed
140
+
141
+ b0 = frame.payload[0] if frame.payload else 0
142
+ param_p = (b0 >> 3) & 0x0F
143
+ param_s = b0 & 0x07
144
+
145
+ event = "button_press" if pressed else "button_release"
146
+
147
+ # Stable identifier for consumers to deduplicate further if needed.
148
+ event_id = f"invoke:{frame.origin:04x}:{frame.age:04x}:{frame.opcode:02x}:{frame.target:04x}"
149
+
150
+ if self._logger.isEnabledFor(logging.DEBUG):
151
+ self._logger.debug(
152
+ "[CASAMBI_BUTTON_EVENT] packet=%s unit=%d button=%d event=%s opcode=0x%02x origin=0x%04x age=0x%04x flags=0x%04x payload=%s",
153
+ packet_seq,
154
+ unit_id,
155
+ button,
156
+ event,
157
+ frame.opcode,
158
+ frame.origin,
159
+ frame.age,
160
+ frame.flags,
161
+ b2a(frame.payload),
162
+ )
163
+
164
+ return {
165
+ # Back-compat / existing consumers
166
+ "unit_id": unit_id,
167
+ "button": button,
168
+ "event": event,
169
+ "message_type": 0x07, # decrypted packet type (SwitchEvent)
170
+ "message_position": frame.offset,
171
+ "extra_data": None,
172
+ # INVOCATION fields
173
+ "invocation_flags": frame.flags,
174
+ "opcode": frame.opcode,
175
+ "origin": frame.origin,
176
+ "origin_unit_id": origin_unit_id,
177
+ "origin_type": origin_type,
178
+ "target": frame.target,
179
+ "target_type": target_type,
180
+ "age": frame.age,
181
+ "origin_handle": frame.origin_handle,
182
+ "payload": frame.payload,
183
+ "payload_hex": b2a(frame.payload),
184
+ "frame_offset": frame.offset,
185
+ "button_event_index": button_event_index,
186
+ "param_p": param_p,
187
+ "param_s": param_s,
188
+ # Diagnostics / correlation
189
+ "packet_sequence": packet_seq,
190
+ "arrival_sequence": arrival_sequence,
191
+ "event_id": event_id,
192
+ "raw_packet": b2a(raw_packet) if raw_packet else None,
193
+ "decrypted_data": b2a(data),
194
+ "frame_hex": b2a(
195
+ data[frame.offset : frame.offset + (9 + (1 if frame.origin_handle is not None else 0) + frame.payload_len)]
196
+ ),
197
+ "received_at": time.time(),
198
+ }
199
+
200
+ # Input notify frames (often accompany wireless switches).
201
+ if (
202
+ target_type == _TARGET_TYPE_INPUT
203
+ and _INPUT_EVENT_MIN <= frame.opcode <= _INPUT_EVENT_MAX
204
+ ):
205
+ stats.frames_input += 1
206
+ input_index = frame.opcode - _INPUT_EVENT_MIN
207
+ input_code = frame.payload[0] if frame.payload else None
208
+ input_b1 = frame.payload[1] if len(frame.payload) >= 2 else None
209
+ input_channel = (input_b1 & 0x07) if input_b1 is not None else None
210
+ input_value16 = (
211
+ int.from_bytes(frame.payload[2:4], "little")
212
+ if len(frame.payload) >= 4
213
+ else None
214
+ )
215
+ button = _guess_button_label_4gang(input_index)
216
+
217
+ # Map common input codes into the legacy "switch" event taxonomy.
218
+ # Observed:
219
+ # - wired: 01xx press, 02xx release, 0cxx release_after_hold
220
+ # - wireless: 09xx hold, 0cxx release_after_hold (+ separate button stream for press/release)
221
+ mapped_event: str | None = None
222
+ if input_code is not None:
223
+ if input_code == 0x09:
224
+ mapped_event = "button_hold"
225
+ elif input_code == 0x0C:
226
+ mapped_event = "button_release_after_hold"
227
+ elif input_code == 0x01:
228
+ mapped_event = "button_press"
229
+ elif input_code == 0x02:
230
+ mapped_event = "button_release"
231
+
232
+ input_mapped_event = mapped_event
233
+
234
+ # Avoid duplicating press/release for wireless switches that also produce the real button stream.
235
+ if mapped_event in ("button_press", "button_release") and (unit_id, button) in self._button_stream_seen:
236
+ mapped_event = None
237
+
238
+ if mapped_event is not None and input_code is not None:
239
+ state_key = (unit_id, input_index)
240
+ last_code = self._last_input_code.get(state_key)
241
+ if last_code == input_code:
242
+ stats.events_suppressed_same_state += 1
243
+ self._logger.debug(
244
+ "[CASAMBI_EVENT_SUPPRESS] input unit=%d input_index=%d button=%d code=0x%02x opcode=0x%02x origin=0x%04x age=0x%04x",
245
+ unit_id,
246
+ input_index,
247
+ button,
248
+ input_code,
249
+ frame.opcode,
250
+ frame.origin,
251
+ frame.age,
252
+ )
253
+ return None
254
+ self._last_input_code[state_key] = input_code
255
+
256
+ if self._logger.isEnabledFor(logging.DEBUG):
257
+ self._logger.debug(
258
+ "[CASAMBI_INPUT_AS_BUTTON] packet=%s unit=%d button=%d event=%s code=0x%02x opcode=0x%02x origin=0x%04x age=0x%04x flags=0x%04x payload=%s",
259
+ packet_seq,
260
+ unit_id,
261
+ button,
262
+ mapped_event,
263
+ input_code,
264
+ frame.opcode,
265
+ frame.origin,
266
+ frame.age,
267
+ frame.flags,
268
+ b2a(frame.payload),
269
+ )
270
+ event = mapped_event or "input_event"
271
+ self._logger.debug(
272
+ "[CASAMBI_INPUT_EVENT] packet=%s unit=%d input=%d opcode=0x%02x origin=0x%04x age=0x%04x flags=0x%04x code=%s ch=%s val=%s payload=%s",
273
+ packet_seq,
274
+ unit_id,
275
+ input_index,
276
+ frame.opcode,
277
+ frame.origin,
278
+ frame.age,
279
+ frame.flags,
280
+ f"0x{input_code:02x}" if input_code is not None else None,
281
+ input_channel,
282
+ input_value16,
283
+ b2a(frame.payload),
284
+ )
285
+ return {
286
+ "unit_id": unit_id,
287
+ "button": button,
288
+ "event": event,
289
+ "message_type": 0x07,
290
+ "message_position": frame.offset,
291
+ "extra_data": None,
292
+ "invocation_flags": frame.flags,
293
+ "opcode": frame.opcode,
294
+ "origin": frame.origin,
295
+ "origin_unit_id": origin_unit_id,
296
+ "origin_type": origin_type,
297
+ "target": frame.target,
298
+ "target_type": target_type,
299
+ "age": frame.age,
300
+ "origin_handle": frame.origin_handle,
301
+ "payload": frame.payload,
302
+ "payload_hex": b2a(frame.payload),
303
+ "frame_offset": frame.offset,
304
+ "input_index": input_index,
305
+ "input_code": input_code,
306
+ "input_b1": input_b1,
307
+ "input_channel": input_channel,
308
+ "input_value16": input_value16,
309
+ "input_mapped_event": input_mapped_event,
310
+ "packet_sequence": packet_seq,
311
+ "arrival_sequence": arrival_sequence,
312
+ "event_id": f"invoke:{frame.origin:04x}:{frame.age:04x}:{frame.opcode:02x}:{frame.target:04x}",
313
+ "raw_packet": b2a(raw_packet) if raw_packet else None,
314
+ "decrypted_data": b2a(data),
315
+ "received_at": time.time(),
316
+ }
317
+
318
+ stats.frames_ignored += 1
319
+ self._logger.debug(
320
+ "[CASAMBI_INVOKE_IGNORED] packet=%s opcode=0x%02x origin=0x%04x target=0x%04x age=0x%04x flags=0x%04x payload=%s",
321
+ packet_seq,
322
+ frame.opcode,
323
+ frame.origin,
324
+ frame.target,
325
+ frame.age,
326
+ frame.flags,
327
+ b2a(frame.payload),
328
+ )
329
+ return None
CasambiBt/_unit.py CHANGED
@@ -3,7 +3,7 @@ from binascii import b2a_hex as b2a
3
3
  from colorsys import hsv_to_rgb, rgb_to_hsv
4
4
  from dataclasses import dataclass
5
5
  from enum import Enum, unique
6
- from typing import Final
6
+ from typing import Any, Final
7
7
 
8
8
  _LOGGER = logging.getLogger(__name__)
9
9
 
@@ -112,6 +112,39 @@ class UnitState:
112
112
  self._xy: tuple[float, float] | None = None
113
113
  self._slider: int | None = None
114
114
  self._onoff: bool | None = None
115
+ # Last raw state bytes, as received from the network.
116
+ self._raw_state: bytes | None = None
117
+ # Unknown controls that we don't have semantic parsing for yet.
118
+ # Items are (offset_bits, length_bits, value_int).
119
+ self._unknown_controls: list[tuple[int, int, int]] = []
120
+
121
+ @property
122
+ def raw_state(self) -> bytes | None:
123
+ return self._raw_state
124
+
125
+ @property
126
+ def unknown_controls(self) -> list[tuple[int, int, int]]:
127
+ # Expose a copy so callers can't mutate internal tracking.
128
+ return list(self._unknown_controls)
129
+
130
+ def as_dict(self) -> dict[str, Any]:
131
+ """Return a stable, JSON-friendly representation for diagnostics."""
132
+ return {
133
+ "dimmer": self.dimmer,
134
+ "vertical": self.vertical,
135
+ "rgb": self.rgb,
136
+ "white": self.white,
137
+ "temperature": self.temperature,
138
+ "colorsource": self.colorsource.name if self.colorsource is not None else None,
139
+ "xy": self.xy,
140
+ "slider": self.slider,
141
+ "onoff": self.onoff,
142
+ "raw_state_hex": b2a(self._raw_state).decode("ascii") if self._raw_state is not None else None,
143
+ "unknown_controls": [
144
+ {"offset": off, "length": length, "value": val}
145
+ for (off, length, val) in self._unknown_controls
146
+ ],
147
+ }
115
148
 
116
149
  def _check_range(
117
150
  self, value: int | float, min: int | float, max: int | float
@@ -429,6 +462,8 @@ class Unit:
429
462
  """
430
463
  if not self._state:
431
464
  self._state = UnitState()
465
+ self._state._raw_state = value
466
+ self._state._unknown_controls = []
432
467
 
433
468
  # TODO: Support for resolutions >8 byte?
434
469
  for c in self.unitType.controls:
@@ -500,6 +535,7 @@ class Unit:
500
535
  _LOGGER.debug(
501
536
  f"Value for unkown control type at {c.offset}: {cInt}. Unit type is {self.unitType.id}."
502
537
  )
538
+ self._state._unknown_controls.append((c.offset, c.length, cInt))
503
539
 
504
540
  _LOGGER.debug(f"Parsed {b2a(value)} to {self.state.__repr__()}")
505
541
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.11
3
+ Version: 0.3.12.dev2
4
4
  Summary: Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
5
5
  Home-page: https://github.com/rankjie/casambi-bt
6
6
  Author: rankjie
@@ -26,7 +26,7 @@ Dynamic: license-file
26
26
 
27
27
  This is a customized fork of the original [casambi-bt](https://github.com/lkempf/casambi-bt) library with additional features and should only be used for special needs:
28
28
 
29
- - **Switch event support** - Receive button press/release events from Casambi switches
29
+ - **Switch event support** - Receive button press/release/hold events from Casambi switches (wired + wireless)
30
30
  - **Improved relay status handling** - Better support for relay units
31
31
  - **Bug fixes and improvements** - Various fixes based on real-world usage
32
32
 
@@ -46,15 +46,38 @@ Have a look at `demo.py` for a small example.
46
46
 
47
47
  ### Switch Event Support
48
48
 
49
- This library now supports receiving switch button events:
49
+ This library supports receiving physical switch events as a decoded stream of INVOCATION frames (ground truth from the official Android app).
50
+
51
+ Event types you can expect:
52
+ - `button_press`
53
+ - `button_release`
54
+ - `button_hold`
55
+ - `button_release_after_hold`
56
+ - `input_event` (raw NotifyInput frame that may accompany presses/holds; useful for diagnostics and some wired devices)
50
57
 
51
58
  ```python
52
59
  from CasambiBt import Casambi
53
60
 
54
61
  def handle_switch_event(event_data):
55
- print(f"Switch event: Unit {event_data['unit_id']}, "
56
- f"Button {event_data['button']}, "
57
- f"Action: {event_data['event']}")
62
+ print(
63
+ "Switch event:",
64
+ {
65
+ "unit_id": event_data.get("unit_id"),
66
+ "button": event_data.get("button"),
67
+ "event": event_data.get("event"),
68
+ # INVOCATION metadata (useful for debugging/correlation)
69
+ "event_id": event_data.get("event_id"),
70
+ "opcode": event_data.get("opcode"),
71
+ "target_type": event_data.get("target_type"),
72
+ "origin": event_data.get("origin"),
73
+ "age": event_data.get("age"),
74
+ # NotifyInput fields (target_type=0x12)
75
+ "input_code": event_data.get("input_code"),
76
+ "input_channel": event_data.get("input_channel"),
77
+ "input_value16": event_data.get("input_value16"),
78
+ "input_mapped_event": event_data.get("input_mapped_event"),
79
+ },
80
+ )
58
81
 
59
82
  casa = Casambi()
60
83
  # ... connect to network ...
@@ -65,6 +88,13 @@ casa.registerSwitchEventHandler(handle_switch_event)
65
88
  # Events will be received when buttons are pressed/released
66
89
  ```
67
90
 
91
+ Notes:
92
+ - Wireless (battery) switches typically send a "button stream" (target_type `0x06`) for press/release, and a NotifyInput stream (target_type `0x12`) for hold/release-after-hold.
93
+ - Wired switches often only send NotifyInput (target_type `0x12`), so `input_code` is mapped into `button_press/button_release/...` when appropriate.
94
+ - The library suppresses same-state retransmits at the protocol layer (edge detection), so Home Assistant-style time-window deduplication should generally not be necessary.
95
+
96
+ For the parsing details and field layout, see `doc/PROTOCOL_PARSING.md`.
97
+
68
98
  ### MacOS
69
99
 
70
100
  MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
@@ -79,3 +109,12 @@ If you have problems connecting to the network please check that your network is
79
109
  ![Gateway settings](/doc/img/gateway.png)
80
110
  ![Network settings](/doc/img/network.png)
81
111
  ![Performance settings](/doc/img/perf.png)
112
+
113
+ ## Development / Offline Testing
114
+
115
+ This repo includes log-driven unit tests for switch parsing:
116
+
117
+ ```bash
118
+ cd casambi-bt
119
+ python -m unittest -v
120
+ ```
@@ -0,0 +1,20 @@
1
+ CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
2
+ CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
3
+ CasambiBt/_casambi.py,sha256=i-60A7zDblZwOVb4UfXz9EEwsbWEFPnrMvVkKMl6amY,34752
4
+ CasambiBt/_client.py,sha256=z3AnTQrZMVLkQTXdyLOZyNkNonU3arMFtEHMxUF59Ig,31581
5
+ CasambiBt/_constants.py,sha256=_AxkG7Btxl4VeS6mO7GJW5Kc9dFs3s9sDmtJ83ZEKNw,359
6
+ CasambiBt/_discover.py,sha256=H7HpiFYIy9ELvmPXXd_ck-5O5invJf15dDIRk-vO5IE,1696
7
+ CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
8
+ CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
9
+ CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
10
+ CasambiBt/_network.py,sha256=Gh0n3FEcOUHUMuBXALwcb3tws-AofpYLegKIquqtZl4,14665
11
+ CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
12
+ CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
13
+ CasambiBt/_unit.py,sha256=KIpvUT_Wm-O2Lmb1JVnNO625-j5j7GqufmZzfTR-jW0,18587
14
+ CasambiBt/errors.py,sha256=0JgDjaKlAKDes0poWzA8nrTUYQ8qdNfBb8dfaqqzCRA,1664
15
+ CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ casambi_bt_revamped-0.3.12.dev2.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
17
+ casambi_bt_revamped-0.3.12.dev2.dist-info/METADATA,sha256=nMUsaWF7HLmwMjYQFq_uJM3N_CGyP7SPmUKaGjKWdQk,4907
18
+ casambi_bt_revamped-0.3.12.dev2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
19
+ casambi_bt_revamped-0.3.12.dev2.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
20
+ casambi_bt_revamped-0.3.12.dev2.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
2
- CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
3
- CasambiBt/_casambi.py,sha256=AfyuzEU2ylJOGLmZ87Qft-aNXI_JK8Ng9Tfk4fWYOwo,34345
4
- CasambiBt/_client.py,sha256=cmRXPxURJpp8xQM-FJe2NBMtfFFC7NnWExTaN65LQ5c,32142
5
- CasambiBt/_constants.py,sha256=_AxkG7Btxl4VeS6mO7GJW5Kc9dFs3s9sDmtJ83ZEKNw,359
6
- CasambiBt/_discover.py,sha256=H7HpiFYIy9ELvmPXXd_ck-5O5invJf15dDIRk-vO5IE,1696
7
- CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
8
- CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
9
- CasambiBt/_network.py,sha256=Gh0n3FEcOUHUMuBXALwcb3tws-AofpYLegKIquqtZl4,14665
10
- CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
11
- CasambiBt/_unit.py,sha256=KR_dvVhCH8WIPGJgZYHyAPVA6ru0KmMOL5NgkxYHIUQ,17042
12
- CasambiBt/errors.py,sha256=0JgDjaKlAKDes0poWzA8nrTUYQ8qdNfBb8dfaqqzCRA,1664
13
- CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- casambi_bt_revamped-0.3.11.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
15
- casambi_bt_revamped-0.3.11.dist-info/METADATA,sha256=iZGwjYA0uCljUzih5kyGU63Fj-EZIE-acg7wE5fWHwM,3154
16
- casambi_bt_revamped-0.3.11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
17
- casambi_bt_revamped-0.3.11.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
18
- casambi_bt_revamped-0.3.11.dist-info/RECORD,,