python-swidget 1.4.15__tar.gz → 1.4.16__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.15 → python_swidget-1.4.16}/PKG-INFO +1 -1
- {python_swidget-1.4.15 → python_swidget-1.4.16}/pyproject.toml +1 -1
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/__init__.py +2 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/cli.py +7 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/discovery.py +21 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/swidgetdevice.py +13 -4
- python_swidget-1.4.16/swidget/swidgetfan.py +276 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/README.md +0 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/exceptions.py +0 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/provision.py +0 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/py.typed +0 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/swidgetdimmer.py +0 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/swidgetoutlet.py +0 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/swidgetswitch.py +0 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/swidgettimerswitch.py +0 -0
- {python_swidget-1.4.15 → python_swidget-1.4.16}/swidget/websocket.py +0 -0
|
@@ -30,6 +30,7 @@ from swidget.swidgetdevice import (
|
|
|
30
30
|
SwidgetDevice,
|
|
31
31
|
)
|
|
32
32
|
from swidget.swidgetdimmer import SwidgetDimmer
|
|
33
|
+
from swidget.swidgetfan import SwidgetFan
|
|
33
34
|
from swidget.swidgetoutlet import SwidgetOutlet
|
|
34
35
|
from swidget.swidgetswitch import SwidgetSwitch
|
|
35
36
|
from swidget.swidgettimerswitch import SwidgetTimerSwitch
|
|
@@ -50,6 +51,7 @@ __all__ = [
|
|
|
50
51
|
"SwidgetDevice",
|
|
51
52
|
"SwidgetComponent",
|
|
52
53
|
"SwidgetDimmer",
|
|
54
|
+
"SwidgetFan",
|
|
53
55
|
"SwidgetOutlet",
|
|
54
56
|
"SwidgetSwitch",
|
|
55
57
|
"SwidgetTimerSwitch",
|
|
@@ -11,6 +11,7 @@ import asyncclick as click
|
|
|
11
11
|
from swidget import (
|
|
12
12
|
SwidgetDevice,
|
|
13
13
|
SwidgetDimmer,
|
|
14
|
+
SwidgetFan,
|
|
14
15
|
SwidgetOutlet,
|
|
15
16
|
SwidgetSwitch,
|
|
16
17
|
SwidgetTimerSwitch,
|
|
@@ -24,6 +25,12 @@ TYPE_TO_CLASS = {
|
|
|
24
25
|
"switch": SwidgetSwitch,
|
|
25
26
|
"outlet": SwidgetOutlet,
|
|
26
27
|
"pana_switch": SwidgetTimerSwitch,
|
|
28
|
+
"pesna_fv05": SwidgetFan,
|
|
29
|
+
"pesna_fv15": SwidgetFan,
|
|
30
|
+
"pesna_fv20": SwidgetFan,
|
|
31
|
+
"pesna_IB150": SwidgetFan,
|
|
32
|
+
"pesna_IB160": SwidgetFan,
|
|
33
|
+
"pesna_fv05_G5": SwidgetFan,
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
|
|
@@ -12,6 +12,7 @@ from swidget.swidgetdevice import DeviceType, SwidgetDevice
|
|
|
12
12
|
|
|
13
13
|
from .exceptions import SwidgetException
|
|
14
14
|
from .swidgetdimmer import SwidgetDimmer
|
|
15
|
+
from .swidgetfan import SwidgetFan
|
|
15
16
|
from .swidgetoutlet import SwidgetOutlet
|
|
16
17
|
from .swidgetswitch import SwidgetSwitch
|
|
17
18
|
from .swidgettimerswitch import SwidgetTimerSwitch
|
|
@@ -194,6 +195,21 @@ async def discover_single(
|
|
|
194
195
|
return dev
|
|
195
196
|
|
|
196
197
|
|
|
198
|
+
_PESNA_DEVICE_TYPES = frozenset(
|
|
199
|
+
{
|
|
200
|
+
DeviceType.PesnaFV05,
|
|
201
|
+
DeviceType.PesnaFV15,
|
|
202
|
+
DeviceType.PesnaFV20,
|
|
203
|
+
DeviceType.PesnaIB150,
|
|
204
|
+
DeviceType.PesnaIB160,
|
|
205
|
+
DeviceType.PesnaFV05G5,
|
|
206
|
+
DeviceType.PesnaFV05WrongSlot,
|
|
207
|
+
DeviceType.PesnaUnrecognized,
|
|
208
|
+
DeviceType.PesnaError,
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
197
213
|
def _get_device_class(device_type: DeviceType) -> Type[SwidgetDevice]:
|
|
198
214
|
"""Find SmartDevice subclass for device described by passed data."""
|
|
199
215
|
if device_type in (DeviceType.Outlet, DeviceType.Outlet20A):
|
|
@@ -206,4 +222,9 @@ def _get_device_class(device_type: DeviceType) -> Type[SwidgetDevice]:
|
|
|
206
222
|
return SwidgetTimerSwitch
|
|
207
223
|
elif device_type == DeviceType.RelaySwitch:
|
|
208
224
|
return SwidgetSwitch
|
|
225
|
+
elif device_type in _PESNA_DEVICE_TYPES:
|
|
226
|
+
# All Pesna* hosts share the same request surface. Per-variant
|
|
227
|
+
# differences (max CFM, mode set, available modules) are
|
|
228
|
+
# surfaced by the device itself in the summary/datapoint.
|
|
229
|
+
return SwidgetFan
|
|
209
230
|
raise SwidgetException("Unknown device type: %s" % device_type)
|
|
@@ -995,17 +995,26 @@ class SwidgetAssembly:
|
|
|
995
995
|
def __init__(self, summary: dict):
|
|
996
996
|
self.type = summary["type"]
|
|
997
997
|
self.components = {
|
|
998
|
-
c["id"]: SwidgetComponent(c
|
|
998
|
+
c["id"]: SwidgetComponent(c) for c in summary["components"]
|
|
999
999
|
}
|
|
1000
1000
|
self.id = summary.get("id")
|
|
1001
1001
|
self.error = summary.get("error")
|
|
1002
1002
|
|
|
1003
1003
|
|
|
1004
1004
|
class SwidgetComponent:
|
|
1005
|
-
"""Component-level representation of a Swidget Assembly.
|
|
1005
|
+
"""Component-level representation of a Swidget Assembly.
|
|
1006
1006
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1007
|
+
Carries the function-state map plus the optional summary-level
|
|
1008
|
+
fields fan hosts emit alongside ``functions`` (``maxCFM``, ``code``,
|
|
1009
|
+
``modules``). Non-fan components don't populate them and they stay
|
|
1010
|
+
at their defaults.
|
|
1011
|
+
"""
|
|
1012
|
+
|
|
1013
|
+
def __init__(self, component):
|
|
1014
|
+
self.functions = {f: None for f in component.get("functions", [])}
|
|
1015
|
+
self.max_cfm = component.get("maxCFM")
|
|
1016
|
+
self.model_code = component.get("code")
|
|
1017
|
+
self.modules = list(component.get("modules", []))
|
|
1009
1018
|
|
|
1010
1019
|
|
|
1011
1020
|
class DeviceConfiguration:
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Module for SwidgetFan.
|
|
2
|
+
|
|
3
|
+
Covers the Pesna* host family (Panasonic FV05/FV15/FV20/IB150/IB160 and
|
|
4
|
+
G5/error/unrecognised variants). All variants share the same request
|
|
5
|
+
surface; per-model differences (max CFM, available modes, module
|
|
6
|
+
slots) are reported by the device itself in the summary/datapoint.
|
|
7
|
+
|
|
8
|
+
Function tags (``exhaust``, ``supply``, ``mode``, ``speed``, ``boost``,
|
|
9
|
+
``timer``, ``light``, ``dutyCycle``, ``filter``, ``raw``) and their
|
|
10
|
+
request/response shapes are documented in
|
|
11
|
+
``swidget-sdk/docs/request_handling.md`` (§"Fan-only host functions")
|
|
12
|
+
and ``swidget-sdk/docs/datapoint_description.md`` (§"Fan-only
|
|
13
|
+
functions"). This class is a thin wrapper around those — it does not
|
|
14
|
+
re-validate the device's capability set; callers should consult
|
|
15
|
+
``component.functions`` (populated from the summary) before invoking a
|
|
16
|
+
function the host doesn't expose.
|
|
17
|
+
"""
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Any, Dict, List, Optional
|
|
20
|
+
|
|
21
|
+
from swidget.exceptions import SwidgetException
|
|
22
|
+
from swidget.swidgetdevice import DeviceType, SwidgetDevice
|
|
23
|
+
|
|
24
|
+
_LOGGER = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_FAN_FUNCTION_TAGS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"exhaust",
|
|
30
|
+
"supply",
|
|
31
|
+
"mode",
|
|
32
|
+
"speed",
|
|
33
|
+
"boost",
|
|
34
|
+
"timer",
|
|
35
|
+
"light",
|
|
36
|
+
"dutyCycle",
|
|
37
|
+
"filter",
|
|
38
|
+
"indoors",
|
|
39
|
+
"outdoors",
|
|
40
|
+
"balancing",
|
|
41
|
+
"modules",
|
|
42
|
+
"error",
|
|
43
|
+
"status",
|
|
44
|
+
"raw",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SwidgetFan(SwidgetDevice):
|
|
50
|
+
"""Representation of a Swidget Fan-controller host (Pesna* family)."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
host,
|
|
55
|
+
token_name: str,
|
|
56
|
+
secret_key: str,
|
|
57
|
+
use_https: bool,
|
|
58
|
+
use_websockets: bool,
|
|
59
|
+
) -> None:
|
|
60
|
+
super().__init__(
|
|
61
|
+
host=host,
|
|
62
|
+
token_name=token_name,
|
|
63
|
+
secret_key=secret_key,
|
|
64
|
+
use_https=use_https,
|
|
65
|
+
use_websockets=use_websockets,
|
|
66
|
+
)
|
|
67
|
+
# Provisional. The real type is overwritten when the summary
|
|
68
|
+
# arrives — at which point we'll know the exact Pesna* variant.
|
|
69
|
+
self.device_type = DeviceType.PesnaUnrecognized
|
|
70
|
+
|
|
71
|
+
# ---- component lookup ------------------------------------------------
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def fan_component_id(self) -> str:
|
|
75
|
+
"""Return the host component id that exposes fan functions.
|
|
76
|
+
|
|
77
|
+
Pesna hosts typically expose a single component (``"0"``) but
|
|
78
|
+
the SDK leaves room for multi-component fan controllers, so we
|
|
79
|
+
scan rather than hardcoding.
|
|
80
|
+
"""
|
|
81
|
+
host = self.assemblies.get("host")
|
|
82
|
+
if host is None:
|
|
83
|
+
raise SwidgetException("Host assembly not loaded yet.")
|
|
84
|
+
for component_id, component in host.components.items():
|
|
85
|
+
if any(fn in _FAN_FUNCTION_TAGS for fn in component.functions):
|
|
86
|
+
return component_id
|
|
87
|
+
raise SwidgetException("No fan-capable host component found.")
|
|
88
|
+
|
|
89
|
+
def _function(self, name: str) -> Any:
|
|
90
|
+
"""Return the current value of a host function, or ``None``."""
|
|
91
|
+
host = self.assemblies.get("host")
|
|
92
|
+
if host is None:
|
|
93
|
+
return None
|
|
94
|
+
component = host.components.get(self.fan_component_id)
|
|
95
|
+
if component is None:
|
|
96
|
+
return None
|
|
97
|
+
return component.functions.get(name)
|
|
98
|
+
|
|
99
|
+
# ---- read-only properties -------------------------------------------
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def max_cfm(self) -> Optional[int]:
|
|
103
|
+
"""Maximum CFM the device hardware supports, from the summary."""
|
|
104
|
+
host = self.assemblies.get("host")
|
|
105
|
+
if host is None:
|
|
106
|
+
return None
|
|
107
|
+
component = host.components.get(self.fan_component_id)
|
|
108
|
+
return getattr(component, "max_cfm", None) if component else None
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def model_code(self) -> Optional[str]:
|
|
112
|
+
"""Hardware-reported fan model code, from the summary."""
|
|
113
|
+
host = self.assemblies.get("host")
|
|
114
|
+
if host is None:
|
|
115
|
+
return None
|
|
116
|
+
component = host.components.get(self.fan_component_id)
|
|
117
|
+
return getattr(component, "model_code", None) if component else None
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def detected_modules(self) -> List[str]:
|
|
121
|
+
"""Add-on modules detected at summary time (``condensation``, etc.)."""
|
|
122
|
+
host = self.assemblies.get("host")
|
|
123
|
+
if host is None:
|
|
124
|
+
return []
|
|
125
|
+
component = host.components.get(self.fan_component_id)
|
|
126
|
+
return list(getattr(component, "modules", []) or []) if component else []
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def exhaust_cfm(self) -> Optional[int]:
|
|
130
|
+
value = self._function("exhaust")
|
|
131
|
+
return value.get("cfm") if isinstance(value, dict) else None
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def supply_cfm(self) -> Optional[int]:
|
|
135
|
+
value = self._function("supply")
|
|
136
|
+
return value.get("cfm") if isinstance(value, dict) else None
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def allowed_exhaust_cfms(self) -> Optional[List[int]]:
|
|
140
|
+
"""Discrete CFM values the exhaust accepts, or ``None`` if unavailable."""
|
|
141
|
+
return self._allowed_cfms("exhaust")
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def allowed_supply_cfms(self) -> Optional[List[int]]:
|
|
145
|
+
return self._allowed_cfms("supply")
|
|
146
|
+
|
|
147
|
+
def _allowed_cfms(self, tag: str) -> Optional[List[int]]:
|
|
148
|
+
value = self._function(tag)
|
|
149
|
+
if not isinstance(value, dict):
|
|
150
|
+
return None
|
|
151
|
+
allowed = value.get("allowed")
|
|
152
|
+
# Per the SDK doc, ``allowed`` may be the literal string
|
|
153
|
+
# ``"unavailable"`` instead of a list — treat that as "unknown".
|
|
154
|
+
if isinstance(allowed, list):
|
|
155
|
+
return list(allowed)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def mode(self) -> Optional[str]:
|
|
160
|
+
value = self._function("mode")
|
|
161
|
+
return value if isinstance(value, str) else None
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def status(self) -> Optional[str]:
|
|
165
|
+
value = self._function("status")
|
|
166
|
+
return value if isinstance(value, str) else None
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def speed(self) -> Optional[str]:
|
|
170
|
+
value = self._function("speed")
|
|
171
|
+
return value if isinstance(value, str) else None
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def boost_state(self) -> Optional[Dict[str, Any]]:
|
|
175
|
+
value = self._function("boost")
|
|
176
|
+
return dict(value) if isinstance(value, dict) else None
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def fan_timer_minutes(self) -> Optional[int]:
|
|
180
|
+
"""Minutes remaining on the fan timer; ``None`` when no timer is active."""
|
|
181
|
+
value = self._function("timer")
|
|
182
|
+
if not isinstance(value, dict):
|
|
183
|
+
return None
|
|
184
|
+
return value.get("minutes")
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def light_on(self) -> Optional[bool]:
|
|
188
|
+
value = self._function("light")
|
|
189
|
+
return value.get("on") if isinstance(value, dict) else None
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def duty_cycle_minutes(self) -> Optional[int]:
|
|
193
|
+
value = self._function("dutyCycle")
|
|
194
|
+
return value.get("minutes") if isinstance(value, dict) else None
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def error_code(self) -> Optional[str]:
|
|
198
|
+
value = self._function("error")
|
|
199
|
+
return value.get("code") if isinstance(value, dict) else None
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def filter_state(self) -> Optional[Dict[str, bool]]:
|
|
203
|
+
value = self._function("filter")
|
|
204
|
+
return dict(value) if isinstance(value, dict) else None
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def indoor_climate(self) -> Optional[Dict[str, float]]:
|
|
208
|
+
value = self._function("indoors")
|
|
209
|
+
return dict(value) if isinstance(value, dict) else None
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def outdoor_climate(self) -> Optional[Dict[str, float]]:
|
|
213
|
+
value = self._function("outdoors")
|
|
214
|
+
return dict(value) if isinstance(value, dict) else None
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def balancing_offset(self) -> Optional[int]:
|
|
218
|
+
value = self._function("balancing")
|
|
219
|
+
return value.get("offset") if isinstance(value, dict) else None
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def module_states(self) -> Dict[str, str]:
|
|
223
|
+
"""Live ``triggered``/``dormant`` state for each fan add-on module."""
|
|
224
|
+
value = self._function("modules")
|
|
225
|
+
return dict(value) if isinstance(value, dict) else {}
|
|
226
|
+
|
|
227
|
+
# ---- commands -------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
async def set_exhaust_cfm(self, cfm: int) -> None:
|
|
230
|
+
await self._send_fan_command("exhaust", {"cfm": int(cfm)})
|
|
231
|
+
|
|
232
|
+
async def set_supply_cfm(self, cfm: int) -> None:
|
|
233
|
+
await self._send_fan_command("supply", {"cfm": int(cfm)})
|
|
234
|
+
|
|
235
|
+
async def set_mode(self, mode: str) -> None:
|
|
236
|
+
await self._send_fan_command("mode", {"mode": mode})
|
|
237
|
+
|
|
238
|
+
async def set_speed(self, speed: str) -> None:
|
|
239
|
+
# ``speed`` requests are a bare string per request_handling.md,
|
|
240
|
+
# but ``send_command`` always wraps the payload, so the device's
|
|
241
|
+
# request envelope ends up as ``{ "speed": "<str>" }`` either
|
|
242
|
+
# way (we send the value unwrapped to match).
|
|
243
|
+
await self._send_fan_command("speed", speed)
|
|
244
|
+
|
|
245
|
+
async def set_boost(
|
|
246
|
+
self, mode: str, minutes: Optional[int] = None
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Configure boost. ``mode`` is one of ``"off"``, ``"timer"``, ``"on"``."""
|
|
249
|
+
payload: Dict[str, Any] = {"mode": mode}
|
|
250
|
+
if minutes is not None:
|
|
251
|
+
payload["minutes"] = int(minutes)
|
|
252
|
+
await self._send_fan_command("boost", payload)
|
|
253
|
+
|
|
254
|
+
async def set_fan_timer(self, minutes: int) -> None:
|
|
255
|
+
await self._send_fan_command("timer", {"minutes": int(minutes)})
|
|
256
|
+
|
|
257
|
+
async def set_light(self, on: bool) -> None:
|
|
258
|
+
await self._send_fan_command("light", {"on": bool(on)})
|
|
259
|
+
|
|
260
|
+
async def set_duty_cycle(self, minutes: int) -> None:
|
|
261
|
+
await self._send_fan_command("dutyCycle", {"minutes": int(minutes)})
|
|
262
|
+
|
|
263
|
+
async def clean_filter(self) -> None:
|
|
264
|
+
await self._send_fan_command("filter", {"clean": True})
|
|
265
|
+
|
|
266
|
+
async def send_raw(self, cmd: str) -> None:
|
|
267
|
+
"""Send a raw passthrough command. Diagnostic only."""
|
|
268
|
+
await self._send_fan_command("raw", {"cmd": cmd})
|
|
269
|
+
|
|
270
|
+
async def _send_fan_command(self, function: str, command: Any) -> None:
|
|
271
|
+
await self.send_command(
|
|
272
|
+
assembly="host",
|
|
273
|
+
component=self.fan_component_id,
|
|
274
|
+
function=function,
|
|
275
|
+
command=command,
|
|
276
|
+
)
|
|
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
|