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/_casambi.py +125 -11
- CasambiBt/_classic_crypto.py +31 -0
- CasambiBt/_client.py +616 -155
- CasambiBt/_constants.py +7 -0
- CasambiBt/_discover.py +3 -2
- CasambiBt/_invocation.py +116 -0
- CasambiBt/_network.py +44 -2
- CasambiBt/_switch_events.py +329 -0
- CasambiBt/_unit.py +37 -1
- CasambiBt/errors.py +12 -0
- casambi_bt_revamped-0.3.12.dev3.dist-info/METADATA +135 -0
- casambi_bt_revamped-0.3.12.dev3.dist-info/RECORD +21 -0
- casambi_bt_revamped-0.3.11.dist-info/METADATA +0 -81
- casambi_bt_revamped-0.3.11.dist-info/RECORD +0 -18
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev3.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev3.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev3.dist-info}/top_level.txt +0 -0
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] =
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
825
|
+
self._logger.debug("Packet type %d not implemented. Ignoring!", packetType)
|
|
470
826
|
|
|
471
|
-
def
|
|
472
|
-
self
|
|
473
|
-
|
|
827
|
+
def _classicEstablishedNotifyCallback(
|
|
828
|
+
self, handle: BleakGATTCharacteristic, data: bytes
|
|
829
|
+
) -> None:
|
|
830
|
+
"""Parse Classic notifications from the CA52 channel.
|
|
474
831
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
486
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
496
|
-
|
|
849
|
+
visitor_key = self._network.classicVisitorKey()
|
|
850
|
+
manager_key = self._network.classicManagerKey()
|
|
497
851
|
|
|
498
|
-
|
|
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
|
-
|
|
501
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
#
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
541
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
551
|
-
|
|
988
|
+
b8 = data[pos + 2]
|
|
989
|
+
state_len = ((b8 >> 4) & 0x0F) + 1
|
|
990
|
+
prio = b8 & 0x0F
|
|
552
991
|
pos += 3
|
|
553
992
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
if pos + length > len(data):
|
|
1023
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
565
1024
|
self._logger.debug(
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
-
|
|
645
|
-
|
|
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
|
-
|
|
649
|
-
|
|
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,
|