python-swidget 1.4.17__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.17 → python_swidget-1.4.20}/PKG-INFO +2 -2
- {python_swidget-1.4.17 → python_swidget-1.4.20}/pyproject.toml +5 -2
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/cli.py +1 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/discovery.py +19 -7
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/swidgetdevice.py +61 -6
- {python_swidget-1.4.17 → python_swidget-1.4.20}/README.md +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/__init__.py +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/exceptions.py +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/provision.py +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/py.typed +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/swidgetdimmer.py +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/swidgetfan.py +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/swidgetoutlet.py +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/swidgetswitch.py +0 -0
- {python_swidget-1.4.17 → python_swidget-1.4.20}/swidget/swidgettimerswitch.py +0 -0
- {python_swidget-1.4.17 → 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
|
|
@@ -59,10 +59,19 @@ devices = dict()
|
|
|
59
59
|
async def detect_secure(host: str, timeout: float = DETECT_TIMEOUT_SEC) -> bool:
|
|
60
60
|
"""Determine whether a Swidget device requires HTTPS+auth.
|
|
61
61
|
|
|
62
|
-
Probes ``/api/v1/summary`` over
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
Probes ``/api/v1/summary`` over HTTP first; a 200 means the device
|
|
63
|
+
is in plaintext mode. Secure-mode firmware running on the same port
|
|
64
|
+
answers 403 ("TLS required") instead, so anything non-200 falls
|
|
65
|
+
through to the HTTPS probe.
|
|
66
|
+
|
|
67
|
+
On HTTPS, both 401 and 403 indicate secure mode:
|
|
68
|
+
- **401 "Authorization Missing"** — no token in the request (our
|
|
69
|
+
probe doesn't carry one).
|
|
70
|
+
- **403 "Forbidden"** — token rejected ("Incorrect Token") or the
|
|
71
|
+
device's secret key has never been provisioned ("Key not set").
|
|
72
|
+
|
|
73
|
+
Both states mean "secure mode is enforced, prompt the caller for
|
|
74
|
+
credentials", so we collapse them into a single True return.
|
|
66
75
|
|
|
67
76
|
:param host: Hostname or IP of the device.
|
|
68
77
|
:param timeout: Per-request timeout in seconds. Defaults are generous
|
|
@@ -83,10 +92,12 @@ async def detect_secure(host: str, timeout: float = DETECT_TIMEOUT_SEC) -> bool:
|
|
|
83
92
|
except (ClientError, asyncio.TimeoutError):
|
|
84
93
|
pass
|
|
85
94
|
|
|
86
|
-
# Fall back to HTTPS.
|
|
95
|
+
# Fall back to HTTPS. 401 ("Authorization Missing") and 403
|
|
96
|
+
# ("Forbidden" — wrong token, or key not set) both mean the
|
|
97
|
+
# firmware is in secure mode; the caller now needs credentials.
|
|
87
98
|
try:
|
|
88
99
|
async with session.get(f"https://{host}/api/v1/summary") as resp:
|
|
89
|
-
if resp.status
|
|
100
|
+
if resp.status in (401, 403):
|
|
90
101
|
return True
|
|
91
102
|
except (ClientError, asyncio.TimeoutError):
|
|
92
103
|
pass
|
|
@@ -199,6 +210,7 @@ _PESNA_DEVICE_TYPES = frozenset(
|
|
|
199
210
|
{
|
|
200
211
|
DeviceType.PesnaFV05,
|
|
201
212
|
DeviceType.PesnaFV15,
|
|
213
|
+
DeviceType.PesnaFV15Plus,
|
|
202
214
|
DeviceType.PesnaFV20,
|
|
203
215
|
DeviceType.PesnaIB150,
|
|
204
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
|