casambi-bt-revamped 0.3.9__py3-none-any.whl → 0.3.12.dev2__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/_cache.py +9 -9
- CasambiBt/_casambi.py +12 -8
- CasambiBt/_client.py +157 -152
- CasambiBt/_invocation.py +116 -0
- CasambiBt/_switch_events.py +329 -0
- CasambiBt/_unit.py +37 -1
- casambi_bt_revamped-0.3.12.dev2.dist-info/METADATA +120 -0
- casambi_bt_revamped-0.3.12.dev2.dist-info/RECORD +20 -0
- {casambi_bt_revamped-0.3.9.dist-info → casambi_bt_revamped-0.3.12.dev2.dist-info}/WHEEL +1 -1
- casambi_bt_revamped-0.3.9.dist-info/METADATA +0 -81
- casambi_bt_revamped-0.3.9.dist-info/RECORD +0 -18
- {casambi_bt_revamped-0.3.9.dist-info → casambi_bt_revamped-0.3.12.dev2.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.9.dist-info → casambi_bt_revamped-0.3.12.dev2.dist-info}/top_level.txt +0 -0
CasambiBt/_cache.py
CHANGED
|
@@ -6,28 +6,28 @@ import shutil
|
|
|
6
6
|
from types import TracebackType
|
|
7
7
|
from typing import Final
|
|
8
8
|
|
|
9
|
-
from
|
|
9
|
+
from anyio import Path
|
|
10
10
|
|
|
11
11
|
_LOGGER = logging.getLogger(__name__)
|
|
12
12
|
|
|
13
|
-
CACHE_PATH_DEFAULT: Final =
|
|
13
|
+
CACHE_PATH_DEFAULT: Final = Path(os.getcwd()) / "casambi-bt-store"
|
|
14
14
|
CACHE_VERSION: Final = 2
|
|
15
15
|
|
|
16
|
-
# We need a global lock since there could be multiple
|
|
16
|
+
# We need a global lock since there could be multiple Casambi instances
|
|
17
17
|
# with their own cache instances pointing to the same folder.
|
|
18
18
|
_cacheLock = asyncio.Lock()
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def _blocking_delete(path:
|
|
21
|
+
def _blocking_delete(path: Path) -> None:
|
|
22
22
|
shutil.rmtree(pathlib.Path(path))
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
class Cache:
|
|
26
|
-
def __init__(self, cachePath:
|
|
26
|
+
def __init__(self, cachePath: Path | pathlib.Path | None) -> None:
|
|
27
27
|
if cachePath is None:
|
|
28
28
|
self._cachePath = CACHE_PATH_DEFAULT
|
|
29
|
-
elif not isinstance(cachePath,
|
|
30
|
-
self._cachePath =
|
|
29
|
+
elif not isinstance(cachePath, Path):
|
|
30
|
+
self._cachePath = Path(cachePath)
|
|
31
31
|
else:
|
|
32
32
|
self._cachePath = cachePath
|
|
33
33
|
|
|
@@ -69,7 +69,7 @@ class Cache:
|
|
|
69
69
|
await self._cachePath.mkdir(mode=0o700)
|
|
70
70
|
await self._cacheVersionFile.write_text(str(CACHE_VERSION))
|
|
71
71
|
|
|
72
|
-
async def __aenter__(self) ->
|
|
72
|
+
async def __aenter__(self) -> Path:
|
|
73
73
|
await _cacheLock.acquire()
|
|
74
74
|
|
|
75
75
|
if self._uuid is None:
|
|
@@ -78,7 +78,7 @@ class Cache:
|
|
|
78
78
|
try:
|
|
79
79
|
await self._ensureCacheValid()
|
|
80
80
|
|
|
81
|
-
cacheDir =
|
|
81
|
+
cacheDir = Path(self._cachePath / self._uuid)
|
|
82
82
|
if not await cacheDir.exists():
|
|
83
83
|
_LOGGER.debug("Creating cache entry for id %s", self._uuid)
|
|
84
84
|
await cacheDir.mkdir()
|
CasambiBt/_casambi.py
CHANGED
|
@@ -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
|
"""
|
CasambiBt/_client.py
CHANGED
|
@@ -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,
|
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
|
+
|
|
@@ -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
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: casambi-bt-revamped
|
|
3
|
+
Version: 0.3.12.dev2
|
|
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
|
+
- **Bug fixes and improvements** - Various fixes based on real-world usage
|
|
32
|
+
|
|
33
|
+
This library provides a bluetooth interface to Casambi-based lights. It is not associated with Casambi.
|
|
34
|
+
|
|
35
|
+
For Home Assistant integration using this library, see [casambi-bt-hass](https://github.com/rankjie/casambi-bt-hass).
|
|
36
|
+
|
|
37
|
+
## Getting started
|
|
38
|
+
|
|
39
|
+
This library is available on PyPi:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
pip install casambi-bt-revamped
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Have a look at `demo.py` for a small example.
|
|
46
|
+
|
|
47
|
+
### Switch Event Support
|
|
48
|
+
|
|
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)
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from CasambiBt import Casambi
|
|
60
|
+
|
|
61
|
+
def handle_switch_event(event_data):
|
|
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
|
+
)
|
|
81
|
+
|
|
82
|
+
casa = Casambi()
|
|
83
|
+
# ... connect to network ...
|
|
84
|
+
|
|
85
|
+
# Register switch event handler
|
|
86
|
+
casa.registerSwitchEventHandler(handle_switch_event)
|
|
87
|
+
|
|
88
|
+
# Events will be received when buttons are pressed/released
|
|
89
|
+
```
|
|
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
|
+
|
|
98
|
+
### MacOS
|
|
99
|
+
|
|
100
|
+
MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
|
|
101
|
+
if you're running this library on MacOS, it will use an undocumented IOBluetooth API to get the MAC Address.
|
|
102
|
+
Without the real MAC address the integration with Casambi will not work.
|
|
103
|
+
If you're running into problems fetching the MAC address on MacOS, try it on a Raspberry Pi.
|
|
104
|
+
|
|
105
|
+
### Casambi network setup
|
|
106
|
+
|
|
107
|
+
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):
|
|
108
|
+
|
|
109
|
+

|
|
110
|
+

|
|
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
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
|
|
2
|
+
CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
|
|
3
|
+
CasambiBt/_casambi.py,sha256=i-60A7zDblZwOVb4UfXz9EEwsbWEFPnrMvVkKMl6amY,34752
|
|
4
|
+
CasambiBt/_client.py,sha256=z3AnTQrZMVLkQTXdyLOZyNkNonU3arMFtEHMxUF59Ig,31581
|
|
5
|
+
CasambiBt/_constants.py,sha256=_AxkG7Btxl4VeS6mO7GJW5Kc9dFs3s9sDmtJ83ZEKNw,359
|
|
6
|
+
CasambiBt/_discover.py,sha256=H7HpiFYIy9ELvmPXXd_ck-5O5invJf15dDIRk-vO5IE,1696
|
|
7
|
+
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
8
|
+
CasambiBt/_invocation.py,sha256=fkG4R0Gv5_amFfD_P6DKuIEe3oKWZW0v8RSU8zDjPdI,2985
|
|
9
|
+
CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
|
|
10
|
+
CasambiBt/_network.py,sha256=Gh0n3FEcOUHUMuBXALwcb3tws-AofpYLegKIquqtZl4,14665
|
|
11
|
+
CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
|
|
12
|
+
CasambiBt/_switch_events.py,sha256=S8OD0dBcw5T4J2C7qfmOQMnTJ7omIXRUYv4PqDOB87E,13137
|
|
13
|
+
CasambiBt/_unit.py,sha256=KIpvUT_Wm-O2Lmb1JVnNO625-j5j7GqufmZzfTR-jW0,18587
|
|
14
|
+
CasambiBt/errors.py,sha256=0JgDjaKlAKDes0poWzA8nrTUYQ8qdNfBb8dfaqqzCRA,1664
|
|
15
|
+
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
casambi_bt_revamped-0.3.12.dev2.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
17
|
+
casambi_bt_revamped-0.3.12.dev2.dist-info/METADATA,sha256=nMUsaWF7HLmwMjYQFq_uJM3N_CGyP7SPmUKaGjKWdQk,4907
|
|
18
|
+
casambi_bt_revamped-0.3.12.dev2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
19
|
+
casambi_bt_revamped-0.3.12.dev2.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
20
|
+
casambi_bt_revamped-0.3.12.dev2.dist-info/RECORD,,
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: casambi-bt-revamped
|
|
3
|
-
Version: 0.3.9
|
|
4
|
-
Summary: Enhanced Casambi Bluetooth client library with switch event support
|
|
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.1.0
|
|
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: aiopath==0.7.*
|
|
20
|
-
Dynamic: license-file
|
|
21
|
-
|
|
22
|
-

|
|
23
|
-
[](https://discord.gg/jgZVugfx)
|
|
24
|
-
|
|
25
|
-
# Casambi Bluetooth Revamped - Enhanced Python library for Casambi networks
|
|
26
|
-
|
|
27
|
-
This is an enhanced fork of the original [casambi-bt](https://github.com/lkempf/casambi-bt) library with additional features:
|
|
28
|
-
|
|
29
|
-
- **Switch event support** - Receive button press/release events from Casambi switches
|
|
30
|
-
- **Improved relay status handling** - Better support for relay units
|
|
31
|
-
- **Bug fixes and improvements** - Various fixes based on real-world usage
|
|
32
|
-
|
|
33
|
-
This library provides a bluetooth interface to Casambi-based lights. It is not associated with Casambi.
|
|
34
|
-
|
|
35
|
-
For Home Assistant integration using this library, see [casambi-bt-hass](https://github.com/rankjie/casambi-bt-hass).
|
|
36
|
-
|
|
37
|
-
## Getting started
|
|
38
|
-
|
|
39
|
-
This library is available on PyPi:
|
|
40
|
-
|
|
41
|
-
```
|
|
42
|
-
pip install casambi-bt-revamped
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Have a look at `demo.py` for a small example.
|
|
46
|
-
|
|
47
|
-
### Switch Event Support
|
|
48
|
-
|
|
49
|
-
This library now supports receiving switch button events:
|
|
50
|
-
|
|
51
|
-
```python
|
|
52
|
-
from CasambiBt import Casambi
|
|
53
|
-
|
|
54
|
-
def handle_switch_event(event_data):
|
|
55
|
-
print(f"Switch event: Unit {event_data['unit_id']}, "
|
|
56
|
-
f"Button {event_data['button']}, "
|
|
57
|
-
f"Action: {event_data['event']}")
|
|
58
|
-
|
|
59
|
-
casa = Casambi()
|
|
60
|
-
# ... connect to network ...
|
|
61
|
-
|
|
62
|
-
# Register switch event handler
|
|
63
|
-
casa.registerSwitchEventHandler(handle_switch_event)
|
|
64
|
-
|
|
65
|
-
# Events will be received when buttons are pressed/released
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
### MacOS
|
|
69
|
-
|
|
70
|
-
MacOS [does not expose the Bluetooth MAC address via their official API](https://github.com/hbldh/bleak/issues/140),
|
|
71
|
-
if you're running this library on MacOS, it will use an undocumented IOBluetooth API to get the MAC Address.
|
|
72
|
-
Without the real MAC address the integration with Casambi will not work.
|
|
73
|
-
If you're running into problems fetching the MAC address on MacOS, try it on a Raspberry Pi.
|
|
74
|
-
|
|
75
|
-
### Casambi network setup
|
|
76
|
-
|
|
77
|
-
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):
|
|
78
|
-
|
|
79
|
-

|
|
80
|
-

|
|
81
|
-

|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
|
|
2
|
-
CasambiBt/_cache.py,sha256=KZ2xbiHAHXUPa8Gw_75Nw9NL4QSY_sTWHbyYXYUDaB0,3865
|
|
3
|
-
CasambiBt/_casambi.py,sha256=AfyuzEU2ylJOGLmZ87Qft-aNXI_JK8Ng9Tfk4fWYOwo,34345
|
|
4
|
-
CasambiBt/_client.py,sha256=cmRXPxURJpp8xQM-FJe2NBMtfFFC7NnWExTaN65LQ5c,32142
|
|
5
|
-
CasambiBt/_constants.py,sha256=_AxkG7Btxl4VeS6mO7GJW5Kc9dFs3s9sDmtJ83ZEKNw,359
|
|
6
|
-
CasambiBt/_discover.py,sha256=H7HpiFYIy9ELvmPXXd_ck-5O5invJf15dDIRk-vO5IE,1696
|
|
7
|
-
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
8
|
-
CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
|
|
9
|
-
CasambiBt/_network.py,sha256=Gh0n3FEcOUHUMuBXALwcb3tws-AofpYLegKIquqtZl4,14665
|
|
10
|
-
CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
|
|
11
|
-
CasambiBt/_unit.py,sha256=KR_dvVhCH8WIPGJgZYHyAPVA6ru0KmMOL5NgkxYHIUQ,17042
|
|
12
|
-
CasambiBt/errors.py,sha256=0JgDjaKlAKDes0poWzA8nrTUYQ8qdNfBb8dfaqqzCRA,1664
|
|
13
|
-
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
casambi_bt_revamped-0.3.9.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
15
|
-
casambi_bt_revamped-0.3.9.dist-info/METADATA,sha256=gZzMScOl8A22GnaSMjFoAwu1Q2kJWwF49GQXcQFd0pk,3044
|
|
16
|
-
casambi_bt_revamped-0.3.9.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
|
|
17
|
-
casambi_bt_revamped-0.3.9.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
18
|
-
casambi_bt_revamped-0.3.9.dist-info/RECORD,,
|
{casambi_bt_revamped-0.3.9.dist-info → casambi_bt_revamped-0.3.12.dev2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.9.dist-info → casambi_bt_revamped-0.3.12.dev2.dist-info}/top_level.txt
RENAMED
|
File without changes
|