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/_constants.py CHANGED
@@ -6,6 +6,13 @@ DEVICE_NAME: Final = "Casambi BT Python"
6
6
  CASA_UUID: Final = "0000fe4d-0000-1000-8000-00805f9b34fb"
7
7
  CASA_AUTH_CHAR_UUID: Final = "c9ffde48-ca5a-0001-ab83-8f519b482f77"
8
8
 
9
+ # Classic firmware/protocol uses different GATT characteristics (see casambi-android t1.C1713d):
10
+ # - 0000ca51-...: connection hash (first 8 bytes are used as CMAC input prefix)
11
+ # - 0000ca52-...: signed data channel (write + notify)
12
+ CASA_UUID_CLASSIC: Final = "0000ca5a-0000-1000-8000-00805f9b34fb"
13
+ CASA_CLASSIC_HASH_CHAR_UUID: Final = "0000ca51-0000-1000-8000-00805f9b34fb"
14
+ CASA_CLASSIC_DATA_CHAR_UUID: Final = "0000ca52-0000-1000-8000-00805f9b34fb"
15
+
9
16
 
10
17
  @unique
11
18
  class ConnectionState(IntEnum):
CasambiBt/_discover.py CHANGED
@@ -5,7 +5,7 @@ from bleak import BleakScanner
5
5
  from bleak.backends.client import BLEDevice
6
6
  from bleak.exc import BleakDBusError, BleakError
7
7
 
8
- from ._constants import CASA_UUID
8
+ from ._constants import CASA_UUID, CASA_UUID_CLASSIC
9
9
  from .errors import BluetoothError
10
10
 
11
11
  _LOGGER = logging.getLogger(__name__)
@@ -39,7 +39,8 @@ async def discover() -> list[BLEDevice]:
39
39
  discovered = []
40
40
  for _, (d, advertisement) in devices_and_advertisements.items():
41
41
  if 963 in advertisement.manufacturer_data:
42
- if CASA_UUID in advertisement.service_uuids:
42
+ # Evolution networks advertise FE4D; Classic networks advertise CA5A.
43
+ if CASA_UUID in advertisement.service_uuids or CASA_UUID_CLASSIC in advertisement.service_uuids:
43
44
  _LOGGER.debug(f"Discovered network at {d.address}")
44
45
  discovered.append(d)
45
46
 
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from typing import Final
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class InvocationFrame:
10
+ """One INVOCATION frame.
11
+
12
+ Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses:
13
+ - flags:u16 (big-endian)
14
+ - opcode:u8
15
+ - origin:u16
16
+ - target:u16
17
+ - age:u16
18
+ - origin_handle?:u8 (if flags & 0x0200)
19
+ - payload: flags & 0x3f bytes
20
+ """
21
+
22
+ flags: int
23
+ opcode: int
24
+ origin: int
25
+ target: int
26
+ age: int
27
+ origin_handle: int | None
28
+ payload: bytes
29
+ offset: int # start offset of this frame in the decrypted type=7 payload
30
+
31
+ @property
32
+ def payload_len(self) -> int:
33
+ return self.flags & 0x3F
34
+
35
+
36
+ _FLAG_HAS_ORIGIN_HANDLE: Final[int] = 0x0200
37
+
38
+
39
+ def parse_invocation_stream(
40
+ data: bytes, *, logger: logging.Logger | None = None
41
+ ) -> list[InvocationFrame]:
42
+ """Parse decrypted packet type=7 payload into INVOCATION frames."""
43
+
44
+ frames: list[InvocationFrame] = []
45
+ pos = 0
46
+
47
+ # Android bails out if < 9 bytes remain.
48
+ while len(data) - pos >= 9:
49
+ frame_offset = pos
50
+
51
+ flags = int.from_bytes(data[pos : pos + 2], "big")
52
+ pos += 2
53
+
54
+ opcode = data[pos]
55
+ pos += 1
56
+
57
+ origin = int.from_bytes(data[pos : pos + 2], "big")
58
+ pos += 2
59
+
60
+ target = int.from_bytes(data[pos : pos + 2], "big")
61
+ pos += 2
62
+
63
+ age = int.from_bytes(data[pos : pos + 2], "big")
64
+ pos += 2
65
+
66
+ origin_handle: int | None = None
67
+ if flags & _FLAG_HAS_ORIGIN_HANDLE:
68
+ if pos >= len(data):
69
+ if logger:
70
+ logger.debug(
71
+ "INVOCATION frame truncated at origin_handle (offset=%d flags=0x%04x).",
72
+ frame_offset,
73
+ flags,
74
+ )
75
+ break
76
+ origin_handle = data[pos]
77
+ pos += 1
78
+
79
+ payload_len = flags & 0x3F
80
+ if pos + payload_len > len(data):
81
+ if logger:
82
+ logger.debug(
83
+ "INVOCATION frame truncated at payload (offset=%d flags=0x%04x payload_len=%d remaining=%d).",
84
+ frame_offset,
85
+ flags,
86
+ payload_len,
87
+ len(data) - pos,
88
+ )
89
+ break
90
+
91
+ payload = data[pos : pos + payload_len]
92
+ pos += payload_len
93
+
94
+ frames.append(
95
+ InvocationFrame(
96
+ flags=flags,
97
+ opcode=opcode,
98
+ origin=origin,
99
+ target=target,
100
+ age=age,
101
+ origin_handle=origin_handle,
102
+ payload=payload,
103
+ offset=frame_offset,
104
+ )
105
+ )
106
+
107
+ if logger and pos != len(data):
108
+ logger.debug(
109
+ "INVOCATION stream has %d trailing bytes (parsed=%d total=%d).",
110
+ len(data) - pos,
111
+ pos,
112
+ len(data),
113
+ )
114
+
115
+ return frames
116
+
CasambiBt/_network.py CHANGED
@@ -44,6 +44,10 @@ class Network:
44
44
  self._networkName: str | None = None
