python-swidget 1.4.18__tar.gz → 1.4.20__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.18
3
+ Version: 1.4.20
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
@@ -23,7 +23,7 @@ Requires-Dist: mistune (<2.0.0) ; extra == "docs"
23
23
  Requires-Dist: pydantic (>=2,<3)
24
24
  Requires-Dist: requests (>=2.32,<3.0)
25
25
  Requires-Dist: sphinx (>=4,<5) ; extra == "docs"
26
- Requires-Dist: sphinx_rtd_theme (>=0,<1) ; extra == "docs"
26
+ Requires-Dist: sphinx_rtd_theme (>=1.2,<2.0) ; extra == "docs"
27
27
  Requires-Dist: sphinxcontrib-programoutput (>=0,<1) ; extra == "docs"
28
28
  Requires-Dist: ssdp (==1.1.1)
29
29
  Requires-Dist: types-requests (>=2.32.0,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-swidget"
3
- version = "1.4.18"
3
+ version = "1.4.20"
4
4
  description = "Python API for Swidget smart devices"
5
5
  license = "GPL-3.0-or-later"
6
6
  authors = ["Swidget"]
@@ -30,7 +30,7 @@ urllib3 = "^2.0"
30
30
  sphinx = { version = "^4", optional = true }
31
31
  m2r = { version = "^0", optional = true }
32
32
  mistune = { version = "<2.0.0", optional = true }
33
- sphinx_rtd_theme = { version = "^0", optional = true }
33
+ sphinx_rtd_theme = { version = "^1.2", optional = true }
34
34
  sphinxcontrib-programoutput = { version = "^0", optional = true }
35
35
 
36
36
  [tool.poetry.dev-dependencies]
@@ -50,6 +50,9 @@ coverage = {version = "^6", extras = ["toml"]}
50
50
  sphinx = "^4"
51
51
  sphinx-autobuild = "^2021.3.14"
52
52
  sphinx-rtd-theme = "^1.2"
53
+ # docutils 0.21.post1 is listed on PyPI with no downloadable files, which
54
+ # breaks poetry's resolver; sphinx 4 needs <0.18 anyway.
55
+ docutils = "<0.21"
53
56
  mypy = "^1.11"
54
57
  black = "^24.4"
55
58
 
@@ -27,6 +27,7 @@ TYPE_TO_CLASS = {
27
27
  "pana_switch": SwidgetTimerSwitch,
28
28
  "pesna_fv05": SwidgetFan,
29
29
  "pesna_fv15": SwidgetFan,
30
+ "pesna_fv15_plus": SwidgetFan,
30
31
  "pesna_fv20": SwidgetFan,
31
32
  "pesna_IB150": SwidgetFan,
32
33
  "pesna_IB160": SwidgetFan,
@@ -2,7 +2,7 @@
2
2
  import asyncio
3
3
  import logging
4
4
  import socket
5
- from typing import Any, Type
5
+ from typing import Any, Optional, Type
6
6
  from urllib.parse import urlparse
7
7
 
8
8
  import ssdp
@@ -210,6 +210,7 @@ _PESNA_DEVICE_TYPES = frozenset(
210
210
  {
211
211
  DeviceType.PesnaFV05,
212
212
  DeviceType.PesnaFV15,
213
+ DeviceType.PesnaFV15Plus,
213
214
  DeviceType.PesnaFV20,
214
215
  DeviceType.PesnaIB150,
215
216
  DeviceType.PesnaIB160,
@@ -6,7 +6,7 @@ import time
6
6
  from collections.abc import Callable
7
7
  from enum import Enum
8
8
  from types import TracebackType
9
- from typing import Any, Dict, List, Optional
9
+ from typing import Any, Dict, List, Optional, Union
10
10
 
11
11
  from aiohttp import ClientSession, TCPConnector
12
12
  from aiohttp.client_exceptions import ClientConnectorError
@@ -39,6 +39,7 @@ class DeviceType(Enum):
39
39
  RelaySwitch = "relay_switch"
40
40
  PesnaFV05 = "pesna_fv05"
41
41
  PesnaFV15 = "pesna_fv15"
42
+ PesnaFV15Plus = "pesna_fv15_plus"
42
43
  PesnaFV20 = "pesna_fv20"
43
44
  PesnaIB150 = "pesna_IB150"
44
45
  PesnaIB160 = "pesna_IB160"
@@ -124,6 +125,24 @@ class SelfDiagnosticErrorCodes(Enum):
124
125
  PART_MATTER = 14
125
126
 
126
127
 
128
+ def _deep_merge_dicts(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
129
+ """Return a new dict with ``updates`` deep-merged onto ``base``.
130
+
131
+ Used by ``process_device_config`` so that partial-update websocket
132
+ pushes don't wipe untouched top-level keys out of the cache. Lists
133
+ and scalars are replaced (not concatenated) — this is a config tree,
134
+ not an event log.
135
+ """
136
+ result: Dict[str, Any] = dict(base)
137
+ for key, value in updates.items():
138
+ existing = result.get(key)
139
+ if isinstance(value, dict) and isinstance(existing, dict):
140
+ result[key] = _deep_merge_dicts(existing, value)
141
+ else:
142
+ result[key] = value
143
+ return result
144
+
145
+
127
146
  class SwidgetDevice:
128
147
  """Core representation of a Swidget device (base class for all device types)."""
129
148
 
@@ -398,12 +417,31 @@ class SwidgetDevice:
398
417
  await self.process_device_config(config)
399
418
 
400
419
  async def process_device_config(self, config) -> None:
401
- """Process a device_config payload from HTTP body or websocket message."""
420
+ """Process a device_config payload from HTTP body or websocket message.
421
+
422
+ Both transports use the same callback path, but the firmware
423
+ pushes a *partial* websocket message after every config write
424
+ (only the changed leaves) using the same request_id as the full
425
+ GET response. Replacing the cache wholesale would let those
426
+ partial pushes silently wipe every other top-level key, which
427
+ in turn breaks every other config-driven entity in the consumer.
428
+
429
+ Deep-merging the incoming dict into the existing cache gives the
430
+ right behaviour for both shapes: a full GET overwrites every
431
+ leaf (functionally a replace), and a partial push updates only
432
+ what it touches.
433
+ """
402
434
  _LOGGER.debug("SwidgetDevice.process_device_config() called")
403
435
  # Strip transport metadata so DeviceConfiguration sees the same
404
436
  # shape regardless of whether the payload arrived via HTTP or WS.
405
437
  cfg = {k: v for k, v in config.items() if k != "request_id"}
406
- self.device_config = DeviceConfiguration(cfg)
438
+ existing = (
439
+ self.device_config.config
440
+ if self.device_config is not None and self.device_config.config_populated()
441
+ else {}
442
+ )
443
+ merged = _deep_merge_dicts(existing, cfg)
444
+ self.device_config = DeviceConfiguration(merged)
407
445
  self._last_update = int(time.time())
408
446
 
409
447
  async def set_device_config(self, updates: Dict[str, Any]) -> None:
@@ -413,10 +451,22 @@ class SwidgetDevice:
413
451
  (only the changed leaves), so callers should pass the same nested
414
452
  shape ``get_device_config()`` returns. After the POST succeeds the
415
453
  local cache is refreshed so reads see the new value immediately.
454
+
455
+ We refresh via HTTP rather than ``get_device_config()`` because
456
+ the websocket variant is fire-and-forget — it sends a request and
457
+ returns before the response is delivered to ``message_callback``.
458
+ Worse, the firmware also pushes a websocket message of the
459
+ *changed leaves only* on every config write, and
460
+ ``process_device_config`` replaces the cache wholesale rather
461
+ than merging — so a partial push arriving after a full GET would
462
+ silently wipe everything else out of the cache. The synchronous
463
+ HTTP read here guarantees the cache is the full config when we
464
+ return.
416
465
  """
417
466
  _LOGGER.debug("SwidgetDevice.set_device_config(%s) called", updates)
418
467
  await self.make_http_request("POST", "device_config", json_payload=updates)
419
- await self.get_device_config()
468
+ config = await self.make_http_request("GET", "device_config")
469
+ await self.process_device_config(config)
420
470
 
421
471
  async def get_summary(self) -> None:
422
472
  """Get a summary of the device over HTTP."""
@@ -561,9 +611,14 @@ class SwidgetDevice:
561
611
  )
562
612
 
563
613
  async def send_command(
564
- self, assembly: str, component: str, function: str, command: dict
614
+ self, assembly: str, component: str, function: str, command: Union[dict, str]
565
615
  ) -> None:
566
- """Send a command to the Swidget device either using a HTTP call or the existing websocket."""
616
+ """Send a command to the Swidget device either using a HTTP call or the existing websocket.
617
+
618
+ ``command`` is placed verbatim under the function key. Most
619
+ functions take an object, but a few (the Pesna fan ``mode`` and
620
+ ``speed``) expect a bare string value.
621
+ """
567
622
  _LOGGER.debug("SwidgetDevice.send_command() called")
568
623
  data = {assembly: {"components": {component: {function: command}}}}
569
624
  _LOGGER.debug(f"Command to send: {data}")