casambi-bt-revamped 0.3.11__py3-none-any.whl → 0.3.12.dev1__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 +11 -7
- CasambiBt/_client.py +144 -147
- CasambiBt/_invocation.py +116 -0
- CasambiBt/_switch_events.py +327 -0
- CasambiBt/_unit.py +37 -1
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev1.dist-info}/METADATA +45 -6
- casambi_bt_revamped-0.3.12.dev1.dist-info/RECORD +20 -0
- 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.dev1.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev1.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev1.dist-info}/top_level.txt +0 -0
CasambiBt/_casambi.py
CHANGED
|
@@ -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
|
|
@@ -453,6 +483,19 @@ class CasambiClient:
|
|
|
453
483
|
if packetType == IncommingPacketType.UnitState:
|
|
454
484
|
self._parseUnitStates(decrypted_data[1:])
|
|
455
485
|
elif packetType == IncommingPacketType.SwitchEvent:
|
|
486
|
+
# Stable logs for offline analysis: packet seq + encrypted + decrypted.
|
|
487
|
+
# (Decrypted data includes the leading packet type byte.)
|
|
488
|
+
self._logger.info(
|
|
489
|
+
"[CASAMBI_RAW_PACKET] Encrypted #%s: %s",
|
|
490
|
+
device_sequence,
|
|
491
|
+
b2a(raw_encrypted_packet),
|
|
492
|
+
)
|
|
493
|
+
self._logger.info(
|
|
494
|
+
"[CASAMBI_DECRYPTED] Type=%d #%s: %s",
|
|
495
|
+
packetType,
|
|
496
|
+
device_sequence,
|
|
497
|
+
b2a(decrypted_data),
|
|
498
|
+
)
|
|
456
499
|
# Pass the device sequence as the packet sequence for consumers,
|
|
457
500
|
# and still include the raw encrypted packet for diagnostics.
|
|
458
501
|
seq_for_consumer = device_sequence if device_sequence is not None else self._inPacketCount
|
|
@@ -469,184 +512,138 @@ class CasambiClient:
|
|
|
469
512
|
self._logger.info(f"Packet type {packetType} not implemented. Ignoring!")
|
|
470
513
|
|
|
471
514
|
def _parseUnitStates(self, data: bytes) -> None:
|
|
515
|
+
# Ground truth: casambi-android `v1.C1775b.V(Q2.h)` parses decrypted packet type=6
|
|
516
|
+
# as a stream of unit state records. Records have optional bytes depending on flags.
|
|
472
517
|
self._logger.info("Parsing incoming unit states...")
|
|
473
|
-
self._logger.debug(
|
|
518
|
+
self._logger.debug("Incoming unit state: %s", b2a(data))
|
|
474
519
|
|
|
475
520
|
pos = 0
|
|
476
521
|
oldPos = 0
|
|
477
522
|
try:
|
|
523
|
+
# Android uses `while (available() >= 4)` as the loop condition.
|
|
478
524
|
while pos <= len(data) - 4:
|
|
479
|
-
|
|
525
|
+
unit_id = data[pos]
|
|
480
526
|
flags = data[pos + 1]
|
|
481
|
-
|
|
482
|
-
|
|
527
|
+
b8 = data[pos + 2]
|
|
528
|
+
state_len = ((b8 >> 4) & 0x0F) + 1
|
|
529
|
+
prio = b8 & 0x0F
|
|
483
530
|
pos += 3
|
|
484
531
|
|
|
485
|
-
online = flags &
|
|
486
|
-
on = flags &
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
532
|
+
online = (flags & 0x02) != 0
|
|
533
|
+
on = (flags & 0x01) != 0
|
|
534
|
+
|
|
535
|
+
con: int | None = None
|
|
536
|
+
sid: int | None = None
|
|
537
|
+
|
|
538
|
+
# Optional bytes, matching Android:
|
|
539
|
+
# - flags&0x04: con (1 byte)
|
|
540
|
+
# - flags&0x08: sid (1 byte)
|
|
541
|
+
# - flags&0x10: extra byte; if missing Android uses 0xFF
|
|
542
|
+
if flags & 0x04:
|
|
543
|
+
con = data[pos]
|
|
544
|
+
pos += 1
|
|
545
|
+
if flags & 0x08:
|
|
546
|
+
sid = data[pos]
|
|
547
|
+
pos += 1
|
|
548
|
+
|
|
549
|
+
if flags & 0x10:
|
|
550
|
+
extra_byte = data[pos]
|
|
551
|
+
pos += 1
|
|
552
|
+
else:
|
|
553
|
+
extra_byte = 0xFF
|
|
494
554
|
|
|
495
|
-
state = data[pos : pos +
|
|
496
|
-
pos +=
|
|
555
|
+
state = data[pos : pos + state_len]
|
|
556
|
+
pos += state_len
|
|
497
557
|
|
|
498
|
-
|
|
558
|
+
padding_len = (flags >> 6) & 0x03
|
|
559
|
+
padding = data[pos : pos + padding_len] if padding_len else b""
|
|
560
|
+
pos += padding_len
|
|
499
561
|
|
|
500
562
|
self._logger.debug(
|
|
501
|
-
|
|
563
|
+
"[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",
|
|
564
|
+
unit_id,
|
|
565
|
+
flags,
|
|
566
|
+
prio,
|
|
567
|
+
online,
|
|
568
|
+
on,
|
|
569
|
+
con,
|
|
570
|
+
sid,
|
|
571
|
+
extra_byte,
|
|
572
|
+
b2a(state),
|
|
573
|
+
b2a(padding),
|
|
502
574
|
)
|
|
503
575
|
|
|
504
576
|
self._dataCallback(
|
|
505
577
|
IncommingPacketType.UnitState,
|
|
506
|
-
{
|
|
578
|
+
{
|
|
579
|
+
"id": unit_id,
|
|
580
|
+
"online": online,
|
|
581
|
+
"on": on,
|
|
582
|
+
"state": state,
|
|
583
|
+
# Additional fields for diagnostics/analysis
|
|
584
|
+
"flags": flags,
|
|
585
|
+
"prio": prio,
|
|
586
|
+
"state_len": state_len,
|
|
587
|
+
"padding_len": padding_len,
|
|
588
|
+
"con": con,
|
|
589
|
+
"sid": sid,
|
|
590
|
+
"extra_byte": extra_byte,
|
|
591
|
+
"extra_float": extra_byte / 255.0,
|
|
592
|
+
},
|
|
507
593
|
)
|
|
508
594
|
|
|
509
595
|
oldPos = pos
|
|
510
596
|
except IndexError:
|
|
511
597
|
self._logger.error(
|
|
512
|
-
|
|
598
|
+
"Ran out of data while parsing unit state! Remaining data %s in %s.",
|
|
599
|
+
b2a(data[oldPos:]),
|
|
600
|
+
b2a(data),
|
|
513
601
|
)
|
|
514
602
|
|
|
515
603
|
def _parseSwitchEvent(
|
|
516
604
|
self, data: bytes, packet_seq: int = None, raw_packet: bytes = None
|
|
517
605
|
) -> None:
|
|
518
|
-
"""Parse
|
|
519
|
-
self._logger.info(
|
|
520
|
-
f"Parsing incoming switch event packet #{packet_seq}... Data: {b2a(data)}"
|
|
521
|
-
)
|
|
522
|
-
|
|
523
|
-
# Special handling for message type 0x29 - likely an extended/aux message
|
|
524
|
-
if len(data) >= 1 and data[0] == 0x29:
|
|
525
|
-
# Log details so we can correlate with outgoing ExtPacketSend trials
|
|
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
|
|
542
|
-
|
|
543
|
-
try:
|
|
544
|
-
while pos <= len(data) - 3:
|
|
545
|
-
oldPos = pos
|
|
606
|
+
"""Parse decrypted packet type=7 payload (INVOCATION stream).
|
|
546
607
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
length = ((data[pos + 2] >> 4) & 15) + 1
|
|
551
|
-
parameter = data[pos + 2] # Full byte, not just lower 4 bits
|
|
552
|
-
pos += 3
|
|
608
|
+
Ground truth: casambi-android `v1.C1775b.Q(Q2.h)` parses decrypted packet type=7
|
|
609
|
+
as a stream of INVOCATION frames. Switch button events are INVOCATIONs.
|
|
610
|
+
"""
|
|
553
611
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
)
|
|
612
|
+
self._logger.info(
|
|
613
|
+
"Parsing incoming switch event packet #%s... Data: %s",
|
|
614
|
+
packet_seq,
|
|
615
|
+
b2a(data),
|
|
616
|
+
)
|
|
617
|
+
self._logger.info(
|
|
618
|
+
"[CASAMBI_SWITCH_PACKET] Full data #%s: hex=%s len=%d",
|
|
619
|
+
packet_seq,
|
|
620
|
+
b2a(data),
|
|
621
|
+
len(data),
|
|
622
|
+
)
|
|
639
623
|
|
|
640
|
-
|
|
624
|
+
events, stats = self._switchDecoder.decode(
|
|
625
|
+
data,
|
|
626
|
+
packet_seq=packet_seq,
|
|
627
|
+
raw_packet=raw_packet,
|
|
628
|
+
arrival_sequence=self._inPacketCount,
|
|
629
|
+
)
|
|
641
630
|
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
631
|
+
self._logger.info(
|
|
632
|
+
"[CASAMBI_SWITCH_SUMMARY] packet=%s frames=%d button_frames=%d input_frames=%d ignored=%d emitted=%d suppressed_same_state=%d",
|
|
633
|
+
packet_seq,
|
|
634
|
+
stats.frames_total,
|
|
635
|
+
stats.frames_button,
|
|
636
|
+
stats.frames_input,
|
|
637
|
+
stats.frames_ignored,
|
|
638
|
+
stats.events_emitted,
|
|
639
|
+
stats.events_suppressed_same_state,
|
|
640
|
+
)
|
|
647
641
|
|
|
648
|
-
|
|
649
|
-
|
|
642
|
+
for ev in events:
|
|
643
|
+
# Back-compat alias: older consumers looked for 'flags'
|
|
644
|
+
if "flags" not in ev:
|
|
645
|
+
ev["flags"] = ev.get("invocation_flags")
|
|
646
|
+
self._dataCallback(IncommingPacketType.SwitchEvent, ev)
|
|
650
647
|
|
|
651
648
|
def _processSwitchMessage(
|
|
652
649
|
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,327 @@
|
|
|
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
|
+
self._logger.info(
|
|
151
|
+
"[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",
|
|
152
|
+
packet_seq,
|
|
153
|
+
unit_id,
|
|
154
|
+
button,
|
|
155
|
+
event,
|
|
156
|
+
frame.opcode,
|
|
157
|
+
frame.origin,
|
|
158
|
+
frame.age,
|
|
159
|
+
frame.flags,
|
|
160
|
+
b2a(frame.payload),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
# Back-compat / existing consumers
|
|
165
|
+
"unit_id": unit_id,
|
|
166
|
+
"button": button,
|
|
167
|
+
"event": event,
|
|
168
|
+
"message_type": 0x07, # decrypted packet type (SwitchEvent)
|
|
169
|
+
"message_position": frame.offset,
|
|
170
|
+
"extra_data": None,
|
|
171
|
+
# INVOCATION fields
|
|
172
|
+
"invocation_flags": frame.flags,
|
|
173
|
+
"opcode": frame.opcode,
|
|
174
|
+
"origin": frame.origin,
|
|
175
|
+
"origin_unit_id": origin_unit_id,
|
|
176
|
+
"origin_type": origin_type,
|
|
177
|
+
"target": frame.target,
|
|
178
|
+
"target_type": target_type,
|
|
179
|
+
"age": frame.age,
|
|
180
|
+
"origin_handle": frame.origin_handle,
|
|
181
|
+
"payload": frame.payload,
|
|
182
|
+
"payload_hex": b2a(frame.payload),
|
|
183
|
+
"frame_offset": frame.offset,
|
|
184
|
+
"button_event_index": button_event_index,
|
|
185
|
+
"param_p": param_p,
|
|
186
|
+
"param_s": param_s,
|
|
187
|
+
# Diagnostics / correlation
|
|
188
|
+
"packet_sequence": packet_seq,
|
|
189
|
+
"arrival_sequence": arrival_sequence,
|
|
190
|
+
"event_id": event_id,
|
|
191
|
+
"raw_packet": b2a(raw_packet) if raw_packet else None,
|
|
192
|
+
"decrypted_data": b2a(data),
|
|
193
|
+
"frame_hex": b2a(
|
|
194
|
+
data[frame.offset : frame.offset + (9 + (1 if frame.origin_handle is not None else 0) + frame.payload_len)]
|
|
195
|
+
),
|
|
196
|
+
"received_at": time.time(),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# Input notify frames (often accompany wireless switches).
|
|
200
|
+
if (
|
|
201
|
+
target_type == _TARGET_TYPE_INPUT
|
|
202
|
+
and _INPUT_EVENT_MIN <= frame.opcode <= _INPUT_EVENT_MAX
|
|
203
|
+
):
|
|
204
|
+
stats.frames_input += 1
|
|
205
|
+
input_index = frame.opcode - _INPUT_EVENT_MIN
|
|
206
|
+
input_code = frame.payload[0] if frame.payload else None
|
|
207
|
+
input_b1 = frame.payload[1] if len(frame.payload) >= 2 else None
|
|
208
|
+
input_channel = (input_b1 & 0x07) if input_b1 is not None else None
|
|
209
|
+
input_value16 = (
|
|
210
|
+
int.from_bytes(frame.payload[2:4], "little")
|
|
211
|
+
if len(frame.payload) >= 4
|
|
212
|
+
else None
|
|
213
|
+
)
|
|
214
|
+
button = _guess_button_label_4gang(input_index)
|
|
215
|
+
|
|
216
|
+
# Map common input codes into the legacy "switch" event taxonomy.
|
|
217
|
+
# Observed:
|
|
218
|
+
# - wired: 01xx press, 02xx release, 0cxx release_after_hold
|
|
219
|
+
# - wireless: 09xx hold, 0cxx release_after_hold (+ separate button stream for press/release)
|
|
220
|
+
mapped_event: str | None = None
|
|
221
|
+
if input_code is not None:
|
|
222
|
+
if input_code == 0x09:
|
|
223
|
+
mapped_event = "button_hold"
|
|
224
|
+
elif input_code == 0x0C:
|
|
225
|
+
mapped_event = "button_release_after_hold"
|
|
226
|
+
elif input_code == 0x01:
|
|
227
|
+
mapped_event = "button_press"
|
|
228
|
+
elif input_code == 0x02:
|
|
229
|
+
mapped_event = "button_release"
|
|
230
|
+
|
|
231
|
+
input_mapped_event = mapped_event
|
|
232
|
+
|
|
233
|
+
# Avoid duplicating press/release for wireless switches that also produce the real button stream.
|
|
234
|
+
if mapped_event in ("button_press", "button_release") and (unit_id, button) in self._button_stream_seen:
|
|
235
|
+
mapped_event = None
|
|
236
|
+
|
|
237
|
+
if mapped_event is not None and input_code is not None:
|
|
238
|
+
state_key = (unit_id, input_index)
|
|
239
|
+
last_code = self._last_input_code.get(state_key)
|
|
240
|
+
if last_code == input_code:
|
|
241
|
+
stats.events_suppressed_same_state += 1
|
|
242
|
+
self._logger.debug(
|
|
243
|
+
"[CASAMBI_EVENT_SUPPRESS] input unit=%d input_index=%d button=%d code=0x%02x opcode=0x%02x origin=0x%04x age=0x%04x",
|
|
244
|
+
unit_id,
|
|
245
|
+
input_index,
|
|
246
|
+
button,
|
|
247
|
+
input_code,
|
|
248
|
+
frame.opcode,
|
|
249
|
+
frame.origin,
|
|
250
|
+
frame.age,
|
|
251
|
+
)
|
|
252
|
+
return None
|
|
253
|
+
self._last_input_code[state_key] = input_code
|
|
254
|
+
|
|
255
|
+
self._logger.info(
|
|
256
|
+
"[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",
|
|
257
|
+
packet_seq,
|
|
258
|
+
unit_id,
|
|
259
|
+
button,
|
|
260
|
+
mapped_event,
|
|
261
|
+
input_code,
|
|
262
|
+
frame.opcode,
|
|
263
|
+
frame.origin,
|
|
264
|
+
frame.age,
|
|
265
|
+
frame.flags,
|
|
266
|
+
b2a(frame.payload),
|
|
267
|
+
)
|
|
268
|
+
event = mapped_event or "input_event"
|
|
269
|
+
self._logger.debug(
|
|
270
|
+
"[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",
|
|
271
|
+
packet_seq,
|
|
272
|
+
unit_id,
|
|
273
|
+
input_index,
|
|
274
|
+
frame.opcode,
|
|
275
|
+
frame.origin,
|
|
276
|
+
frame.age,
|
|
277
|
+
frame.flags,
|
|
278
|
+
f"0x{input_code:02x}" if input_code is not None else None,
|
|
279
|
+
input_channel,
|
|
280
|
+
input_value16,
|
|
281
|
+
b2a(frame.payload),
|
|
282
|
+
)
|
|
283
|
+
return {
|
|
284
|
+
"unit_id": unit_id,
|
|
285
|
+
"button": button,
|
|
286
|
+
"event": event,
|
|
287
|
+
"message_type": 0x07,
|
|
288
|
+
"message_position": frame.offset,
|
|
289
|
+
"extra_data": None,
|
|
290
|
+
"invocation_flags": frame.flags,
|
|
291
|
+
"opcode": frame.opcode,
|
|
292
|
+
"origin": frame.origin,
|
|
293
|
+
"origin_unit_id": origin_unit_id,
|
|
294
|
+
"origin_type": origin_type,
|
|
295
|
+
"target": frame.target,
|
|
296
|
+
"target_type": target_type,
|
|
297
|
+
"age": frame.age,
|
|
298
|
+
"origin_handle": frame.origin_handle,
|
|
299
|
+
"payload": frame.payload,
|
|
300
|
+
"payload_hex": b2a(frame.payload),
|
|
301
|
+
"frame_offset": frame.offset,
|
|
302
|
+
"input_index": input_index,
|
|
303
|
+
"input_code": input_code,
|
|
304
|
+
"input_b1": input_b1,
|
|
305
|
+
"input_channel": input_channel,
|
|
306
|
+
"input_value16": input_value16,
|
|
307
|
+
"input_mapped_event": input_mapped_event,
|
|
308
|
+
"packet_sequence": packet_seq,
|
|
309
|
+
"arrival_sequence": arrival_sequence,
|
|
310
|
+
"event_id": f"invoke:{frame.origin:04x}:{frame.age:04x}:{frame.opcode:02x}:{frame.target:04x}",
|
|
311
|
+
"raw_packet": b2a(raw_packet) if raw_packet else None,
|
|
312
|
+
"decrypted_data": b2a(data),
|
|
313
|
+
"received_at": time.time(),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
stats.frames_ignored += 1
|
|
317
|
+
self._logger.debug(
|
|
318
|
+
"[CASAMBI_INVOKE_IGNORED] packet=%s opcode=0x%02x origin=0x%04x target=0x%04x age=0x%04x flags=0x%04x payload=%s",
|
|
319
|
+
packet_seq,
|
|
320
|
+
frame.opcode,
|
|
321
|
+
frame.origin,
|
|
322
|
+
frame.target,
|
|
323
|
+
frame.age,
|
|
324
|
+
frame.flags,
|
|
325
|
+
b2a(frame.payload),
|
|
326
|
+
)
|
|
327
|
+
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
|
|
|
@@ -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.dev1
|
|
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
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
|
|
2
|
+
CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
|
|
3
|
+
CasambiBt/_casambi.py,sha256=s02mWvQVqdCSp-DGu2syAgnahf5POKoMX4qdSJwzwfg,34750
|
|
4
|
+
CasambiBt/_client.py,sha256=Tjv8md1JQuJyoapDQtudOB5j0vGM-OVjpkwdVvqkM0I,31084
|
|
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=PMh4k8fGg9u-_wm-HpqVS5b55X40vANIN8BiQ85WN1Q,12917
|
|
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.dev1.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
17
|
+
casambi_bt_revamped-0.3.12.dev1.dist-info/METADATA,sha256=97haknVYhqQQGnbPpyTY7jIz1UEQ3esQPrbtsrz1ZWs,4907
|
|
18
|
+
casambi_bt_revamped-0.3.12.dev1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
19
|
+
casambi_bt_revamped-0.3.12.dev1.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
20
|
+
casambi_bt_revamped-0.3.12.dev1.dist-info/RECORD,,
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
|
|
2
|
-
CasambiBt/_cache.py,sha256=3bQil8vhSy4f4sf9JusMfEdQC7d3cJuva9qHhyKro-0,3808
|
|
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.11.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
15
|
-
casambi_bt_revamped-0.3.11.dist-info/METADATA,sha256=iZGwjYA0uCljUzih5kyGU63Fj-EZIE-acg7wE5fWHwM,3154
|
|
16
|
-
casambi_bt_revamped-0.3.11.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
17
|
-
casambi_bt_revamped-0.3.11.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
18
|
-
casambi_bt_revamped-0.3.11.dist-info/RECORD,,
|
|
File without changes
|
{casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{casambi_bt_revamped-0.3.11.dist-info → casambi_bt_revamped-0.3.12.dev1.dist-info}/top_level.txt
RENAMED
|
File without changes
|