casambi-bt-revamped 0.3.7.dev3__py3-none-any.whl → 0.3.12.dev15__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,11 +1,15 @@
1
1
  import asyncio
2
+ import inspect
2
3
  import logging
4
+ import os
5
+ import platform
3
6
  import struct
7
+ import time
4
8
  from binascii import b2a_hex as b2a
5
9
  from collections.abc import Callable
6
- from enum import IntEnum, unique
10
+ from enum import Enum, IntEnum, auto, unique
7
11
  from hashlib import sha256
8
- from typing import Any, Final
12
+ from typing import Any, Final, Literal
9
13
 
10
14
  from bleak import BleakClient
11
15
  from bleak.backends.characteristic import BleakGATTCharacteristic
@@ -21,13 +25,24 @@ from cryptography.exceptions import InvalidSignature
21
25
  from cryptography.hazmat.primitives.asymmetric import ec
22
26
 
23
27
  from ._constants import CASA_AUTH_CHAR_UUID, ConnectionState
28
+ from ._constants import (
29
+ CASA_CLASSIC_CA53_CHAR_UUID,
30
+ CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID,
31
+ CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID,
32
+ CASA_CLASSIC_DATA_CHAR_UUID,
33
+ CASA_CLASSIC_HASH_CHAR_UUID,
34
+ )
35
+ from ._classic_crypto import classic_cmac_prefix
24
36
  from ._encryption import Encryptor
25
37
  from ._network import Network
38
+ from ._switch_events import SwitchEventStreamDecoder
26
39
 
27
40
  # We need to move these imports here to prevent a cycle.
28
41
  from .errors import ( # noqa: E402
29
42
  BluetoothError,
30
43
  ConnectionStateError,
44
+ ClassicHandshakeError,
45
+ ClassicKeysMissingError,
31
46
  NetworkNotFoundError,
32
47
  ProtocolError,
33
48
  UnsupportedProtocolVersion,
@@ -41,8 +56,35 @@ class IncommingPacketType(IntEnum):
41
56
  NetworkConfig = 9
42
57
 
43
58
 
59
+ class ProtocolMode(Enum):
60
+ EVO = auto()
61
+ CLASSIC = auto()
62
+
63
+
64
+ class _LogBurstLimiter:
65
+ """Simple in-process log rate limiter (per key).
66
+
67
+ Home Assistant warns if a logger emits too many messages. We keep some high-signal
68
+ WARNING logs for Classic reverse engineering but avoid spamming.
69
+ """
70
+
71
+ def __init__(self) -> None:
72
+ self._state: dict[str, tuple[float, int]] = {}
73
+
74
+ def allow(self, key: str, *, burst: int, window_s: float) -> bool:
75
+ now = time.monotonic()
76
+ start, count = self._state.get(key, (now, 0))
77
+ if (now - start) > window_s:
78
+ start, count = now, 0
79
+ if count >= burst:
80
+ self._state[key] = (start, count)
81
+ return False
82
+ self._state[key] = (start, count + 1)
83
+ return True
84
+
85
+
44
86
  MIN_VERSION: Final[int] = 10
45
- MAX_VERSION: Final[int] = 10
87
+ MAX_VERSION: Final[int] = 11
46
88
 
47
89
 
48
90
  class CasambiClient:
@@ -78,21 +120,73 @@ class CasambiClient:
78
120
  else address_or_device
79
121
  )
80
122
  self._logger = logging.getLogger(__name__)
123
+ self._switchDecoder = SwitchEventStreamDecoder(self._logger)
81
124
  self._connectionState: ConnectionState = ConnectionState.NONE
82
125
  self._dataCallback = dataCallback
83
126
  self._disconnectedCallback = disonnectedCallback
84
127
  self._activityLock = asyncio.Lock()
85
128
 
86
- self._checkProtocolVersion(network.protocolVersion)
87
-
88
- def _checkProtocolVersion(self, version: int) -> None:
129
+ # Determined at runtime by inspecting GATT services/characteristics.
130
+ self._protocolMode: ProtocolMode | None = None
131
+ self._dataCharUuid: str | None = None
132
+ # EVO only: protocolVersion from the device-provided NodeInfo (byte1).
133
+ self._deviceProtocolVersion: int | None = None
134
+
135
+ # Classic protocol state
136
+ self._classicConnHash8: bytes | None = None
137
+ self._classicTxSeq: int = 0 # 16-bit sequence number (big endian on the wire)
138
+ self._classicCmdDiv: int = 0 # 8-bit per-command divider/id (matches u1.C1751c.b0)
139
+ # Classic header framing mode:
140
+ # - "conformant": [auth][sig][seq16][payload]
141
+ # - "legacy": [sig][payload]
142
+ # Ground truth: casambi-android `t1.P.n(...)` and `t1.P.o(...)`.
143
+ self._classicHeaderMode: str | None = None # "conformant" | "legacy"
144
+ # Classic transport diagnostics / channel selection.
145
+ self._classicTxCharUuid: str | None = None
146
+ self._classicNotifyCharUuids: set[str] = set()
147
+ self._classicHashSource: str | None = None # "ca51" | "ca52_0001" | None
148
+ self._classicFirstRxTs: float | None = None
149
+ self._classicNoRxTask: asyncio.Task[None] | None = None
150
+
151
+ # Rate limit WARNING logs (especially Classic RX) to keep HA usable.
152
+ self._logLimiter = _LogBurstLimiter()
153
+ self._classicRxFrames = 0
154
+ self._classicRxVerified = 0
155
+ self._classicRxUnverifiable = 0
156
+ self._classicRxParseFail = 0
157
+ self._classicRxType6 = 0
158
+ self._classicRxType7 = 0
159
+ self._classicRxType9 = 0
160
+ self._classicRxCmdStream = 0
161
+ self._classicRxUnknown = 0
162
+ self._classicRxClassicStates = 0
163
+ # Per-kind sample counters to ensure we emit at least a few examples for reverse engineering.
164
+ self._classicRxKindSamples: dict[str, int] = {}
165
+ self._classicRxLastStatsTs = time.monotonic()
166
+
167
+ # Classic diagnostic packet history (for dump_classic_diagnostics service)
168
+ self._classicTxHistory: list[dict[str, Any]] = []
169
+ self._classicRxHistory: list[dict[str, Any]] = []
170
+ self._classicDiagMaxHistory = 50 # Keep last 50 TX and RX packets
171
+
172
+ @property
173
+ def protocolMode(self) -> ProtocolMode | None:
174
+ return self._protocolMode
175
+
176
+ def _checkProtocolVersion(self, version: int, *, source: str = "unknown") -> None:
89
177
  if version < MIN_VERSION:
90
- raise UnsupportedProtocolVersion(
91
- f"Legacy version aren't supported currently. Your network version is {version}. Minimum version is {MIN_VERSION}."
178
+ # Legacy protocol versions are intentionally allowed. We keep this check as a warning
179
+ # because packet layouts/handshakes may differ and we want actionable tester logs.
180
+ msg = (
181
+ f"Legacy protocol version detected ({source}={version}). "
182
+ f"Versions < {MIN_VERSION} are not fully verified; attempting to continue."
92
183
  )
184
+ self._logger.warning(msg)
185
+ return
93
186
  if version > MAX_VERSION:
94
187
  self._logger.warning(
95
- "Version too new. Your network version is %i. Highest supported version is %i. Continue at your own risk.",
188
+ "Version too new (%s=%i). Highest supported version is %i. Continue at your own risk.",
189
+ source,
96
190
  version,
97
191
  MAX_VERSION,
98
192
  )
@@ -110,6 +204,23 @@ class CasambiClient:
110
204
  self._outPacketCount = 2
111
205
  self._inPacketCount = 1
112
206
 
207
+ # Reset protocol-specific state (important for reconnects).
208
+ self._protocolMode = None
209
+ self._dataCharUuid = None
210
+ self._deviceProtocolVersion = None
211
+
212
+ self._classicConnHash8 = None
213
+ self._classicTxSeq = 0
214
+ self._classicCmdDiv = 0
215
+ self._classicHeaderMode = None
216
+ self._classicTxCharUuid = None
217
+ self._classicNotifyCharUuids.clear()
218
+ self._classicHashSource = None
219
+ self._classicFirstRxTs = None
220
+ if self._classicNoRxTask is not None:
221
+ self._classicNoRxTask.cancel()
222
+ self._classicNoRxTask = None
223
+
113
224
  # Reset callback queue
114
225
  self._callbackQueue = asyncio.Queue()
115
226
  self._callbackTask = asyncio.create_task(self._processCallbacks())
@@ -121,6 +232,33 @@ class CasambiClient:
121
232
  else await get_device(self.address)
122
233
  )
123
234
 
235
+ if not device and isinstance(self._address_or_devive, str) and platform.system() == "Darwin":
236
+ # macOS CoreBluetooth typically reports random per-device identifiers as addresses
237
+ # unless `use_bdaddr` is enabled. Our `discover()` uses that flag so try it here.
238
+ try:
239
+ from ._discover import discover as discover_networks # local import to avoid cycles
240
+
241
+ networks = await discover_networks()
242
+ wanted = self.address.replace(":", "").lower()
243
+ for d in networks:
244
+ if d.address.replace(":", "").lower() == wanted:
245
+ device = d
246
+ break
247
+
248
+ if not device:
249
+ self._logger.warning(
250
+ "macOS BLE lookup by address failed. Discovered %d Casambi networks, but none match %s. Discovered=%s",
251
+ len(networks),
252
+ self.address,
253
+ [d.address for d in networks[:10]],
254
+ )
255
+ except Exception:
256
+ self._logger.debug(
257
+ "macOS fallback discovery failed while trying to find %s.",
258
+ self.address,
259
+ exc_info=True,
260
+ )
261
+
124
262
  if not device:
125
263
  self._logger.error("Failed to discover client.")
126
264
  raise NetworkNotFoundError
@@ -146,12 +284,401 @@ class CasambiClient:
146
284
  self._logger.info(f"Connected to {self.address}")
147
285
  self._connectionState = ConnectionState.CONNECTED
148
286
 
