casambi-bt-revamped 0.3.7.dev9__py3-none-any.whl → 0.3.12.dev15__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/__init__.py CHANGED
@@ -3,6 +3,7 @@
3
3
  # Import everything that should be public
4
4
  # ruff: noqa: F401
5
5
 
6
+ from ._version import __version__
6
7
  from ._casambi import Casambi
7
8
  from ._discover import discover
8
9
  from ._unit import (
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 aiopath import AsyncPath # type: ignore
9
+ from anyio import Path
10
10
 
11
11
  _LOGGER = logging.getLogger(__name__)
12
12
 
13
- CACHE_PATH_DEFAULT: Final = AsyncPath(os.getcwd()) / "casambi-bt-store"
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 Caambi instances
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: AsyncPath) -> None:
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: AsyncPath | pathlib.Path | None) -> None:
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, AsyncPath):
30
- self._cachePath = AsyncPath(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) -> AsyncPath:
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 = AsyncPath(self._cachePath / self._uuid)
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
@@ -10,7 +10,7 @@ from bleak.backends.device import BLEDevice
10
10
  from httpx import AsyncClient, RequestError
11
11
 
12
12
  from ._cache import Cache
13
- from ._client import CasambiClient, ConnectionState, IncommingPacketType
13
+ from ._client import CasambiClient, ConnectionState, IncommingPacketType, ProtocolMode
14
14
  from ._network import Network
15
15
  from ._operation import OpCode, OperationsContext
16
16
  from ._unit import Group, Scene, Unit, UnitControlType, UnitState
@@ -169,8 +169,15 @@ class Casambi:
169
169
  self._casaClient = cast(CasambiClient, self._casaClient)
170
170
  await self._casaClient.connect()
171
171
  try:
172
- await self._casaClient.exchangeKey()
173
- await self._casaClient.authenticate()
172
+ if self._casaClient.protocolMode == ProtocolMode.EVO:
173
+ # EVO requires key exchange + authenticate.
174
+ await self._casaClient.exchangeKey()
175
+ await self._casaClient.authenticate()
176
+ elif self._casaClient.protocolMode == ProtocolMode.CLASSIC:
177
+ # Classic needs an init write to trigger state broadcasts.
178
+ # In EVO the key exchange/auth handshake implicitly signals the
179
+ # device; Classic has no such handshake so we send a time-sync.
180
+ await self._casaClient.classicSendInit()
174
181
  except ProtocolError as e:
175
182
  await self._casaClient.disconnect()
176
183
  raise e
@@ -201,6 +208,40 @@ class Casambi:
201
208
  raise ValueError()
202
209
 
203
210
  payload = level.to_bytes(1, byteorder="big", signed=False)
211
+
212
+ # Classic protocol uses signed command frames (u1.C1753e / u1.EnumC1754f).
213
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
214
+ # Check if we should use the alternative simple format from BLE captures
215
+ import os
216
+ use_simple = os.environ.get("CASAMBI_BT_CLASSIC_FORMAT", "").lower() == "simple"
217
+
218
+ if use_simple:
219
+ # Simple format: [counter][unit_id][param_len][dimmer]
220
+ # For "all units", use unit_id=0xFF
221
+ if isinstance(target, Unit):
222
+ cmd = self._casaClient.buildClassicCommandSimple(target.deviceId, level)
223
+ elif isinstance(target, Group):
224
+ # Groups in simple format: not fully confirmed, try group ID
225
+ cmd = self._casaClient.buildClassicCommandSimple(target.groudId, level)
226
+ elif target is None:
227
+ cmd = self._casaClient.buildClassicCommandSimple(0xFF, level)
228
+ else:
229
+ raise TypeError(f"Unkown target type {type(target)}")
230
+ else:
231
+ # EnumC1754f ordinals (ground truth: casambi-android u1.EnumC1754f):
232
+ # - AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
233
+ if isinstance(target, Unit):
234
+ cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
235
+ elif isinstance(target, Group):
236
+ cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
237
+ elif target is None:
238
+ cmd = self._casaClient.buildClassicCommand(4, payload)
239
+ else:
240
+ raise TypeError(f"Unkown target type {type(target)}")
241
+
242
+ await self._casaClient.send(cmd)
243
+ return
244
+
204
245
  await self._send(target, payload, OpCode.SetLevel)
