python-swidget 1.4.16__tar.gz → 1.4.18__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.16
3
+ Version: 1.4.18
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.16"
3
+ version = "1.4.18"
4
4
  description = "Python API for Swidget smart devices"
5
5
  license = "GPL-3.0-or-later"
6
6
  authors = ["Swidget"]
@@ -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 HTTPS first (no credentials). A 403
63
- response means the firmware enforces auth the device is in secure
64
- mode. If HTTPS is unreachable, falls back to HTTP; a 200 there means
65
- the device is in plaintext mode.
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. A 403 means the firmware is enforcing auth.
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 == 403:
100
+ if resp.status in (401, 403):
90
101
  return True
91
102
  except (ClientError, asyncio.TimeoutError):
92
103
  pass
@@ -1004,14 +1004,27 @@ class SwidgetAssembly:
1004
1004
  class SwidgetComponent:
1005
1005
  """Component-level representation of a Swidget Assembly.
1006
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.
1007
+ ``summary_functions`` is the immutable list of function tags the
1008
+ device declared in its summary — this is the schema. ``functions``
1009
+ starts as a same-keyed dict of placeholder ``None`` values and is
1010
+ later mutated by ``process_state`` to carry live datapoint values.
1011
+
1012
+ Process_state also leaks in keys that aren't in the summary
1013
+ functions list (e.g. fans emit a ``modules`` map in state that
1014
+ isn't a declared function tag), so ``functions.keys()`` is *not*
1015
+ schema-stable across summary refreshes. Anything that needs a
1016
+ stable schema fingerprint (entity wiring, structure-change
1017
+ detection) must read ``summary_functions``, not ``functions``.
1018
+
1019
+ ``max_cfm``, ``model_code`` and ``modules`` come from the optional
1020
+ summary-level fields fan hosts emit alongside ``functions`` —
1021
+ non-fan components don't populate them.
1011
1022
  """
1012
1023
 
1013
1024
  def __init__(self, component):
1014
- self.functions = {f: None for f in component.get("functions", [])}
1025
+ funcs = list(component.get("functions", []))
1026
+ self.summary_functions: tuple[str, ...] = tuple(funcs)
1027
+ self.functions = {f: None for f in funcs}
1015
1028
  self.max_cfm = component.get("maxCFM")
1016
1029
  self.model_code = component.get("code")
1017
1030
  self.modules = list(component.get("modules", []))