casambi-bt-revamped 0.3.11__py3-none-any.whl → 0.3.12.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
CasambiBt/_client.py CHANGED
@@ -1,10 +1,12 @@
1
1
  import asyncio
2
2
  import inspect
3
3
  import logging
4
+ import os
5
+ import platform
4
6
  import struct
5
7
  from binascii import b2a_hex as b2a
6
8
  from collections.abc import Callable
7
- from enum import IntEnum, unique
9
+ from enum import Enum, IntEnum, auto, unique
8
10
  from hashlib import sha256
9
11
  from typing import Any, Final
10
12
 
@@ -22,13 +24,18 @@ from cryptography.exceptions import InvalidSignature
22
24
  from cryptography.hazmat.primitives.asymmetric import ec
23
25
 
24
26
  from ._constants import CASA_AUTH_CHAR_UUID, ConnectionState
27
+ from ._constants import CASA_CLASSIC_DATA_CHAR_UUID, CASA_CLASSIC_HASH_CHAR_UUID
28
+ from ._classic_crypto import classic_cmac_prefix
25
29
  from ._encryption import Encryptor
26
30
  from ._network import Network
31
+ from ._switch_events import SwitchEventStreamDecoder
27
32
 
28
33
  # We need to move these imports here to prevent a cycle.
29
34
  from .errors import ( # noqa: E402
30
35
  BluetoothError,
31
36
  ConnectionStateError,
37
+ ClassicHandshakeError,
38
+ ClassicKeysMissingError,
32
39
  NetworkNotFoundError,
33
40
  ProtocolError,
34
41
  UnsupportedProtocolVersion,
@@ -42,8 +49,13 @@ class IncommingPacketType(IntEnum):
42
49
  NetworkConfig = 9
43
50
 
44
51
 
52
+ class ProtocolMode(Enum):
53
+ EVO = auto()
54
+ CLASSIC = auto()
55
+
56
+
45
57
  MIN_VERSION: Final[int] = 10
46
- MAX_VERSION: Final[int] = 10
58
+ MAX_VERSION: Final[int] = 11
47
59
 
48
60
 
49
61
  class CasambiClient:
@@ -79,12 +91,33 @@ class CasambiClient:
79
91
  else address_or_device
80
92
  )
81
93
  self._logger = logging.getLogger(__name__)
94
+ self._switchDecoder = SwitchEventStreamDecoder(self._logger)
82
95
  self._connectionState: ConnectionState = ConnectionState.NONE
83
96
  self._dataCallback = dataCallback
84
97
  self._disconnectedCallback = disonnectedCallback
85
98
  self._activityLock = asyncio.Lock()
86
99
 
87
- self._checkProtocolVersion(network.protocolVersion)
100
+ # Determined at runtime by inspecting GATT services/characteristics.
101
+ self._protocolMode: ProtocolMode | None = None
102
+ self._dataCharUuid: str | None = None
103
+
104
+ # Classic protocol state
105
+ self._classicConnHash8: bytes | None = None
106
+ self._classicTxSeq: int = 0 # 16-bit sequence number (big endian on the wire)
107
+ self._classicCmdDiv: int = 0 # 8-bit per-command divider/id (matches u1.C1751c.b0)
108
+
109
+ # Avoid log spam in Home Assistant: raw notify hexdumps are opt-in.
110
+ self._logRawNotifies: bool = os.getenv("CASAMBI_BT_LOG_RAW_NOTIFIES", "").strip() in {
111
+ "1",
112
+ "true",
113
+ "TRUE",
114
+ "yes",
115
+ "YES",
116
+ }
117
+
118
+ @property
119
+ def protocolMode(self) -> ProtocolMode | None:
120
+ return self._protocolMode
88
121
 
89
122
  def _checkProtocolVersion(self, version: int) -> None:
90
123
  if version < MIN_VERSION:
@@ -122,6 +155,33 @@ class CasambiClient:
122
155
  else await get_device(self.address)
123
156
  )
124
157
 