205
246
 
206
247
  async def setVertical(self, target: Unit | Group | None, vertical: int) -> None:
@@ -219,6 +260,21 @@ class Casambi:
219
260
  raise ValueError()
220
261
 
221
262
  payload = vertical.to_bytes(1, byteorder="big", signed=False)
263
+
264
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
265
+ # EnumC1754f ordinals: AllUnitsVertical=22, UnitVertical=24, GroupVertical=29
266
+ if isinstance(target, Unit):
267
+ cmd = self._casaClient.buildClassicCommand(24, payload, target_id=target.deviceId)
268
+ elif isinstance(target, Group):
269
+ cmd = self._casaClient.buildClassicCommand(29, payload, target_id=target.groudId)
270
+ elif target is None:
271
+ cmd = self._casaClient.buildClassicCommand(22, payload)
272
+ else:
273
+ raise TypeError(f"Unkown target type {type(target)}")
274
+
275
+ await self._casaClient.send(cmd)
276
+ return
277
+
222
278
  await self._send(target, payload, OpCode.SetVertical)
223
279
 
224
280
  async def setSlider(self, target: Unit | Group | None, value: int) -> None:
@@ -255,6 +311,21 @@ class Casambi:
255
311
  raise ValueError()
256
312
 
257
313
  payload = level.to_bytes(1, byteorder="big", signed=False)
314
+
315
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
316
+ # EnumC1754f ordinals: AllUnitsWhite=23, UnitWhite=25, GroupWhite=30
317
+ if isinstance(target, Unit):
318
+ cmd = self._casaClient.buildClassicCommand(25, payload, target_id=target.deviceId)
319
+ elif isinstance(target, Group):
320
+ cmd = self._casaClient.buildClassicCommand(30, payload, target_id=target.groudId)
321
+ elif target is None:
322
+ cmd = self._casaClient.buildClassicCommand(23, payload)
323
+ else:
324
+ raise TypeError(f"Unkown target type {type(target)}")
325
+
326
+ await self._casaClient.send(cmd)
327
+ return
328
+
258
329
  await self._send(target, payload, OpCode.SetWhite)
259
330
 