45
45
  self._networkRevision: int | None = None
46
46
  self._protocolVersion: int = -1
47
+ # Classic networks do not have a `keyStore`; instead they expose visitor/manager keys.
48
+ # Ground truth: casambi-android `D1.Z0` exports `visitorKey`/`managerKey`.
49
+ self._classicVisitorKey: bytes | None = None
50
+ self._classicManagerKey: bytes | None = None
47
51
  self._rawNetworkData: dict | None = None
48
52
 
49
53
  self._unitTypes: dict[int, tuple[UnitType | None, datetime]] = {}
@@ -154,6 +158,19 @@ class Network:
154
158
  def protocolVersion(self) -> int:
155
159
  return self._protocolVersion
156
160
 
161
+ def classicVisitorKey(self) -> bytes | None:
162
+ return self._classicVisitorKey
163
+
164
+ def classicManagerKey(self) -> bytes | None:
165
+ return self._classicManagerKey
166
+
167
+ def classicBestKey(self) -> bytes | None:
168
+ # Prefer manager key if present, otherwise visitor key.
169
+ return self._classicManagerKey or self._classicVisitorKey
170
+
171
+ def hasClassicKeys(self) -> bool:
172
+ return bool(self._classicVisitorKey or self._classicManagerKey)
173
+
157
174
  @property
158
175
  def rawNetworkData(self) -> dict | None:
159
176
  return self._rawNetworkData
@@ -263,8 +280,33 @@ class Network:
263
280
  keys = network["network"]["keyStore"]["keys"]
264
281
  for k in keys:
265
282
  await self._keystore.addKey(k)
266
-
267
- # TODO: Parse managerKey and visitorKey for classic networks.
283
+ # Evolution network: classic keys not used
284
+ self._classicVisitorKey = None
285
+ self._classicManagerKey = None
286
+ else:
287
+ # Classic network: parse visitorKey / managerKey (hex strings).
288
+ # Ground truth: casambi-android `D1.Z0` exports these fields.
289
+ visitor_hex = network["network"].get("visitorKey")
290
+ manager_hex = network["network"].get("managerKey")
291
+
292
+ def _parse_hex_key(v: object) -> bytes | None:
293
+ if not isinstance(v, str):
294
+ return None
295
+ v = v.strip()
296
+ if not v:
297
+ return None
298
+ try:
299
+ return bytes.fromhex(v)
300
+ except ValueError:
301
+ return None
302
+
303
+ self._classicVisitorKey = _parse_hex_key(visitor_hex)
304
+ self._classicManagerKey = _parse_hex_key(manager_hex)
305
+ self._logger.info(
306
+ "Classic keys present: visitor=%s manager=%s",
307
+ bool(self._classicVisitorKey),
308
+ bool(self._classicManagerKey),
309
+ )
268
310
 
269
311
  # Parse units
270
312
  self.units = []