158
+ if not device and isinstance(self._address_or_devive, str) and platform.system() == "Darwin":
159
+ # macOS CoreBluetooth typically reports random per-device identifiers as addresses
160
+ # unless `use_bdaddr` is enabled. Our `discover()` uses that flag so try it here.
161
+ try:
162
+ from ._discover import discover as discover_networks # local import to avoid cycles
163
+
164
+ networks = await discover_networks()
165
+ wanted = self.address.replace(":", "").lower()
166
+ for d in networks:
167
+ if d.address.replace(":", "").lower() == wanted:
168
+ device = d
169
+ break
170
+
171
+ if not device:
172
+ self._logger.warning(
173
+ "macOS BLE lookup by address failed. Discovered %d Casambi networks, but none match %s. Discovered=%s",
174
+ len(networks),
175
+ self.address,
176
+ [d.address for d in networks[:10]],
177
+ )
178
+ except Exception:
179
+ self._logger.debug(
180
+ "macOS fallback discovery failed while trying to find %s.",
181
+ self.address,
182
+ exc_info=True,
183
+ )
184
+
125
185
  if not device:
126
186
  self._logger.error("Failed to discover client.")
127
187
  raise NetworkNotFoundError
@@ -147,6 +207,115 @@ class CasambiClient:
147
207
  self._logger.info(f"Connected to {self.address}")
148
208
  self._connectionState = ConnectionState.CONNECTED
149
209
 
210
+ # Detect protocol mode by available characteristics.
211
+ services = await self._gattClient.get_services()
212
+
213
+ def _has_char(uuid: str) -> bool:
214
+ uuid_l = uuid.lower()
215
+ for s in services:
216
+ for c in s.characteristics:
217
+ if c.uuid.lower() == uuid_l:
218
+ return True
219
+ return False
220
+
221
+ # Classic (non-conformant) uses CA51 (connection hash) + CA52 (data channel).
222
+ if _has_char(CASA_CLASSIC_HASH_CHAR_UUID) and _has_char(CASA_CLASSIC_DATA_CHAR_UUID):
223
+ if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
224
+ raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
225
+
226
+ if not self._network.hasClassicKeys():
227
+ raise ClassicKeysMissingError(
228
+ "Classic protocol detected but network has no visitorKey/managerKey."
229
+ )
230
+
231
+ self._protocolMode = ProtocolMode.CLASSIC
232
+ self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
233
+
234
+ # Read connection hash (first 8 bytes are used for CMAC signing).
235
+ raw_hash = await self._gattClient.read_gatt_char(CASA_CLASSIC_HASH_CHAR_UUID)
236
+ if raw_hash is None or len(raw_hash) < 8:
237
+ raise ClassicHandshakeError(
238
+ f"Classic connection hash read failed/too short (len={0 if raw_hash is None else len(raw_hash)})."
239
+ )
240
+ self._classicConnHash8 = bytes(raw_hash[:8])
241
+ # Android seeds the command divider with a random byte on startup (u1.C1751c).
242
+ self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
243
+ self._classicTxSeq = 0
244
+
245
+ # Start notify on the data channel.
246
+ notify_kwargs: dict[str, Any] = {}
247
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
248
+ if "bluez" in notify_params:
249
+ notify_kwargs["bluez"] = {"use_start_notify": True}
250
+ await self._gattClient.start_notify(
251
+ CASA_CLASSIC_DATA_CHAR_UUID,
252
+ self._queueCallback,
253
+ **notify_kwargs,
254
+ )
255
+
256
+ # Classic has no EVO-style key exchange/auth; we can send immediately.
257
+ self._connectionState = ConnectionState.AUTHENTICATED
258
+ self._logger.info("Protocol mode selected: CLASSIC")
259
+ if self._logger.isEnabledFor(logging.DEBUG):
260
+ self._logger.debug(
261
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
262
+ len(self._classicConnHash8),
263
+ b2a(self._classicConnHash8),
264
+ )
265
+ return
266
+
267
+ # Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
268
+ if _has_char(CASA_AUTH_CHAR_UUID):
269
+ first = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
270
+ if first and len(first) >= 2 and first[0] == 0x01:
271
+ # EVO NodeInfo packet starts with 0x01.
272
+ self._protocolMode = ProtocolMode.EVO
273
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
274
+ self._checkProtocolVersion(self._network.protocolVersion)
275
+ self._logger.info("Protocol mode selected: EVO")
276
+ return
277
+
278
+ # Otherwise, treat as Classic conformant: read provides connection hash.
279
+ if os.getenv("CASAMBI_BT_DISABLE_CLASSIC", "").strip() in {"1", "true", "TRUE", "yes", "YES"}:
280
+ raise ProtocolError("Classic protocol detected but disabled via CASAMBI_BT_DISABLE_CLASSIC=1")
281
+ if not self._network.hasClassicKeys():
282
+ raise ClassicKeysMissingError(
283
+ "Classic protocol detected but network has no visitorKey/managerKey."
284
+ )
285
+ if first is None or len(first) < 8:
286
+ raise ClassicHandshakeError(
287
+ f"Classic connection hash read failed/too short (len={0 if first is None else len(first)})."
288
+ )
289
+
290
+ self._protocolMode = ProtocolMode.CLASSIC
291
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
292
+ self._classicConnHash8 = bytes(first[:8])
293
+ self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
294
+ self._classicTxSeq = 0
295
+
296
+ notify_kwargs: dict[str, Any] = {}
297
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
298
+ if "bluez" in notify_params:
299
+ notify_kwargs["bluez"] = {"use_start_notify": True}
300
+ await self._gattClient.start_notify(
301
+ CASA_AUTH_CHAR_UUID,
302
+ self._queueCallback,
303
+ **notify_kwargs,
304
+ )
305
+ self._connectionState = ConnectionState.AUTHENTICATED
306
+ self._logger.info("Protocol mode selected: CLASSIC")
307
+ if self._logger.isEnabledFor(logging.DEBUG):
308
+ self._logger.debug(
309
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
310
+ len(self._classicConnHash8),
311
+ b2a(self._classicConnHash8),
312
+ )
313
+ return
314
+
315
+ raise ProtocolError(
316
+ "No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic conformant auth char)."
317
+ )
318
+
150
319
  def _on_disconnect(self, client: BleakClient) -> None:
151
320
  if self._connectionState != ConnectionState.NONE:
152
321
  self._logger.info(f"Received disconnect callback from {self.address}")
@@ -262,7 +431,13 @@ class CasambiClient:
262
431
  def _callbackMulitplexer(
263
432
  self, handle: BleakGATTCharacteristic, data: bytes
264
433
  ) -> None:
265
- self._logger.debug(f"Callback on handle {handle}: {b2a(data)}")
434
+ if self._logRawNotifies and self._logger.isEnabledFor(logging.DEBUG):
435
+ self._logger.debug(
436
+ "Callback on handle %s (%s): %s",
437
+ getattr(handle, "handle", "?"),
438
+ getattr(handle, "uuid", "?"),
439
+ b2a(data),
440
+ )
266
441
 
267
442
  if self._connectionState == ConnectionState.CONNECTED:
268
443
  self._exchNofityCallback(handle, data)
@@ -402,6 +577,12 @@ class CasambiClient:
402
577
  return self._nonce[:4] + id + self._nonce[8:]
403
578
 
404
579
  async def send(self, packet: bytes) -> None:
580
+ # EVO sends INVOCATION operations (packet type=0x07) inside the encrypted channel.
581
+ # Classic sends signed command frames on the CA52 channel.
582
+ if self._protocolMode == ProtocolMode.CLASSIC:
583
+ await self._sendClassicSigned(packet)
584
+ return
585
+
405
586
  self._checkState(ConnectionState.AUTHENTICATED)
406
587
 
407
588
  await self._activityLock.acquire()
@@ -422,9 +603,167 @@ class CasambiClient:
422
603
  finally:
423
604
  self._activityLock.release()
424
605
 
606
+ def _classic_next_seq(self) -> int:
607
+ # 16-bit sequence inserted in the header (big endian) and included in CMAC input.
608
+ self._classicTxSeq = (self._classicTxSeq + 1) & 0xFFFF
609
+ if self._classicTxSeq == 0:
610
+ self._classicTxSeq = 1
611
+ return self._classicTxSeq
612
+
613
+ def _classic_next_div(self) -> int:
614
+ # 8-bit command divider/id. Android uses a random start and increments 1..255.
615
+ self._classicCmdDiv += 1
616
+ if self._classicCmdDiv == 0 or self._classicCmdDiv > 255:
617
+ self._classicCmdDiv = 1
618
+ return self._classicCmdDiv
619
+
620
+ def buildClassicCommand(
621
+ self,
622
+ command_ordinal: int,
623
+ payload: bytes,
624
+ *,
625
+ target_id: int | None = None,
626
+ lifetime: int = 200,
627
+ div: int | None = None,
628
+ ) -> bytes:
629
+ """Build one Classic command record (u1.C1753e export format).
630
+
631
+ This is the message that follows the Classic signed header and 16-bit sequence.
632
+ """
633
+ if div is None:
634
+ div = self._classic_next_div()
635
+ if div < 0 or div > 255:
636
+ raise ValueError("div must fit in one byte")
637
+ if lifetime < 0 or lifetime > 255:
638
+ raise ValueError("lifetime must fit in one byte")
639
+ if target_id is not None and (target_id < 0 or target_id > 255):
640
+ raise ValueError("target_id must fit in one byte")
641
+
642
+ # Two leading bytes are patched after we know the final length:
643
+ # - byte0 = (len + 239) mod 256
644
+ # - byte1 = ordinal | 0x40 (div present) | 0x80 (target present)
645
+ b = bytearray()
646
+ b.append(0)
647
+ b.append(0)
648
+
649
+ type_flags = command_ordinal & 0x3F
650
+
651
+ # div present
652
+ b.append(div & 0xFF)
653
+ type_flags |= 0x40
654
+
655
+ if target_id is not None and target_id > 0:
656
+ b.append(target_id & 0xFF)
657
+ type_flags |= 0x80
658
+
659
+ b.append(lifetime & 0xFF)
660
+ b.extend(payload)
661
+
662
+ msg_len = len(b)
663
+ b[0] = (msg_len + 239) & 0xFF
664
+ b[1] = type_flags & 0xFF
665
+
666
+ if self._logger.isEnabledFor(logging.DEBUG):
667
+ self._logger.debug(
668
+ "[CASAMBI_CLASSIC_CMD_BUILD] ord=%d target=%s div=%d lifetime=%d len=%d payload=%s",
669
+ command_ordinal,
670
+ target_id,
671
+ div,
672
+ lifetime,
673
+ msg_len,
674
+ b2a(payload),
675
+ )
676
+
677
+ return bytes(b)
678
+
679
+ async def _sendClassicSigned(self, command_bytes: bytes, *, use_manager: bool | None = None) -> None:
680
+ self._checkState(ConnectionState.AUTHENTICATED)
681
+ if self._protocolMode != ProtocolMode.CLASSIC:
682
+ raise ProtocolError("Classic send called while not in Classic protocol mode.")
683
+ if not self._dataCharUuid:
684
+ raise ProtocolError("Classic data characteristic UUID not set.")
685
+ if self._classicConnHash8 is None:
686
+ raise ClassicHandshakeError("Classic connection hash not available.")
687
+
688
+ # Decide whether to use visitor or manager key.
689
+ if use_manager is None:
690
+ use_manager = os.getenv("CASAMBI_BT_CLASSIC_USE_MANAGER", "").strip() in {
691
+ "1",
692
+ "true",
693
+ "TRUE",
694
+ "yes",
695
+ "YES",
696
+ }
697
+
698
+ visitor_key = self._network.classicVisitorKey()
699
+ manager_key = self._network.classicManagerKey()
700
+
701
+ key_name = "visitor"
702
+ auth_level = 0x02
703
+ sig_len = 4
704
+ key = visitor_key
705
+
706
+ if use_manager or key is None:
707
+ if manager_key is None:
708
+ # If we were forced to use manager but don't have one, fall back to visitor if present.
709
+ if visitor_key is None:
710
+ raise ClassicKeysMissingError(
711
+ "Classic network has no visitorKey/managerKey available."
712
+ )
713
+ key = visitor_key
714
+ else:
715
+ key_name = "manager"
716
+ auth_level = 0x03
717
+ sig_len = 16
718
+ key = manager_key
719
+
720
+ seq = self._classic_next_seq()
721
+
722
+ # Header layout (rVar.Z=true / "conformant" classic):
723
+ # [0] auth_level (2 visitor / 3 manager)
724
+ # [1..sig_len] CMAC prefix placeholder (filled after CMAC computation)
725
+ # [1+sig_len .. 1+sig_len+1] 16-bit sequence, big endian (included in CMAC input)
726
+ # [..] command bytes
727
+ pkt = bytearray()
728
+ pkt.append(auth_level)
729
+ pkt.extend(b"\x00" * sig_len)
730
+ pkt.extend(b"\x00\x00")
731
+ pkt.extend(command_bytes)
732
+
733
+ seq_off = 1 + sig_len
734
+ pkt[seq_off] = (seq >> 8) & 0xFF
735
+ pkt[seq_off + 1] = seq & 0xFF
736
+
737
+ cmac_input = bytes(pkt[seq_off:]) # includes seq + command bytes
738
+ prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
739
+ pkt[1 : 1 + sig_len] = prefix
740
+
741
+ if self._logger.isEnabledFor(logging.DEBUG):
742
+ self._logger.debug(
743
+ "[CASAMBI_CLASSIC_TX] key=%s auth=0x%02x sig_len=%d seq=0x%04x cmd_len=%d total_len=%d",
744
+ key_name,
745
+ auth_level,
746
+ sig_len,
747
+ seq,
748
+ len(command_bytes),
749
+ len(pkt),
750
+ )
751
+ self._logger.debug(
752
+ "[CASAMBI_CLASSIC_TX_RAW] %s",
753
+ b2a(bytes(pkt[: min(len(pkt), 64)])) + (b"..." if len(pkt) > 64 else b""),
754
+ )
755
+
756
+ # Classic packets can exceed 20 bytes when using a 16-byte manager signature.
757
+ # Bleak needs a write-with-response for long writes on most backends.
758
+ await self._gattClient.write_gatt_char(self._dataCharUuid, bytes(pkt), response=True)
759
+
425
760
  def _establishedNofityCallback(
426
761
  self, handle: BleakGATTCharacteristic, data: bytes
427
762
  ) -> None:
763
+ if self._protocolMode == ProtocolMode.CLASSIC:
764
+ self._classicEstablishedNotifyCallback(handle, data)
765
+ return
766
+
428
767
  # TODO: Check incoming counter and direction flag
429
768
  self._inPacketCount += 1
430
769
 
@@ -448,11 +787,28 @@ class CasambiClient:
448
787
  return
449
788
 
450
789
  packetType = decrypted_data[0]
451
- self._logger.debug(f"Incoming data of type {packetType}: {b2a(decrypted_data)}")
790
+ if self._logger.isEnabledFor(logging.DEBUG):
791
+ self._logger.debug(
792
+ "Incoming data of type %d: %s", packetType, b2a(decrypted_data)
793
+ )
452
794
 
453
795
  if packetType == IncommingPacketType.UnitState:
454
796
  self._parseUnitStates(decrypted_data[1:])
455
797
  elif packetType == IncommingPacketType.SwitchEvent:
798
+ # Stable logs for offline analysis: packet seq + encrypted + decrypted.
799
+ # (Decrypted data includes the leading packet type byte.)
800
+ if self._logger.isEnabledFor(logging.DEBUG):
801
+ self._logger.debug(
802
+ "[CASAMBI_RAW_PACKET] Encrypted #%s: %s",
803
+ device_sequence,
804
+ b2a(raw_encrypted_packet),
805
+ )
806
+ self._logger.debug(
807
+ "[CASAMBI_DECRYPTED] Type=%d #%s: %s",
808
+ packetType,
809
+ device_sequence,
810
+ b2a(decrypted_data),
811
+ )
456
812
  # Pass the device sequence as the packet sequence for consumers,
457
813
  # and still include the raw encrypted packet for diagnostics.
458
814
  seq_for_consumer = device_sequence if device_sequence is not None else self._inPacketCount
@@ -466,187 +822,292 @@ class CasambiClient:
466
822
  # In the future we might want to parse the revision and issue a warning if there is a mismatch.
467
823
  pass
468
824
  else:
469
- self._logger.info(f"Packet type {packetType} not implemented. Ignoring!")
825
+ self._logger.debug("Packet type %d not implemented. Ignoring!", packetType)
470
826
 
471
- def _parseUnitStates(self, data: bytes) -> None:
472
- self._logger.info("Parsing incoming unit states...")
473
- self._logger.debug(f"Incoming unit state: {b2a(data)}")
827
+ def _classicEstablishedNotifyCallback(
828
+ self, handle: BleakGATTCharacteristic, data: bytes
829
+ ) -> None:
830
+ """Parse Classic notifications from the CA52 channel.
474
831
 
475
- pos = 0
476
- oldPos = 0
477
- try:
478
- while pos <= len(data) - 4:
479
- id = data[pos]
480
- flags = data[pos + 1]
481
- stateLen = ((data[pos + 2] >> 4) & 15) + 1
482
- prio = data[pos + 2] & 15
483
- pos += 3
832
+ Classic packets are CMAC-signed (prefix embedded into the header).
833
+ Ground truth: casambi-android `t1.P.o(...)`.
834
+ """
835
+ self._inPacketCount += 1
484
836
 
485
- online = flags & 2 != 0
486
- on = flags & 1 != 0
837
+ raw = bytes(data)
838
+ if self._logger.isEnabledFor(logging.DEBUG):
839
+ self._logger.debug(
840
+ "[CASAMBI_CLASSIC_RX_RAW] len=%d hex=%s",
841
+ len(raw),
842
+ b2a(raw[: min(len(raw), 64)]) + (b"..." if len(raw) > 64 else b""),
843
+ )
487
844
 
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
845
+ if self._classicConnHash8 is None:
846
+ self._logger.debug("[CASAMBI_CLASSIC_RX] Missing connection hash; cannot verify CMAC.")
847
+ return
494
848
 
495
- state = data[pos : pos + stateLen]
496
- pos += stateLen
849
+ visitor_key = self._network.classicVisitorKey()
850
+ manager_key = self._network.classicManagerKey()
497
851
 
498
- pos += (flags >> 6) & 3 # Padding?
852
+ verified = False
853
+ key_name: str | None = None
854
+ sig_len: int | None = None
855
+ payload_with_seq: bytes | None = None
499
856
 
500
- self._logger.debug(
501
- f"Parsed state: Id {id}, prio {prio}, online {online}, on {on}, state {b2a(state)}1"
502
- )
857
+ # Try visitor (4-byte prefix) first, then manager (16-byte prefix).
858
+ # Some frames may be unsigned; in that case verification will fail and we'll fall back.
859
+ candidates: list[tuple[str, bytes | None, int]] = [
860
+ ("visitor", visitor_key, 4),
861
+ ("manager", manager_key, 16),
862
+ ]
503
863
 
504
- self._dataCallback(
505
- IncommingPacketType.UnitState,
506
- {"id": id, "online": online, "on": on, "state": state},
507
- )
864
+ for name, key, slen in candidates:
865
+ if key is None:
866
+ continue
867
+ header_len = 1 + slen + 2
868
+ if len(raw) < header_len:
869
+ continue
508
870
 
509
- oldPos = pos
510
- except IndexError:
511
- self._logger.error(
512
- f"Ran out of data while parsing unit state! Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
513
- )
871
+ auth_level = raw[0]
872
+ sig = raw[1 : 1 + slen]
873
+ cmac_input = raw[1 + slen :] # seq(2) + payload
514
874
 
515
- def _parseSwitchEvent(
516
- self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
517
- ) -> 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
- )
875
+ try:
876
+ expected = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, slen)
877
+ except Exception:
878
+ continue
879
+
880
+ if expected == sig:
881
+ verified = True
882
+ key_name = name
883
+ sig_len = slen
884
+ payload_with_seq = cmac_input
885
+ if self._logger.isEnabledFor(logging.DEBUG):
886
+ seq = int.from_bytes(cmac_input[:2], byteorder="big", signed=False)
887
+ self._logger.debug(
888
+ "[CASAMBI_CLASSIC_RX_VERIFY] ok key=%s auth=0x%02x sig_len=%d seq=0x%04x",
889
+ name,
890
+ auth_level,
891
+ slen,
892
+ seq,
893
+ )
894
+ break
895
+
896
+ if not verified:
897
+ if self._logger.isEnabledFor(logging.DEBUG):
898
+ self._logger.debug("[CASAMBI_CLASSIC_RX_VERIFY] failed (no matching CMAC prefix)")
899
+ # Best-effort: treat raw bytes as payload.
900
+ payload = raw
901
+ else:
902
+ assert payload_with_seq is not None
903
+ # Drop the 16-bit sequence from the payload for higher-level parsing.
904
+ payload = payload_with_seq[2:]
905
+
906
+ if not payload:
907
+ return
522
908
 
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)}"
909
+ # If the payload starts with a known EVO packet type, reuse existing parsers.
910
+ packet_type = payload[0]
911
+ if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
912
+ if self._logger.isEnabledFor(logging.DEBUG):
913
+ self._logger.debug(
914
+ "[CASAMBI_CLASSIC_RX_PAYLOAD] type=%d len=%d hex=%s",
915
+ packet_type,
916
+ len(payload),
917
+ b2a(payload[: min(len(payload), 64)])
918
+ + (b"..." if len(payload) > 64 else b""),
532
919
  )
920
+ if packet_type == IncommingPacketType.UnitState:
921
+ self._parseUnitStates(payload[1:])
922
+ elif packet_type == IncommingPacketType.SwitchEvent:
923
+ self._parseSwitchEvent(payload[1:], None, raw)
533
924
  else:
534
- self._logger.info(
535
- f"Ext-like message 0x29 at packet head with insufficient length: {b2a(data)}"
536
- )
925
+ # ignore network config
926
+ pass
537
927
  return
538
928
 
929
+ # Otherwise, attempt to parse a stream of Classic "command" records:
930
+ # record[0] = (len + 239) mod 256, so len = (b0 - 239) & 0xFF.
539
931
  pos = 0
540
- oldPos = 0
541
- switch_events_found = 0
932
+ while pos + 2 <= len(payload):
933
+ enc_len = payload[pos]
934
+ rec_len = (enc_len - 239) & 0xFF
935
+ if rec_len < 2 or pos + rec_len > len(payload):
936
+ break
937
+ rec = payload[pos : pos + rec_len]
938
+ pos += rec_len
939
+
940
+ typ = rec[1]
941
+ ordinal = typ & 0x3F
942
+ has_div = (typ & 0x40) != 0
943
+ has_target = (typ & 0x80) != 0
944
+ p = 2
945
+ div = rec[p] if has_div and p < len(rec) else None
946
+ if has_div:
947
+ p += 1
948
+ target = rec[p] if has_target and p < len(rec) else None
949
+ if has_target:
950
+ p += 1
951
+ lifetime = rec[p] if p < len(rec) else None
952
+ if lifetime is not None:
953
+ p += 1
954
+ rec_payload = rec[p:] if p <= len(rec) else b""
955
+
956
+ if self._logger.isEnabledFor(logging.DEBUG):
957
+ self._logger.debug(
958
+ "[CASAMBI_CLASSIC_CMD] ord=%d div=%s target=%s lifetime=%s payload=%s",
959
+ ordinal,
960
+ div,
961
+ target,
962
+ lifetime,
963
+ b2a(rec_payload),
964
+ )
542
965
 
543
- try:
544
- while pos <= len(data) - 3:
545
- oldPos = pos
966
+ # Any trailing bytes that don't form a full record are logged for analysis.
967
+ if self._logger.isEnabledFor(logging.DEBUG) and pos < len(payload):
968
+ self._logger.debug(
969
+ "[CASAMBI_CLASSIC_CMD_TRAILING] len=%d hex=%s",
970
+ len(payload) - pos,
971
+ b2a(payload[pos:]),
972
+ )
546
973
 
547
- # Parse message header
548
- message_type = data[pos]
974
+ def _parseUnitStates(self, data: bytes) -> None:
975
+ # Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
976
+ # as a stream of unit state records. Records have optional bytes depending on flags.
977
+ self._logger.debug("Parsing incoming unit states...")
978
+ if self._logger.isEnabledFor(logging.DEBUG):
979
+ self._logger.debug("Incoming unit state: %s", b2a(data))
980
+
981
+ pos = 0
982
+ oldPos = 0
983
+ try:
984
+ # Android uses `while (available() >= 4)` as the loop condition.
985
+ while pos <= len(data) - 4:
986
+ unit_id = data[pos]
549
987
  flags = data[pos + 1]
550
- length = ((data[pos + 2] >> 4) & 15) + 1
551
- parameter = data[pos + 2] # Full byte, not just lower 4 bits
988
+ b8 = data[pos + 2]
989
+ state_len = ((b8 >> 4) & 0x0F) + 1
990
+ prio = b8 & 0x0F
552
991
  pos += 3
553
992
 
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
993
+ online = (flags & 0x02) != 0
994
+ on = (flags & 0x01) != 0
995
+
996
+ con: int | None = None
997
+ sid: int | None = None
998
+
999
+ # Optional bytes, matching Android:
1000
+ # - flags&0x04: con (1 byte)
1001
+ # - flags&0x08: sid (1 byte)
1002
+ # - flags&0x10: extra byte; if missing Android uses 0xFF
1003
+ if flags & 0x04:
1004
+ con = data[pos]
1005
+ pos += 1
1006
+ if flags & 0x08:
1007
+ sid = data[pos]
1008
+ pos += 1
1009
+
1010
+ if flags & 0x10:
1011
+ extra_byte = data[pos]
1012
+ pos += 1
1013
+ else:
1014
+ extra_byte = 0xFF
1015
+
1016
+ state = data[pos : pos + state_len]
1017
+ pos += state_len
1018
+
1019
+ padding_len = (flags >> 6) & 0x03
1020
+ padding = data[pos : pos + padding_len] if padding_len else b""
1021
+ pos += padding_len
562
1022
 
563
- # Check if we have enough data for the payload
564
- if pos + length > len(data):
1023
+ if self._logger.isEnabledFor(logging.DEBUG):
565
1024
  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,
1025
+ "[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",
1026
+ unit_id,
614
1027
  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)}"
1028
+ prio,
1029
+ online,
1030
+ on,
1031
+ con,
1032
+ sid,
1033
+ extra_byte,
1034
+ b2a(state),
1035
+ b2a(padding),
638
1036
  )
639
1037
 
640
- oldPos = pos
1038
+ self._dataCallback(
1039
+ IncommingPacketType.UnitState,
1040
+ {
1041
+ "id": unit_id,
1042
+ "online": online,
1043
+ "on": on,
1044
+ "state": state,
1045
+ # Additional fields for diagnostics/analysis
1046
+ "flags": flags,
1047
+ "prio": prio,
1048
+ "state_len": state_len,
1049
+ "padding_len": padding_len,
1050
+ "con": con,
1051
+ "sid": sid,
1052
+ "extra_byte": extra_byte,
1053
+ "extra_float": extra_byte / 255.0,
1054
+ },
1055
+ )
641
1056
 
1057
+ oldPos = pos
642
1058
  except IndexError:
643
1059
  self._logger.error(
644
- f"Ran out of data while parsing switch event packet! "
645
- f"Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
1060
+ "Ran out of data while parsing unit state! Remaining data %s in %s.",
1061
+ b2a(data[oldPos:]),
1062
+ b2a(data),
1063
+ )
1064
+
1065
+ def _parseSwitchEvent(
1066
+ self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
1067
+ ) -> None:
1068
+ """Parse decrypted packet type=7 payload (INVOCATION stream).
1069
+
1070
+ Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses decrypted packet type=7
1071
+ as a stream of INVOCATION frames. Switch button events are INVOCATIONs.
1072
+ """
1073
+
1074
+ if self._logger.isEnabledFor(logging.DEBUG):
1075
+ data_hex = b2a(data)
1076
+ self._logger.debug(
1077
+ "Parsing incoming switch event packet #%s... Data: %s",
1078
+ packet_seq,
1079
+ data_hex,
1080
+ )
1081
+ self._logger.debug(
1082
+ "[CASAMBI_SWITCH_PACKET] Full data #%s: hex=%s len=%d",
1083
+ packet_seq,
1084
+ data_hex,
1085
+ len(data),
646
1086
  )
647
1087
 
648
- if switch_events_found == 0:
649
- self._logger.debug(f"No switch events found in packet: {b2a(data)}")
1088
+ events, stats = self._switchDecoder.decode(
1089
+ data,
1090
+ packet_seq=packet_seq,
1091
+ raw_packet=raw_packet,
1092
+ arrival_sequence=self._inPacketCount,
1093
+ )
1094
+
1095
+ self._logger.debug(
1096
+ "[CASAMBI_SWITCH_SUMMARY] packet=%s frames=%d button_frames=%d input_frames=%d ignored=%d emitted=%d suppressed_same_state=%d",
1097
+ packet_seq,
1098
+ stats.frames_total,
1099
+ stats.frames_button,
1100
+ stats.frames_input,
1101
+ stats.frames_ignored,
1102
+ stats.events_emitted,
1103
+ stats.events_suppressed_same_state,
1104
+ )
1105
+
1106
+ for ev in events:
1107
+ # Back-compat alias: older consumers looked for 'flags'
1108
+ if "flags" not in ev:
1109
+ ev["flags"] = ev.get("invocation_flags")
1110
+ self._dataCallback(IncommingPacketType.SwitchEvent, ev)
650
1111
 
651
1112
  def _processSwitchMessage(
652
1113
  self,