287
+ # Detect protocol mode.
288
+ #
289
+ # Important: Home Assistant wraps BleakClient (HaBleakClientWrapper) which does not implement
290
+ # `get_services()`. Therefore we use "try-read" probing instead of enumerating GATT services.
291
+ #
292
+ # Order:
293
+ # 1) Classic "non-conformant": CA51 (hash) + CA52 (data channel)
294
+ # 2) EVO: auth char read starts with 0x01 (NodeInfo)
295
+ # 3) Classic "conformant": auth char read returns connection hash (first 8 bytes used)
296
+
297
+ cloud_protocol = getattr(self._network, "protocolVersion", None)
298
+ ca51_prefix: bytes | None = None
299
+ ca51_err: str | None = None
300
+ ca52_notify_err: str | None = None
301
+ ca53_notify_err: str | None = None
302
+ auth_prefix: bytes | None = None
303
+ auth_err: str | None = None
304
+ c0002_prefix: bytes | None = None
305
+ c0002_err: str | None = None
306
+ c0003_notify_err: str | None = None
307
+ device_nodeinfo_protocol: int | None = None
308
+
309
+ def _log_probe_summary(mode: str, *, classic_variant: str | None = None) -> None:
310
+ # One stable, high-signal line for testers.
311
+ self._logger.warning(
312
+ "[CASAMBI_PROTOCOL_PROBE] address=%s mode=%s cloud_protocol=%s nodeinfo_b1=%s data_uuid=%s "
313
+ "classic_variant=%s hash_source=%s classic_tx_uuid=%s classic_notify_uuids=%s "
314
+ "ca51_hash8_present=%s conn_hash8_ready=%s "
315
+ "auth_read_prefix=%s ca51_read_prefix=%s ca51_read_error=%s auth_read_error=%s "
316
+ "ca52_notify_error=%s ca53_notify_error=%s c0002_read_prefix=%s c0002_read_error=%s c0003_notify_error=%s",
317
+ self.address,
318
+ mode,
319
+ cloud_protocol,
320
+ device_nodeinfo_protocol,
321
+ self._dataCharUuid,
322
+ classic_variant,
323
+ self._classicHashSource,
324
+ self._classicTxCharUuid,
325
+ sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
326
+ bool(classic_hash and len(classic_hash) >= 8),
327
+ self._classicConnHash8 is not None,
328
+ auth_prefix,
329
+ ca51_prefix,
330
+ ca51_err,
331
+ auth_err,
332
+ ca52_notify_err,
333
+ ca53_notify_err,
334
+ c0002_prefix,
335
+ c0002_err,
336
+ c0003_notify_err,
337
+ )
338
+
339
+ classic_hash: bytes | None = None
340
+ try:
341
+ classic_hash = await self._gattClient.read_gatt_char(CASA_CLASSIC_HASH_CHAR_UUID)
342
+ ca51_prefix = b2a(classic_hash[:10]) if classic_hash else None
343
+ if self._logger.isEnabledFor(logging.DEBUG):
344
+ self._logger.debug(
345
+ "[CASAMBI_GATT_PROBE] read ca51 ok len=%d prefix=%s",
346
+ 0 if classic_hash is None else len(classic_hash),
347
+ ca51_prefix,
348
+ )
349
+ except Exception as e:
350
+ classic_hash = None
351
+ ca51_err = type(e).__name__
352
+ if self._logger.isEnabledFor(logging.DEBUG):
353
+ self._logger.debug("[CASAMBI_GATT_PROBE] read ca51 fail err=%s", ca51_err)
354
+
355
+ if classic_hash and len(classic_hash) >= 8:
356
+ self._protocolMode = ProtocolMode.CLASSIC
357
+ self._dataCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
358
+ self._classicTxCharUuid = CASA_CLASSIC_DATA_CHAR_UUID
359
+ self._classicHeaderMode = "legacy"
360
+ self._classicHashSource = "ca51"
361
+
362
+ # Read connection hash (first 8 bytes are used for CMAC signing).
363
+ raw_hash = classic_hash
364
+ if raw_hash is None or len(raw_hash) < 8:
365
+ raise ClassicHandshakeError(
366
+ f"Classic connection hash read failed/too short (len={0 if raw_hash is None else len(raw_hash)})."
367
+ )
368
+ self._classicConnHash8 = bytes(raw_hash[:8])
369
+
370
+ # Parse Android's extended connection hash fields for diagnostics.
371
+ # Offset 8: unitId, 9: flags_lo, 10: MTU, 11: protocolVersion, 12: flags_hi
372
+ if len(raw_hash) >= 13:
373
+ ext_unit_id = raw_hash[8]
374
+ ext_flags_lo = raw_hash[9]
375
+ ext_mtu = raw_hash[10]
376
+ ext_proto_ver = raw_hash[11]
377
+ ext_flags_hi = raw_hash[12]
378
+ self._logger.warning(
379
+ "[CASAMBI_CLASSIC_CONN_HASH_EXT] variant=legacy unitId=%d flags=0x%04x mtu=%d protocolVersion=%d raw=%s",
380
+ ext_unit_id,
381
+ (ext_flags_hi << 8) | ext_flags_lo,
382
+ ext_mtu,
383
+ ext_proto_ver,
384
+ b2a(bytes(raw_hash[:min(len(raw_hash), 20)])),
385
+ )
386
+
387
+ # Android seeds the command divider with a random byte on startup (u1.C1751c).
388
+ self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
389
+ self._classicTxSeq = 0
390
+
391
+ # Start notify on the data channel.
392
+ notify_kwargs: dict[str, Any] = {}
393
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
394
+ if "bluez" in notify_params:
395
+ notify_kwargs["bluez"] = {"use_start_notify": True}
396
+ try:
397
+ await self._gattClient.start_notify(
398
+ CASA_CLASSIC_DATA_CHAR_UUID,
399
+ self._queueCallback,
400
+ **notify_kwargs,
401
+ )
402
+ except Exception as e:
403
+ ca52_notify_err = type(e).__name__
404
+ # Some firmwares may expose Classic signing on the EVO UUID instead.
405
+ # Fall through to auth-char probing if CA52 isn't available.
406
+ if self._logger.isEnabledFor(logging.DEBUG):
407
+ self._logger.debug(
408
+ "[CASAMBI_GATT_PROBE] start_notify ca52 fail err=%s; trying auth UUID probing.",
409
+ type(e).__name__,
410
+ exc_info=True,
411
+ )
412
+ self._protocolMode = None
413
+ self._dataCharUuid = None
414
+ self._classicConnHash8 = None
415
+ self._classicTxCharUuid = None
416
+ self._classicNotifyCharUuids.clear()
417
+ self._classicHeaderMode = None
418
+ self._classicHashSource = None
419
+ # continue detection below
420
+ else:
421
+ self._classicNotifyCharUuids.add(CASA_CLASSIC_DATA_CHAR_UUID.lower())
422
+ # Some Classic firmwares also expose state/config notifications on CA53.
423
+ try:
424
+ await self._gattClient.start_notify(
425
+ CASA_CLASSIC_CA53_CHAR_UUID,
426
+ self._queueCallback,
427
+ **notify_kwargs,
428
+ )
429
+ except Exception as e:
430
+ ca53_notify_err = type(e).__name__
431
+ else:
432
+ self._classicNotifyCharUuids.add(CASA_CLASSIC_CA53_CHAR_UUID.lower())
433
+
434
+ # Classic has no EVO-style key exchange/auth; we can send immediately.
435
+ self._connectionState = ConnectionState.AUTHENTICATED
436
+ self._logger.info("Protocol mode selected: CLASSIC")
437
+ if self._logger.isEnabledFor(logging.DEBUG):
438
+ self._logger.debug("[CASAMBI_GATT_PROBE] start_notify ca52 ok")
439
+ self._logger.debug(
440
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
441
+ len(self._classicConnHash8),
442
+ b2a(self._classicConnHash8),
443
+ )
444
+ self._logger.warning(
445
+ "[CASAMBI_CLASSIC_SELECTED] address=%s variant=ca52_legacy data_uuid=%s tx_uuid=%s notify_uuids=%s header_mode=%s conn_hash8_prefix=%s",
446
+ self.address,
447
+ self._dataCharUuid,
448
+ self._classicTxCharUuid,
449
+ sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
450
+ self._classicHeaderMode,
451
+ b2a(self._classicConnHash8),
452
+ )
453
+ self._logger.warning(
454
+ "[CASAMBI_CLASSIC_KEYS] visitor=%s manager=%s cloud_session_is_manager=%s",
455
+ self._network.classicVisitorKey() is not None,
456
+ self._network.classicManagerKey() is not None,
457
+ getattr(self._network, "isManager", lambda: False)(),
458
+ )
459
+ await self._classicEnumerateAndSubscribeGatt(notify_kwargs)
460
+ _log_probe_summary("CLASSIC", classic_variant="ca52_legacy")
461
+ # Emit a warning if we never see Classic RX frames; this is a common failure mode.
462
+ self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
463
+ return
464
+
465
+ # Conformant devices can expose the Classic signed channel on the EVO-style UUID too.
466
+ first: bytes | None = None
467
+ try:
468
+ first = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
469
+ auth_prefix = b2a(first[:10]) if first else None
470
+ if self._logger.isEnabledFor(logging.DEBUG):
471
+ self._logger.debug(
472
+ "[CASAMBI_GATT_PROBE] read auth ok len=%d first_byte=%s prefix=%s",
473
+ 0 if first is None else len(first),
474
+ None if not first else f"0x{first[0]:02x}",
475
+ auth_prefix,
476
+ )
477
+ except Exception as e:
478
+ first = None
479
+ auth_err = type(e).__name__
480
+ if self._logger.isEnabledFor(logging.DEBUG):
481
+ self._logger.debug("[CASAMBI_GATT_PROBE] read auth fail err=%s", auth_err)
482
+
483
+ if first and len(first) >= 2 and first[0] == 0x01:
484
+ # EVO NodeInfo packet starts with 0x01.
485
+ device_nodeinfo_protocol = first[1]
486
+ self._deviceProtocolVersion = device_nodeinfo_protocol
487
+ mtu = unit = flags = None
488
+ nonce_prefix = None
489
+ if len(first) >= 23:
490
+ try:
491
+ mtu, unit, flags, nonce = struct.unpack_from(">BHH16s", first, 2)
492
+ nonce_prefix = b2a(nonce[:8])
493
+ except Exception:
494
+ if self._logger.isEnabledFor(logging.DEBUG):
495
+ self._logger.debug("Failed to parse NodeInfo fields for logging.", exc_info=True)
496
+
497
+ self._logger.info(
498
+ "[CASAMBI_EVO_NODEINFO] cloud_protocol=%s nodeinfo_b1=%s mtu=%s unit=%s flags=%s nonce_prefix=%s len=%d prefix=%s",
499
+ cloud_protocol,
500
+ device_nodeinfo_protocol,
501
+ mtu,
502
+ unit,
503
+ None if flags is None else f"0x{flags:04x}",
504
+ nonce_prefix,
505
+ len(first),
506
+ b2a(first[: min(len(first), 32)]),
507
+ )
508
+ if len(first) < 23:
509
+ self._logger.warning(
510
+ "[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s nodeinfo_b1=%s prefix=%s",
511
+ len(first),
512
+ cloud_protocol,
513
+ device_nodeinfo_protocol,
514
+ b2a(first[: min(len(first), 32)]),
515
+ )
516
+
517
+ self._protocolMode = ProtocolMode.EVO
518
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
519
+ self._classicHeaderMode = None
520
+ self._logger.info("Protocol mode selected: EVO")
521
+ _log_probe_summary("EVO")
522
+ return
523
+
524
+ if first is not None:
525
+ # Otherwise, treat as Classic conformant: read provides connection hash.
526
+ if len(first) < 8:
527
+ raise ClassicHandshakeError(
528
+ f"Classic connection hash read failed/too short (len={len(first)})."
529
+ )
530
+
531
+ self._protocolMode = ProtocolMode.CLASSIC
532
+ self._dataCharUuid = CASA_AUTH_CHAR_UUID
533
+ self._classicTxCharUuid = CASA_AUTH_CHAR_UUID
534
+ self._classicHeaderMode = "conformant"
535
+ self._classicHashSource = "ca52_0001"
536
+ self._classicConnHash8 = bytes(first[:8])
537
+
538
+ # Parse Android's extended connection hash fields for diagnostics.
539
+ # Offset 8: unitId, 9: flags_lo, 10: MTU, 11: protocolVersion, 12: flags_hi
540
+ if len(first) >= 13:
541
+ ext_unit_id = first[8]
542
+ ext_flags_lo = first[9]
543
+ ext_mtu = first[10]
544
+ ext_proto_ver = first[11]
545
+ ext_flags_hi = first[12]
546
+ self._logger.warning(
547
+ "[CASAMBI_CLASSIC_CONN_HASH_EXT] variant=conformant unitId=%d flags=0x%04x mtu=%d protocolVersion=%d raw=%s",
548
+ ext_unit_id,
549
+ (ext_flags_hi << 8) | ext_flags_lo,
550
+ ext_mtu,
551
+ ext_proto_ver,
552
+ b2a(bytes(first[:min(len(first), 20)])),
553
+ )
554
+
555
+ self._classicCmdDiv = int.from_bytes(os.urandom(1), "big") or 1
556
+ self._classicTxSeq = 0
557
+
558
+ # Probe mapped Classic CA51 (0002) for diagnostics; some firmwares use it for time/config.
559
+ try:
560
+ v = await self._gattClient.read_gatt_char(CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID)
561
+ c0002_prefix = b2a(v[:10]) if v else None
562
+ if self._logger.isEnabledFor(logging.DEBUG):
563
+ self._logger.debug(
564
+ "[CASAMBI_GATT_PROBE] read classic-0002 ok len=%d prefix=%s",
565
+ 0 if v is None else len(v),
566
+ c0002_prefix,
567
+ )
568
+ except Exception as e:
569
+ c0002_err = type(e).__name__
570
+ if self._logger.isEnabledFor(logging.DEBUG):
571
+ self._logger.debug(
572
+ "[CASAMBI_GATT_PROBE] read classic-0002 fail err=%s",
573
+ c0002_err,
574
+ )
575
+
576
+ notify_kwargs: dict[str, Any] = {}
577
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
578
+ if "bluez" in notify_params:
579
+ notify_kwargs["bluez"] = {"use_start_notify": True}
580
+ try:
581
+ await self._gattClient.start_notify(
582
+ CASA_AUTH_CHAR_UUID,
583
+ self._queueCallback,
584
+ **notify_kwargs,
585
+ )
586
+ except Exception as e:
587
+ ca52_notify_err = type(e).__name__
588
+ else:
589
+ self._classicNotifyCharUuids.add(CASA_AUTH_CHAR_UUID.lower())
590
+
591
+ # Probe mapped Classic CA53 (0003) notify: some firmwares may emit state/config here.
592
+ try:
593
+ await self._gattClient.start_notify(
594
+ CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID,
595
+ self._queueCallback,
596
+ **notify_kwargs,
597
+ )
598
+ except Exception as e:
599
+ c0003_notify_err = type(e).__name__
600
+ else:
601
+ self._classicNotifyCharUuids.add(CASA_CLASSIC_CONFORMANT_CA53_CHAR_UUID.lower())
602
+
603
+ self._connectionState = ConnectionState.AUTHENTICATED
604
+ self._logger.info("Protocol mode selected: CLASSIC")
605
+ if self._logger.isEnabledFor(logging.DEBUG):
606
+ if ca52_notify_err is None:
607
+ self._logger.debug("[CASAMBI_GATT_PROBE] start_notify auth ok (classic conformant)")
608
+ else:
609
+ self._logger.debug(
610
+ "[CASAMBI_GATT_PROBE] start_notify auth fail err=%s (classic conformant)",
611
+ ca52_notify_err,
612
+ )
613
+ self._logger.debug(
614
+ "[CASAMBI_CLASSIC_CONN_HASH] len=%d hash=%s",
615
+ len(self._classicConnHash8),
616
+ b2a(self._classicConnHash8),
617
+ )
618
+ self._logger.warning(
619
+ "[CASAMBI_CLASSIC_SELECTED] address=%s variant=auth_uuid_conformant data_uuid=%s tx_uuid=%s notify_uuids=%s header_mode=%s conn_hash8_prefix=%s",
620
+ self.address,
621
+ self._dataCharUuid,
622
+ self._classicTxCharUuid,
623
+ sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
624
+ self._classicHeaderMode,
625
+ b2a(self._classicConnHash8),
626
+ )
627
+ self._logger.warning(
628
+ "[CASAMBI_CLASSIC_KEYS] visitor=%s manager=%s cloud_session_is_manager=%s",
629
+ self._network.classicVisitorKey() is not None,
630
+ self._network.classicManagerKey() is not None,
631
+ getattr(self._network, "isManager", lambda: False)(),
632
+ )
633
+ await self._classicEnumerateAndSubscribeGatt(notify_kwargs)
634
+ _log_probe_summary("CLASSIC", classic_variant="auth_uuid_conformant")
635
+ self._classicNoRxTask = asyncio.create_task(self._classic_no_rx_watchdog(30.0))
636
+ return
637
+
638
+ _log_probe_summary("UNKNOWN")
639
+ raise ProtocolError(
640
+ "No supported Casambi characteristics found (Classic ca51/ca52 or EVO/Classic-conformant auth char)."
641
+ )
642
+
643
+ async def _classic_no_rx_watchdog(self, after_s: float) -> None:
644
+ """Emit one high-signal log if Classic RX stays silent after connect.
645
+
646
+ This helps testers capture actionable logs when Classic control/updates don't work yet.
647
+ """
648
+ try:
649
+ await asyncio.sleep(after_s)
650
+ if self._protocolMode != ProtocolMode.CLASSIC:
651
+ return
652
+ if self._classicFirstRxTs is not None:
653
+ return
654
+
655
+ self._logger.warning(
656
+ "[CASAMBI_CLASSIC_NO_RX] after_s=%s notify_uuids=%s tx_uuid=%s header_mode=%s "
657
+ "conn_hash8_prefix=%s visitor=%s manager=%s cloud_session_is_manager=%s",
658
+ after_s,
659
+ sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else None,
660
+ self._classicTxCharUuid,
661
+ self._classicHeaderMode,
662
+ None if self._classicConnHash8 is None else b2a(self._classicConnHash8),
663
+ self._network.classicVisitorKey() is not None,
664
+ self._network.classicManagerKey() is not None,
665
+ getattr(self._network, "isManager", lambda: False)(),
666
+ )
667
+ except asyncio.CancelledError:
668
+ return
669
+ except Exception:
670
+ # Never fail the connection because of diagnostics.
671
+ self._logger.debug("Classic no-RX watchdog failed.", exc_info=True)
672
+
149
673
  def _on_disconnect(self, client: BleakClient) -> None:
150
674
  if self._connectionState != ConnectionState.NONE:
151
675
  self._logger.info(f"Received disconnect callback from {self.address}")
152
676
  if self._connectionState == ConnectionState.AUTHENTICATED:
153
677
  self._logger.debug("Executing disconnect callback.")
154
678
  self._disconnectedCallback()
679
+ if self._classicNoRxTask is not None:
680
+ self._classicNoRxTask.cancel()
681
+ self._classicNoRxTask = None
155
682
  self._connectionState = ConnectionState.NONE
156
683
 
157
684
  async def exchangeKey(self) -> None:
@@ -163,15 +690,38 @@ class CasambiClient:
163
690
  try:
164
691
  # Initiate communication with device
165
692
  firstResp = await self._gattClient.read_gatt_char(CASA_AUTH_CHAR_UUID)
166
- self._logger.debug(f"Got {b2a(firstResp)}")
693
+ if self._logger.isEnabledFor(logging.DEBUG):
694
+ self._logger.debug(
695
+ "[CASAMBI_EVO_NODEINFO_RAW] len=%d prefix=%s",
696
+ len(firstResp),
697
+ b2a(firstResp[: min(len(firstResp), 32)]),
698
+ )
167
699
 
168
- # Check type and protocol version
169
- if not (
170
- firstResp[0] == 0x1 and firstResp[1] == self._network.protocolVersion
171
- ):
700
+ cloud_protocol = getattr(self._network, "protocolVersion", None)
701
+
702
+ # EVO key exchange expects the NodeInfo packet (0x01 ...).
703
+ if len(firstResp) < 2 or firstResp[0] != 0x01:
172
704
  self._logger.error(
173
- "Unexpected answer from device! Wrong device or protocol version? Trying to continue."
705
+ "[CASAMBI_EVO_NODEINFO_UNEXPECTED] expected_prefix=01 len=%d prefix=%s",
706
+ len(firstResp),
707
+ b2a(firstResp[: min(len(firstResp), 32)]),
174
708
  )
709
+ raise ProtocolError("Unexpected NodeInfo response while starting key exchange.")
710
+
711
+ device_protocol = firstResp[1]
712
+ self._deviceProtocolVersion = device_protocol
713
+ # Do not interpret NodeInfo byte1 as "cloud protocolVersion".
714
+ # Some firmwares use a different numbering scheme, so mismatch warnings are misleading.
715
+
716
+ if len(firstResp) < 23:
717
+ self._logger.error(
718
+ "[CASAMBI_EVO_NODEINFO_SHORT] len=%d cloud_protocol=%s nodeinfo_b1=%s prefix=%s",
719
+ len(firstResp),
720
+ cloud_protocol,
721
+ device_protocol,
722
+ b2a(firstResp[: min(len(firstResp), 32)]),
723
+ )
724
+ raise ProtocolError("NodeInfo response too short while starting key exchange.")
175
725
 
176
726
  # Parse device info
