python-swidget 1.4.13__tar.gz → 1.4.15__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-swidget
3
- Version: 1.4.13
3
+ Version: 1.4.15
4
4
  Summary: Python API for Swidget smart devices
5
5
  Home-page: https://github.com/swidget/python-swidget
6
6
  License: GPL-3.0-or-later
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-swidget"
3
- version = "1.4.13"
3
+ version = "1.4.15"
4
4
  description = "Python API for Swidget smart devices"
5
5
  license = "GPL-3.0-or-later"
6
6
  authors = ["Swidget"]
@@ -18,6 +18,36 @@ from .swidgettimerswitch import SwidgetTimerSwitch
18
18
 
19
19
  RESPONSE_SEC = 5
20
20
  SWIDGET_STS = ("urn:swidget:pico:1", "urn:swidget:video:1")
21
+
22
+ # Per-ST raw device-id length (in hex chars) before any UUID padding. The
23
+ # firmware embeds the device id in the SSDP USN differently per family:
24
+ # pico uses a fixed UUID prefix + the 12-char id as the last segment;
25
+ # video pads the 24-char id with zeros to fit a 32-char UUID. Both can
26
+ # be recovered as long as we know the original length per ST.
27
+ _USN_DEVICE_ID_LENGTH = {
28
+ "urn:swidget:pico:1": 12,
29
+ "urn:swidget:video:1": 24,
30
+ }
31
+
32
+
33
+ def device_id_from_ssdp(usn: str, st: str) -> Optional[str]:
34
+ """Extract the canonical device id from an SSDP USN/ST pair.
35
+
36
+ Returns None when the inputs aren't recognized — callers should treat
37
+ that as "skip this discovery" rather than synthesizing an id.
38
+ """
39
+ if not usn or not usn.startswith("uuid:") or st not in _USN_DEVICE_ID_LENGTH:
40
+ return None
41
+ uuid_part = usn[len("uuid:") :]
42
+ expected_len = _USN_DEVICE_ID_LENGTH[st]
43
+ if st == "urn:swidget:pico:1":
44
+ # Last hyphenated segment carries the real 12-char MAC.
45
+ last = uuid_part.rsplit("-", 1)[-1]
46
+ return last if len(last) == expected_len else None
47
+ # Video (and any future variant that pads): dehyphenate, take the
48
+ # leading expected_len chars, ignore the trailing zero padding.
49
+ flat = uuid_part.replace("-", "")
50
+ return flat[:expected_len] if len(flat) >= expected_len else None
21
51
  # Generous timeout: TLS handshakes on the device's MCU can take several
22
52
  # seconds on first connection.
23
53
  DETECT_TIMEOUT_SEC = 10
@@ -89,22 +119,27 @@ class SwidgetProtocol(ssdp.SimpleServiceDiscoveryProtocol):
89
119
  def response_received(self, response: ssdp.SSDPResponse, addr: tuple):
90
120
  """Handle an incoming response."""
91
121
  headers = {h[0]: h[1] for h in response.headers}
92
- mac_address = headers["USN"].split("-")[-1]
122
+ st = headers.get("ST", "")
123
+ if st not in SWIDGET_STS:
124
+ return
125
+ device_id = device_id_from_ssdp(headers.get("USN", ""), st)
126
+ if not device_id:
127
+ _LOGGER.debug("Skipping SSDP response with unparseable USN: %s", headers)
128
+ return
93
129
  ip_address = urlparse(headers["LOCATION"]).hostname
94
- if headers["ST"] in SWIDGET_STS:
95
- device_type = headers["SERVER"].split(" ")[1].split("+")[0]
96
- insert_type = headers["SERVER"].split(" ")[1].split("+")[1].split("/")[0]
97
- friendly_name = headers["SERVER"].split("/")[2].strip('"')
98
- devices[mac_address] = SwidgetDiscoveredDevice(
99
- mac=mac_address,
100
- host=ip_address,
101
- friendly_name=friendly_name,
102
- host_type=device_type,
103
- insert_type=insert_type,
104
- )
105
- _LOGGER.debug(
106
- f"Discovered Swidget device via SSDP: '{friendly_name}' at {ip_address} Type:{device_type}/{insert_type}"
107
- )
130
+ device_type = headers["SERVER"].split(" ")[1].split("+")[0]
131
+ insert_type = headers["SERVER"].split(" ")[1].split("+")[1].split("/")[0]
132
+ friendly_name = headers["SERVER"].split("/")[2].strip('"')
133
+ devices[device_id] = SwidgetDiscoveredDevice(
134
+ mac=device_id,
135
+ host=ip_address,
136
+ friendly_name=friendly_name,
137
+ host_type=device_type,
138
+ insert_type=insert_type,
139
+ )
140
+ _LOGGER.debug(
141
+ f"Discovered Swidget device via SSDP: '{friendly_name}' at {ip_address} Type:{device_type}/{insert_type}"
142
+ )
108
143
 
109
144
 
110
145
  async def discover_devices(timeout=RESPONSE_SEC):
@@ -249,6 +249,9 @@ class SwidgetDevice:
249
249
  ):
250
250
  _LOGGER.debug("Calling SwidgetDevice.process_state()")
251
251
  await self.process_state(message)
252
+ elif message["request_id"] == "device_config":
253
+ _LOGGER.debug("Calling SwidgetDevice.process_device_config()")
254
+ await self.process_device_config(message)
252
255
  else:
253
256
  message_type = ["request_id"]
254
257
  _LOGGER.error(
@@ -374,11 +377,33 @@ class SwidgetDevice:
374
377
  raise SwidgetConnectionException from e
375
378
 
376
379
  async def get_device_config(self) -> Any:
377
- """Get the config of the device."""
380
+ """Refresh the local device_config cache.
381
+
382
+ Uses the websocket when available — the response lands on
383
+ ``message_callback`` with ``request_id == "device_config"`` and
384
+ ``process_device_config`` updates the cache. Falls back to HTTP
385
+ before the socket is connected (e.g. during entry pre-load).
386
+ """
378
387
  _LOGGER.debug("SwidgetDevice.get_device_config() called")
379
- _LOGGER.debug("Sending get_summary() command over http")
388
+ if self.use_websockets and self.connected:
389
+ _LOGGER.debug("In websocket mode. Sending get_device_config over websocket")
390
+ await self._websocket.send_str(
391
+ json.dumps(
392
+ {"type": "get_device_config", "request_id": "device_config"}
393
+ )
394
+ )
395
+ return
396
+ _LOGGER.debug("In http mode. Sending get_device_config over http")
380
397
  config = await self.make_http_request("GET", "device_config")
381
- self.device_config = DeviceConfiguration(config)
398
+ await self.process_device_config(config)
399
+
400
+ async def process_device_config(self, config) -> None:
401
+ """Process a device_config payload from HTTP body or websocket message."""
402
+ _LOGGER.debug("SwidgetDevice.process_device_config() called")
403
+ # Strip transport metadata so DeviceConfiguration sees the same
404
+ # shape regardless of whether the payload arrived via HTTP or WS.
405
+ cfg = {k: v for k, v in config.items() if k != "request_id"}
406
+ self.device_config = DeviceConfiguration(cfg)
382
407
  self._last_update = int(time.time())
383
408
 
384
409
  async def set_device_config(self, updates: Dict[str, Any]) -> None:
@@ -499,7 +524,12 @@ class SwidgetDevice:
499
524
  self._last_update = int(time.time())
500
525
 
501
526
  async def update(self) -> None:
502
- """Update the state, summary, config and name of the device."""
527
+ """Refresh state and summary; device_config is fetched separately.
528
+
529
+ device_config rarely changes, and a successful set_device_config
530
+ already pushes a fresh copy into the local cache, so we don't
531
+ re-fetch it on every coordinator poll.
532
+ """
503
533
  _LOGGER.debug("SwidgetDevice.update() called")
504
534
  if self._last_update == 0:
505
535
  _LOGGER.debug("Performing the initial update to obtain sysinfo")
@@ -507,8 +537,6 @@ class SwidgetDevice:
507
537
  await self.get_state()
508
538
  if self._friendly_name == "Unknown Swidget Device":
509
539
  await self.get_friendly_name()
510
- if not self.device_config.config_populated():
511
- await self.get_device_config()
512
540
  elif (int(time.time()) - self._last_update) < 5:
513
541
  _LOGGER.debug("update() recently called, not executing")
514
542
  else: