python-swidget 1.4.18__tar.gz → 1.4.21__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.21
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.21"
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."""
@@ -452,7 +502,11 @@ class SwidgetDevice:
452
502
  # and flicker the UI.
453
503
  for assembly_key, new_assembly in new_assemblies.items():
454
504
  old_assembly = self.assemblies.get(assembly_key)
455
- if old_assembly is None:
505
+ if (
506
+ old_assembly is None
507
+ or old_assembly.type != new_assembly.type
508
+ or old_assembly.id != new_assembly.id
509
+ ):
456
510
  continue
457
511
  for component_id, new_component in new_assembly.components.items():
458
512
  old_component = old_assembly.components.get(component_id)
@@ -463,6 +517,17 @@ class SwidgetDevice:
463
517
  new_component.functions[fn_name] = old_component.functions[
464
518
  fn_name
465
519
  ]
520
+ # Fan module readings live in state["modules"], but
521
+ # "modules" is not a summary function tag. Preserve
522
+ # readings only for modules the new summary still lists;
523
+ # removed modules must not retain stale sensor state.
524
+ module_state = old_component.functions.get("modules")
525
+ if new_component.modules and isinstance(module_state, dict):
526
+ new_component.functions["modules"] = {
527
+ name: module_state[name]
528
+ for name in new_component.modules
529
+ if name in module_state
530
+ }
466
531
  self.assemblies = new_assemblies
467
532
  self.device_type = DeviceType(self.assemblies["host"].type)
468
533
  self.insert_type = InsertType(self.assemblies["insert"].type)
@@ -561,9 +626,14 @@ class SwidgetDevice:
561
626
  )
562
627
 
563
628
  async def send_command(
564
- self, assembly: str, component: str, function: str, command: dict
629
+ self, assembly: str, component: str, function: str, command: Union[dict, str]
565
630
  ) -> None:
566
- """Send a command to the Swidget device either using a HTTP call or the existing websocket."""
631
+ """Send a command to the Swidget device either using a HTTP call or the existing websocket.
632
+
633
+ ``command`` is placed verbatim under the function key. Most
634
+ functions take an object, but a few (the Pesna fan ``mode`` and
635
+ ``speed``) expect a bare string value.
636
+ """
567
637
  _LOGGER.debug("SwidgetDevice.send_command() called")
568
638
  data = {assembly: {"components": {component: {function: command}}}}
569
639
  _LOGGER.debug(f"Command to send: {data}")
@@ -1009,11 +1079,11 @@ class SwidgetComponent:
1009
1079
  starts as a same-keyed dict of placeholder ``None`` values and is
1010
1080
  later mutated by ``process_state`` to carry live datapoint values.
1011
1081
 
1012
- Process_state also leaks in keys that aren't in the summary
1082
+ Process_state also adds keys that aren't in the summary
1013
1083
  functions list (e.g. fans emit a ``modules`` map in state that
1014
1084
  isn't a declared function tag), so ``functions.keys()`` is *not*
1015
- schema-stable across summary refreshes. Anything that needs a
1016
- stable schema fingerprint (entity wiring, structure-change
1085
+ the declared schema. Anything that needs a stable schema
1086
+ fingerprint (entity wiring, structure-change
1017
1087
  detection) must read ``summary_functions``, not ``functions``.
1018
1088
 
1019
1089
  ``max_cfm``, ``model_code`` and ``modules`` come from the optional
@@ -1024,7 +1094,7 @@ class SwidgetComponent:
1024
1094
  def __init__(self, component):
1025
1095
  funcs = list(component.get("functions", []))
1026
1096
  self.summary_functions: tuple[str, ...] = tuple(funcs)
1027
- self.functions = {f: None for f in funcs}
1097
+ self.functions: dict[str, Any] = {f: None for f in funcs}
1028
1098
  self.max_cfm = component.get("maxCFM")
1029
1099
  self.model_code = component.get("code")
1030
1100
  self.modules = list(component.get("modules", []))