177
727
  self._mtu, self._unit, self._flags, self._nonce = struct.unpack_from(
@@ -183,8 +733,15 @@ class CasambiClient:
183
733
 
184
734
  # Device will initiate key exchange, so listen for that
185
735
  self._logger.debug("Starting notify")
736
+ notify_kwargs: dict[str, Any] = {}
737
+ notify_params = inspect.signature(self._gattClient.start_notify).parameters
738
+ if "bluez" in notify_params:
739
+ notify_kwargs["bluez"] = {"use_start_notify": True}
740
+
186
741
  await self._gattClient.start_notify(
187
- CASA_AUTH_CHAR_UUID, self._queueCallback
742
+ CASA_AUTH_CHAR_UUID,
743
+ self._queueCallback,
744
+ **notify_kwargs,
188
745
  )
189
746
  finally:
190
747
  self._activityLock.release()
@@ -233,24 +790,36 @@ class CasambiClient:
233
790
  self._callbackQueue.put_nowait((handle, data))
234
791
 
235
792
  async def _processCallbacks(self) -> None:
236
- while True:
237
- handle, data = await self._callbackQueue.get()
238
-
239
- # Try to loose any races here.
240
- # Otherwise a state change caused by the last packet might not have been handled yet
241
- await asyncio.sleep(0.001)
242
- await self._activityLock.acquire()
243
- try:
244
- self._callbackMulitplexer(handle, data)
245
- finally:
246
- self._callbackQueue.task_done()
247
- self._activityLock.release()
793
+ try:
794
+ while True:
795
+ handle, data = await self._callbackQueue.get()
796
+
797
+ # Try to loose any races here.
798
+ # Otherwise a state change caused by the last packet might not have been handled yet
799
+ await asyncio.sleep(0.001)
800
+ await self._activityLock.acquire()
801
+ try:
802
+ self._callbackMulitplexer(handle, data)
803
+ except Exception:
804
+ self._logger.warning(
805
+ "[CASAMBI_CALLBACK_ERROR] unhandled exception in callback multiplexer",
806
+ exc_info=True,
807
+ )
808
+ finally:
809
+ self._callbackQueue.task_done()
810
+ self._activityLock.release()
811
+ except asyncio.CancelledError:
812
+ # Task cancelled during shutdown; log at debug and exit cleanly.
813
+ self._logger.debug("Callback processing task cancelled during shutdown.")
814
+ raise
248
815
 
249
816
  def _callbackMulitplexer(
250
817
  self, handle: BleakGATTCharacteristic, data: bytes
251
818
  ) -> None:
252
- self._logger.debug(f"Callback on handle {handle}: {b2a(data)}")
253
-
819
+ if self._logger.isEnabledFor(logging.DEBUG):
820
+ self._logger.debug(
821
+ "[CASAMBI_MUX] state=%s len=%d", self._connectionState, len(data)
822
+ )
254
823
  if self._connectionState == ConnectionState.CONNECTED:
255
824
  self._exchNofityCallback(handle, data)
256
825
  elif self._connectionState == ConnectionState.KEY_EXCHANGED:
@@ -389,6 +958,12 @@ class CasambiClient:
389
958
  return self._nonce[:4] + id + self._nonce[8:]
390
959
 
391
960
  async def send(self, packet: bytes) -> None:
961
+ # EVO sends INVOCATION operations (packet type=0x07) inside the encrypted channel.
962
+ # Classic sends signed command frames on the CA52 channel.
963
+ if self._protocolMode == ProtocolMode.CLASSIC:
964
+ await self._sendClassic(packet)
965
+ return
966
+
392
967
  self._checkState(ConnectionState.AUTHENTICATED)
393
968
 
394
969
  await self._activityLock.acquire()
@@ -409,15 +984,554 @@ class CasambiClient:
409
984
  finally:
410
985
  self._activityLock.release()
411
986
 
987
+ def _classic_next_seq(self) -> int:
988
+ # 16-bit sequence inserted in the header (big endian) and included in CMAC input.
989
+ self._classicTxSeq = (self._classicTxSeq + 1) & 0xFFFF
990
+ if self._classicTxSeq == 0:
991
+ self._classicTxSeq = 1
992
+ return self._classicTxSeq
993
+
994
+ def _classic_next_div(self) -> int:
995
+ # 8-bit command divider/id. Android uses a random start and increments 1..255.
996
+ self._classicCmdDiv += 1
997
+ if self._classicCmdDiv == 0 or self._classicCmdDiv > 255:
998
+ self._classicCmdDiv = 1
999
+ return self._classicCmdDiv
1000
+
1001
+ def buildClassicCommand(
1002
+ self,
1003
+ command_ordinal: int,
1004
+ payload: bytes,
1005
+ *,
1006
+ target_id: int | None = None,
1007
+ lifetime: int = 200,
1008
+ div: int | None = None,
1009
+ ) -> bytes:
1010
+ """Build one Classic command record (u1.C1753e export format).
1011
+
1012
+ This is the message that follows the Classic signed header and 16-bit sequence.
1013
+ """
1014
+ if div is None:
1015
+ div = self._classic_next_div()
1016
+ if div < 0 or div > 255:
1017
+ raise ValueError("div must fit in one byte")
1018
+ if lifetime < 0 or lifetime > 255:
1019
+ raise ValueError("lifetime must fit in one byte")
1020
+ if target_id is not None and (target_id < 0 or target_id > 255):
1021
+ raise ValueError("target_id must fit in one byte")
1022
+
1023
+ # Two leading bytes are patched after we know the final length:
1024
+ # - byte0 = (len + 239) mod 256
1025
+ # - byte1 = ordinal | 0x40 (div present) | 0x80 (target present)
1026
+ b = bytearray()
1027
+ b.append(0)
1028
+ b.append(0)
1029
+
1030
+ type_flags = command_ordinal & 0x3F
1031
+
1032
+ # div present
1033
+ b.append(div & 0xFF)
1034
+ type_flags |= 0x40
1035
+
1036
+ if target_id is not None and target_id > 0:
1037
+ b.append(target_id & 0xFF)
1038
+ type_flags |= 0x80
1039
+
1040
+ b.append(lifetime & 0xFF)
1041
+ b.extend(payload)
1042
+
1043
+ msg_len = len(b)
1044
+ b[0] = (msg_len + 239) & 0xFF
1045
+ b[1] = type_flags & 0xFF
1046
+
1047
+ if self._logger.isEnabledFor(logging.DEBUG):
1048
+ self._logger.debug(
1049
+ "[CASAMBI_CLASSIC_CMD_BUILD] ord=%d target=%s div=%d lifetime=%d len=%d payload=%s",
1050
+ command_ordinal,
1051
+ target_id,
1052
+ div,
1053
+ lifetime,
1054
+ msg_len,
1055
+ b2a(payload),
1056
+ )
1057
+
1058
+ return bytes(b)
1059
+
1060
+ def buildClassicCommandSimple(
1061
+ self,
1062
+ unit_id: int,
1063
+ dimmer: int,
1064
+ extra: int | None = None,
1065
+ ) -> bytes:
1066
+ """Build a Classic command using the simple format from BLE captures.
1067
+
1068
+ This alternative format was observed in real BLE captures and differs from
1069
+ the Android u1.C1753e command record format. Use with env variable
1070
+ CASAMBI_BT_CLASSIC_FORMAT=simple to experiment.
1071
+
1072
+ Format (before header added by _sendClassic):
1073
+ [counter:1][unit_id:1][param_len:1][dimmer:1][extra:1?]
1074
+
1075
+ The header (added by _sendClassic) is:
1076
+ - Conformant: [auth:1][cmac:4|16][seq:2]
1077
+ - Legacy: [cmac:4]
1078
+
1079
+ Args:
1080
+ unit_id: Target unit ID (0-255, use 0xFF for "all units")
1081
+ dimmer: Dimmer/level value (0-255)
1082
+ extra: Optional extra parameter (e.g., temperature/vertical value)
1083
+
1084
+ Returns:
1085
+ Command bytes to pass to _sendClassic
1086
+ """
1087
+ counter = self._classic_next_div()
1088
+ if extra is not None:
1089
+ return bytes([counter, unit_id & 0xFF, 2, dimmer & 0xFF, extra & 0xFF])
1090
+ else:
1091
+ return bytes([counter, unit_id & 0xFF, 1, dimmer & 0xFF])
1092
+
1093
+ async def _sendClassic(
1094
+ self,
1095
+ command_bytes: bytes,
1096
+ *,
1097
+ target_uuid: str | None = None,
1098
+ key_preference: Literal["auto", "visitor", "manager"] = "auto",
1099
+ response: bool | None = None,
1100
+ ) -> None:
1101
+ self._checkState(ConnectionState.AUTHENTICATED)
1102
+ if self._protocolMode != ProtocolMode.CLASSIC:
1103
+ raise ProtocolError("Classic send called while not in Classic protocol mode.")
1104
+ tx_uuid = target_uuid or self._classicTxCharUuid or self._dataCharUuid
1105
+ if not tx_uuid:
1106
+ raise ProtocolError("Classic TX characteristic UUID not set.")
1107
+ if self._classicConnHash8 is None:
1108
+ raise ClassicHandshakeError("Classic connection hash not available.")
1109
+
1110
+ visitor_key = self._network.classicVisitorKey()
1111
+ manager_key = self._network.classicManagerKey()
1112
+
1113
+ # Parse the command record for logs (u1.C1753e export format).
1114
+ cmd_ordinal: int | None = None
1115
+ cmd_div: int | None = None
1116
+ cmd_target: int | None = None
1117
+ cmd_lifetime: int | None = None
1118
+ cmd_payload_len: int | None = None
1119
+ try:
1120
+ if len(command_bytes) >= 2:
1121
+ typ = command_bytes[1]
1122
+ cmd_ordinal = typ & 0x3F
1123
+ has_div = (typ & 0x40) != 0
1124
+ has_target = (typ & 0x80) != 0
1125
+ p = 2
1126
+ if has_div and p < len(command_bytes):
1127
+ cmd_div = command_bytes[p]
1128
+ p += 1
1129
+ if has_target and p < len(command_bytes):
1130
+ cmd_target = command_bytes[p]
1131
+ p += 1
1132
+ if p < len(command_bytes):
1133
+ cmd_lifetime = command_bytes[p]
1134
+ p += 1
1135
+ if p <= len(command_bytes):
1136
+ cmd_payload_len = len(command_bytes) - p
1137
+ except Exception:
1138
+ # If parsing fails, keep fields as None.
1139
+ pass
1140
+
1141
+ # Classic key selection:
1142
+ #
1143
+ # Android (v3.16) explicitly uses "visitor" signing (auth_level=2 / 4-byte sig)
1144
+ # for the Classic enable-notify bootstrap packets (sendVersion + sendTime), even
1145
+ # when a managerKey exists.
1146
+ #
1147
+ # For normal commands we keep the historical behavior ("auto" == prefer manager
1148
+ # when the cloud session is manager), but allow overrides so init can match Android.
1149
+ key_name = "none"
1150
+ auth_level = 0x02
1151
+ key: bytes | None = None
1152
+
1153
+ if key_preference == "visitor":
1154
+ if visitor_key is not None:
1155
+ key_name, auth_level, key = "visitor", 0x02, visitor_key
1156
+ elif manager_key is not None:
1157
+ # Fallback: some networks have managerKey only.
1158
+ key_name, auth_level, key = "manager", 0x03, manager_key
1159
+ else:
1160
+ key_name, auth_level, key = "none", 0x02, None
1161
+ elif key_preference == "manager":
1162
+ if manager_key is not None:
1163
+ key_name, auth_level, key = "manager", 0x03, manager_key
1164
+ elif visitor_key is not None:
1165
+ key_name, auth_level, key = "visitor", 0x02, visitor_key
1166
+ else:
1167
+ key_name, auth_level, key = "none", 0x03, None
1168
+ else:
1169
+ # "auto" (legacy behavior)
1170
+ if manager_key is not None and getattr(self._network, "isManager", lambda: False)():
1171
+ key_name, auth_level, key = "manager", 0x03, manager_key
1172
+ elif visitor_key is not None:
1173
+ key_name, auth_level, key = "visitor", 0x02, visitor_key
1174
+ elif manager_key is not None:
1175
+ key_name, auth_level, key = "manager", 0x03, manager_key
1176
+
1177
+ header_mode = self._classicHeaderMode or "conformant"
1178
+
1179
+ seq: int | None = None
1180
+ sig_len: int
1181
+ pkt = bytearray()
1182
+
1183
+ if header_mode == "conformant":
1184
+ sig_len = 16 if auth_level == 0x03 else 4
1185
+ seq = self._classic_next_seq()
1186
+
1187
+ # Header layout (rVar.Z=true / "conformant" classic):
1188
+ # [0] auth_level (2 visitor / 3 manager)
1189
+ # [1..sig_len] CMAC prefix placeholder (filled after CMAC computation)
1190
+ # [1+sig_len .. 1+sig_len+1] 16-bit sequence, big endian (included in CMAC input)
1191
+ # [..] command bytes
1192
+ pkt.append(auth_level)
1193
+ pkt.extend(b"\x00" * sig_len)
1194
+ pkt.extend(b"\x00\x00")
1195
+ pkt.extend(command_bytes)
1196
+
1197
+ seq_off = 1 + sig_len
1198
+ pkt[seq_off] = (seq >> 8) & 0xFF
1199
+ pkt[seq_off + 1] = seq & 0xFF
1200
+
1201
+ if key is not None:
1202
+ cmac_input = bytes(pkt[seq_off:]) # includes seq + command bytes
1203
+ prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
1204
+ pkt[1 : 1 + sig_len] = prefix
1205
+
1206
+ elif header_mode == "legacy":
1207
+ # Legacy/non-conformant classic: only a 4-byte CMAC prefix, no auth byte, no seq.
1208
+ sig_len = 4
1209
+ pkt.extend(b"\x00" * sig_len)
1210
+ pkt.extend(command_bytes)
1211
+
1212
+ if key is not None:
1213
+ cmac_input = bytes(command_bytes)
1214
+ prefix = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
1215
+ pkt[0:sig_len] = prefix
1216
+ else:
1217
+ raise ProtocolError(f"Unknown Classic header mode: {header_mode}")
1218
+
1219
+ signed = key is not None
1220
+ if not signed and self._logLimiter.allow("classic_tx_unsigned", burst=10, window_s=300.0):
1221
+ self._logger.warning(
1222
+ "[CASAMBI_CLASSIC_TX_UNSIGNED] reason=keys_missing visitor=%s manager=%s",
1223
+ visitor_key is not None,
1224
+ manager_key is not None,
1225
+ )
1226
+
1227
+ # WARNING-level TX logs are intentional: they are needed for Classic reverse engineering.
1228
+ # Keep payload logging minimal (prefix only).
1229
+ if self._logLimiter.allow("classic_tx", burst=50, window_s=60.0):
1230
+ auth_str = f"0x{auth_level:02x}" if header_mode == "conformant" else None
1231
+ self._logger.warning(
1232
+ "[CASAMBI_CLASSIC_TX] header=%s key=%s signed=%s tx_uuid=%s auth=%s sig_len=%d seq=%s "
1233
+ "cmd_len=%d cmd_ord=%s target=%s div=%s lifetime=%s payload_len=%s "
1234
+ "total_len=%d prefix=%s",
1235
+ header_mode,
1236
+ key_name,
1237
+ signed,
1238
+ tx_uuid,
1239
+ auth_str,
1240
+ sig_len,
1241
+ None if seq is None else f"0x{seq:04x}",
1242
+ len(command_bytes),
1243
+ cmd_ordinal,
1244
+ cmd_target,
1245
+ cmd_div,
1246
+ cmd_lifetime,
1247
+ cmd_payload_len,
1248
+ len(pkt),
1249
+ b2a(bytes(pkt[: min(len(pkt), 24)])),
1250
+ )
1251
+
1252
+ # Android uses WRITE_TYPE_NO_RESPONSE (1) for version/state writes (n0 path)
1253
+ # and WRITE_TYPE_DEFAULT (2) = with-response for time-sync (X path).
1254
+ # If caller didn't specify, default to True for backward compatibility
1255
+ # (also needed for long writes with 16-byte manager signature).
1256
+ use_response = response if response is not None else True
1257
+ tx_result = "pending"
1258
+ try:
1259
+ await self._gattClient.write_gatt_char(tx_uuid, bytes(pkt), response=use_response)
1260
+ tx_result = "ok"
1261
+ except Exception as e:
1262
+ tx_result = f"error: {type(e).__name__}: {e}"
1263
+ raise
1264
+ finally:
1265
+ # Record TX in diagnostic history
1266
+ tx_entry = {
1267
+ "timestamp": time.monotonic(),
1268
+ "header_mode": header_mode,
1269
+ "key": key_name,
1270
+ "signed": signed,
1271
+ "tx_uuid": tx_uuid,
1272
+ "auth_level": auth_level if header_mode == "conformant" else None,
1273
+ "sig_len": sig_len,
1274
+ "seq": seq,
1275
+ "cmd_ordinal": cmd_ordinal,
1276
+ "cmd_target": cmd_target,
1277
+ "cmd_div": cmd_div,
1278
+ "cmd_lifetime": cmd_lifetime,
1279
+ "cmd_payload_len": cmd_payload_len,
1280
+ "total_len": len(pkt),
1281
+ "pre_sign_hex": b2a(command_bytes).decode("ascii"),
1282
+ "post_sign_hex": b2a(bytes(pkt)).decode("ascii"),
1283
+ "result": tx_result,
1284
+ }
1285
+ self._classicTxHistory.append(tx_entry)
1286
+ if len(self._classicTxHistory) > self._classicDiagMaxHistory:
1287
+ self._classicTxHistory = self._classicTxHistory[-self._classicDiagMaxHistory:]
1288
+
1289
+ # Enhanced TX diagnostic log
1290
+ self._logger.warning(
1291
+ "[CLASSIC_DIAG_TX_RESULT] result=%s header=%s seq=%s total_len=%d",
1292
+ tx_result,
1293
+ header_mode,
1294
+ None if seq is None else f"0x{seq:04x}",
1295
+ len(pkt),
1296
+ )
1297
+
1298
+ async def _classicEnumerateAndSubscribeGatt(
1299
+ self, notify_kwargs: dict[str, Any]
1300
+ ) -> None:
1301
+ """Enumerate all GATT characteristics and subscribe to any notifiable ones.
1302
+
1303
+ This discovers characteristics beyond the manually-probed CA51/CA52/CA53
1304
+ UUIDs and subscribes to any that support notify or indicate, which may be
1305
+ needed for receiving Classic state/config notifications.
1306
+ """
1307
+ try:
1308
+ total_chars = 0
1309
+ for svc in self._gattClient.services:
1310
+ for char in svc.characteristics:
1311
+ total_chars += 1
1312
+ char_uuid = str(char.uuid).lower()
1313
+ props = char.properties
1314
+ self._logger.warning(
1315
+ "[CASAMBI_CLASSIC_GATT_CHAR] uuid=%s props=%s handle=%d",
1316
+ char_uuid,
1317
+ props,
1318
+ char.handle,
1319
+ )
1320
+ if char_uuid not in self._classicNotifyCharUuids:
1321
+ if "notify" in props or "indicate" in props:
1322
+ try:
1323
+ await self._gattClient.start_notify(
1324
+ char.uuid,
1325
+ self._queueCallback,
1326
+ **notify_kwargs,
1327
+ )
1328
+ self._classicNotifyCharUuids.add(char_uuid)
1329
+ self._logger.warning(
1330
+ "[CASAMBI_CLASSIC_GATT_SUB] subscribed uuid=%s",
1331
+ char_uuid,
1332
+ )
1333
+ except Exception as e:
1334
+ self._logger.warning(
1335
+ "[CASAMBI_CLASSIC_GATT_SUB] failed uuid=%s err=%s",
1336
+ char_uuid,
1337
+ type(e).__name__,
1338
+ )
1339
+ self._logger.warning(
1340
+ "[CASAMBI_CLASSIC_GATT_ENUM] total_chars=%d subscribed_uuids=%s",
1341
+ total_chars,
1342
+ sorted(self._classicNotifyCharUuids),
1343
+ )
1344
+ except Exception as e:
1345
+ self._logger.warning(
1346
+ "[CASAMBI_CLASSIC_GATT_ENUM] services enumeration unavailable: %s",
1347
+ type(e).__name__,
1348
+ )
1349
+
1350
+ async def classicSendInit(self) -> None:
1351
+ """Send Classic post-connection initialization (version + time-sync).
1352
+
1353
+ Ground truth (casambi-android v3.16):
1354
+ - Enable notify on CA52/0001 (CCCD) (handled by Bleak start_notify)
1355
+ - Send "version" on CA52/0001: bytes [0,1,11]
1356
+ (`Z0/AbstractC0151u.h0()` in Android)
1357
+ - Then send time-sync on CA51/0002 (cmd 10 legacy / 7 conformant)
1358
+ (`Z0/AbstractC0142k.X()` in Android)
1359
+
1360
+ Classic often stays silent until this bootstrap is completed, so we do it
1361
+ right after the BLE connection is established.
1362
+
1363
+ The payload is sent raw via _sendClassic (NOT wrapped in buildClassicCommand).
1364
+ """
1365
+ self._checkState(ConnectionState.AUTHENTICATED)
1366
+ if self._protocolMode != ProtocolMode.CLASSIC:
1367
+ return
1368
+
1369
+ # Ensure notify setup has a moment to settle (Android delays ~100ms after CCCD write).
1370
+ await asyncio.sleep(0.1)
1371
+
1372
+ # 1) Send Classic "version" packet on CA52/0001.
1373
+ version_uuid = self._classicTxCharUuid or self._dataCharUuid
1374
+ if version_uuid:
1375
+ try:
1376
+ self._logger.warning(
1377
+ "[CASAMBI_CLASSIC_INIT] sending version len=3 target_uuid=%s header_mode=%s",
1378
+ version_uuid,
1379
+ self._classicHeaderMode,
1380
+ )
1381
+ await self._sendClassic(
1382
+ b"\x00\x01\x0b",
1383
+ target_uuid=version_uuid,
1384
+ # Android uses visitor auth for this bootstrap packet.
1385
+ key_preference="visitor",
1386
+ # Android n0():998 uses WRITE_TYPE_NO_RESPONSE for classic.
1387
+ response=False,
1388
+ )
1389
+ self._logger.warning("[CASAMBI_CLASSIC_INIT] version sent successfully")
1390
+ except Exception:
1391
+ self._logger.warning(
1392
+ "[CASAMBI_CLASSIC_INIT] version send failed (continuing with time-sync)",
1393
+ exc_info=True,
1394
+ )
1395
+
1396
+ import datetime as _dt
1397
+
1398
+ now = _dt.datetime.now()
1399
+
1400
+ # Timezone offset in minutes from UTC.
1401
+ local_tz = _dt.datetime.now(_dt.timezone.utc).astimezone().tzinfo
1402
+ utc_offset_minutes = 0
1403
+ if local_tz is not None:
1404
+ offset = local_tz.utcoffset(now)
1405
+ if offset is not None:
1406
+ utc_offset_minutes = int(offset.total_seconds()) // 60
1407
+
1408
+ # Determine time-sync target and command byte per Android AbstractC1717h.X():
1409
+ # - Non-conformant: write to CA51, command byte 10
1410
+ # - Conformant: write to 0002 (mapped CA51), command byte 7
1411
+ if self._classicHeaderMode == "conformant":
1412
+ timesync_uuid = CASA_CLASSIC_CONFORMANT_CA51_CHAR_UUID # 0002
1413
+ timesync_cmd = 7
1414
+ else:
1415
+ timesync_uuid = CASA_CLASSIC_HASH_CHAR_UUID # CA51
1416
+ timesync_cmd = 10
1417
+
1418
+ # Build the time-sync payload.
1419
+ # Format: [cmd][year:2BE][month:1][day:1][hour:1][min:1][sec:1]
1420
+ # [tz_offset:2BE signed][dst_transition:4BE][dst_change:1]
1421
+ # [timestamp1:3BE][timestamp2:3BE][zero:2][millis:3BE][extra:1]
1422
+ payload = bytearray()
1423
+ payload.append(timesync_cmd)
1424
+ payload.extend(struct.pack(">H", now.year))
1425
+ payload.append(now.month)
1426
+ payload.append(now.day)
1427
+ payload.append(now.hour)
1428
+ payload.append(now.minute)
1429
+ payload.append(now.second)
1430
+ payload.extend(struct.pack(">h", utc_offset_minutes))
1431
+ # DST transition data and change minutes (0 = no DST info).
1432
+ payload.extend(struct.pack(">I", 0))
1433
+ payload.append(0)
1434
+ # Classic extra bytes: lon/lat (fixed-point), zero short, millis, trailing lon high byte.
1435
+ # Android AbstractC1717h.X() lines 323-328: j() = 3-byte big-endian write
1436
+ # (Q2.t.java:59-63), NOT 4-byte. Plus trailing writeByte(iK0 >> 24).
1437
+ #
1438
+ # In casambi-android v3.16 these values are derived from:
1439
+ # - longitude: round(longitude * 65536)
1440
+ # - latitude: round(latitude * 65536)
1441
+ # and sent as:
1442
+ # j(lon32) + j(lat32) + ... + writeByte(lon32 >> 24)
1443
+ lon_fp32 = 0
1444
+ lat_fp32 = 0
1445
+ try:
1446
+ raw = getattr(self._network, "rawNetworkData", None)
1447
+ net = raw.get("network") if isinstance(raw, dict) else None
1448
+ if isinstance(net, dict):
1449
+ lon = net.get("longitude")
1450
+ lat = net.get("latitude")
1451
+ if isinstance(lon, (int, float, str)):
1452
+ lon_fp32 = int(round(float(lon) * 65536.0))
1453
+ if isinstance(lat, (int, float, str)):
1454
+ lat_fp32 = int(round(float(lat) * 65536.0))
1455
+ except Exception:
1456
+ # Never fail init due to missing location; send zeros.
1457
+ lon_fp32 = 0
1458
+ lat_fp32 = 0
1459
+
1460
+ for v in (lon_fp32, lat_fp32):
1461
+ payload.append((v >> 16) & 0xFF)
1462
+ payload.append((v >> 8) & 0xFF)
1463
+ payload.append(v & 0xFF)
1464
+ payload.extend(struct.pack(">H", 0)) # writeShort(0)
1465
+ millis_val = now.microsecond // 1000 * 1000
1466
+ payload.append((millis_val >> 16) & 0xFF)
1467
+ payload.append((millis_val >> 8) & 0xFF)
1468
+ payload.append(millis_val & 0xFF)
1469
+ payload.append((lon_fp32 >> 24) & 0xFF) # writeByte(lon >> 24)
1470
+
1471
+ self._logger.warning(
1472
+ "[CASAMBI_CLASSIC_INIT] sending time-sync len=%d cmd=%d target_uuid=%s header_mode=%s hex=%s",
1473
+ len(payload),
1474
+ timesync_cmd,
1475
+ timesync_uuid,
1476
+ self._classicHeaderMode,
1477
+ b2a(bytes(payload)),
1478
+ )
1479
+
1480
+ try:
1481
+ await self._sendClassic(
1482
+ bytes(payload),
1483
+ target_uuid=timesync_uuid,
1484
+ # Android uses visitor auth for this bootstrap packet.
1485
+ key_preference="visitor",
1486
+ # Android X():314 uses WRITE_TYPE_DEFAULT (2) = with-response.
1487
+ response=True,
1488
+ )
1489
+ self._logger.warning("[CASAMBI_CLASSIC_INIT] time-sync sent successfully")
1490
+ except Exception:
1491
+ self._logger.warning(
1492
+ "[CASAMBI_CLASSIC_INIT] time-sync send failed",
1493
+ exc_info=True,
1494
+ )
1495
+
412
1496
  def _establishedNofityCallback(
413
1497
  self, handle: BleakGATTCharacteristic, data: bytes
414
1498
  ) -> None:
1499
+ # Route notifications based on characteristic UUID when available.
1500
+ # This helps with mixed/legacy setups where multiple Classic channels might be active.
1501
+ try:
1502
+ handle_uuid = str(getattr(handle, "uuid", "")).lower()
1503
+ except Exception:
1504
+ handle_uuid = ""
1505
+ if handle_uuid and handle_uuid in self._classicNotifyCharUuids:
1506
+ self._logger.debug(
1507
+ "[CASAMBI_NOTIFY_ROUTE] classic_by_uuid uuid=%s len=%d",
1508
+ handle_uuid,
1509
+ len(data),
1510
+ )
1511
+ self._classicEstablishedNotifyCallback(handle, data)
1512
+ return
1513
+ if self._protocolMode == ProtocolMode.CLASSIC:
1514
+ self._logger.debug(
1515
+ "[CASAMBI_NOTIFY_ROUTE] classic_by_mode uuid=%s len=%d",
1516
+ handle_uuid,
1517
+ len(data),
1518
+ )
1519
+ self._classicEstablishedNotifyCallback(handle, data)
1520
+ return
1521
+
415
1522
  # TODO: Check incoming counter and direction flag
416
1523
  self._inPacketCount += 1
417
1524
 
418
1525
  # Store raw encrypted packet for reference
419
1526
  raw_encrypted_packet = data[:]
420
1527
 
1528
+ # Extract the device-provided 4-byte little-endian counter from the
1529
+ # encrypted header. This is the true per-session packet sequence.
1530
+ try:
1531
+ device_sequence = int.from_bytes(data[:4], byteorder="little", signed=False)
1532
+ except Exception:
1533
+ device_sequence = None
1534
+
421
1535
  try:
422
1536
  decrypted_data = self._encryptor.decryptAndVerify(
423
1537
  data, data[:4] + self._nonce[4:]
@@ -428,13 +1542,33 @@ class CasambiClient:
428
1542
  return
429
1543
 
430
1544
  packetType = decrypted_data[0]
431
- self._logger.debug(f"Incoming data of type {packetType}: {b2a(decrypted_data)}")
1545
+ if self._logger.isEnabledFor(logging.DEBUG):
1546
+ self._logger.debug(
1547
+ "Incoming data of type %d: %s", packetType, b2a(decrypted_data)
1548
+ )
432
1549
 
433
1550
  if packetType == IncommingPacketType.UnitState:
434
1551
  self._parseUnitStates(decrypted_data[1:])
435
1552
  elif packetType == IncommingPacketType.SwitchEvent:
1553
+ # Stable logs for offline analysis: packet seq + encrypted + decrypted.
1554
+ # (Decrypted data includes the leading packet type byte.)
1555
+ if self._logger.isEnabledFor(logging.DEBUG):
1556
+ self._logger.debug(
1557
+ "[CASAMBI_RAW_PACKET] Encrypted #%s: %s",
1558
+ device_sequence,
1559
+ b2a(raw_encrypted_packet),
1560
+ )
1561
+ self._logger.debug(
1562
+ "[CASAMBI_DECRYPTED] Type=%d #%s: %s",
1563
+ packetType,
1564
+ device_sequence,
1565
+ b2a(decrypted_data),
1566
+ )
1567
+ # Pass the device sequence as the packet sequence for consumers,
1568
+ # and still include the raw encrypted packet for diagnostics.
1569
+ seq_for_consumer = device_sequence if device_sequence is not None else self._inPacketCount
436
1570
  self._parseSwitchEvent(
437
- decrypted_data[1:], self._inPacketCount, raw_encrypted_packet
1571
+ decrypted_data[1:], seq_for_consumer, raw_encrypted_packet
438
1572
  )
439
1573
  elif packetType == IncommingPacketType.NetworkConfig:
440
1574
  # We don't care about the config the network thinks it has.
@@ -443,165 +1577,735 @@ class CasambiClient:
443
1577
  # In the future we might want to parse the revision and issue a warning if there is a mismatch.
444
1578
  pass
445
1579
  else:
446
- self._logger.info(f"Packet type {packetType} not implemented. Ignoring!")
1580
+ self._logger.debug("Packet type %d not implemented. Ignoring!", packetType)
447
1581
 
448
- def _parseUnitStates(self, data: bytes) -> None:
449
- self._logger.info("Parsing incoming unit states...")
450
- self._logger.debug(f"Incoming unit state: {b2a(data)}")
1582
+ def _classicEstablishedNotifyCallback(
1583
+ self, handle: BleakGATTCharacteristic, data: bytes
1584
+ ) -> None:
1585
+ """Parse Classic notifications from the CA52 channel.
451
1586
 
452
- pos = 0
453
- oldPos = 0
1587
+ Classic packets are CMAC-signed (prefix embedded into the header).
1588
+ Ground truth: casambi-android `t1.P.o(...)`.
1589
+ """
1590
+ self._inPacketCount += 1
1591
+ self._classicRxFrames += 1
1592
+ rx_ts = time.monotonic()
1593
+ if self._classicFirstRxTs is None:
1594
+ self._classicFirstRxTs = rx_ts
1595
+
1596
+ raw = bytes(data)
1597
+
1598
+ # Enhanced RX diagnostic logging
454
1599
  try:
455
- while pos <= len(data) - 4:
456
- id = data[pos]
457
- flags = data[pos + 1]
458
- stateLen = ((data[pos + 2] >> 4) & 15) + 1
459
- prio = data[pos + 2] & 15
460
- pos += 3
1600
+ handle_uuid = str(getattr(handle, "uuid", "unknown")).lower()
1601
+ except Exception:
1602
+ handle_uuid = "unknown"
1603
+
1604
+ self._logger.warning(
1605
+ "[CLASSIC_DIAG_RX] #%d handle=%s len=%d hex=%s",
1606
+ self._classicRxFrames,
1607
+ handle_uuid,
1608
+ len(raw),
1609
+ b2a(raw[: min(len(raw), 48)]).decode("ascii") + ("..." if len(raw) > 48 else ""),
1610
+ )
461
1611
 
462
- online = flags & 2 != 0
463
- on = flags & 1 != 0
1612
+ if self._logger.isEnabledFor(logging.DEBUG):
1613
+ self._logger.debug(
1614
+ "[CASAMBI_CLASSIC_RX_RAW] len=%d hex=%s",
1615
+ len(raw),
1616
+ b2a(raw[: min(len(raw), 64)]) + (b"..." if len(raw) > 64 else b""),
1617
+ )
464
1618
 
465
- if flags & 4:
466
- pos += 1 # TODO: con?
467
- if flags & 8:
468
- pos += 1 # TODO: sid?
469
- if flags & 16:
470
- pos += 1 # Unkown value
1619
+ if self._classicConnHash8 is None:
1620
+ if self._logLimiter.allow("classic_rx_no_hash", burst=5, window_s=60.0):
1621
+ self._logger.warning("[CASAMBI_CLASSIC_RX] missing_connection_hash len=%d", len(raw))
1622
+ return
471
1623
 
472
- state = data[pos : pos + stateLen]
473
- pos += stateLen
1624
+ visitor_key = self._network.classicVisitorKey()
1625
+ manager_key = self._network.classicManagerKey()
474
1626
 
475
- pos += (flags >> 6) & 3 # Padding?
1627
+ def _plausible_payload(payload: bytes) -> bool:
1628
+ if not payload:
1629
+ return False
1630
+ if payload[0] in (
1631
+ IncommingPacketType.UnitState,
1632
+ IncommingPacketType.SwitchEvent,
1633
+ IncommingPacketType.NetworkConfig,
1634
+ ):
1635
+ return True
1636
+ # Classic command record stream: record[0] = (len+239) mod 256
1637
+ if len(payload) >= 2:
1638
+ rec_len = (payload[0] - 239) & 0xFF
1639
+ if 2 <= rec_len <= len(payload):
1640
+ return True
1641
+ return False
1642
+
1643
+ def _score(verified: bool | None, payload: bytes) -> int:
1644
+ plausible = _plausible_payload(payload)
1645
+ if verified is True:
1646
+ return 100
1647
+ if plausible and verified is None:
1648
+ return 50
1649
+ if plausible and verified is False:
1650
+ return 20
1651
+ return 0
1652
+
1653
+ def _parse_conformant(raw_bytes: bytes) -> dict[str, Any] | None:
1654
+ if len(raw_bytes) < 1 + 4 + 2:
1655
+ return None
1656
+ auth_level = raw_bytes[0]
1657
+ if auth_level == 0x02:
1658
+ sig_len = 4
1659
+ key_name = "visitor"
1660
+ key = visitor_key
1661
+ elif auth_level == 0x03:
1662
+ sig_len = 16
1663
+ key_name = "manager"
1664
+ key = manager_key
1665
+ else:
1666
+ return None
476
1667
 
477
- self._logger.debug(
478
- f"Parsed state: Id {id}, prio {prio}, online {online}, on {on}, state {b2a(state)}1"
1668
+ header_len = 1 + sig_len + 2
1669
+ if len(raw_bytes) < header_len:
1670
+ return None
1671
+
1672
+ sig = raw_bytes[1 : 1 + sig_len]
1673
+ cmac_input = raw_bytes[1 + sig_len :] # seq(2) + payload
1674
+ seq = int.from_bytes(cmac_input[:2], byteorder="big", signed=False)
1675
+ payload = cmac_input[2:]
1676
+
1677
+ verified: bool | None
1678
+ if key is None:
1679
+ verified = None
1680
+ else:
1681
+ try:
1682
+ expected = classic_cmac_prefix(key, self._classicConnHash8, cmac_input, sig_len)
1683
+ except Exception:
1684
+ verified = False
1685
+ else:
1686
+ verified = expected == sig
1687
+
1688
+ return {
1689
+ "mode": "conformant",
1690
+ "auth_level": auth_level,
1691
+ "sig_len": sig_len,
1692
+ "seq": seq,
1693
+ "key_name": key_name if key is not None else None,
1694
+ "verified": verified,
1695
+ "payload": payload,
1696
+ }
1697
+
1698
+ def _parse_legacy(raw_bytes: bytes, *, sig_len: int) -> dict[str, Any] | None:
1699
+ if len(raw_bytes) < sig_len + 1:
1700
+ return None
1701
+ sig = raw_bytes[:sig_len]
1702
+ payload = raw_bytes[sig_len:]
1703
+
1704
+ # In non-conformant mode Android still selects visitor/manager key for CMAC,
1705
+ # but the header contains only the CMAC prefix (typically 4 bytes).
1706
+ verified: bool | None = None
1707
+ key_name: str | None = None
1708
+
1709
+ keys_to_try: list[tuple[str, bytes | None]] = [
1710
+ ("visitor", visitor_key),
1711
+ ("manager", manager_key),
1712
+ ]
1713
+ any_key = any(k is not None for _, k in keys_to_try)
1714
+ if any_key:
1715
+ verified = False
1716
+ for nm, key in keys_to_try:
1717
+ if key is None:
1718
+ continue
1719
+ try:
1720
+ expected = classic_cmac_prefix(key, self._classicConnHash8, payload, sig_len)
1721
+ except Exception:
1722
+ continue
1723
+ if expected == sig:
1724
+ verified = True
1725
+ key_name = nm
1726
+ break
1727
+
1728
+ return {
1729
+ "mode": "legacy",
1730
+ "auth_level": None,
1731
+ "sig_len": sig_len,
1732
+ "seq": None,
1733
+ "key_name": key_name,
1734
+ "verified": verified,
1735
+ "payload": payload,
1736
+ }
1737
+
1738
+ def _parse_raw(raw_bytes: bytes) -> dict[str, Any] | None:
1739
+ """Parse as raw (unsigned) Classic data.
1740
+
1741
+ Android classic gateway a1.c.V() receives raw bytes with NO CMAC
1742
+ header — byte 0 is the unit_id directly. Adding this as a candidate
1743
+ avoids silently dropping unsigned notifications.
1744
+ """
1745
+ if not raw_bytes:
1746
+ return None
1747
+ return {
1748
+ "mode": "raw",
1749
+ "auth_level": None,
1750
+ "sig_len": 0,
1751
+ "seq": None,
1752
+ "key_name": None,
1753
+ "verified": None, # raw = unverifiable
1754
+ "payload": raw_bytes,
1755
+ }
1756
+
1757
+ # Try the currently selected header mode first, then fall back.
1758
+ # Some mixed/legacy setups differ between CA52 (legacy) and auth-UUID (conformant).
1759
+ parsed_candidates: list[dict[str, Any]] = []
1760
+ preferred = self._classicHeaderMode or "conformant"
1761
+ if preferred == "legacy":
1762
+ for sl in (4, 16):
1763
+ r = _parse_legacy(raw, sig_len=sl)
1764
+ if r is not None:
1765
+ parsed_candidates.append(r)
1766
+ r = _parse_conformant(raw)
1767
+ if r is not None:
1768
+ parsed_candidates.append(r)
1769
+ else:
1770
+ r = _parse_conformant(raw)
1771
+ if r is not None:
1772
+ parsed_candidates.append(r)
1773
+ for sl in (4, 16):
1774
+ r = _parse_legacy(raw, sig_len=sl)
1775
+ if r is not None:
1776
+ parsed_candidates.append(r)
1777
+
1778
+ # Add a raw (unsigned) candidate — needed because Android classic
1779
+ # gateway receives raw bytes with no CMAC header.
1780
+ raw_candidate = _parse_raw(raw)
1781
+ if raw_candidate is not None:
1782
+ parsed_candidates.append(raw_candidate)
1783
+
1784
+ if not parsed_candidates:
1785
+ self._classicRxParseFail += 1
1786
+ if self._logLimiter.allow("classic_rx_parse_fail", burst=5, window_s=60.0):
1787
+ self._logger.warning(
1788
+ "[CASAMBI_CLASSIC_RX_PARSE_FAIL] len=%d prefix=%s",
1789
+ len(raw),
1790
+ b2a(raw[: min(len(raw), 32)]),
479
1791
  )
1792
+ return
480
1793
 
481
- self._dataCallback(
482
- IncommingPacketType.UnitState,
483
- {"id": id, "online": online, "on": on, "state": state},
1794
+ # Choose best candidate by score; tie-breaker prefers current mode.
1795
+ for c in parsed_candidates:
1796
+ c["score"] = _score(c["verified"], c["payload"])
1797
+
1798
+ parsed_candidates.sort(
1799
+ key=lambda c: (
1800
+ c["score"],
1801
+ 1 if c["mode"] == preferred else 0,
1802
+ -c["sig_len"],
1803
+ ),
1804
+ reverse=True,
1805
+ )
1806
+ best = parsed_candidates[0]
1807
+
1808
+ if best["score"] == 0:
1809
+ self._classicRxParseFail += 1
1810
+ if self._logLimiter.allow("classic_rx_unplausible", burst=5, window_s=60.0):
1811
+ self._logger.warning(
1812
+ "[CASAMBI_CLASSIC_RX_UNPLAUSIBLE] preferred=%s len=%d prefix=%s",
1813
+ preferred,
1814
+ len(raw),
1815
+ b2a(raw[: min(len(raw), 32)]),
484
1816
  )
1817
+ return
485
1818
 
486
- oldPos = pos
487
- except IndexError:
488
- self._logger.error(
489
- f"Ran out of data while parsing unit state! Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
1819
+ payload = best["payload"]
1820
+ verified = best["verified"]
1821
+ if verified is True:
1822
+ self._classicRxVerified += 1
1823
+ elif verified is None:
1824
+ self._classicRxUnverifiable += 1
1825
+
1826
+ # Record RX in diagnostic history
1827
+ rx_entry = {
1828
+ "timestamp": rx_ts,
1829
+ "handle_uuid": handle_uuid,
1830
+ "header_mode": best["mode"],
1831
+ "verified": verified,
1832
+ "auth_level": best["auth_level"],
1833
+ "sig_len": best["sig_len"],
1834
+ "seq": best["seq"],
1835
+ "payload_len": len(payload),
1836
+ "raw_hex": b2a(raw).decode("ascii"),
1837
+ "payload_hex": b2a(payload).decode("ascii"),
1838
+ "score": best["score"],
1839
+ }
1840
+ self._classicRxHistory.append(rx_entry)
1841
+ if len(self._classicRxHistory) > self._classicDiagMaxHistory:
1842
+ self._classicRxHistory = self._classicRxHistory[-self._classicDiagMaxHistory:]
1843
+
1844
+ # Enhanced RX parse result log
1845
+ self._logger.warning(
1846
+ "[CLASSIC_DIAG_RX_PARSE] mode=%s verified=%s auth=%s sig_len=%d seq=%s score=%d payload_len=%d",
1847
+ best["mode"],
1848
+ verified,
1849
+ None if best["auth_level"] is None else f"0x{best['auth_level']:02x}",
1850
+ best["sig_len"],
1851
+ None if best["seq"] is None else f"0x{best['seq']:04x}",
1852
+ best["score"],
1853
+ len(payload),
1854
+ )
1855
+
1856
+ # Auto-correct header mode if the other format parses much better.
1857
+ # Never switch to "raw" — raw is not a header mode, only a fallback parse.
1858
+ if best["mode"] != preferred and best["mode"] in ("conformant", "legacy"):
1859
+ # Only switch if we got a stronger signal (verified or plausible payload with fewer assumptions).
1860
+ if best["score"] >= 50 and self._logLimiter.allow("classic_rx_mode_switch", burst=3, window_s=3600.0):
1861
+ self._logger.warning(
1862
+ "[CASAMBI_CLASSIC_RX_MODE] switching %s -> %s (score=%d verified=%s sig_len=%d)",
1863
+ preferred,
1864
+ best["mode"],
1865
+ best["score"],
1866
+ verified,
1867
+ best["sig_len"],
1868
+ )
1869
+ self._classicHeaderMode = best["mode"]
1870
+
1871
+ # Sample RX logs (limited) + periodic stats (limited).
1872
+ if self._logLimiter.allow("classic_rx_sample", burst=10, window_s=60.0):
1873
+ self._logger.warning(
1874
+ "[CASAMBI_CLASSIC_RX] header=%s verified=%s auth=%s sig_len=%d seq=%s payload_prefix=%s",
1875
+ best["mode"],
1876
+ verified,
1877
+ None if best["auth_level"] is None else f"0x{best['auth_level']:02x}",
1878
+ best["sig_len"],
1879
+ None if best["seq"] is None else f"0x{best['seq']:04x}",
1880
+ b2a(payload[: min(len(payload), 32)]),
1881
+ )
1882
+ now = time.monotonic()
1883
+ if (now - self._classicRxLastStatsTs) > 60.0 and self._logLimiter.allow(
1884
+ "classic_rx_stats", burst=2, window_s=60.0
1885
+ ):
1886
+ self._classicRxLastStatsTs = now
1887
+ self._logger.warning(
1888
+ "[CASAMBI_CLASSIC_RX_STATS] frames=%d verified=%d unverifiable=%d parse_fail=%d header=%s "
1889
+ "type6=%d type7=%d type9=%d cmdstream=%d unknown=%d classic_states=%d",
1890
+ self._classicRxFrames,
1891
+ self._classicRxVerified,
1892
+ self._classicRxUnverifiable,
1893
+ self._classicRxParseFail,
1894
+ self._classicHeaderMode,
1895
+ self._classicRxType6,
1896
+ self._classicRxType7,
1897
+ self._classicRxType9,
1898
+ self._classicRxCmdStream,
1899
+ self._classicRxUnknown,
1900
+ self._classicRxClassicStates,
490
1901
  )
491
1902
 
492
- def _parseSwitchEvent(
493
- self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
494
- ) -> None:
495
- """Parse switch event packet which contains multiple message types."""
496
- self._logger.info(
497
- f"Parsing incoming switch event packet #{packet_seq}... Data: {b2a(data)}"
498
- )
1903
+ # Classic payloads use a completely different format from EVO.
1904
+ # Classic: byte 0 is a type indicator (0=netconfig, 255=log, else=unit_id).
1905
+ # EVO: byte 0 is a packet type (6=UnitState, 7=Switch, 9=NetConfig).
1906
+ # Dispatch Classic through its own parser to avoid misinterpretation.
1907
+ if self._protocolMode == ProtocolMode.CLASSIC:
1908
+ self._dispatchClassicPayload(payload)
1909
+ return
499
1910
 
500
- # Special handling for message type 0x29 - not a switch event
501
- if len(data) >= 1 and data[0] == 0x29:
1911
+ # If the payload starts with a known EVO packet type, reuse existing parsers.
1912
+ packet_type = payload[0]
1913
+ if packet_type in (IncommingPacketType.UnitState, IncommingPacketType.SwitchEvent, IncommingPacketType.NetworkConfig):
1914
+ kind = f"type{int(packet_type)}"
1915
+ if packet_type == IncommingPacketType.UnitState:
1916
+ self._classicRxType6 += 1
1917
+ kind = "type6_unitstate"
1918
+ elif packet_type == IncommingPacketType.SwitchEvent:
1919
+ self._classicRxType7 += 1
1920
+ kind = "type7_switch"
1921
+ else:
1922
+ self._classicRxType9 += 1
1923
+ kind = "type9_netconf"
1924
+
1925
+ # Emit a few per-kind examples for reverse engineering.
1926
+ if self._classicRxKindSamples.get(kind, 0) < 3:
1927
+ self._classicRxKindSamples[kind] = self._classicRxKindSamples.get(kind, 0) + 1
1928
+ self._logger.warning(
1929
+ "[CASAMBI_CLASSIC_RX_KIND] kind=%s header=%s verified=%s sig_len=%d seq=%s payload_prefix=%s",
1930
+ kind,
1931
+ best["mode"],
1932
+ verified,
1933
+ best["sig_len"],
1934
+ None if best["seq"] is None else f"0x{best['seq']:04x}",
1935
+ b2a(payload[: min(len(payload), 32)]),
1936
+ )
1937
+
1938
+ if self._logger.isEnabledFor(logging.DEBUG):
1939
+ self._logger.debug(
1940
+ "[CASAMBI_CLASSIC_RX_PAYLOAD] type=%d len=%d hex=%s",
1941
+ packet_type,
1942
+ len(payload),
1943
+ b2a(payload[: min(len(payload), 64)])
1944
+ + (b"..." if len(payload) > 64 else b""),
1945
+ )
1946
+ if packet_type == IncommingPacketType.UnitState:
1947
+ self._parseUnitStates(payload[1:])
1948
+ elif packet_type == IncommingPacketType.SwitchEvent:
1949
+ self._parseSwitchEvent(payload[1:], None, raw)
1950
+ else:
1951
+ # ignore network config
1952
+ pass
1953
+ return
1954
+
1955
+ # Otherwise, attempt to parse a stream of Classic "command" records:
1956
+ # record[0] = (len + 239) mod 256, so len = (b0 - 239) & 0xFF.
1957
+ pos = 0
1958
+ parsed_any = False
1959
+ while pos + 2 <= len(payload):
1960
+ enc_len = payload[pos]
1961
+ rec_len = (enc_len - 239) & 0xFF
1962
+ if rec_len < 2 or pos + rec_len > len(payload):
1963
+ break
1964
+ rec = payload[pos : pos + rec_len]
1965
+ pos += rec_len
1966
+ parsed_any = True
1967
+
1968
+ typ = rec[1]
1969
+ ordinal = typ & 0x3F
1970
+ has_div = (typ & 0x40) != 0
1971
+ has_target = (typ & 0x80) != 0
1972
+ p = 2
1973
+ div = rec[p] if has_div and p < len(rec) else None
1974
+ if has_div:
1975
+ p += 1
1976
+ target = rec[p] if has_target and p < len(rec) else None
1977
+ if has_target:
1978
+ p += 1
1979
+ lifetime = rec[p] if p < len(rec) else None
1980
+ if lifetime is not None:
1981
+ p += 1
1982
+ rec_payload = rec[p:] if p <= len(rec) else b""
1983
+
1984
+ if self._logger.isEnabledFor(logging.DEBUG):
1985
+ self._logger.debug(
1986
+ "[CASAMBI_CLASSIC_CMD] ord=%d div=%s target=%s lifetime=%s payload=%s",
1987
+ ordinal,
1988
+ div,
1989
+ target,
1990
+ lifetime,
1991
+ b2a(rec_payload),
1992
+ )
1993
+
1994
+ if parsed_any:
1995
+ self._classicRxCmdStream += 1
1996
+ kind = "cmdstream"
1997
+ else:
1998
+ self._classicRxUnknown += 1
1999
+ kind = "unknown"
2000
+
2001
+ if self._classicRxKindSamples.get(kind, 0) < 3:
2002
+ self._classicRxKindSamples[kind] = self._classicRxKindSamples.get(kind, 0) + 1
2003
+ self._logger.warning(
2004
+ "[CASAMBI_CLASSIC_RX_KIND] kind=%s header=%s verified=%s sig_len=%d seq=%s payload_prefix=%s",
2005
+ kind,
2006
+ best["mode"],
2007
+ verified,
2008
+ best["sig_len"],
2009
+ None if best["seq"] is None else f"0x{best['seq']:04x}",
2010
+ b2a(payload[: min(len(payload), 32)]),
2011
+ )
2012
+
2013
+ # Any trailing bytes that don't form a full record are logged for analysis.
2014
+ if self._logger.isEnabledFor(logging.DEBUG) and pos < len(payload):
502
2015
  self._logger.debug(
503
- f"Ignoring message type 0x29 (not a switch event): {b2a(data)}"
2016
+ "[CASAMBI_CLASSIC_CMD_TRAILING] len=%d hex=%s",
2017
+ len(payload) - pos,
2018
+ b2a(payload[pos:]),
504
2019
  )
2020
+
2021
+ def _dispatchClassicPayload(self, payload: bytes) -> None:
2022
+ """Dispatch a verified Classic payload based on its type indicator.
2023
+
2024
+ Classic payloads (from C1751c.V()) use a different format from EVO:
2025
+ - byte 0 == 0: network config data
2026
+ - byte 0 == 255: log message
2027
+ - otherwise: unit state stream (byte 0 is the first unit_id)
2028
+ """
2029
+ if not payload:
505
2030
  return
506
2031
 
507
- pos = 0
508
- oldPos = 0
509
- switch_events_found = 0
2032
+ first_byte = payload[0]
510
2033
 
511
- try:
512
- while pos <= len(data) - 3:
513
- oldPos = pos
2034
+ # Log full payload for the first 10 Classic payloads regardless of type.
2035
+ if self._classicRxClassicStates < 10:
2036
+ self._logger.warning(
2037
+ "[CASAMBI_CLASSIC_DISPATCH] #%d type_byte=%d len=%d hex=%s",
2038
+ self._classicRxClassicStates,
2039
+ first_byte,
2040
+ len(payload),
2041
+ b2a(payload[: min(len(payload), 64)]).decode("ascii")
2042
+ + ("..." if len(payload) > 64 else ""),
2043
+ )
514
2044
 
515
- # Parse message header
516
- message_type = data[pos]
517
- flags = data[pos + 1]
518
- length = ((data[pos + 2] >> 4) & 15) + 1
519
- parameter = data[pos + 2] # Full byte, not just lower 4 bits
520
- pos += 3
2045
+ if first_byte == 0:
2046
+ self._logger.debug("[CASAMBI_CLASSIC_NETCONFIG] len=%d", len(payload))
2047
+ return
2048
+
2049
+ if first_byte == 255:
2050
+ self._logger.debug("[CASAMBI_CLASSIC_LOG] len=%d", len(payload))
2051
+ return
521
2052
 
522
- # Sanity check: message type should be reasonable
523
- if message_type > 0x80:
2053
+ # Unit state stream: entire payload is passed (first byte is the first unit_id).
2054
+ self._classicRxClassicStates += 1
2055
+ self._parseClassicUnitStates(payload)
2056
+
2057
+ def _parseClassicUnitStates(self, data: bytes) -> None:
2058
+ """Parse Classic unit state records.
2059
+
2060
+ Ground truth: casambi-android a1.c.V() (line 226+).
2061
+ Format is completely different from EVO _parseUnitStates:
2062
+ - flags lower nibble = state_len (EVO uses a separate byte)
2063
+ - flags bit 4 = priority 14, bit 5 = extra1, bit 6 = extra2, bit 7 = online
2064
+ - unit_id 0xF0 = command response (cmd_id + seq + payload)
2065
+ """
2066
+ self._logger.debug("Parsing Classic unit states...")
2067
+ if self._logger.isEnabledFor(logging.DEBUG):
2068
+ self._logger.debug("[CASAMBI_CLASSIC_STATES_RAW] len=%d hex=%s", len(data), b2a(data))
2069
+
2070
+ pos = 0
2071
+ old_pos = 0
2072
+ records_parsed = 0
2073
+ try:
2074
+ # Android uses fVar.available() >= 3 as loop guard.
2075
+ while pos + 3 <= len(data):
2076
+ unit_id = data[pos]
2077
+ flags = data[pos + 1]
2078
+ pos += 2
2079
+
2080
+ state_len = flags & 0x0F
2081
+ has_extra1 = (flags & 0x20) != 0
2082
+ has_extra2 = (flags & 0x40) != 0
2083
+ # Android a1.c.java:286: (b6 & 128) != 0 → online (NOT offline).
2084
+ # Confirmed by N1.java:1298 log "Set unit ONLINE=" + z6.
2085
+ online = (flags & 0x80) != 0
2086
+
2087
+ # 0xF0 = command response record (Android a1.c.java:260-270).
2088
+ # Format: cmd_id(1) + seq(1) + payload(state_len - 2).
2089
+ if unit_id == 0xF0:
2090
+ cmd_id = data[pos] if pos < len(data) else None
2091
+ seq_byte = data[pos + 1] if pos + 1 < len(data) else None
524
2092
  self._logger.debug(
525
- f"Skipping invalid message type 0x{message_type:02x} at position {oldPos}"
2093
+ "[CASAMBI_CLASSIC_CMD_RESP] cmd_id=%s seq=%s state_len=%d",
2094
+ cmd_id,
2095
+ seq_byte,
2096
+ state_len,
526
2097
  )
527
- # Try to resync by looking for next valid message
528
- pos = oldPos + 1
2098
+ pos += state_len
529
2099
  continue
530
2100
 
531
- # Check if we have enough data for the payload
532
- if pos + length > len(data):
533
- self._logger.debug(
534
- f"Incomplete message at position {oldPos}. "
535
- f"Type: 0x{message_type:02x}, declared length: {length}, available: {len(data) - pos}"
536
- )
2101
+ extra1 = 0
2102
+ if has_extra1:
2103
+ if pos >= len(data):
2104
+ break
2105
+ extra1 = data[pos]
2106
+ pos += 1
2107
+
2108
+ extra2 = 0
2109
+ if has_extra2:
2110
+ if pos >= len(data):
2111
+ break
2112
+ extra2 = data[pos]
2113
+ pos += 1
2114
+
2115
+ if pos + state_len > len(data):
537
2116
  break
538
2117
 
539
- # Extract the payload
540
- payload = data[pos : pos + length]
541
- pos += length
542
-
543
- # Process based on message type
544
- if message_type == 0x08 or message_type == 0x10: # Switch/button events
545
- switch_events_found += 1
546
- # Extract button ID - try both upper and lower nibbles
547
- button_lower = parameter & 0x0F
548
- button_upper = (parameter >> 4) & 0x0F
549
-
550
- # Use upper 4 bits if lower 4 bits are 0, otherwise use lower 4 bits
551
- if button_lower == 0 and button_upper != 0:
552
- button = button_upper
553
- self._logger.debug(
554
- f"EVO button extraction: parameter=0x{parameter:02x}, using upper nibble, button={button}"
555
- )
556
- else:
557
- button = button_lower
558
- self._logger.debug(
559
- f"EVO button extraction: parameter=0x{parameter:02x}, using lower nibble, button={button}"
560
- )
561
-
562
- # For type 0x10 messages, we need to pass additional data beyond the declared payload
563
- if message_type == 0x10:
564
- # Extend to include at least 10 bytes from message start for state byte
565
- extended_end = min(oldPos + 11, len(data))
566
- full_message_data = data[oldPos:extended_end]
567
- else:
568
- full_message_data = data
569
- self._processSwitchMessage(
570
- message_type,
2118
+ state = data[pos : pos + state_len]
2119
+ pos += state_len
2120
+ records_parsed += 1
2121
+
2122
+ # Log the first few parsed records at WARNING level for tester visibility.
2123
+ if records_parsed <= 10 or self._logger.isEnabledFor(logging.DEBUG):
2124
+ self._logger.warning(
2125
+ "[CASAMBI_CLASSIC_STATE_PARSED] unit=%d flags=0x%02x state_len=%d "
2126
+ "online=%s extra1=%d extra2=%d state=%s",
2127
+ unit_id,
571
2128
  flags,
572
- button,
573
- payload,
574
- full_message_data,
575
- oldPos,
576
- packet_seq,
577
- raw_packet,
578
- )
579
- elif message_type == 0x29:
580
- # 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, 0x1F, 0x2A]:
583
- # Known non-switch message types - log at debug level
584
- self._logger.debug(
585
- f"Non-switch message type 0x{message_type:02x}: flags=0x{flags:02x}, "
586
- f"param={parameter}, payload={b2a(payload)}"
2129
+ state_len,
2130
+ online,
2131
+ extra1,
2132
+ extra2,
2133
+ b2a(state),
587
2134
  )
2135
+ # Let Unit.is_on derive actual on/off from state bytes (dimmer, onoff).
2136
+ on = True
2137
+
2138
+ self._dataCallback(
2139
+ IncommingPacketType.UnitState,
2140
+ {
2141
+ "id": unit_id,
2142
+ "online": online,
2143
+ "on": on,
2144
+ "state": state,
2145
+ "flags": flags,
2146
+ # Android a1.c.java:291: (b6 & 16) != 0 ? 14 : 0
2147
+ "prio": 14 if (flags & 0x10) else 0,
2148
+ "state_len": state_len,
2149
+ "padding_len": 0,
2150
+ "con": None,
2151
+ "sid": None,
2152
+ "extra_byte": extra1,
2153
+ "extra_float": extra1 / 255.0 if extra1 else 0.0,
2154
+ },
2155
+ )
2156
+
2157
+ old_pos = pos
2158
+ except IndexError:
2159
+ self._logger.error(
2160
+ "Ran out of data while parsing Classic unit state! Remaining data %s in %s.",
2161
+ b2a(data[old_pos:]),
2162
+ b2a(data),
2163
+ )
2164
+
2165
+ if records_parsed > 0:
2166
+ self._logger.debug(
2167
+ "[CASAMBI_CLASSIC_STATES_DONE] records=%d remaining=%d",
2168
+ records_parsed,
2169
+ len(data) - pos,
2170
+ )
2171
+
2172
+ def _parseUnitStates(self, data: bytes) -> None:
2173
+ # Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
2174
+ # as a stream of unit state records. Records have optional bytes depending on flags.
2175
+ self._logger.debug("Parsing incoming unit states...")
2176
+ if self._logger.isEnabledFor(logging.DEBUG):
2177
+ self._logger.debug("Incoming unit state: %s", b2a(data))
2178
+
2179
+ pos = 0
2180
+ oldPos = 0
2181
+ try:
2182
+ # Android uses `while (available() >= 4)` as the loop condition.
2183
+ while pos <= len(data) - 4:
2184
+ unit_id = data[pos]
2185
+ flags = data[pos + 1]
2186
+ b8 = data[pos + 2]
2187
+ state_len = ((b8 >> 4) & 0x0F) + 1
2188
+ prio = b8 & 0x0F
2189
+ pos += 3
2190
+
2191
+ online = (flags & 0x02) != 0
2192
+ on = (flags & 0x01) != 0
2193
+
2194
+ con: int | None = None
2195
+ sid: int | None = None
2196
+
2197
+ # Optional bytes, matching Android:
2198
+ # - flags&0x04: con (1 byte)
2199
+ # - flags&0x08: sid (1 byte)
2200
+ # - flags&0x10: extra byte; if missing Android uses 0xFF
2201
+ if flags & 0x04:
2202
+ con = data[pos]
2203
+ pos += 1
2204
+ if flags & 0x08:
2205
+ sid = data[pos]
2206
+ pos += 1
2207
+
2208
+ if flags & 0x10:
2209
+ extra_byte = data[pos]
2210
+ pos += 1
588
2211
  else:
589
- # Unknown message types - log at info level
590
- self._logger.info(
591
- f"Unknown message type 0x{message_type:02x}: flags=0x{flags:02x}, "
592
- f"param={parameter}, payload={b2a(payload)}"
2212
+ extra_byte = 0xFF
2213
+
2214
+ state = data[pos : pos + state_len]
2215
+ pos += state_len
2216
+
2217
+ padding_len = (flags >> 6) & 0x03
2218
+ padding = data[pos : pos + padding_len] if padding_len else b""
2219
+ pos += padding_len
2220
+
2221
+ if self._logger.isEnabledFor(logging.DEBUG):
2222
+ self._logger.debug(
2223
+ "[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",
2224
+ unit_id,
2225
+ flags,
2226
+ prio,
2227
+ online,
2228
+ on,
2229
+ con,
2230
+ sid,
2231
+ extra_byte,
2232
+ b2a(state),
2233
+ b2a(padding),
593
2234
  )
594
2235
 
595
- oldPos = pos
2236
+ self._dataCallback(
2237
+ IncommingPacketType.UnitState,
2238
+ {
2239
+ "id": unit_id,
2240
+ "online": online,
2241
+ "on": on,
2242
+ "state": state,
2243
+ # Additional fields for diagnostics/analysis
2244
+ "flags": flags,
2245
+ "prio": prio,
2246
+ "state_len": state_len,
2247
+ "padding_len": padding_len,
2248
+ "con": con,
2249
+ "sid": sid,
2250
+ "extra_byte": extra_byte,
2251
+ "extra_float": extra_byte / 255.0,
2252
+ },
2253
+ )
596
2254
 
2255
+ oldPos = pos
597
2256
  except IndexError:
598
2257
  self._logger.error(
599
- f"Ran out of data while parsing switch event packet! "
600
- f"Remaining data {b2a(data[oldPos:])} in {b2a(data)}."
2258
+ "Ran out of data while parsing unit state! Remaining data %s in %s.",
2259
+ b2a(data[oldPos:]),
2260
+ b2a(data),
2261
+ )
2262
+
2263
+ def _parseSwitchEvent(
2264
+ self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
2265
+ ) -> None:
2266
+ """Parse decrypted packet type=7 payload (INVOCATION stream).
2267
+
2268
+ Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses decrypted packet type=7
2269
+ as a stream of INVOCATION frames. Switch button events are INVOCATIONs.
2270
+ """
2271
+
2272
+ if self._logger.isEnabledFor(logging.DEBUG):
2273
+ data_hex = b2a(data)
2274
+ self._logger.debug(
2275
+ "Parsing incoming switch event packet #%s... Data: %s",
2276
+ packet_seq,
2277
+ data_hex,
2278
+ )
2279
+ self._logger.debug(
2280
+ "[CASAMBI_SWITCH_PACKET] Full data #%s: hex=%s len=%d",
2281
+ packet_seq,
2282
+ data_hex,
2283
+ len(data),
601
2284
  )
602
2285
 
603
- if switch_events_found == 0:
604
- self._logger.debug(f"No switch events found in packet: {b2a(data)}")
2286
+ events, stats = self._switchDecoder.decode(
2287
+ data,
2288
+ packet_seq=packet_seq,
2289
+ raw_packet=raw_packet,
2290
+ arrival_sequence=self._inPacketCount,
2291
+ )
2292
+
2293
+ self._logger.debug(
2294
+ "[CASAMBI_SWITCH_SUMMARY] packet=%s frames=%d button_frames=%d input_frames=%d ignored=%d emitted=%d suppressed_same_state=%d",
2295
+ packet_seq,
2296
+ stats.frames_total,
2297
+ stats.frames_button,
2298
+ stats.frames_input,
2299
+ stats.frames_ignored,
2300
+ stats.events_emitted,
2301
+ stats.events_suppressed_same_state,
2302
+ )
2303
+
2304
+ for ev in events:
2305
+ # Back-compat alias: older consumers looked for 'flags'
2306
+ if "flags" not in ev:
2307
+ ev["flags"] = ev.get("invocation_flags")
2308
+ self._dataCallback(IncommingPacketType.SwitchEvent, ev)
605
2309
 
606
2310
  def _processSwitchMessage(
607
2311
  self,
@@ -694,13 +2398,12 @@ class CasambiClient:
694
2398
  f"action={action_display} ({event_string}), flags=0x{flags:02x}"
695
2399
  )
696
2400
 
697
- # Filter out type 0x08 messages with button=0 (likely notifications)
698
- if message_type == 0x08 and button == 0:
699
- self._logger.debug(
700
- f"Filtering out type 0x08 notification event: button={button}, unit_id={unit_id}, "
701
- f"action={action_display}, flags=0x{flags:02x}"
2401
+ # Log detailed info about type 0x08 messages (now processed, not filtered)
2402
+ if message_type == 0x08:
2403
+ self._logger.info(
2404
+ f"Type 0x08 event processed: button={button}, unit_id={unit_id}, "
2405
+ f"action={action_display}, event={event_string}, flags=0x{flags:02x}"
702
2406
  )
703
- return
704
2407
 
705
2408
  self._dataCallback(
706
2409
  IncommingPacketType.SwitchEvent,
@@ -712,7 +2415,11 @@ class CasambiClient:
712
2415
  "event": event_string,
713
2416
  "flags": flags,
714
2417
  "extra_data": extra_data,
2418
+ # packet_sequence is the device-provided sequence number when available
2419
+ # (true 32-bit counter from the BLE header), otherwise the local arrival index.
715
2420
  "packet_sequence": packet_seq,
2421
+ # Include the local arrival index for debugging and correlation.
2422
+ "arrival_sequence": self._inPacketCount,
716
2423
  "raw_packet": b2a(raw_packet) if raw_packet else None,
717
2424
  "decrypted_data": b2a(full_data),
718
2425
  "message_position": start_pos,
@@ -723,9 +2430,22 @@ class CasambiClient:
723
2430
  async def disconnect(self) -> None:
724
2431
  self._logger.info("Disconnecting...")
725
2432
 
2433
+ if self._classicNoRxTask is not None:
2434
+ self._classicNoRxTask.cancel()
2435
+ self._classicNoRxTask = None
2436
+
726
2437
  if self._callbackTask is not None:
2438
+ # Cancel and await the background callback task to avoid
2439
+ # 'Task was destroyed but it is pending' warnings.
727
2440
  self._callbackTask.cancel()
728
- self._callbackTask = None
2441
+ try:
2442
+ await self._callbackTask
2443
+ except asyncio.CancelledError:
2444
+ pass
2445
+ except Exception:
2446
+ self._logger.debug("Callback task finished with exception during disconnect.", exc_info=True)
2447
+ finally:
2448
+ self._callbackTask = None
729
2449
 
730
2450
  if self._gattClient is not None and self._gattClient.is_connected:
731
2451
  try:
@@ -735,3 +2455,39 @@ class CasambiClient:
735
2455
 
736
2456
  self._connectionState = ConnectionState.NONE
737
2457
  self._logger.info("Disconnected.")
2458
+
2459
+ def getClassicDiagnostics(self) -> dict[str, Any]:
2460
+ """Return Classic protocol diagnostic state for external services.
2461
+
2462
+ This method provides a snapshot of Classic protocol state including:
2463
+ - Connection parameters (hash, mode, UUIDs)
2464
+ - RX/TX statistics
2465
+ - Last N TX and RX packets
2466
+ - Any detected errors or anomalies
2467
+
2468
+ Safe to call from HA services for dump_classic_diagnostics.
2469
+ """
2470
+ return {
2471
+ "protocol_mode": self._protocolMode.name if self._protocolMode else None,
2472
+ "classic_header_mode": self._classicHeaderMode,
2473
+ "classic_hash_source": self._classicHashSource,
2474
+ "classic_conn_hash8_hex": b2a(self._classicConnHash8).decode("ascii") if self._classicConnHash8 else None,
2475
+ "classic_tx_uuid": self._classicTxCharUuid,
2476
+ "classic_notify_uuids": sorted(self._classicNotifyCharUuids) if self._classicNotifyCharUuids else [],
2477
+ "classic_first_rx_ts": self._classicFirstRxTs,
2478
+ "classic_rx_stats": {
2479
+ "frames": self._classicRxFrames,
2480
+ "verified": self._classicRxVerified,
2481
+ "unverifiable": self._classicRxUnverifiable,
2482
+ "parse_fail": self._classicRxParseFail,
2483
+ "type6_unitstate": self._classicRxType6,
2484
+ "type7_switch": self._classicRxType7,
2485
+ "type9_netconf": self._classicRxType9,
2486
+ "cmdstream": self._classicRxCmdStream,
2487
+ "unknown": self._classicRxUnknown,
2488
+ },
2489
+ "classic_tx_count": len(self._classicTxHistory),
2490
+ "classic_rx_count": len(self._classicRxHistory),
2491
+ "classic_tx_history": self._classicTxHistory[-20:], # Last 20
2492
+ "classic_rx_history": self._classicRxHistory[-20:], # Last 20
2493
+ }