casambi-bt-revamped 0.3.7.dev11__tar.gz → 0.3.7.dev13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {casambi_bt_revamped-0.3.7.dev11/src/casambi_bt_revamped.egg-info → casambi_bt_revamped-0.3.7.dev13}/PKG-INFO +1 -1
  2. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/setup.cfg +1 -1
  3. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_casambi.py +264 -0
  4. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_client.py +44 -23
  5. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_operation.py +13 -2
  6. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13/src/casambi_bt_revamped.egg-info}/PKG-INFO +1 -1
  7. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/LICENSE +0 -0
  8. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/README.md +0 -0
  9. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/pyproject.toml +0 -0
  10. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/__init__.py +0 -0
  11. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_cache.py +0 -0
  12. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_constants.py +0 -0
  13. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_discover.py +0 -0
  14. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_encryption.py +0 -0
  15. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_keystore.py +0 -0
  16. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_network.py +0 -0
  17. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/_unit.py +0 -0
  18. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/errors.py +0 -0
  19. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/CasambiBt/py.typed +0 -0
  20. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/casambi_bt_revamped.egg-info/SOURCES.txt +0 -0
  21. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/casambi_bt_revamped.egg-info/dependency_links.txt +0 -0
  22. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/casambi_bt_revamped.egg-info/requires.txt +0 -0
  23. {casambi_bt_revamped-0.3.7.dev11 → casambi_bt_revamped-0.3.7.dev13}/src/casambi_bt_revamped.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.7.dev11
3
+ Version: 0.3.7.dev13
4
4
  Summary: Enhanced Casambi Bluetooth client library with switch event support
5
5
  Home-page: https://github.com/rankjie/casambi-bt
6
6
  Author: rankjie
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = casambi-bt-revamped
3
- version = 0.3.7.dev11
3
+ version = 0.3.7.dev13
4
4
  author = rankjie
5
5
  author_email = rankjie@gmail.com
6
6
  description = Enhanced Casambi Bluetooth client library with switch event support
@@ -551,6 +551,270 @@ 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
+ # Attempt to start a switch session before sending chunks. Some devices
687
+ # expect an AcquireSwitchSession (opcode 42) handshake prior to ExtPacketSend.
688
+ try:
689
+ await self.start_switch_session(unit_id)
690
+ self._logger.info("Started switch session (AcquireSwitchSession) for unit %s", unit_id)
691
+ except Exception as e:
692
+ # Non-fatal: proceed without session if device doesn't require it.
693
+ self._logger.info(
694
+ "AcquireSwitchSession not acknowledged or unsupported for unit %s (%s); proceeding with ExtPacketSend only.",
695
+ unit_id,
696
+ e,
697
+ )
698
+
699
+ # Chunk into 61-byte pieces (ExtPacket payload allows 63 total minus 2-byte header)
700
+ max_chunk = 61
701
+ seq = 0
702
+ for off in range(0, len(data), max_chunk):
703
+ chunk = data[off : off + max_chunk]
704
+ await self.send_ext_packet(unit_id, seq, chunk, lifetime=9)
705
+ self._logger.debug(
706
+ "ExtPacket chunk sent: unit=%s seq=%s size=%s/%s", unit_id, seq, len(chunk), len(data)
707
+ )
708
+ seq = (seq + 1) & 0xFF
709
+ self._logger.info(
710
+ "ExtPacket switchConfig attempt complete (unit=%s, total_bytes=%s, chunks=%s)",
711
+ unit_id,
712
+ len(data),
713
+ (len(data) + max_chunk - 1) // max_chunk,
714
+ )
715
+
716
+ async def update_button_config(
717
+ self,
718
+ unit_id: int,
719
+ button_index: int,
720
+ action_type: str,
721
+ target_id: int | None = None,
722
+ *,
723
+ exclusive_scenes: bool | None = None,
724
+ long_press_all_off: bool | None = None,
725
+ toggle_disabled: bool | None = None,
726
+ ) -> dict:
727
+ """Update one button entry in a unit's switchConfig in the cached network data.
728
+
729
+ Android mapping for actions (EnumC0105q):
730
+ - none -> 0, target 0
731
+ - scene -> 1, target=sceneID
732
+ - control_unit -> 2, target=deviceID
733
+ - control_group -> 3, target=groupID
734
+ - all_units -> 4, target=255
735
+ - resume_automation -> 6, target=0
736
+ - resume_automation_group -> 7, target=groupID
737
+
738
+ Returns the updated switchConfig dict.
739
+ """
740
+ if button_index < 0 or button_index > 7:
741
+ raise ValueError("button_index must be in range 0..7")
742
+
743
+ raw = self.rawNetworkData
744
+ if not raw:
745
+ raise RuntimeError("No raw network data loaded. Connect and update first.")
746
+
747
+ # Locate the unit's JSON entry
748
+ units = raw.get("network", {}).get("units", [])
749
+ unit_data = None
750
+ for u in units:
751
+ if u.get("deviceID") == unit_id:
752
+ unit_data = u
753
+ break
754
+ if not unit_data:
755
+ raise ValueError(f"Unit {unit_id} not found in raw network data")
756
+
757
+ switch_config = unit_data.get("switchConfig") or {}
758
+ buttons: list[dict] = switch_config.get("buttons") or []
759
+
760
+ action_map = {
761
+ "none": 0,
762
+ "scene": 1,
763
+ "control_unit": 2,
764
+ "control_group": 3,
765
+ "all_units": 4,
766
+ "resume_automation": 6,
767
+ "resume_automation_group": 7,
768
+ }
769
+ if action_type not in action_map:
770
+ raise ValueError(
771
+ "Unsupported action_type. Use one of: none, scene, control_unit, "
772
+ "control_group, all_units, resume_automation, resume_automation_group"
773
+ )
774
+ action_code = action_map[action_type]
775
+
776
+ if action_type == "all_units":
777
+ resolved_target = 255
778
+ elif action_type in ("none", "resume_automation"):
779
+ resolved_target = 0
780
+ else:
781
+ if target_id is None:
782
+ raise ValueError(f"target_id is required for action_type '{action_type}'")
783
+ resolved_target = int(target_id)
784
+
785
+ # Find or create entry for this button index
786
+ existing = None
787
+ for b in buttons:
788
+ if b.get("type") == button_index:
789
+ existing = b
790
+ break
791
+ entry = existing or {"type": button_index, "action": 0, "target": 0}
792
+ entry["action"] = action_code
793
+ entry["target"] = resolved_target
794
+ if not existing:
795
+ buttons.append(entry)
796
+
797
+ # Persist and apply optional flags
798
+ switch_config["buttons"] = buttons
799
+ if exclusive_scenes is not None:
800
+ switch_config["exclusiveScenes"] = bool(exclusive_scenes)
801
+ if long_press_all_off is not None:
802
+ switch_config["longPressAllOff"] = bool(long_press_all_off)
803
+ if toggle_disabled is not None:
804
+ switch_config["toggleDisabled"] = bool(toggle_disabled)
805
+
806
+ unit_data["switchConfig"] = switch_config
807
+
808
+ self._logger.info(
809
+ "Updated switchConfig (unit=%s, button=%s, action=%s, target=%s)",
810
+ unit_id,
811
+ button_index,
812
+ action_type,
813
+ resolved_target,
814
+ )
815
+
816
+ return switch_config
817
+
554
818
  async def disconnect(self) -> None:
555
819
  """Disconnect from the network."""
556
820
  if self._casaClient:
@@ -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 - not a switch event
500
+ # Special handling for message type 0x29 - likely an extended/aux message
501
501
  if len(data) >= 1 and data[0] == 0x29:
502
- self._logger.debug(
503
- f"Ignoring message type 0x29 (not a switch event): {b2a(data)}"
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
@@ -543,21 +552,32 @@ class CasambiClient:
543
552
  # Process based on message type
544
553
  if message_type == 0x08 or message_type == 0x10: # Switch/button events
545
554
  switch_events_found += 1
546
- # Extract button ID - try both upper and lower nibbles
547
- button_lower = parameter & 0x0F
548
- button_upper = (parameter >> 4) & 0x0F
549
-
550
- # Use upper 4 bits if lower 4 bits are 0, otherwise use lower 4 bits
551
- if button_lower == 0 and button_upper != 0:
552
- button = button_upper
555
+
556
+ # Button extraction differs between type 0x08 and type 0x10
557
+ if message_type == 0x08:
558
+ # For type 0x08, the lower nibble is a code that maps to physical button id
559
+ # Using formula: ((code + 2) % 4) + 1 based on reverse engineering findings
560
+ code_nibble = parameter & 0x0F
561
+ button = ((code_nibble + 2) % 4) + 1
553
562
  self._logger.debug(
554
- f"EVO button extraction: parameter=0x{parameter:02x}, using upper nibble, button={button}"
563
+ f"Type 0x08 button extraction: parameter=0x{parameter:02x}, code={code_nibble}, button={button}"
555
564
  )
556
565
  else:
557
- button = button_lower
558
- self._logger.debug(
559
- f"EVO button extraction: parameter=0x{parameter:02x}, using lower nibble, button={button}"
560
- )
566
+ # For type 0x10, use existing logic
567
+ button_lower = parameter & 0x0F
568
+ button_upper = (parameter >> 4) & 0x0F
569
+
570
+ # Use upper 4 bits if lower 4 bits are 0, otherwise use lower 4 bits
571
+ if button_lower == 0 and button_upper != 0:
572
+ button = button_upper
573
+ self._logger.debug(
574
+ f"Type 0x10 button extraction: parameter=0x{parameter:02x}, using upper nibble, button={button}"
575
+ )
576
+ else:
577
+ button = button_lower
578
+ self._logger.debug(
579
+ f"Type 0x10 button extraction: parameter=0x{parameter:02x}, using lower nibble, button={button}"
580
+ )
561
581
 
562
582
  # For type 0x10 messages, we need to pass additional data beyond the declared payload
563
583
  if message_type == 0x10:
@@ -577,8 +597,10 @@ class CasambiClient:
577
597
  raw_packet,
578
598
  )
579
599
  elif message_type == 0x29:
580
- # This shouldn't happen due to check above, but just in case
581
- self._logger.debug("Ignoring embedded type 0x29 message")
600
+ # Extended/aux message embedded in switch event packet
601
+ self._logger.info(
602
+ f"Embedded 0x29 ext-like msg: flags=0x{flags:02x}, param=0x{parameter & 0x0F:01x}, payload={b2a(payload)}"
603
+ )
582
604
  elif message_type in [0x00, 0x06, 0x09, 0x1F, 0x2A]:
583
605
  # Known non-switch message types - log at debug level
584
606
  self._logger.debug(
@@ -694,13 +716,12 @@ class CasambiClient:
694
716
  f"action={action_display} ({event_string}), flags=0x{flags:02x}"
695
717
  )
696
718
 
697
- # Filter out all type 0x08 messages
719
+ # Log detailed info about type 0x08 messages (now processed, not filtered)
698
720
  if message_type == 0x08:
699
- self._logger.debug(
700
- f"Filtering out type 0x08 event: button={button}, unit_id={unit_id}, "
701
- f"action={action_display}, flags=0x{flags:02x}"
721
+ self._logger.info(
722
+ f"Type 0x08 event processed: button={button}, unit_id={unit_id}, "
723
+ f"action={action_display}, event={event_string}, flags=0x{flags:02x}"
702
724
  )
703
- return
704
725
 
705
726
  self._dataCallback(
706
727
  IncommingPacketType.SwitchEvent,
@@ -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(self, op: OpCode, target: int, payload: bytes) -> bytes:
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
- flags = (self.lifetime & 15) << 11 | len(payload)
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: casambi-bt-revamped
3
- Version: 0.3.7.dev11
3
+ Version: 0.3.7.dev13
4
4
  Summary: Enhanced Casambi Bluetooth client library with switch event support
5
5
  Home-page: https://github.com/rankjie/casambi-bt
6
6
  Author: rankjie