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.
- {python_swidget-1.4.18 → python_swidget-1.4.20}/PKG-INFO +2 -2
- {python_swidget-1.4.18 → python_swidget-1.4.20}/pyproject.toml +5 -2
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/cli.py +1 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/discovery.py +2 -1
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/swidgetdevice.py +61 -6
- {python_swidget-1.4.18 → python_swidget-1.4.20}/README.md +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/__init__.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/exceptions.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/provision.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/py.typed +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/swidgetdimmer.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/swidgetfan.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/swidgetoutlet.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/swidgetswitch.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/swidget/swidgettimerswitch.py +0 -0
- {python_swidget-1.4.18 → python_swidget-1.4.20}/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.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
|
|
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.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 = "^
|
|
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."""
|
|
@@ -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}")
|
|
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
|