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/__init__.py +1 -0
- CasambiBt/_cache.py +9 -9
- CasambiBt/_casambi.py +421 -11
- CasambiBt/_classic_crypto.py +146 -0
- CasambiBt/_client.py +1916 -160
- CasambiBt/_constants.py +16 -0
- CasambiBt/_discover.py +3 -2
- CasambiBt/_invocation.py +116 -0
- CasambiBt/_network.py +195 -23
- CasambiBt/_operation.py +13 -2
- CasambiBt/_switch_events.py +329 -0
- CasambiBt/_unit.py +59 -3
- CasambiBt/_version.py +10 -0
- CasambiBt/errors.py +12 -0
- casambi_bt_revamped-0.3.12.dev15.dist-info/METADATA +135 -0
- casambi_bt_revamped-0.3.12.dev15.dist-info/RECORD +22 -0
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/WHEEL +1 -1
- casambi_bt_revamped-0.3.7.dev3.dist-info/METADATA +0 -81
- casambi_bt_revamped-0.3.7.dev3.dist-info/RECORD +0 -18
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.7.dev3.dist-info → casambi_bt_revamped-0.3.12.dev15.dist-info}/top_level.txt +0 -0
|
@@ -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
|
-
|
|
273
|
-
|
|
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
|
+

|
|
23
|
+
[](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
|
+

|
|
125
|
+

|
|
126
|
+

|
|
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,,
|