python-swidget 1.4.14__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.14
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.14"
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)
@@ -249,6 +249,9 @@ class SwidgetDevice:
249
249
  ):
250
250
  _LOGGER.debug("Calling SwidgetDevice.process_state()")
251
251
  await self.process_state(message)
252
+ elif message["request_id"] == "device_config":
253
+ _LOGGER.debug("Calling SwidgetDevice.process_device_config()")
254
+ await self.process_device_config(message)
252
255
  else:
253
256
  message_type = ["request_id"]
254
257
  _LOGGER.error(
@@ -374,11 +377,33 @@ class SwidgetDevice:
374
377
  raise SwidgetConnectionException from e
375
378
 
376
379
  async def get_device_config(self) -> Any:
377
- """Get the config of the device."""
380
+ """Refresh the local device_config cache.
381
+
382
+ Uses the websocket when available — the response lands on
383
+ ``message_callback`` with ``request_id == "device_config"`` and
384
+ ``process_device_config`` updates the cache. Falls back to HTTP
385
+ before the socket is connected (e.g. during entry pre-load).
386
+ """
378
387
  _LOGGER.debug("SwidgetDevice.get_device_config() called")
379
- _LOGGER.debug("Sending get_summary() command over http")
388
+ if self.use_websockets and self.connected:
389
+ _LOGGER.debug("In websocket mode. Sending get_device_config over websocket")
390
+ await self._websocket.send_str(
391
+ json.dumps(
392
+ {"type": "get_device_config", "request_id": "device_config"}
393
+ )
394
+ )
395
+ return
396
+ _LOGGER.debug("In http mode. Sending get_device_config over http")
380
397
  config = await self.make_http_request("GET", "device_config")
381
- self.device_config = DeviceConfiguration(config)
398
+ await self.process_device_config(config)
399
+
400
+ async def process_device_config(self, config) -> None:
401
+ """Process a device_config payload from HTTP body or websocket message."""
402
+ _LOGGER.debug("SwidgetDevice.process_device_config() called")
403
+ # Strip transport metadata so DeviceConfiguration sees the same
404
+ # shape regardless of whether the payload arrived via HTTP or WS.
405
+ cfg = {k: v for k, v in config.items() if k != "request_id"}
406
+ self.device_config = DeviceConfiguration(cfg)
382
407
  self._last_update = int(time.time())
383
408
 
384
409
  async def set_device_config(self, updates: Dict[str, Any]) -> None:
@@ -499,7 +524,12 @@ class SwidgetDevice:
499
524
  self._last_update = int(time.time())
500
525
 
501
526
  async def update(self) -> None:
502
- """Update the state, summary, config and name of the device."""
527
+ """Refresh state and summary; device_config is fetched separately.
528
+
529
+ device_config rarely changes, and a successful set_device_config
530
+ already pushes a fresh copy into the local cache, so we don't
531
+ re-fetch it on every coordinator poll.
532
+ """
503
533
  _LOGGER.debug("SwidgetDevice.update() called")
504
534
  if self._last_update == 0:
505
535
  _LOGGER.debug("Performing the initial update to obtain sysinfo")
@@ -507,8 +537,6 @@ class SwidgetDevice:
507
537
  await self.get_state()
508
538
  if self._friendly_name == "Unknown Swidget Device":
509
539
  await self.get_friendly_name()
510
- if not self.device_config.config_populated():
511
- await self.get_device_config()
512
540
  elif (int(time.time()) - self._last_update) < 5:
513
541
  _LOGGER.debug("update() recently called, not executing")
514
542
  else:
@@ -967,17 +995,26 @@ class SwidgetAssembly:
967
995
  def __init__(self, summary: dict):
968
996
  self.type = summary["type"]
969
997
  self.components = {
970
- c["id"]: SwidgetComponent(c["functions"]) for c in summary["components"]
998
+ c["id"]: SwidgetComponent(c) for c in summary["components"]
971
999
  }
972
1000
  self.id = summary.get("id")
973
1001
  self.error = summary.get("error")
974
1002
 
975
1003
 
976
1004
  class SwidgetComponent:
977
- """Component-level representation of a Swidget Assembly."""
1005
+ """Component-level representation of a Swidget Assembly.
1006
+
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
+ """
978
1012
 
979
- def __init__(self, functions):
980
- self.functions = {f: None for f in functions}
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", []))
981
1018
 
982
1019
 
983
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
+ )