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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-swidget
3
- Version: 1.4.15
3
+ Version: 1.4.16
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-swidget"
3
- version = "1.4.15"
3
+ version = "1.4.16"
4
4
  description = "Python API for Swidget smart devices"
5
5
  license = "GPL-3.0-or-later"
6
6
  authors = ["Swidget"]
@@ -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["functions"]) for c in summary["components"]
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
- def __init__(self, functions):
1008
- self.functions = {f: None for f in functions}
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
+ )