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.
@@ -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
 
@@ -111,6 +111,40 @@ class UnitState:
111
111
  self._colorsource: ColorSource | None = None
112
112
  self._xy: tuple[float, float] | None = None
113
113
  self._slider: int | None = None
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
+ }
114
148
 
115
149
  def _check_range(
116
150
  self, value: int | float, min: int | float, max: int | float
@@ -269,9 +303,20 @@ class UnitState:
269
303
  def slider(self) -> None:
270
304
  self.slider = None
271
305
 
272
- def __repr__(self) -> str:
273
- return f"UnitState(dimmer={self.dimmer}, vertical={self._vertical}, rgb={self.rgb.__repr__()}, white={self.white}, temperature={self.temperature}, colorsource={self.colorsource}, xy={self.xy}, slider={self.slider})"
306
+ @property
307
+ def onoff(self) -> bool | None:
308
+ return self._onoff
309
+
310
+ @onoff.setter
311
+ def onoff(self, value: bool) -> None:
312
+ self._onoff = value
274
313
 
314
+ @onoff.deleter
315
+ def onoff(self) -> None:
316
+ self._onoff = None
317
+
318
+ def __repr__(self) -> str:
319
+ return f"UnitState(dimmer={self.dimmer}, vertical={self._vertical}, rgb={self.rgb.__repr__()}, white={self.white}, temperature={self.temperature}, colorsource={self.colorsource}, xy={self.xy}, slider={self.slider}, onoff={self.onoff})"
275
320
 
276
321
  # TODO: Make unit immutable (refactor state, on, online out of it)
277
322
  @dataclass(init=True, repr=True)
@@ -285,6 +330,7 @@ class Unit:
285
330
  :ivar firmwareVersion: Firmware version of the unit.
286
331
 
287
332
  :ivar unitType: Type of the unit. Determines the capabilities.
333
+ :ivar securityKey: Optional per-unit key (seen on some legacy/mixed networks). Not used yet.
288
334
  """
289
335
 
290
336
  _typeId: int
@@ -295,6 +341,7 @@ class Unit:
295
341
  firmwareVersion: str
296
342
 
297
343
  unitType: UnitType
344
+ securityKey: bytes | None = None
298
345
 
299
346
  _state: UnitState | None = None
300
347
  _on: bool = False
@@ -308,6 +355,8 @@ class Unit:
308
355
  @property
309
356
  def is_on(self) -> bool:
310
357
  """Determine whether the unit is turned on."""
358
+ if self.unitType.get_control(UnitControlType.ONOFF) and self._state:
359
+ return self._on and self._state.onoff is True
311
360
  if self.unitType.get_control(UnitControlType.DIMMER) and self._state:
312
361
  return (
313
362
  self._on and self._state.dimmer is not None and self._state.dimmer > 0
@@ -385,6 +434,8 @@ class Unit:
385
434
  elif c.type == UnitControlType.SLIDER and state.slider is not None:
386
435
  scale = UnitState.SLIDER_RESOLUTION - c.length
387
436
  scaledValue = state.slider >> scale
437
+ elif c.type == UnitControlType.ONOFF and state.onoff is not None:
438
+ scaledValue = 1 if state.onoff else 0
388
439
 
389
440
  # Use default if unsupported type or unset value in state
390
441
  else:
@@ -413,6 +464,8 @@ class Unit:
413
464
  """
414
465
  if not self._state:
415
466
  self._state = UnitState()
467
+ self._state._raw_state = value
468
+ self._state._unknown_controls = []
416
469
 
417
470
  # TODO: Support for resolutions >8 byte?
418
471
  for c in self.unitType.controls:
@@ -477,11 +530,14 @@ class Unit:
477
530
  elif c.type == UnitControlType.SLIDER:
478
531
  scale = UnitState.SLIDER_RESOLUTION - c.length
479
532
  self._state.slider = cInt << scale
533
+ elif c.type == UnitControlType.ONOFF:
534
+ self._state.onoff = cInt != 0
480
535
  elif c.type == UnitControlType.UNKOWN:
481
536
  # Might be useful for implementing more state types
482
537
  _LOGGER.debug(
483
538
  f"Value for unkown control type at {c.offset}: {cInt}. Unit type is {self.unitType.id}."
484
539
  )
540
+ self._state._unknown_controls.append((c.offset, c.length, cInt))
485
541
 
486
542
  _LOGGER.debug(f"Parsed {b2a(value)} to {self.state.__repr__()}")
487
543
 
CasambiBt/_version.py ADDED
@@ -0,0 +1,10 @@
1
+ """Package version (kept in-sync with setup.cfg).
2
+
3
+ Home Assistant integrations sometimes run with strict event-loop blocking checks.
4
+ Avoid using importlib.metadata in hot paths by providing a static version string.
5
+ """
6
+
7
+ __all__ = ["__version__"]
8
+
9
+ # NOTE: Must match `casambi-bt/setup.cfg` [metadata] version.
10
+ __version__ = "0.3.12.dev15"
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
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: casambi-bt-revamped
3
+ Version: 0.3.12.dev15
4
+ Summary: Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
5
+ Home-page: https://github.com/rankjie/casambi-bt
6
+ Author: rankjie
7
+ Author-email: rankjie@gmail.com
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Development Status :: 4 - Beta
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: bleak!=2.0.0,>=0.22
16
+ Requires-Dist: cryptography>=40.0.0
17
+ Requires-Dist: httpx>=0.25
18
+ Requires-Dist: bleak_retry_connector>=3.6.0
19
+ Requires-Dist: anyio>=4.10.0
20
+ Dynamic: license-file
21
+
22
+ ![PyPI](https://img.shields.io/pypi/v/casambi-bt-revamped)
23
+ [![Discord](https://img.shields.io/discord/1186445089317326888)](https://discord.gg/jgZVugfx)
24
+
25
+ # Casambi Bluetooth Revamped - Python library for Casambi networks
26
+
27
+ This is a customized fork of the original [casambi-bt](https://github.com/lkempf/casambi-bt) library with additional features and should only be used for special needs:
28
+
29
+ - **Switch event support** - Receive button press/release/hold events from Casambi switches (wired + wireless)
30
+ - **Improved relay status handling** - Better support for relay units
31
+ - **Classic protocol (experimental)** - Basic unit control for Classic (legacy) firmware networks
32
+ - **Bug fixes and improvements** - Various fixes based on real-world usage
33
+
34
+ This library provides a bluetooth interface to Casambi-based lights. It is not associated with Casambi.
35
+
36
+ For Home Assistant integration using this library, see [casambi-bt-hass](https://github.com/rankjie/casambi-bt-hass).
37
+
38
+ ## Getting started
39
+
40
+ This library is available on PyPi:
41
+
42
+ ```
43
+ pip install casambi-bt-revamped
44
+ ```
45
+
46
+ Have a look at `demo.py` for a small example.
47
+
48
+ ### Switch Event Support
49
+
50
+ This library supports receiving physical switch events as a decoded stream of INVOCATION frames (ground truth from the official Android app).
51
+
52
+ Event types you can expect:
53
+ - `button_press`
54
+ - `button_release`
55
+ - `button_hold`
56
+ - `button_release_after_hold`
57
+ - `input_event` (raw NotifyInput frame that may accompany presses/holds; useful for diagnostics and some wired devices)
58
+
59
+ ```python
60
+ from CasambiBt import Casambi
61
+
62
+ def handle_switch_event(event_data):
63
+ print(
64
+ "Switch event:",
65
+ {
66
+ "unit_id": event_data.get("unit_id"),
67
+ "button": event_data.get("button"),
68
+ "event": event_data.get("event"),
69
+ # INVOCATION metadata (useful for debugging/correlation)
70
+ "event_id": event_data.get("event_id"),
71
+ "opcode": event_data.get("opcode"),
72
+ "target_type": event_data.get("target_type"),
73
+ "origin": event_data.get("origin"),
74
+ "age": event_data.get("age"),
75
+ # NotifyInput fields (target_type=0x12)
76
+ "input_code": event_data.get("input_code"),
77
+ "input_channel": event_data.get("input_channel"),
78
+ "input_value16": event_data.get("input_value16"),
79
+ "input_mapped_event": event_data.get("input_mapped_event"),
80
+ },
81
+ )
82
+
83
+ casa = Casambi()
84
+ # ... connect to network ...
85
+
86
+ # Register switch event handler
87
+ casa.registerSwitchEventHandler(handle_switch_event)
88
+
89
+ # Events will be received when buttons are pressed/released
90
+ ```
91
+
92
+ Notes:
93
+ - Wireless (battery) switches typically send a "button stream" (target_type `0x06`) for press/release, and a NotifyInput stream (target_type `0x12`) for hold/release-after-hold.
94
+ - Wired switches often only send NotifyInput (target_type `0x12`), so `input_code` is mapped into `button_press/button_release/...` when appropriate.
95
+ - The library suppresses same-state retransmits at the protocol layer (edge detection), so Home Assistant-style time-window deduplication should generally not be necessary.
96
+
97
+ For the parsing details and field layout, see `doc/PROTOCOL_PARSING.md`.
98
+
99
+ ### Classic (Legacy Firmware) Support (Experimental)
100
+
101
+ This library can also connect to **Classic** Casambi networks and send **unit control** commands.
102
+
103
+ How it works (ground truth: the bundled Android app sources):
104
+ - Classic devices expose a CMAC-signed data channel (`ca51`/`ca52`) or a "Classic conformant" signed channel on the EVO UUID.
105
+ - The cloud network JSON exposes `visitorKey` / `managerKey` (hex strings) instead of an EVO `keyStore`.
106
+ - Commands are signed with AES-CMAC and sent as Classic "command records" (see `doc/PROTOCOL_PARSING.md`).
107
+
108
+ Environment flags:
109
+ - `CASAMBI_BT_DISABLE_CLASSIC=1` to refuse Classic connections (fail fast)
110
+ - `CASAMBI_BT_CLASSIC_USE_MANAGER=1` to sign with the 16-byte manager signature (default is visitor/4-byte prefix)
111
+ - `CASAMBI_BT_LOG_RAW_NOTIFIES=1` to enable very verbose per-notify hexdumps (mainly for Classic debugging)
112
+
113
+ ### MacOS
114
+
115
+ MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
116
+ if you're running this library on MacOS, it will use an undocumented IOBluetooth API to get the MAC Address.
117
+ Without the real MAC address the integration with Casambi will not work.
118
+ If you're running into problems fetching the MAC address on MacOS, try it on a Raspberry Pi.
119
+
120
+ ### Casambi network setup
121
+
122
+ If you have problems connecting to the network please check that your network is configured appropriately before creating an issue. The network I test this with uses the **Evoultion firmware** and is configured as follows (screenshots are for the iOS app but the Android app should look very similar):
123
+
124
+ ![Gateway settings](/doc/img/gateway.png)
125
+ ![Network settings](/doc/img/network.png)
126
+ ![Performance settings](/doc/img/perf.png)
127
+
128
+ ## Development / Offline Testing
129
+
130
+ This repo includes log-driven unit tests for switch parsing:
131
+
132
+ ```bash
133
+ cd casambi-bt
134
+ python -m unittest -v
135
+ ```
@@ -0,0 +1,22 @@
1
+ CasambiBt/__init__.py,sha256=iJdTF4oeXfj5d5gfGxQkacqUjtnQo0IW-zFPJvFjWWk,336
2
+ CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
3
+ CasambiBt/_casambi.py,sha256=dAZZ0S2-t2ShLbW78AE9lOLBzOmhBOTTXJky-6khdkE,41981
4
+ CasambiBt/_classic_crypto.py,sha256=XIp3JBaeY8hIUv5kB0ygVG_eRx9AgHHF4ts2--CFm78,4973
5
+ CasambiBt/_client.py,sha256=desVYyMhMB0vKjEHLWw2C-7CO7yvB6FWwoqySaN1Huk,104023
6
+ CasambiBt/_constants.py,sha256=86heoDdb5iPaRrPmK2DIIl-4uSxbFFcnCo9zlCvTLww,1290
7
+ CasambiBt/_discover.py,sha256=jLc6H69JddrCURgtANZEjws6_UbSzXJtvJkbKTaIUHY,1849
8
+ CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
9
+ CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
10
+ CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
11
+ CasambiBt/_network.py,sha256=3ZUedQlHzzuHHiG5KxDLnK0AIz0TjzG1_vwg0UGsO9U,22132
12
+ CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
13
+ CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
14
+ CasambiBt/_unit.py,sha256=nxbg_8UCCVB9WI8dUS21g2JrGyPKcefqKMSusMOhLOo,18721
15
+ CasambiBt/_version.py,sha256=IDHDoRoj0CDeYUiiYZnh5B9lFsUQxzQcLpRI4i7oeag,338
16
+ CasambiBt/errors.py,sha256=1L_Q8og_N_BRYEKizghAQXr6tihlHykFgtcCHUDcBas,1961
17
+ CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ casambi_bt_revamped-0.3.12.dev15.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
19
+ casambi_bt_revamped-0.3.12.dev15.dist-info/METADATA,sha256=Od28-hLz4ACPfPu81xo72RmmIxjHucz61kprTotR9Wg,5878
20
+ casambi_bt_revamped-0.3.12.dev15.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
21
+ casambi_bt_revamped-0.3.12.dev15.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
22
+ casambi_bt_revamped-0.3.12.dev15.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5