casambi-bt-revamped 0.3.11__tar.gz → 0.3.12.dev2__tar.gz
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.
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/PKG-INFO +45 -6
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/README.md +44 -5
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/setup.cfg +1 -1
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_casambi.py +12 -8
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_client.py +157 -152
- casambi_bt_revamped-0.3.12.dev2/src/CasambiBt/_invocation.py +116 -0
- casambi_bt_revamped-0.3.12.dev2/src/CasambiBt/_switch_events.py +329 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_unit.py +37 -1
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/casambi_bt_revamped.egg-info/PKG-INFO +45 -6
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/casambi_bt_revamped.egg-info/SOURCES.txt +5 -1
- casambi_bt_revamped-0.3.12.dev2/tests/test_switch_event_logs.py +205 -0
- casambi_bt_revamped-0.3.12.dev2/tests/test_unit_state_logs.py +124 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/LICENSE +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/pyproject.toml +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/__init__.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_cache.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_constants.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_discover.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_encryption.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_keystore.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_network.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/_operation.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/errors.py +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/CasambiBt/py.typed +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
- {casambi_bt_revamped-0.3.11 → casambi_bt_revamped-0.3.12.dev2}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.12.dev2
|
|
4
4
|
Summary: Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
|
|
5
5
|
Home-page: https://github.com/rankjie/casambi-bt
|
|
6
6
|
Author: rankjie
|
|
@@ -26,7 +26,7 @@ Dynamic: license-file
|
|
|
26
26
|
|
|
27
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
28
|
|
|
29
|
-
- **Switch event support** - Receive button press/release events from Casambi switches
|
|
29
|
+
- **Switch event support** - Receive button press/release/hold events from Casambi switches (wired + wireless)
|
|
30
30
|
- **Improved relay status handling** - Better support for relay units
|
|
31
31
|
- **Bug fixes and improvements** - Various fixes based on real-world usage
|
|
32
32
|
|
|
@@ -46,15 +46,38 @@ Have a look at `demo.py` for a small example.
|
|
|
46
46
|
|
|
47
47
|
### Switch Event Support
|
|
48
48
|
|
|
49
|
-
This library
|
|
49
|
+
This library supports receiving physical switch events as a decoded stream of INVOCATION frames (ground truth from the official Android app).
|
|
50
|
+
|
|
51
|
+
Event types you can expect:
|
|
52
|
+
- `button_press`
|
|
53
|
+
- `button_release`
|
|
54
|
+
- `button_hold`
|
|
55
|
+
- `button_release_after_hold`
|
|
56
|
+
- `input_event` (raw NotifyInput frame that may accompany presses/holds; useful for diagnostics and some wired devices)
|
|
50
57
|
|
|
51
58
|
```python
|
|
52
59
|
from CasambiBt import Casambi
|
|
53
60
|
|
|
54
61
|
def handle_switch_event(event_data):
|
|
55
|
-
print(
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
print(
|
|
63
|
+
"Switch event:",
|
|
64
|
+
{
|
|
65
|
+
"unit_id": event_data.get("unit_id"),
|
|
66
|
+
"button": event_data.get("button"),
|
|
67
|
+
"event": event_data.get("event"),
|
|
68
|
+
# INVOCATION metadata (useful for debugging/correlation)
|
|
69
|
+
"event_id": event_data.get("event_id"),
|
|
70
|
+
"opcode": event_data.get("opcode"),
|
|
71
|
+
"target_type": event_data.get("target_type"),
|
|
72
|
+
"origin": event_data.get("origin"),
|
|
73
|
+
"age": event_data.get("age"),
|
|
74
|
+
# NotifyInput fields (target_type=0x12)
|
|
75
|
+
"input_code": event_data.get("input_code"),
|
|
76
|
+
"input_channel": event_data.get("input_channel"),
|
|
77
|
+
"input_value16": event_data.get("input_value16"),
|
|
78
|
+
"input_mapped_event": event_data.get("input_mapped_event"),
|
|
79
|
+
},
|
|
80
|
+
)
|
|
58
81
|
|
|
59
82
|
casa = Casambi()
|
|
60
83
|
# ... connect to network ...
|
|
@@ -65,6 +88,13 @@ casa.registerSwitchEventHandler(handle_switch_event)
|
|
|
65
88
|
# Events will be received when buttons are pressed/released
|
|
66
89
|
```
|
|
67
90
|
|
|
91
|
+
Notes:
|
|
92
|
+
- 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.
|
|
93
|
+
- Wired switches often only send NotifyInput (target_type `0x12`), so `input_code` is mapped into `button_press/button_release/...` when appropriate.
|
|
94
|
+
- The library suppresses same-state retransmits at the protocol layer (edge detection), so Home Assistant-style time-window deduplication should generally not be necessary.
|
|
95
|
+
|
|
96
|
+
For the parsing details and field layout, see `doc/PROTOCOL_PARSING.md`.
|
|
97
|
+
|
|
68
98
|
### MacOS
|
|
69
99
|
|
|
70
100
|
MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
|
|
@@ -79,3 +109,12 @@ If you have problems connecting to the network please check that your network is
|
|
|
79
109
|

|
|
80
110
|

|
|
81
111
|

|
|
112
|
+
|
|
113
|
+
## Development / Offline Testing
|
|
114
|
+
|
|
115
|
+
This repo includes log-driven unit tests for switch parsing:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
cd casambi-bt
|
|
119
|
+
python -m unittest -v
|
|
120
|
+
```
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
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:
|
|
7
7
|
|
|
8
|
-
- **Switch event support** - Receive button press/release events from Casambi switches
|
|
8
|
+
- **Switch event support** - Receive button press/release/hold events from Casambi switches (wired + wireless)
|
|
9
9
|
- **Improved relay status handling** - Better support for relay units
|
|
10
10
|
- **Bug fixes and improvements** - Various fixes based on real-world usage
|
|
11
11
|
|
|
@@ -25,15 +25,38 @@ Have a look at `demo.py` for a small example.
|
|
|
25
25
|
|
|
26
26
|
### Switch Event Support
|
|
27
27
|
|
|
28
|
-
This library
|
|
28
|
+
This library supports receiving physical switch events as a decoded stream of INVOCATION frames (ground truth from the official Android app).
|
|
29
|
+
|
|
30
|
+
Event types you can expect:
|
|
31
|
+
- `button_press`
|
|
32
|
+
- `button_release`
|
|
33
|
+
- `button_hold`
|
|
34
|
+
- `button_release_after_hold`
|
|
35
|
+
- `input_event` (raw NotifyInput frame that may accompany presses/holds; useful for diagnostics and some wired devices)
|
|
29
36
|
|
|
30
37
|
```python
|
|
31
38
|
from CasambiBt import Casambi
|
|
32
39
|
|
|
33
40
|
def handle_switch_event(event_data):
|
|
34
|
-
print(
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
print(
|
|
42
|
+
"Switch event:",
|
|
43
|
+
{
|
|
44
|
+
"unit_id": event_data.get("unit_id"),
|
|
45
|
+
"button": event_data.get("button"),
|
|
46
|
+
"event": event_data.get("event"),
|
|
47
|
+
# INVOCATION metadata (useful for debugging/correlation)
|
|
48
|
+
"event_id": event_data.get("event_id"),
|
|
49
|
+
"opcode": event_data.get("opcode"),
|
|
50
|
+
"target_type": event_data.get("target_type"),
|
|
51
|
+
"origin": event_data.get("origin"),
|
|
52
|
+
"age": event_data.get("age"),
|
|
53
|
+
# NotifyInput fields (target_type=0x12)
|
|
54
|
+
"input_code": event_data.get("input_code"),
|
|
55
|
+
"input_channel": event_data.get("input_channel"),
|
|
56
|
+
"input_value16": event_data.get("input_value16"),
|
|
57
|
+
"input_mapped_event": event_data.get("input_mapped_event"),
|
|
58
|
+
},
|
|
59
|
+
)
|
|
37
60
|
|
|
38
61
|
casa = Casambi()
|
|
39
62
|
# ... connect to network ...
|
|
@@ -44,6 +67,13 @@ casa.registerSwitchEventHandler(handle_switch_event)
|
|
|
44
67
|
# Events will be received when buttons are pressed/released
|
|
45
68
|
```
|
|
46
69
|
|
|
70
|
+
Notes:
|
|
71
|
+
- 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.
|
|
72
|
+
- Wired switches often only send NotifyInput (target_type `0x12`), so `input_code` is mapped into `button_press/button_release/...` when appropriate.
|
|
73
|
+
- The library suppresses same-state retransmits at the protocol layer (edge detection), so Home Assistant-style time-window deduplication should generally not be necessary.
|
|
74
|
+
|
|
75
|
+
For the parsing details and field layout, see `doc/PROTOCOL_PARSING.md`.
|
|
76
|
+
|
|
47
77
|
### MacOS
|
|
48
78
|
|
|
49
79
|
MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
|
|
@@ -58,3 +88,12 @@ If you have problems connecting to the network please check that your network is
|
|
|
58
88
|

|
|
59
89
|

|
|
60
90
|

|
|
91
|
+
|
|
92
|
+
## Development / Offline Testing
|
|
93
|
+
|
|
94
|
+
This repo includes log-driven unit tests for switch parsing:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
cd casambi-bt
|
|
98
|
+
python -m unittest -v
|
|
99
|
+
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[metadata]
|
|
2
2
|
name = casambi-bt-revamped
|
|
3
|
-
version = 0.3.
|
|
3
|
+
version = 0.3.12.dev2
|
|
4
4
|
author = rankjie
|
|
5
5
|
author_email = rankjie@gmail.com
|
|
6
6
|
description = Forked Casambi Bluetooth client library with switch event support, use original if no special need. https://github.com/lkempf/casambi-bt
|
|
@@ -400,7 +400,7 @@ class Casambi:
|
|
|
400
400
|
def _dataCallback(
|
|
401
401
|
self, packetType: IncommingPacketType, data: dict[str, Any]
|
|
402
402
|
) -> None:
|
|
403
|
-
self._logger.
|
|
403
|
+
self._logger.debug("Incomming data callback of type %s", packetType)
|
|
404
404
|
if packetType == IncommingPacketType.UnitState:
|
|
405
405
|
self._logger.debug(
|
|
406
406
|
f"Handling changed state {b2a(data['state'])} for unit {data['id']}"
|
|
@@ -473,13 +473,17 @@ class Casambi:
|
|
|
473
473
|
"""Register a new handler for switch events.
|
|
474
474
|
|
|
475
475
|
This handler is called whenever a switch event is received.
|
|
476
|
-
The handler is supplied with a dictionary containing:
|
|
477
|
-
- unit_id:
|
|
478
|
-
- button:
|
|
479
|
-
- event:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
-
|
|
476
|
+
The handler is supplied with a dictionary containing (at minimum):
|
|
477
|
+
- unit_id: target unit id (from INVOCATION target high byte)
|
|
478
|
+
- button: best-effort "label" (typically 1..4 for 4-gang switches)
|
|
479
|
+
- event: "button_press" | "button_release" | "input_event"
|
|
480
|
+
|
|
481
|
+
Switch events are parsed from decrypted packet type=7 (INVOCATION stream),
|
|
482
|
+
matching casambi-android `v1.C1775b.Q(Q2.h)`. Extra diagnostic keys include:
|
|
483
|
+
- invocation_flags, opcode, origin, target, target_type, age, origin_handle
|
|
484
|
+
- button_event_index (0..7), param_p, param_s
|
|
485
|
+
- input_index (0..7), input_code, input_b1, input_channel, input_value16, input_mapped_event
|
|
486
|
+
- packet_sequence, arrival_sequence, raw_packet, decrypted_data, payload_hex, frame_offset, event_id
|
|
483
487
|
|
|
484
488
|
:param handler: The method to call when a switch event is received.
|
|
485
489
|
"""
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import inspect
|
|
3
3
|
import logging
|
|
4
|
+
import platform
|
|
4
5
|
import struct
|
|
5
6
|
from binascii import b2a_hex as b2a
|
|
6
7
|
from collections.abc import Callable
|
|
@@ -24,6 +25,7 @@ from cryptography.hazmat.primitives.asymmetric import ec
|
|
|
24
25
|
from ._constants import CASA_AUTH_CHAR_UUID, ConnectionState
|
|
25
26
|
from ._encryption import Encryptor
|
|
26
27
|
from ._network import Network
|
|
28
|
+
from ._switch_events import SwitchEventStreamDecoder
|
|
27
29
|
|
|
28
30
|
# We need to move these imports here to prevent a cycle.
|
|
29
31
|
from .errors import ( # noqa: E402
|
|
@@ -43,7 +45,7 @@ class IncommingPacketType(IntEnum):
|
|
|
43
45
|
|
|
44
46
|
|
|
45
47
|
MIN_VERSION: Final[int] = 10
|
|
46
|
-
MAX_VERSION: Final[int] =
|
|
48
|
+
MAX_VERSION: Final[int] = 11
|
|
47
49
|
|
|
48
50
|
|
|
49
51
|
class CasambiClient:
|
|
@@ -79,6 +81,7 @@ class CasambiClient:
|
|
|
79
81
|
else address_or_device
|
|
80
82
|
)
|
|
81
83
|
self._logger = logging.getLogger(__name__)
|
|
84
|
+
self._switchDecoder = SwitchEventStreamDecoder(self._logger)
|
|
82
85
|
self._connectionState: ConnectionState = ConnectionState.NONE
|
|
83
86
|
self._dataCallback = dataCallback
|
|
84
87
|
self._disconnectedCallback = disonnectedCallback
|
|
@@ -122,6 +125,33 @@ class CasambiClient:
|
|
|
122
125
|
else await get_device(self.address)
|
|
123
126
|
)
|
|
124
127
|
|
|
128
|
+
if not device and isinstance(self._address_or_devive, str) and platform.system() == "Darwin":
|
|
129
|
+
# macOS CoreBluetooth typically reports random per-device identifiers as addresses
|
|
130
|
+
# unless `use_bdaddr` is enabled. Our `discover()` uses that flag so try it here.
|
|
131
|
+
try:
|
|
132
|
+
from ._discover import discover as discover_networks # local import to avoid cycles
|
|
133
|
+
|
|
134
|
+
networks = await discover_networks()
|
|
135
|
+
wanted = self.address.replace(":", "").lower()
|
|
136
|
+
for d in networks:
|
|
137
|
+
if d.address.replace(":", "").lower() == wanted:
|
|
138
|
+
device = d
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
if not device:
|
|
142
|
+
self._logger.warning(
|
|
143
|
+
"macOS BLE lookup by address failed. Discovered %d Casambi networks, but none match %s. Discovered=%s",
|
|
144
|
+
len(networks),
|
|
145
|
+
self.address,
|
|
146
|
+
[d.address for d in networks[:10]],
|
|
147
|
+
)
|
|
148
|
+
except Exception:
|
|
149
|
+
self._logger.debug(
|
|
150
|
+
"macOS fallback discovery failed while trying to find %s.",
|
|
151
|
+
self.address,
|
|
152
|
+
exc_info=True,
|
|
153
|
+
)
|
|
154
|
+
|
|
125
155
|
if not device:
|
|
126
156
|
self._logger.error("Failed to discover client.")
|
|
127
157
|
raise NetworkNotFoundError
|
|
@@ -448,11 +478,28 @@ class CasambiClient:
|
|
|
448
478
|
return
|
|
449
479
|
|
|
450
480
|
packetType = decrypted_data[0]
|
|
451
|
-
self._logger.
|
|
481
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
482
|
+
self._logger.debug(
|
|
483
|
+
"Incoming data of type %d: %s", packetType, b2a(decrypted_data)
|
|
484
|
+
)
|
|
452
485
|
|
|
453
486
|
if packetType == IncommingPacketType.UnitState:
|
|
454
487
|
self._parseUnitStates(decrypted_data[1:])
|
|
455
488
|
elif packetType == IncommingPacketType.SwitchEvent:
|
|
489
|
+
# Stable logs for offline analysis: packet seq + encrypted + decrypted.
|
|
490
|
+
# (Decrypted data includes the leading packet type byte.)
|
|
491
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
492
|
+
self._logger.debug(
|
|
493
|
+
"[CASAMBI_RAW_PACKET] Encrypted #%s: %s",
|
|
494
|
+
device_sequence,
|
|
495
|
+
b2a(raw_encrypted_packet),
|
|
496
|
+
)
|
|
497
|
+
self._logger.debug(
|
|
498
|
+
"[CASAMBI_DECRYPTED] Type=%d #%s: %s",
|
|
499
|
+
packetType,
|
|
500
|
+
device_sequence,
|
|
501
|
+
b2a(decrypted_data),
|
|
502
|
+
)
|
|
456
503
|
# Pass the device sequence as the packet sequence for consumers,
|
|
457
504
|
# and still include the raw encrypted packet for diagnostics.
|
|
458
505
|
seq_for_consumer = device_sequence if device_sequence is not None else self._inPacketCount
|
|
@@ -466,187 +513,145 @@ class CasambiClient:
|
|
|
466
513
|
# In the future we might want to parse the revision and issue a warning if there is a mismatch.
|
|
467
514
|
pass
|
|
468
515
|
else:
|
|
469
|
-
self._logger.
|
|
516
|
+
self._logger.debug("Packet type %d not implemented. Ignoring!", packetType)
|
|
470
517
|
|
|
471
518
|
def _parseUnitStates(self, data: bytes) -> None:
|
|
472
|
-
|
|
473
|
-
|
|
519
|
+
# Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
|
|
520
|
+
# as a stream of unit state records. Records have optional bytes depending on flags.
|
|
521
|
+
self._logger.debug("Parsing incoming unit states...")
|
|
522
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
523
|
+
self._logger.debug("Incoming unit state: %s", b2a(data))
|
|
474
524
|
|
|
475
525
|
pos = 0
|
|
476
526
|
oldPos = 0
|
|
477
527
|
try:
|
|
528
|
+
# Android uses `while (available() >= 4)` as the loop condition.
|
|
478
529
|
while pos <= len(data) - 4:
|
|
479
|
-
|
|
530
|
+
unit_id = data[pos]
|
|
480
531
|
flags = data[pos + 1]
|
|
481
|
-
|
|
482
|
-
|
|
532
|
+
b8 = data[pos + 2]
|
|
533
|
+
state_len = ((b8 >> 4) & 0x0F) + 1
|
|
534
|
+
prio = b8 & 0x0F
|
|
483
535
|
pos += 3
|
|
484
536
|
|
|
485
|
-
online = flags &
|
|
486
|
-
on = flags &
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
537
|
+
online = (flags & 0x02) != 0
|
|
538
|
+
on = (flags & 0x01) != 0
|
|
539
|
+
|
|
540
|
+
con: int | None = None
|
|
541
|
+
sid: int | None = None
|
|
542
|
+
|
|
543
|
+
# Optional bytes, matching Android:
|
|
544
|
+
# - flags&0x04: con (1 byte)
|
|
545
|
+
# - flags&0x08: sid (1 byte)
|
|
546
|
+
# - flags&0x10: extra byte; if missing Android uses 0xFF
|
|
547
|
+
if flags & 0x04:
|
|
548
|
+
con = data[pos]
|
|
549
|
+
pos += 1
|
|
550
|
+
if flags & 0x08:
|
|
551
|
+
sid = data[pos]
|
|
552
|
+
pos += 1
|
|
553
|
+
|
|
554
|
+
if flags & 0x10:
|
|
555
|
+
extra_byte = data[pos]
|
|
556
|
+
pos += 1
|
|
557
|
+
else:
|
|
558
|
+
extra_byte = 0xFF
|
|
494
559
|
|
|
495
|
-
state = data[pos : pos +
|
|
496
|
-
pos +=
|
|
560
|
+
state = data[pos : pos + state_len]
|
|
561
|
+
pos += state_len
|
|
497
562
|
|
|
498
|
-
|
|
563
|
+
padding_len = (flags >> 6) & 0x03
|
|
564
|
+
padding = data[pos : pos + padding_len] if padding_len else b""
|
|
565
|
+
pos += padding_len
|
|
499
566
|
|
|
500
|
-
self._logger.
|
|
501
|
-
|
|
502
|
-
|
|
567
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
568
|
+
self._logger.debug(
|
|
569
|
+
"[CASAMBI_UNITSTATE_PARSED] unit=%d flags=0x%02x prio=%d online=%s on=%s con=%s sid=%s extra_byte=%d state=%s padding=%s",
|
|
570
|
+
unit_id,
|
|
571
|
+
flags,
|
|
572
|
+
prio,
|
|
573
|
+
online,
|
|
574
|
+
on,
|
|
575
|
+
con,
|
|
576
|
+
sid,
|
|
577
|
+
extra_byte,
|
|
578
|
+
b2a(state),
|
|
579
|
+
b2a(padding),
|
|
580
|
+
)
|
|
503
581
|
|
|
504
582
|
self._dataCallback(
|
|
505
583
|
IncommingPacketType.UnitState,
|
|
506
|
-
{
|
|
584
|
+
{
|
|
585
|
+
"id": unit_id,
|
|
586
|
+
"online": online,
|
|
587
|
+
"on": on,
|
|
588
|
+
"state": state,
|
|
589
|
+
# Additional fields for diagnostics/analysis
|
|
590
|
+
"flags": flags,
|
|
591
|
+
"prio": prio,
|
|
592
|
+
"state_len": state_len,
|
|
593
|
+
"padding_len": padding_len,
|
|
594
|
+
"con": con,
|
|
595
|
+
"sid": sid,
|
|
596
|
+
"extra_byte": extra_byte,
|
|
597
|
+
"extra_float": extra_byte / 255.0,
|
|
598
|
+
},
|
|
507
599
|
)
|
|
508
600
|
|
|
509
601
|
oldPos = pos
|
|
510
602
|
except IndexError:
|
|
511
603
|
self._logger.error(
|
|
512
|
-
|
|
604
|
+
"Ran out of data while parsing unit state! Remaining data %s in %s.",
|
|
605
|
+
b2a(data[oldPos:]),
|
|
606
|
+
b2a(data),
|
|
513
607
|
)
|
|
514
608
|
|
|
515
609
|
def _parseSwitchEvent(
|
|
516
610
|
self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
|
|
517
611
|
) -> None:
|
|
518
|
-
"""Parse
|
|
519
|
-
self._logger.info(
|
|
520
|
-
f"Parsing incoming switch event packet #{packet_seq}... Data: {b2a(data)}"
|
|
521
|
-
)
|
|
612
|
+
"""Parse decrypted packet type=7 payload (INVOCATION stream).
|
|
522
613
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if len(data) >= 3:
|
|
527
|
-
length = ((data[2] >> 4) & 15) + 1
|
|
528
|
-
parameter = data[2] & 15
|
|
529
|
-
payload = data[3 : 3 + min(length, max(0, len(data) - 3))]
|
|
530
|
-
self._logger.info(
|
|
531
|
-
f"Ext-like message at packet head: flags=0x{data[1]:02x}, param={parameter}, payload={b2a(payload)}"
|
|
532
|
-
)
|
|
533
|
-
else:
|
|
534
|
-
self._logger.info(
|
|
535
|
-
f"Ext-like message 0x29 at packet head with insufficient length: {b2a(data)}"
|
|
536
|
-
)
|
|
537
|
-
return
|
|
538
|
-
|
|
539
|
-
pos = 0
|
|
540
|
-
oldPos = 0
|
|
541
|
-
switch_events_found = 0
|
|
614
|
+
Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses decrypted packet type=7
|
|
615
|
+
as a stream of INVOCATION frames. Switch button events are INVOCATIONs.
|
|
616
|
+
"""
|
|
542
617
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
self._logger.debug(
|
|
557
|
-
f"Skipping invalid message type 0x{message_type:02x} at position {oldPos}"
|
|
558
|
-
)
|
|
559
|
-
# Try to resync by looking for next valid message
|
|
560
|
-
pos = oldPos + 1
|
|
561
|
-
continue
|
|
562
|
-
|
|
563
|
-
# Check if we have enough data for the payload
|
|
564
|
-
if pos + length > len(data):
|
|
565
|
-
self._logger.debug(
|
|
566
|
-
f"Incomplete message at position {oldPos}. "
|
|
567
|
-
f"Type: 0x{message_type:02x}, declared length: {length}, available: {len(data) - pos}"
|
|
568
|
-
)
|
|
569
|
-
break
|
|
570
|
-
|
|
571
|
-
# Extract the payload
|
|
572
|
-
payload = data[pos : pos + length]
|
|
573
|
-
pos += length
|
|
574
|
-
|
|
575
|
-
# Process based on message type
|
|
576
|
-
if message_type == 0x08 or message_type == 0x10: # Switch/button events
|
|
577
|
-
switch_events_found += 1
|
|
578
|
-
|
|
579
|
-
# Button extraction differs between type 0x08 and type 0x10
|
|
580
|
-
if message_type == 0x08:
|
|
581
|
-
# For type 0x08, the lower nibble is a code that maps to physical button id
|
|
582
|
-
# Using formula: ((code + 2) % 4) + 1 based on reverse engineering findings
|
|
583
|
-
code_nibble = parameter & 0x0F
|
|
584
|
-
button = ((code_nibble + 2) % 4) + 1
|
|
585
|
-
self._logger.debug(
|
|
586
|
-
f"Type 0x08 button extraction: parameter=0x{parameter:02x}, code={code_nibble}, button={button}"
|
|
587
|
-
)
|
|
588
|
-
else:
|
|
589
|
-
# For type 0x10, use existing logic
|
|
590
|
-
button_lower = parameter & 0x0F
|
|
591
|
-
button_upper = (parameter >> 4) & 0x0F
|
|
592
|
-
|
|
593
|
-
# Use upper 4 bits if lower 4 bits are 0, otherwise use lower 4 bits
|
|
594
|
-
if button_lower == 0 and button_upper != 0:
|
|
595
|
-
button = button_upper
|
|
596
|
-
self._logger.debug(
|
|
597
|
-
f"Type 0x10 button extraction: parameter=0x{parameter:02x}, using upper nibble, button={button}"
|
|
598
|
-
)
|
|
599
|
-
else:
|
|
600
|
-
button = button_lower
|
|
601
|
-
self._logger.debug(
|
|
602
|
-
f"Type 0x10 button extraction: parameter=0x{parameter:02x}, using lower nibble, button={button}"
|
|
603
|
-
)
|
|
604
|
-
|
|
605
|
-
# For type 0x10 messages, we need to pass additional data beyond the declared payload
|
|
606
|
-
if message_type == 0x10:
|
|
607
|
-
# Extend to include at least 10 bytes from message start for state byte
|
|
608
|
-
extended_end = min(oldPos + 11, len(data))
|
|
609
|
-
full_message_data = data[oldPos:extended_end]
|
|
610
|
-
else:
|
|
611
|
-
full_message_data = data
|
|
612
|
-
self._processSwitchMessage(
|
|
613
|
-
message_type,
|
|
614
|
-
flags,
|
|
615
|
-
button,
|
|
616
|
-
payload,
|
|
617
|
-
full_message_data,
|
|
618
|
-
oldPos,
|
|
619
|
-
packet_seq,
|
|
620
|
-
raw_packet,
|
|
621
|
-
)
|
|
622
|
-
elif message_type == 0x29:
|
|
623
|
-
# Extended/aux message embedded in switch event packet
|
|
624
|
-
self._logger.info(
|
|
625
|
-
f"Embedded 0x29 ext-like msg: flags=0x{flags:02x}, param=0x{parameter & 0x0F:01x}, payload={b2a(payload)}"
|
|
626
|
-
)
|
|
627
|
-
elif message_type in [0x00, 0x06, 0x09, 0x1F, 0x2A]:
|
|
628
|
-
# Known non-switch message types - log at debug level
|
|
629
|
-
self._logger.debug(
|
|
630
|
-
f"Non-switch message type 0x{message_type:02x}: flags=0x{flags:02x}, "
|
|
631
|
-
f"param={parameter}, payload={b2a(payload)}"
|
|
632
|
-
)
|
|
633
|
-
else:
|
|
634
|
-
# Unknown message types - log at info level
|
|
635
|
-
self._logger.info(
|
|
636
|
-
f"Unknown message type 0x{message_type:02x}: flags=0x{flags:02x}, "
|
|
637
|
-
f"param={parameter}, payload={b2a(payload)}"
|
|
638
|
-
)
|
|
618
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
619
|
+
data_hex = b2a(data)
|
|
620
|
+
self._logger.debug(
|
|
621
|
+
"Parsing incoming switch event packet #%s... Data: %s",
|
|
622
|
+
packet_seq,
|
|
623
|
+
data_hex,
|
|
624
|
+
)
|
|
625
|
+
self._logger.debug(
|
|
626
|
+
"[CASAMBI_SWITCH_PACKET] Full data #%s: hex=%s len=%d",
|
|
627
|
+
packet_seq,
|
|
628
|
+
data_hex,
|
|
629
|
+
len(data),
|
|
630
|
+
)
|
|
639
631
|
|
|
640
|
-
|
|
632
|
+
events, stats = self._switchDecoder.decode(
|
|
633
|
+
data,
|
|
634
|
+
packet_seq=packet_seq,
|
|
635
|
+
raw_packet=raw_packet,
|
|
636
|
+
arrival_sequence=self._inPacketCount,
|
|
637
|
+
)
|
|
641
638
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
639
|
+
self._logger.debug(
|
|
640
|
+
"[CASAMBI_SWITCH_SUMMARY] packet=%s frames=%d button_frames=%d input_frames=%d ignored=%d emitted=%d suppressed_same_state=%d",
|
|
641
|
+
packet_seq,
|
|
642
|
+
stats.frames_total,
|
|
643
|
+
stats.frames_button,
|
|
644
|
+
stats.frames_input,
|
|
645
|
+
stats.frames_ignored,
|
|
646
|
+
stats.events_emitted,
|
|
647
|
+
stats.events_suppressed_same_state,
|
|
648
|
+
)
|
|
647
649
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
+
for ev in events:
|
|
651
|
+
# Back-compat alias: older consumers looked for 'flags'
|
|
652
|
+
if "flags" not in ev:
|
|
653
|
+
ev["flags"] = ev.get("invocation_flags")
|
|
654
|
+
self._dataCallback(IncommingPacketType.SwitchEvent, ev)
|
|
650
655
|
|
|
651
656
|
def _processSwitchMessage(
|
|
652
657
|
self,
|