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.
- {python_swidget-1.4.18 → python_swidget-1.4.21}/PKG-INFO +2 -2
- {python_swidget-1.4.18 → python_swidget-1.4.21}/pyproject.toml +5 -2
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/cli.py +1 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/discovery.py +2 -1
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/swidgetdevice.py +81 -11
- {python_swidget-1.4.18 → python_swidget-1.4.21}/README.md +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/__init__.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/exceptions.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/provision.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/py.typed +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/swidgetdimmer.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/swidgetfan.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/swidgetoutlet.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/swidgetswitch.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/swidgettimerswitch.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.21}/swidget/websocket.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-swidget
|
|
3
|
-
Version: 1.4.
|
|
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
|
|
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.
|
|
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 = "^
|
|
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
|
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
1016
|
-
|
|
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", []))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|