260
331
  async def setColor(
@@ -272,6 +343,27 @@ class Casambi:
272
343
  :raises ValueError: The supplied rgbColor isn't in range
273
344
  """
274
345
 
346
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
347
+ # Classic uses RGB payload (3 bytes) directly.
348
+ r, g, b = rgbColor
349
+ if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255):
350
+ raise ValueError("rgbColor out of range.")
351
+ payload = bytes([r & 0xFF, g & 0xFF, b & 0xFF])
352
+
353
+ # EnumC1754f ordinals: AllUnitsColor=6, UnitColor=9, GroupColor=28
354
+ if isinstance(target, Unit):
355
+ cmd = self._casaClient.buildClassicCommand(9, payload, target_id=target.deviceId)
356
+ elif isinstance(target, Group):
357
+ cmd = self._casaClient.buildClassicCommand(28, payload, target_id=target.groudId)
358
+ elif target is None:
359
+ cmd = self._casaClient.buildClassicCommand(6, payload)
360
+ else:
361
+ raise TypeError(f"Unkown target type {type(target)}")
362
+
363
+ await self._casaClient.send(cmd)
364
+ return
365
+
366
+ # Evolution uses HS payload (hue 10-bit + sat 8-bit) for SetColor.
275
367
  state = UnitState()
276
368
  state.rgb = rgbColor
277
369
  hs: tuple[float, float] = state.hs # type: ignore[assignment]
@@ -300,6 +392,21 @@ class Casambi:
300
392
 
301
393
  temperature = int(temperature / 50)
302
394
  payload = temperature.to_bytes(1, byteorder="big", signed=False)
395
+
396
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
397
+ # EnumC1754f ordinals: AllUnitsTemperature=5, UnitTemperature=8, GroupTemperature=27
398
+ if isinstance(target, Unit):
399
+ cmd = self._casaClient.buildClassicCommand(8, payload, target_id=target.deviceId)
400
+ elif isinstance(target, Group):
401
+ cmd = self._casaClient.buildClassicCommand(27, payload, target_id=target.groudId)
402
+ elif target is None:
403
+ cmd = self._casaClient.buildClassicCommand(5, payload)
404
+ else:
405
+ raise TypeError(f"Unkown target type {type(target)}")
406
+
407
+ await self._casaClient.send(cmd)
408
+ return
409
+
303
410
  await self._send(target, payload, OpCode.SetTemperature)
304
411
 
305
412
  async def setColorXY(
@@ -317,6 +424,10 @@ class Casambi:
317
424
  :raises ValueError: The supplied XYColor isn't in range or not supported by the supplied unit.
318
425
  """
319
426
 
427
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
428
+ # Classic command set (u1.EnumC1754f) only exposes RGB color control.
429
+ raise ValueError("XY color control is not supported on Classic networks.")
430
+
320
431
  if xyColor[0] < 0.0 or xyColor[0] > 1.0 or xyColor[1] < 0.0 or xyColor[1] > 1.0:
321
432
  raise ValueError("Color out of range.")
322
433
 
@@ -345,6 +456,22 @@ class Casambi:
345
456
  :return: Nothing is returned by this function. To get the new state register a change handler.
346
457
  """
347
458
 
459
+ if self._casaClient is not None and self._casaClient.protocolMode == ProtocolMode.CLASSIC:
460
+ # Classic uses a longer payload for "restore last level" (ground truth: casambi-android u1.C1751c.o()).
461
+ payload = bytes([0xFF, 0x01, 0x00, 0x00, 0x01])
462
+ # EnumC1754f ordinals: AllUnitsLevel=4, UnitLevel=7, GroupLevel=26
463
+ if isinstance(target, Unit):
464
+ cmd = self._casaClient.buildClassicCommand(7, payload, target_id=target.deviceId)
465
+ elif isinstance(target, Group):
466
+ cmd = self._casaClient.buildClassicCommand(26, payload, target_id=target.groudId)
467
+ elif target is None:
468
+ cmd = self._casaClient.buildClassicCommand(4, payload)
469
+ else:
470
+ raise TypeError(f"Unkown target type {type(target)}")
471
+
472
+ await self._casaClient.send(cmd)
473
+ return
474
+
348
475
  # Use -1 to indicate special packet format
349
476
  # Use RestoreLastLevel flag (1) and UseFullTimeFlag (4).
350
477
  # Not sure what UseFullTime does but this is what the app uses.
@@ -367,6 +494,11 @@ class Casambi:
367
494
  ConnectionState.AUTHENTICATED,
368
495
  ConnectionState.NONE,
369
496
  )
497
+ if self._casaClient.protocolMode == ProtocolMode.CLASSIC:
498
+ # Classic uses a completely different command encoding (u1.C1753e/u1.EnumC1754f).
499
+ # Public APIs that support Classic handle it explicitly; anything reaching here would
500
+ # send an EVO INVOCATION packet which is not valid on Classic.
501
+ raise ProtocolError(f"Operation {opcode.name} is not supported on Classic networks via INVOCATION.")
370
502
 
371
503
  targetCode = 0
372
504
  if isinstance(target, Unit):
@@ -400,7 +532,7 @@ class Casambi:
400
532
  def _dataCallback(
401
533
  self, packetType: IncommingPacketType, data: dict[str, Any]
402
534
  ) -> None:
403
- self._logger.info(f"Incomming data callback of type {packetType}")
535
+ self._logger.debug("Incomming data callback of type %s", packetType)
404
536
  if packetType == IncommingPacketType.UnitState:
405
537
  self._logger.debug(
406
538
  f"Handling changed state {b2a(data['state'])} for unit {data['id']}"
@@ -473,13 +605,17 @@ class Casambi:
473
605
  """Register a new handler for switch events.
474
606
 
475
607
  This handler is called whenever a switch event is received.
476
- The handler is supplied with a dictionary containing:
477
- - unit_id: The ID of the switch unit
478
- - button: The button number that was pressed/released
479
- - event: Either "button_press" or "button_release"
480
- - message_type: The raw message type (0x08 or 0x10)
481
- - flags: Additional flags from the message
482
- - extra_data: Any additional data from the message
608
+ The handler is supplied with a dictionary containing (at minimum):
609
+ - unit_id: target unit id (from INVOCATION target high byte)
610
+ - button: best-effort "label" (typically 1..4 for 4-gang switches)
611
+ - event: "button_press" | "button_release" | "input_event"
612
+
613
+ Switch events are parsed from decrypted packet type=7 (INVOCATION stream),
614
+ matching casambi-android `v1.C1775b.Q(Q2.h)`. Extra diagnostic keys include:
615
+ - invocation_flags, opcode, origin, target, target_type, age, origin_handle
616
+ - button_event_index (0..7), param_p, param_s
617
+ - input_index (0..7), input_code, input_b1, input_channel, input_value16, input_mapped_event
618
+ - packet_sequence, arrival_sequence, raw_packet, decrypted_data, payload_hex, frame_offset, event_id
483
619
 
484
620
  :param handler: The method to call when a switch event is received.
485
621
  """
@@ -551,6 +687,270 @@ class Casambi:
551
687
  exc_info=True,
552
688
  )
553
689
 
690
+ async def setParameter(self, unitId: int, parameterTag: int, parameterData: bytes) -> None:
691
+ """Send a SetParameter command to a unit.
692
+
693
+ Args:
694
+ unitId: The ID of the unit to send the command to
695
+ parameterTag: The parameter tag/ID to update
696
+ parameterData: The raw parameter data to send
697
+ """
698
+ if not self._casaClient:
699
+ raise RuntimeError("Not connected to network")
700
+
701
+ # Build payload: [parameter_tag][parameter_data]
702
+ # Note: SetParameter supports max 31 bytes after the tag.
703
+ payload = bytes([parameterTag]) + parameterData[:31]
704
+
705
+ # Send using OpCode.SetParameter (26)
706
+ opPkt = self._opContext.prepareOperation(OpCode.SetParameter, (unitId << 8) | 0x01, payload)
707
+ await self._casaClient.send(opPkt)
708
+
709
+ async def send_ext_packet(self, unit_id: int, seq: int, chunk: bytes, *, lifetime: int = 9) -> None:
710
+ """Send an extended packet (ExtPacketSend / opcode 43) to a unit.
711
+
712
+ Payload is framed as: [0x29][seq][chunk...]
713
+
714
+ Notes:
715
+ - Total payload must be <= 63 bytes, so len(chunk) <= 61.
716
+ - Lifetime defaults to 9 to mirror longer-running multi-chunk ops seen on Android.
717
+ """
718
+ if not self._casaClient:
719
+ raise RuntimeError("Not connected to network")
720
+
721
+ if seq < 0 or seq > 255:
722
+ raise ValueError("seq must be 0..255")
723
+ if len(chunk) > 61:
724
+ raise ValueError("chunk too large; max 61 bytes to fit ExtPacket payload")
725
+
726
+ payload = bytes([0x29, seq & 0xFF]) + chunk
727
+ target = (int(unit_id) << 8) | 0x01
728
+ opPkt = self._opContext.prepareOperation(
729
+ OpCode.ExtPacketSend,
730
+ target,
731
+ payload,
732
+ lifetime=lifetime,
733
+ )
734
+ await self._casaClient.send(opPkt)
735
+
736
+ async def start_switch_session(self, unit_id: int, *, payload: bytes = b"", lifetime: int = 9) -> None:
737
+ """Experimental: send AcquireSwitchSession (opcode 42).
738
+
739
+ Payload is device-specific and currently unknown for switchConfig; by default
740
+ sends an empty payload. Use for experimentation only until framing is confirmed.
741
+ """
742
+ if not self._casaClient:
743
+ raise RuntimeError("Not connected to network")
744
+ if len(payload) > 63:
745
+ raise ValueError("payload too large; max 63 bytes")
746
+ target = (int(unit_id) << 8) | 0x01
747
+ opPkt = self._opContext.prepareOperation(
748
+ OpCode.AcquireSwitchSession,
749
+ target,
750
+ payload,
751
+ lifetime=lifetime,
752
+ )
753
+ await self._casaClient.send(opPkt)
754
+
755
+ async def apply_switch_config_ble(self, unit_id: int, *, parameter_tag: int | None = None) -> None:
756
+ """Attempt to push the unit's switchConfig to the device over BLE.
757
+
758
+ This method currently supports only very small configurations that fit
759
+ into a single SetParameter payload (max 31 bytes after the tag).
760
+
761
+ Args:
762
+ unit_id: The ID of the unit to update.
763
+ parameter_tag: Optional override for the parameter tag; if not provided,
764
+ a default of 1 is used (subject to device variation).
765
+
766
+ Raises:
767
+ RuntimeError: If not connected or no raw network data.
768
+ ValueError: If the encoded switchConfig is too large for SetParameter.
769
+ """
770
+ if not self._casaClient:
771
+ raise RuntimeError("Not connected to network")
772
+ if not self._casaNetwork or not self._casaNetwork.rawNetworkData:
773
+ raise RuntimeError("No raw network data available; connect and update first")
774
+
775
+ units = self._casaNetwork.rawNetworkData.get("network", {}).get("units", [])
776
+ unit_data = next((u for u in units if u.get("deviceID") == unit_id), None)
777
+ if not unit_data:
778
+ raise ValueError(f"Unit {unit_id} not found in raw network data")
779
+
780
+ switch_config = unit_data.get("switchConfig") or {}
781
+
782
+ import json
783
+ config_bytes = json.dumps(switch_config, separators=(",", ":")).encode("utf-8")
784
+
785
+ # Protocol restriction: SetParameter accepts max 31 bytes after tag.
786
+ if len(config_bytes) > 31:
787
+ raise ValueError(
788
+ "switchConfig exceeds 31 bytes; multi-packet BLE apply is required. "
789
+ "This library will need an extended writer (AcquireSwitchSession/ExtPacketSend) to support large configs."
790
+ )
791
+
792
+ tag = 1 if parameter_tag is None else int(parameter_tag)
793
+ await self.setParameter(unit_id, tag, config_bytes)
794
+ self._logger.info(
795
+ "Applied switchConfig via SetParameter (tag=%s, bytes=%s) for unit %s",
796
+ tag,
797
+ len(config_bytes),
798
+ unit_id,
799
+ )
800
+
801
+ async def apply_switch_config_ble_large(self, unit_id: int) -> None:
802
+ """Experimental: attempt to push switchConfig using ExtPacketSend in chunks.
803
+
804
+ WARNING: Framing is not fully confirmed for switchConfig updates. This method
805
+ sends raw JSON bytes chunked as consecutive ExtPacket frames with header
806
+ [0x29][seq]. Use only for experimentation and logs; correctness is not guaranteed.
807
+ """
808
+ if not self._casaClient:
809
+ raise RuntimeError("Not connected to network")
810
+ if not self._casaNetwork or not self._casaNetwork.rawNetworkData:
811
+ raise RuntimeError("No raw network data available; connect and update first")
812
+
813
+ units = self._casaNetwork.rawNetworkData.get("network", {}).get("units", [])
814
+ unit_data = next((u for u in units if u.get("deviceID") == unit_id), None)
815
+ if not unit_data:
816
+ raise ValueError(f"Unit {unit_id} not found in raw network data")
817
+
818
+ switch_config = unit_data.get("switchConfig") or {}
819
+ import json
820
+ data = json.dumps(switch_config, separators=(",", ":")).encode("utf-8")
821
+
822
+ # Attempt to start a switch session before sending chunks. Some devices
823
+ # expect an AcquireSwitchSession (opcode 42) handshake prior to ExtPacketSend.
824
+ try:
825
+ await self.start_switch_session(unit_id)
826
+ self._logger.info("Started switch session (AcquireSwitchSession) for unit %s", unit_id)
827
+ except Exception as e:
828
+ # Non-fatal: proceed without session if device doesn't require it.
829
+ self._logger.info(
830
+ "AcquireSwitchSession not acknowledged or unsupported for unit %s (%s); proceeding with ExtPacketSend only.",
831
+ unit_id,
832
+ e,
833
+ )
834
+
835
+ # Chunk into 61-byte pieces (ExtPacket payload allows 63 total minus 2-byte header)
836
+ max_chunk = 61
837
+ seq = 0
838
+ for off in range(0, len(data), max_chunk):
839
+ chunk = data[off : off + max_chunk]
840
+ await self.send_ext_packet(unit_id, seq, chunk, lifetime=9)
841
+ self._logger.debug(
842
+ "ExtPacket chunk sent: unit=%s seq=%s size=%s/%s", unit_id, seq, len(chunk), len(data)
843
+ )
844
+ seq = (seq + 1) & 0xFF
845
+ self._logger.info(
846
+ "ExtPacket switchConfig attempt complete (unit=%s, total_bytes=%s, chunks=%s)",
847
+ unit_id,
848
+ len(data),
849
+ (len(data) + max_chunk - 1) // max_chunk,
850
+ )
851
+
852
+ async def update_button_config(
853
+ self,
854
+ unit_id: int,
855
+ button_index: int,
856
+ action_type: str,
857
+ target_id: int | None = None,
858
+ *,
859
+ exclusive_scenes: bool | None = None,
860
+ long_press_all_off: bool | None = None,
861
+ toggle_disabled: bool | None = None,
862
+ ) -> dict:
863
+ """Update one button entry in a unit's switchConfig in the cached network data.
864
+
865
+ Android mapping for actions (EnumC0105q):
866
+ - none -> 0, target 0
867
+ - scene -> 1, target=sceneID
868
+ - control_unit -> 2, target=deviceID
869
+ - control_group -> 3, target=groupID
870
+ - all_units -> 4, target=255
871
+ - resume_automation -> 6, target=0
872
+ - resume_automation_group -> 7, target=groupID
873
+
874
+ Returns the updated switchConfig dict.
875
+ """
876
+ if button_index < 0 or button_index > 7:
877
+ raise ValueError("button_index must be in range 0..7")
878
+
879
+ raw = self.rawNetworkData
880
+ if not raw:
881
+ raise RuntimeError("No raw network data loaded. Connect and update first.")
882
+
883
+ # Locate the unit's JSON entry
884
+ units = raw.get("network", {}).get("units", [])
885
+ unit_data = None
886
+ for u in units:
887
+ if u.get("deviceID") == unit_id:
888
+ unit_data = u
889
+ break
890
+ if not unit_data:
891
+ raise ValueError(f"Unit {unit_id} not found in raw network data")
892
+
893
+ switch_config = unit_data.get("switchConfig") or {}
894
+ buttons: list[dict] = switch_config.get("buttons") or []
895
+
896
+ action_map = {
897
+ "none": 0,
898
+ "scene": 1,
899
+ "control_unit": 2,
900
+ "control_group": 3,
901
+ "all_units": 4,
902
+ "resume_automation": 6,
903
+ "resume_automation_group": 7,
904
+ }
905
+ if action_type not in action_map:
906
+ raise ValueError(
907
+ "Unsupported action_type. Use one of: none, scene, control_unit, "
908
+ "control_group, all_units, resume_automation, resume_automation_group"
909
+ )
910
+ action_code = action_map[action_type]
911
+
912
+ if action_type == "all_units":
913
+ resolved_target = 255
914
+ elif action_type in ("none", "resume_automation"):
915
+ resolved_target = 0
916
+ else:
917
+ if target_id is None:
918
+ raise ValueError(f"target_id is required for action_type '{action_type}'")
919
+ resolved_target = int(target_id)
920
+
921
+ # Find or create entry for this button index
922
+ existing = None
923
+ for b in buttons:
924
+ if b.get("type") == button_index:
925
+ existing = b
926
+ break
927
+ entry = existing or {"type": button_index, "action": 0, "target": 0}
928
+ entry["action"] = action_code
929
+ entry["target"] = resolved_target
930
+ if not existing:
931
+ buttons.append(entry)
932
+
933
+ # Persist and apply optional flags
934
+ switch_config["buttons"] = buttons
935
+ if exclusive_scenes is not None:
936
+ switch_config["exclusiveScenes"] = bool(exclusive_scenes)
937
+ if long_press_all_off is not None:
938
+ switch_config["longPressAllOff"] = bool(long_press_all_off)
939
+ if toggle_disabled is not None:
940
+ switch_config["toggleDisabled"] = bool(toggle_disabled)
941
+
942
+ unit_data["switchConfig"] = switch_config
943
+
944
+ self._logger.info(
945
+ "Updated switchConfig (unit=%s, button=%s, action=%s, target=%s)",
946
+ unit_id,
947
+ button_index,
948
+ action_type,
949
+ resolved_target,
950
+ )
951
+
952
+ return switch_config
953
+
554
954
  async def disconnect(self) -> None:
555
955
  """Disconnect from the network."""
556
956
  if self._casaClient: