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/_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
|
-
|
|
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
|
|
CasambiBt/_invocation.py
ADDED
|
@@ -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
|
-
|
|
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
|