casambi-bt-revamped 0.3.7.dev10__py3-none-any.whl → 0.3.7.dev12__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 +251 -0
- CasambiBt/_client.py +17 -6
- CasambiBt/_operation.py +13 -2
- {casambi_bt_revamped-0.3.7.dev10.dist-info → casambi_bt_revamped-0.3.7.dev12.dist-info}/METADATA +1 -1
- {casambi_bt_revamped-0.3.7.dev10.dist-info → casambi_bt_revamped-0.3.7.dev12.dist-info}/RECORD +8 -8
- {casambi_bt_revamped-0.3.7.dev10.dist-info → casambi_bt_revamped-0.3.7.dev12.dist-info}/WHEEL +0 -0
- {casambi_bt_revamped-0.3.7.dev10.dist-info → casambi_bt_revamped-0.3.7.dev12.dist-info}/licenses/LICENSE +0 -0
- {casambi_bt_revamped-0.3.7.dev10.dist-info → casambi_bt_revamped-0.3.7.dev12.dist-info}/top_level.txt +0 -0
CasambiBt/_casambi.py
CHANGED
|
@@ -551,6 +551,257 @@ class Casambi:
|
|
|
551
551
|
exc_info=True,
|
|
552
552
|
)
|
|
553
553
|
|
|
554
|
+
async def setParameter(self, unitId: int, parameterTag: int, parameterData: bytes) -> None:
|
|
555
|
+
"""Send a SetParameter command to a unit.
|
|
556
|
+
|
|
557
|
+
Args:
|
|
558
|
+
unitId: The ID of the unit to send the command to
|
|
559
|
+
parameterTag: The parameter tag/ID to update
|
|
560
|
+
parameterData: The raw parameter data to send
|
|
561
|
+
"""
|
|
562
|
+
if not self._casaClient:
|
|
563
|
+
raise RuntimeError("Not connected to network")
|
|
564
|
+
|
|
565
|
+
# Build payload: [parameter_tag][parameter_data]
|
|
566
|
+
# Note: SetParameter supports max 31 bytes after the tag.
|
|
567
|
+
payload = bytes([parameterTag]) + parameterData[:31]
|
|
568
|
+
|
|
569
|
+
# Send using OpCode.SetParameter (26)
|
|
570
|
+
opPkt = self._opContext.prepareOperation(OpCode.SetParameter, (unitId << 8) | 0x01, payload)
|
|
571
|
+
await self._casaClient.send(opPkt)
|
|
572
|
+
|
|
573
|
+
async def send_ext_packet(self, unit_id: int, seq: int, chunk: bytes, *, lifetime: int = 9) -> None:
|
|
574
|
+
"""Send an extended packet (ExtPacketSend / opcode 43) to a unit.
|
|
575
|
+
|
|
576
|
+
Payload is framed as: [0x29][seq][chunk...]
|
|
577
|
+
|
|
578
|
+
Notes:
|
|
579
|
+
- Total payload must be <= 63 bytes, so len(chunk) <= 61.
|
|
580
|
+
- Lifetime defaults to 9 to mirror longer-running multi-chunk ops seen on Android.
|
|
581
|
+
"""
|
|
582
|
+
if not self._casaClient:
|
|
583
|
+
raise RuntimeError("Not connected to network")
|
|
584
|
+
|
|
585
|
+
if seq < 0 or seq > 255:
|
|
586
|
+
raise ValueError("seq must be 0..255")
|
|
587
|
+
if len(chunk) > 61:
|
|
588
|
+
raise ValueError("chunk too large; max 61 bytes to fit ExtPacket payload")
|
|
589
|
+
|
|
590
|
+
payload = bytes([0x29, seq & 0xFF]) + chunk
|
|
591
|
+
target = (int(unit_id) << 8) | 0x01
|
|
592
|
+
opPkt = self._opContext.prepareOperation(
|
|
593
|
+
OpCode.ExtPacketSend,
|
|
594
|
+
target,
|
|
595
|
+
payload,
|
|
596
|
+
lifetime=lifetime,
|
|
597
|
+
)
|
|
598
|
+
await self._casaClient.send(opPkt)
|
|
599
|
+
|
|
600
|
+
async def start_switch_session(self, unit_id: int, *, payload: bytes = b"", lifetime: int = 9) -> None:
|
|
601
|
+
"""Experimental: send AcquireSwitchSession (opcode 42).
|
|
602
|
+
|
|
603
|
+
Payload is device-specific and currently unknown for switchConfig; by default
|
|
604
|
+
sends an empty payload. Use for experimentation only until framing is confirmed.
|
|
605
|
+
"""
|
|
606
|
+
if not self._casaClient:
|
|
607
|
+
raise RuntimeError("Not connected to network")
|
|
608
|
+
if len(payload) > 63:
|
|
609
|
+
raise ValueError("payload too large; max 63 bytes")
|
|
610
|
+
target = (int(unit_id) << 8) | 0x01
|
|
611
|
+
opPkt = self._opContext.prepareOperation(
|
|
612
|
+
OpCode.AcquireSwitchSession,
|
|
613
|
+
target,
|
|
614
|
+
payload,
|
|
615
|
+
lifetime=lifetime,
|
|
616
|
+
)
|
|
617
|
+
await self._casaClient.send(opPkt)
|
|
618
|
+
|
|
619
|
+
async def apply_switch_config_ble(self, unit_id: int, *, parameter_tag: int | None = None) -> None:
|
|
620
|
+
"""Attempt to push the unit's switchConfig to the device over BLE.
|
|
621
|
+
|
|
622
|
+
This method currently supports only very small configurations that fit
|
|
623
|
+
into a single SetParameter payload (max 31 bytes after the tag).
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
unit_id: The ID of the unit to update.
|
|
627
|
+
parameter_tag: Optional override for the parameter tag; if not provided,
|
|
628
|
+
a default of 1 is used (subject to device variation).
|
|
629
|
+
|
|
630
|
+
Raises:
|
|
631
|
+
RuntimeError: If not connected or no raw network data.
|
|
632
|
+
ValueError: If the encoded switchConfig is too large for SetParameter.
|
|
633
|
+
"""
|
|
634
|
+
if not self._casaClient:
|
|
635
|
+
raise RuntimeError("Not connected to network")
|
|
636
|
+
if not self._casaNetwork or not self._casaNetwork.rawNetworkData:
|
|
637
|
+
raise RuntimeError("No raw network data available; connect and update first")
|
|
638
|
+
|
|
639
|
+
units = self._casaNetwork.rawNetworkData.get("network", {}).get("units", [])
|
|
640
|
+
unit_data = next((u for u in units if u.get("deviceID") == unit_id), None)
|
|
641
|
+
if not unit_data:
|
|
642
|
+
raise ValueError(f"Unit {unit_id} not found in raw network data")
|
|
643
|
+
|
|
644
|
+
switch_config = unit_data.get("switchConfig") or {}
|
|
645
|
+
|
|
646
|
+
import json
|
|
647
|
+
config_bytes = json.dumps(switch_config, separators=(",", ":")).encode("utf-8")
|
|
648
|
+
|
|
649
|
+
# Protocol restriction: SetParameter accepts max 31 bytes after tag.
|
|
650
|
+
if len(config_bytes) > 31:
|
|
651
|
+
raise ValueError(
|
|
652
|
+
"switchConfig exceeds 31 bytes; multi-packet BLE apply is required. "
|
|
653
|
+
"This library will need an extended writer (AcquireSwitchSession/ExtPacketSend) to support large configs."
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
tag = 1 if parameter_tag is None else int(parameter_tag)
|
|
657
|
+
await self.setParameter(unit_id, tag, config_bytes)
|
|
658
|
+
self._logger.info(
|
|
659
|
+
"Applied switchConfig via SetParameter (tag=%s, bytes=%s) for unit %s",
|
|
660
|
+
tag,
|
|
661
|
+
len(config_bytes),
|
|
662
|
+
unit_id,
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
async def apply_switch_config_ble_large(self, unit_id: int) -> None:
|
|
666
|
+
"""Experimental: attempt to push switchConfig using ExtPacketSend in chunks.
|
|
667
|
+
|
|
668
|
+
WARNING: Framing is not fully confirmed for switchConfig updates. This method
|
|
669
|
+
sends raw JSON bytes chunked as consecutive ExtPacket frames with header
|
|
670
|
+
[0x29][seq]. Use only for experimentation and logs; correctness is not guaranteed.
|
|
671
|
+
"""
|
|
672
|
+
if not self._casaClient:
|
|
673
|
+
raise RuntimeError("Not connected to network")
|
|
674
|
+
if not self._casaNetwork or not self._casaNetwork.rawNetworkData:
|
|
675
|
+
raise RuntimeError("No raw network data available; connect and update first")
|
|
676
|
+
|
|
677
|
+
units = self._casaNetwork.rawNetworkData.get("network", {}).get("units", [])
|
|
678
|
+
unit_data = next((u for u in units if u.get("deviceID") == unit_id), None)
|
|
679
|
+
if not unit_data:
|
|
680
|
+
raise ValueError(f"Unit {unit_id} not found in raw network data")
|
|
681
|
+
|
|
682
|
+
switch_config = unit_data.get("switchConfig") or {}
|
|
683
|
+
import json
|
|
684
|
+
data = json.dumps(switch_config, separators=(",", ":")).encode("utf-8")
|
|
685
|
+
|
|
686
|
+
# Chunk into 61-byte pieces (ExtPacket payload allows 63 total minus 2-byte header)
|
|
687
|
+
max_chunk = 61
|
|
688
|
+
seq = 0
|
|
689
|
+
for off in range(0, len(data), max_chunk):
|
|
690
|
+
chunk = data[off : off + max_chunk]
|
|
691
|
+
await self.send_ext_packet(unit_id, seq, chunk, lifetime=9)
|
|
692
|
+
self._logger.debug(
|
|
693
|
+
"ExtPacket chunk sent: unit=%s seq=%s size=%s/%s", unit_id, seq, len(chunk), len(data)
|
|
694
|
+
)
|
|
695
|
+
seq = (seq + 1) & 0xFF
|
|
696
|
+
self._logger.info(
|
|
697
|
+
"ExtPacket switchConfig attempt complete (unit=%s, total_bytes=%s, chunks=%s)",
|
|
698
|
+
unit_id,
|
|
699
|
+
len(data),
|
|
700
|
+
(len(data) + max_chunk - 1) // max_chunk,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
async def update_button_config(
|
|
704
|
+
self,
|
|
705
|
+
unit_id: int,
|
|
706
|
+
button_index: int,
|
|
707
|
+
action_type: str,
|
|
708
|
+
target_id: int | None = None,
|
|
709
|
+
*,
|
|
710
|
+
exclusive_scenes: bool | None = None,
|
|
711
|
+
long_press_all_off: bool | None = None,
|
|
712
|
+
toggle_disabled: bool | None = None,
|
|
713
|
+
) -> dict:
|
|
714
|
+
"""Update one button entry in a unit's switchConfig in the cached network data.
|
|
715
|
+
|
|
716
|
+
Android mapping for actions (EnumC0105q):
|
|
717
|
+
- none -> 0, target 0
|
|
718
|
+
- scene -> 1, target=sceneID
|
|
719
|
+
- control_unit -> 2, target=deviceID
|
|
720
|
+
- control_group -> 3, target=groupID
|
|
721
|
+
- all_units -> 4, target=255
|
|
722
|
+
- resume_automation -> 6, target=0
|
|
723
|
+
- resume_automation_group -> 7, target=groupID
|
|
724
|
+
|
|
725
|
+
Returns the updated switchConfig dict.
|
|
726
|
+
"""
|
|
727
|
+
if button_index < 0 or button_index > 7:
|
|
728
|
+
raise ValueError("button_index must be in range 0..7")
|
|
729
|
+
|
|
730
|
+
raw = self.rawNetworkData
|
|
731
|
+
if not raw:
|
|
732
|
+
raise RuntimeError("No raw network data loaded. Connect and update first.")
|
|
733
|
+
|
|
734
|
+
# Locate the unit's JSON entry
|
|
735
|
+
units = raw.get("network", {}).get("units", [])
|
|
736
|
+
unit_data = None
|
|
737
|
+
for u in units:
|
|
738
|
+
if u.get("deviceID") == unit_id:
|
|
739
|
+
unit_data = u
|
|
740
|
+
break
|
|
741
|
+
if not unit_data:
|
|
742
|
+
raise ValueError(f"Unit {unit_id} not found in raw network data")
|
|
743
|
+
|
|
744
|
+
switch_config = unit_data.get("switchConfig") or {}
|
|
745
|
+
buttons: list[dict] = switch_config.get("buttons") or []
|
|
746
|
+
|
|
747
|
+
action_map = {
|
|
748
|
+
"none": 0,
|
|
749
|
+
"scene": 1,
|
|
750
|
+
"control_unit": 2,
|
|
751
|
+
"control_group": 3,
|
|
752
|
+
"all_units": 4,
|
|
753
|
+
"resume_automation": 6,
|
|
754
|
+
"resume_automation_group": 7,
|
|
755
|
+
}
|
|
756
|
+
if action_type not in action_map:
|
|
757
|
+
raise ValueError(
|
|
758
|
+
"Unsupported action_type. Use one of: none, scene, control_unit, "
|
|
759
|
+
"control_group, all_units, resume_automation, resume_automation_group"
|
|
760
|
+
)
|
|
761
|
+
action_code = action_map[action_type]
|
|
762
|
+
|
|
763
|
+
if action_type == "all_units":
|
|
764
|
+
resolved_target = 255
|
|
765
|
+
elif action_type in ("none", "resume_automation"):
|
|
766
|
+
resolved_target = 0
|
|
767
|
+
else:
|
|
768
|
+
if target_id is None:
|
|
769
|
+
raise ValueError(f"target_id is required for action_type '{action_type}'")
|
|
770
|
+
resolved_target = int(target_id)
|
|
771
|
+
|
|
772
|
+
# Find or create entry for this button index
|
|
773
|
+
existing = None
|
|
774
|
+
for b in buttons:
|
|
775
|
+
if b.get("type") == button_index:
|
|
776
|
+
existing = b
|
|
777
|
+
break
|
|
778
|
+
entry = existing or {"type": button_index, "action": 0, "target": 0}
|
|
779
|
+
entry["action"] = action_code
|
|
780
|
+
entry["target"] = resolved_target
|
|
781
|
+
if not existing:
|
|
782
|
+
buttons.append(entry)
|
|
783
|
+
|
|
784
|
+
# Persist and apply optional flags
|
|
785
|
+
switch_config["buttons"] = buttons
|
|
786
|
+
if exclusive_scenes is not None:
|
|
787
|
+
switch_config["exclusiveScenes"] = bool(exclusive_scenes)
|
|
788
|
+
if long_press_all_off is not None:
|
|
789
|
+
switch_config["longPressAllOff"] = bool(long_press_all_off)
|
|
790
|
+
if toggle_disabled is not None:
|
|
791
|
+
switch_config["toggleDisabled"] = bool(toggle_disabled)
|
|
792
|
+
|
|
793
|
+
unit_data["switchConfig"] = switch_config
|
|
794
|
+
|
|
795
|
+
self._logger.info(
|
|
796
|
+
"Updated switchConfig (unit=%s, button=%s, action=%s, target=%s)",
|
|
797
|
+
unit_id,
|
|
798
|
+
button_index,
|
|
799
|
+
action_type,
|
|
800
|
+
resolved_target,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
return switch_config
|
|
804
|
+
|
|
554
805
|
async def disconnect(self) -> None:
|
|
555
806
|
"""Disconnect from the network."""
|
|
556
807
|
if self._casaClient:
|
CasambiBt/_client.py
CHANGED
|
@@ -497,11 +497,20 @@ class CasambiClient:
|
|
|
497
497
|
f"Parsing incoming switch event packet #{packet_seq}... Data: {b2a(data)}"
|
|
498
498
|
)
|
|
499
499
|
|
|
500
|
-
# Special handling for message type 0x29 -
|
|
500
|
+
# Special handling for message type 0x29 - likely an extended/aux message
|
|
501
501
|
if len(data) >= 1 and data[0] == 0x29:
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
502
|
+
# Log details so we can correlate with outgoing ExtPacketSend trials
|
|
503
|
+
if len(data) >= 3:
|
|
504
|
+
length = ((data[2] >> 4) & 15) + 1
|
|
505
|
+
parameter = data[2] & 15
|
|
506
|
+
payload = data[3 : 3 + min(length, max(0, len(data) - 3))]
|
|
507
|
+
self._logger.info(
|
|
508
|
+
f"Ext-like message at packet head: flags=0x{data[1]:02x}, param={parameter}, payload={b2a(payload)}"
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
self._logger.info(
|
|
512
|
+
f"Ext-like message 0x29 at packet head with insufficient length: {b2a(data)}"
|
|
513
|
+
)
|
|
505
514
|
return
|
|
506
515
|
|
|
507
516
|
pos = 0
|
|
@@ -577,8 +586,10 @@ class CasambiClient:
|
|
|
577
586
|
raw_packet,
|
|
578
587
|
)
|
|
579
588
|
elif message_type == 0x29:
|
|
580
|
-
#
|
|
581
|
-
self._logger.
|
|
589
|
+
# Extended/aux message embedded in switch event packet
|
|
590
|
+
self._logger.info(
|
|
591
|
+
f"Embedded 0x29 ext-like msg: flags=0x{flags:02x}, param=0x{parameter & 0x0F:01x}, payload={b2a(payload)}"
|
|
592
|
+
)
|
|
582
593
|
elif message_type in [0x00, 0x06, 0x09, 0x1F, 0x2A]:
|
|
583
594
|
# Known non-switch message types - log at debug level
|
|
584
595
|
self._logger.debug(
|
CasambiBt/_operation.py
CHANGED
|
@@ -11,6 +11,9 @@ class OpCode(IntEnum):
|
|
|
11
11
|
SetWhite = 5
|
|
12
12
|
SetColor = 7
|
|
13
13
|
SetSlider = 12
|
|
14
|
+
SetParameter = 26
|
|
15
|
+
AcquireSwitchSession = 42
|
|
16
|
+
ExtPacketSend = 43
|
|
14
17
|
SetState = 48
|
|
15
18
|
SetColorXY = 54
|
|
16
19
|
|
|
@@ -20,11 +23,19 @@ class OperationsContext:
|
|
|
20
23
|
self.origin: int = 1
|
|
21
24
|
self.lifetime: int = 5
|
|
22
25
|
|
|
23
|
-
def prepareOperation(
|
|
26
|
+
def prepareOperation(
|
|
27
|
+
self,
|
|
28
|
+
op: OpCode,
|
|
29
|
+
target: int,
|
|
30
|
+
payload: bytes,
|
|
31
|
+
*,
|
|
32
|
+
lifetime: int | None = None,
|
|
33
|
+
) -> bytes:
|
|
24
34
|
if len(payload) > 63:
|
|
25
35
|
raise ValueError("Payload too long")
|
|
26
36
|
|
|
27
|
-
|
|
37
|
+
lt = self.lifetime if lifetime is None else int(lifetime)
|
|
38
|
+
flags = (lt & 15) << 11 | len(payload)
|
|
28
39
|
|
|
29
40
|
# Ensure that origin can't overflow.
|
|
30
41
|
# TODO: Check that unsigned is actually correct here.
|
{casambi_bt_revamped-0.3.7.dev10.dist-info → casambi_bt_revamped-0.3.7.dev12.dist-info}/RECORD
RENAMED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
CasambiBt/__init__.py,sha256=TW445xSu5PV3TyMjJfwaA1JoWvQQ8LXhZgGdDTfWf3s,302
|
|
2
2
|
CasambiBt/_cache.py,sha256=KZ2xbiHAHXUPa8Gw_75Nw9NL4QSY_sTWHbyYXYUDaB0,3865
|
|
3
|
-
CasambiBt/_casambi.py,sha256=
|
|
4
|
-
CasambiBt/_client.py,sha256=
|
|
3
|
+
CasambiBt/_casambi.py,sha256=QIolS2a8fg6iRsP2Rck2THQTRXVuOZdsvbz9K2TBgho,33686
|
|
4
|
+
CasambiBt/_client.py,sha256=rblDtrg0BnJVtx4UJhBqKpbimT7iNIW3sW9UVG2BNRE,29480
|
|
5
5
|
CasambiBt/_constants.py,sha256=_AxkG7Btxl4VeS6mO7GJW5Kc9dFs3s9sDmtJ83ZEKNw,359
|
|
6
6
|
CasambiBt/_discover.py,sha256=H7HpiFYIy9ELvmPXXd_ck-5O5invJf15dDIRk-vO5IE,1696
|
|
7
7
|
CasambiBt/_encryption.py,sha256=CLcoOOrggQqhJbnr_emBnEnkizpWDvb_0yFnitq4_FM,3831
|
|
8
8
|
CasambiBt/_keystore.py,sha256=Jdiq0zMPDmhfpheSojKY6sTUpmVrvX_qOyO7yCYd3kw,2788
|
|
9
9
|
CasambiBt/_network.py,sha256=Gh0n3FEcOUHUMuBXALwcb3tws-AofpYLegKIquqtZl4,14665
|
|
10
|
-
CasambiBt/_operation.py,sha256
|
|
10
|
+
CasambiBt/_operation.py,sha256=Q5UccsrtNp_B_wWqwH_3eLFW_yF6A55FMmfUKDk2WrI,1059
|
|
11
11
|
CasambiBt/_unit.py,sha256=M-Q8-Xd3qjJSUEvsFtic8E4xDc_gtWYakbTGyoIA-P8,16377
|
|
12
12
|
CasambiBt/errors.py,sha256=0JgDjaKlAKDes0poWzA8nrTUYQ8qdNfBb8dfaqqzCRA,1664
|
|
13
13
|
CasambiBt/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
-
casambi_bt_revamped-0.3.7.
|
|
15
|
-
casambi_bt_revamped-0.3.7.
|
|
16
|
-
casambi_bt_revamped-0.3.7.
|
|
17
|
-
casambi_bt_revamped-0.3.7.
|
|
18
|
-
casambi_bt_revamped-0.3.7.
|
|
14
|
+
casambi_bt_revamped-0.3.7.dev12.dist-info/licenses/LICENSE,sha256=TAIIitFxpxEDi6Iju7foW4TDQmWvC-IhLVLhl67jKmQ,11341
|
|
15
|
+
casambi_bt_revamped-0.3.7.dev12.dist-info/METADATA,sha256=T1lxLD35kAoeXwmbedvpNYNcjaf1EWmObAKTIcCK7xY,3049
|
|
16
|
+
casambi_bt_revamped-0.3.7.dev12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
17
|
+
casambi_bt_revamped-0.3.7.dev12.dist-info/top_level.txt,sha256=uNbqLjtecFosoFzpGAC89-5icikWODKI8rOjbi8v_sA,10
|
|
18
|
+
casambi_bt_revamped-0.3.7.dev12.dist-info/RECORD,,
|
{casambi_bt_revamped-0.3.7.dev10.dist-info → casambi_bt_revamped-0.3.7.dev12.dist-info}/WHEEL
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|