@@ -0,0 +1,329 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from binascii import b2a_hex as b2a
6
+ from dataclasses import dataclass
7
+ from typing import Any, Final
8
+
9
+ from ._invocation import InvocationFrame, parse_invocation_stream
10
+
11
+
12
+ _BUTTON_EVENT_MIN: Final[int] = 29 # FunctionButtonEvent0
13
+ _BUTTON_EVENT_MAX: Final[int] = 36 # FunctionButtonEvent7
14
+ _INPUT_EVENT_MIN: Final[int] = 64 # FunctionNotifyInput0
15
+ _INPUT_EVENT_MAX: Final[int] = 71 # FunctionNotifyInput7
16
+
17
+ _TARGET_TYPE_BUTTON: Final[int] = 0x06
18
+ _TARGET_TYPE_INPUT: Final[int] = 0x12
19
+
20
+
21
+ def _guess_button_label_4gang(button_event_index: int) -> int:
22
+ """Casambi app labels a typical 4-button switch as 1..4.
23
+
24
+ Observed mapping for 4-gang switches in provided logs:
25
+ - ButtonEvent0 -> label 4
26
+ - ButtonEvent1 -> label 1
27
+ - ButtonEvent2 -> label 2
28
+ - ButtonEvent3 -> label 3
29
+ """
30
+
31
+ if 0 <= button_event_index <= 3:
32
+ return ((button_event_index + 3) % 4) + 1
33
+ return button_event_index
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class SwitchDecoderStats:
38
+ frames_total: int = 0
39
+ frames_button: int = 0
40
+ frames_input: int = 0
41
+ frames_ignored: int = 0
42
+ events_emitted: int = 0
43
+ events_suppressed_same_state: int = 0
44
+
45
+
46
+ class SwitchEventStreamDecoder:
47
+ """Decode decrypted packet type=7 payload into high-level switch events."""
48
+
49
+ def __init__(self, logger: logging.Logger | None = None) -> None:
50
+ self._logger = logger or logging.getLogger(__name__)
51
+ # (unit_id, button_event_index) -> pressed(bool)
52
+ self._last_pressed: dict[tuple[int, int], bool] = {}
53
+ # (unit_id, input_index) -> last input code (payload[0]) we emitted as a semantic event.
54
+ self._last_input_code: dict[tuple[int, int], int] = {}
55
+ # (unit_id, button_label) -> observed real button stream (target_type=0x06) for that button.
56
+ # If present, we avoid creating synthetic press/release events from input frames.
57
+ self._button_stream_seen: set[tuple[int, int]] = set()
58
+
59
+ def reset(self) -> None:
60
+ self._last_pressed.clear()
61
+ self._last_input_code.clear()
62
+ self._button_stream_seen.clear()
63
+
64
+ def decode(
65
+ self,
66
+ data: bytes,
67
+ *,
68
+ packet_seq: int | None = None,
69
+ raw_packet: bytes | None = None,
70
+ arrival_sequence: int | None = None,
71
+ ) -> tuple[list[dict[str, Any]], SwitchDecoderStats]:
72
+ """Decode one decrypted switch packet payload."""
73
+
74
+ frames = parse_invocation_stream(data, logger=self._logger)
75
+ stats = SwitchDecoderStats(frames_total=len(frames))
76
+ events: list[dict[str, Any]] = []
77
+
78
+ for frame in frames:
79
+ ev = self._decode_frame(
80
+ frame,
81
+ data=data,
82
+ packet_seq=packet_seq,
83
+ raw_packet=raw_packet,
84
+ arrival_sequence=arrival_sequence,
85
+ stats=stats,
86
+ )
87
+ if ev is None:
88
+ continue
89
+ events.append(ev)
90
+ stats.events_emitted += 1
91
+
92
+ return events, stats
93
+
94
+ def _decode_frame(
95
+ self,
96
+ frame: InvocationFrame,
97
+ *,
98
+ data: bytes,
99
+ packet_seq: int | None,
100
+ raw_packet: bytes | None,
101
+ arrival_sequence: int | None,
102
+ stats: SwitchDecoderStats,
103
+ ) -> dict[str, Any] | None:
104
+ unit_id = (frame.target >> 8) & 0xFF
105
+ target_type = frame.target & 0xFF
106
+
107
+ origin_unit_id = (frame.origin >> 8) & 0xFF
108
+ origin_type = frame.origin & 0xFF
109
+
110
+ # Button events (press/release) are INVOCATIONs targeted at type 0x06.
111
+ if (
112
+ target_type == _TARGET_TYPE_BUTTON
113
+ and _BUTTON_EVENT_MIN <= frame.opcode <= _BUTTON_EVENT_MAX
114
+ ):
115
+ stats.frames_button += 1
116
+
117
+ button_event_index = frame.opcode - _BUTTON_EVENT_MIN
118
+ button = _guess_button_label_4gang(button_event_index)
119
+ self._button_stream_seen.add((unit_id, button))
120
+
121
+ pressed = bool(frame.payload and (frame.payload[0] & 0x80))
122
+ state_key = (unit_id, button_event_index)
123
+ last_pressed = self._last_pressed.get(state_key)
124
+
125
+ # Wireless switches retransmit; drop repeated same-state frames to avoid duplicate events.
126
+ if last_pressed is not None and last_pressed == pressed:
127
+ stats.events_suppressed_same_state += 1
128
+ self._logger.debug(
129
+ "[CASAMBI_EVENT_SUPPRESS] unit=%d button_index=%d button=%d pressed=%s opcode=0x%02x origin=0x%04x age=0x%04x",
130
+ unit_id,
131
+ button_event_index,
132
+ button,
133
+ pressed,
134
+ frame.opcode,
135
+ frame.origin,
136
+ frame.age,
137
+ )
138
+ return None
139
+ self._last_pressed[state_key] = pressed
140
+
141
+ b0 = frame.payload[0] if frame.payload else 0
142
+ param_p = (b0 >> 3) & 0x0F
143
+ param_s = b0 & 0x07
144
+
145
+ event = "button_press" if pressed else "button_release"
146
+
147
+ # Stable identifier for consumers to deduplicate further if needed.
148
+ event_id = f"invoke:{frame.origin:04x}:{frame.age:04x}:{frame.opcode:02x}:{frame.target:04x}"
149
+
150
+ if self._logger.isEnabledFor(logging.DEBUG):
151
+ self._logger.debug(
152
+ "[CASAMBI_BUTTON_EVENT] packet=%s unit=%d button=%d event=%s opcode=0x%02x origin=0x%04x age=0x%04x flags=0x%04x payload=%s",
153
+ packet_seq,
154
+ unit_id,
155
+ button,
156
+ event,
157
+ frame.opcode,
158
+ frame.origin,
159
+ frame.age,
160
+ frame.flags,
161
+ b2a(frame.payload),
162
+ )
163
+
164
+ return {
165
+ # Back-compat / existing consumers
166
+ "unit_id": unit_id,
167
+ "button": button,
168
+ "event": event,
169
+ "message_type": 0x07, # decrypted packet type (SwitchEvent)
170
+ "message_position": frame.offset,
171
+ "extra_data": None,
172
+ # INVOCATION fields
173
+ "invocation_flags": frame.flags,
174
+ "opcode": frame.opcode,
175
+ "origin": frame.origin,
176
+ "origin_unit_id": origin_unit_id,
177
+ "origin_type": origin_type,
178
+ "target": frame.target,
179
+ "target_type": target_type,
180
+ "age": frame.age,
181
+ "origin_handle": frame.origin_handle,
182
+ "payload": frame.payload,
183
+ "payload_hex": b2a(frame.payload),
184
+ "frame_offset": frame.offset,
185
+ "button_event_index": button_event_index,
186
+ "param_p": param_p,
187
+ "param_s": param_s,
188
+ # Diagnostics / correlation
189
+ "packet_sequence": packet_seq,
190
+ "arrival_sequence": arrival_sequence,
191
+ "event_id": event_id,
192
+ "raw_packet": b2a(raw_packet) if raw_packet else None,
193
+ "decrypted_data": b2a(data),
194
+ "frame_hex": b2a(
195
+ data[frame.offset : frame.offset + (9 + (1 if frame.origin_handle is not None else 0) + frame.payload_len)]
196
+ ),
197
+ "received_at": time.time(),
198
+ }
199
+
200
+ # Input notify frames (often accompany wireless switches).
201
+ if (
202
+ target_type == _TARGET_TYPE_INPUT
203
+ and _INPUT_EVENT_MIN <= frame.opcode <= _INPUT_EVENT_MAX
204
+ ):
205
+ stats.frames_input += 1
206
+ input_index = frame.opcode - _INPUT_EVENT_MIN
207
+ input_code = frame.payload[0] if frame.payload else None
208
+ input_b1 = frame.payload[1] if len(frame.payload) >= 2 else None
209
+ input_channel = (input_b1 & 0x07) if input_b1 is not None else None
210
+ input_value16 = (
211
+ int.from_bytes(frame.payload[2:4], "little")
212
+ if len(frame.payload) >= 4
213
+ else None
214
+ )
215
+ button = _guess_button_label_4gang(input_index)
216
+
217
+ # Map common input codes into the legacy "switch" event taxonomy.
218
+ # Observed:
219
+ # - wired: 01xx press, 02xx release, 0cxx release_after_hold
220
+ # - wireless: 09xx hold, 0cxx release_after_hold (+ separate button stream for press/release)
221
+ mapped_event: str | None = None
222
+ if input_code is not None:
223
+ if input_code == 0x09:
224
+ mapped_event = "button_hold"
225
+ elif input_code == 0x0C:
226
+ mapped_event = "button_release_after_hold"
227
+ elif input_code == 0x01:
228
+ mapped_event = "button_press"
229
+ elif input_code == 0x02:
230
+ mapped_event = "button_release"
231
+
232
+ input_mapped_event = mapped_event
233
+
234
+ # Avoid duplicating press/release for wireless switches that also produce the real button stream.
235
+ if mapped_event in ("button_press", "button_release") and (unit_id, button) in self._button_stream_seen:
236
+ mapped_event = None
237
+
238
+ if mapped_event is not None and input_code is not None:
239
+ state_key = (unit_id, input_index)
240
+ last_code = self._last_input_code.get(state_key)
241
+ if last_code == input_code:
242
+ stats.events_suppressed_same_state += 1
243
+ self._logger.debug(
244
+ "[CASAMBI_EVENT_SUPPRESS] input unit=%d input_index=%d button=%d code=0x%02x opcode=0x%02x origin=0x%04x age=0x%04x",
245
+ unit_id,
246
+ input_index,
247
+ button,
248
+ input_code,
249
+ frame.opcode,
250
+ frame.origin,
251
+ frame.age,
252
+ )
253
+ return None
254
+ self._last_input_code[state_key] = input_code
255
+
256
+ if self._logger.isEnabledFor(logging.DEBUG):
257
+ self._logger.debug(
258
+ "[CASAMBI_INPUT_AS_BUTTON] packet=%s unit=%d button=%d event=%s code=0x%02x opcode=0x%02x origin=0x%04x age=0x%04x flags=0x%04x payload=%s",
259
+ packet_seq,
260
+ unit_id,
261
+ button,
262
+ mapped_event,
263
+ input_code,
264
+ frame.opcode,
265
+ frame.origin,
266
+ frame.age,
267
+ frame.flags,
268
+ b2a(frame.payload),
269
+ )
270
+ event = mapped_event or "input_event"
271
+ self._logger.debug(
272
+ "[CASAMBI_INPUT_EVENT] packet=%s unit=%d input=%d opcode=0x%02x origin=0x%04x age=0x%04x flags=0x%04x code=%s ch=%s val=%s payload=%s",
273
+ packet_seq,
274
+ unit_id,
275
+ input_index,
276
+ frame.opcode,
277
+ frame.origin,
278
+ frame.age,
279
+ frame.flags,
280
+ f"0x{input_code:02x}" if input_code is not None else None,
281
+ input_channel,
282
+ input_value16,
283
+ b2a(frame.payload),
284
+ )
285
+ return {
286
+ "unit_id": unit_id,
287
+ "button": button,
288
+ "event": event,
289
+ "message_type": 0x07,
290
+ "message_position": frame.offset,
291
+ "extra_data": None,
292
+ "invocation_flags": frame.flags,
293
+ "opcode": frame.opcode,
294
+ "origin": frame.origin,
295
+ "origin_unit_id": origin_unit_id,
296
+ "origin_type": origin_type,
297
+ "target": frame.target,
298
+ "target_type": target_type,
299
+ "age": frame.age,
300
+ "origin_handle": frame.origin_handle,
301
+ "payload": frame.payload,
302
+ "payload_hex": b2a(frame.payload),
303
+ "frame_offset": frame.offset,
304
+ "input_index": input_index,
305
+ "input_code": input_code,
306
+ "input_b1": input_b1,
307
+ "input_channel": input_channel,
308
+ "input_value16": input_value16,
309
+ "input_mapped_event": input_mapped_event,
310
+ "packet_sequence": packet_seq,
311
+ "arrival_sequence": arrival_sequence,
312
+ "event_id": f"invoke:{frame.origin:04x}:{frame.age:04x}:{frame.opcode:02x}:{frame.target:04x}",
313
+ "raw_packet": b2a(raw_packet) if raw_packet else None,
314
+ "decrypted_data": b2a(data),
315
+ "received_at": time.time(),
316
+ }
317
+
318
+ stats.frames_ignored += 1
319
+ self._logger.debug(
320
+ "[CASAMBI_INVOKE_IGNORED] packet=%s opcode=0x%02x origin=0x%04x target=0x%04x age=0x%04x flags=0x%04x payload=%s",
321
+ packet_seq,
322
+ frame.opcode,
323
+ frame.origin,
324
+ frame.target,
325
+ frame.age,
326
+ frame.flags,
327
+ b2a(frame.payload),
328
+ )
329
+ return None
CasambiBt/_unit.py CHANGED
@@ -3,7 +3,7 @@ from binascii import b2a_hex as b2a
3
3
  from colorsys import hsv_to_rgb, rgb_to_hsv
4
4
  from dataclasses import dataclass
5
5
  from enum import Enum, unique
6
- from typing import Final
6
+ from typing import Any, Final
7
7
 
8
8
  _LOGGER = logging.getLogger(__name__)
9
9
 
@@ -112,6 +112,39 @@ class UnitState:
112
112
  self._xy: tuple[float, float] | None = None
113
113
  self._slider: int | None = None
114
114
  self._onoff: bool | None = None
115
+ # Last raw state bytes, as received from the network.
116
+ self._raw_state: bytes | None = None
117
+ # Unknown controls that we don't have semantic parsing for yet.
118
+ # Items are (offset_bits, length_bits, value_int).
119
+ self._unknown_controls: list[tuple[int, int, int]] = []
120
+
121
+ @property
122
+ def raw_state(self) -> bytes | None:
123
+ return self._raw_state
124
+
125
+ @property
126
+ def unknown_controls(self) -> list[tuple[int, int, int]]:
127
+ # Expose a copy so callers can't mutate internal tracking.
128
+ return list(self._unknown_controls)
129
+
130
+ def as_dict(self) -> dict[str, Any]:
131
+ """Return a stable, JSON-friendly representation for diagnostics."""
132
+ return {
133
+ "dimmer": self.dimmer,
134
+ "vertical": self.vertical,
135
+ "rgb": self.rgb,
136
+ "white": self.white,
137
+ "temperature": self.temperature,
138
+ "colorsource": self.colorsource.name if self.colorsource is not None else None,
139
+ "xy": self.xy,
140
+ "slider": self.slider,
141
+ "onoff": self.onoff,
142
+ "raw_state_hex": b2a(self._raw_state).decode("ascii") if self._raw_state is not None else None,
143
+ "unknown_controls": [
144
+ {"offset": off, "length": length, "value": val}
145
+ for (off, length, val) in self._unknown_controls
146
+ ],
147
+ }
115
148
 
116
149
  def _check_range(
117
150
  self, value: int | float, min: int | float, max: int | float
@@ -429,6 +462,8 @@ class Unit:
429
462
  """
430
463
  if not self._state:
431
464
  self._state = UnitState()
465
+ self._state._raw_state = value
466
+ self._state._unknown_controls = []
432
467
 
433
468
  # TODO: Support for resolutions >8 byte?
434
469
  for c in self.unitType.controls:
@@ -500,6 +535,7 @@ class Unit:
500
535
  _LOGGER.debug(
501
536
  f"Value for unkown control type at {c.offset}: {cInt}. Unit type is {self.unitType.id}."
502
537
  )
538
+ self._state._unknown_controls.append((c.offset, c.length, cInt))
503
539
 
504
540
  _LOGGER.debug(f"Parsed {b2a(value)} to {self.state.__repr__()}")
505
541
 
CasambiBt/errors.py CHANGED
@@ -69,3 +69,15 @@ class UnsupportedProtocolVersion(CasambiBtError):
69
69
  """Exception that is raised when the network has an unsupported version."""
70
70
 
71
71
  pass
72
+
73
+
74
+ class ClassicKeysMissingError(ProtocolError):
75
+ """Classic network is missing visitorKey/managerKey required for signing packets."""
76
+
77
+ pass
78
+
79
+
80
+ class ClassicHandshakeError(ProtocolError):
81
+ """Classic network handshake/initialization failed (e.g. connection hash unavailable)."""
82
+
83
+ pass