quilt-hp-python 0.5.0__tar.gz → 0.5.1__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.
Files changed (121) hide show
  1. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/CHANGELOG.md +25 -0
  2. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/PKG-INFO +5 -1
  3. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/README.md +4 -0
  4. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/reference/client.md +1 -1
  5. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/pyproject.toml +1 -1
  6. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/__init__.py +1 -1
  7. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/client.py +2 -1
  8. quilt_hp_python-0.5.1/src/quilt_hp/models/_helpers.py +52 -0
  9. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/enums.py +15 -10
  10. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/space.py +1 -6
  11. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/system.py +8 -22
  12. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/services/streaming.py +2 -18
  13. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/tokens.py +41 -1
  14. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/transport.py +7 -28
  15. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_cli_surfaces_extra.py +1 -1
  16. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_streaming.py +1 -1
  17. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_transport.py +3 -3
  18. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_transport_interceptor_extra.py +13 -13
  19. quilt_hp_python-0.5.0/src/quilt_hp/models/_helpers.py +0 -31
  20. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/.github/copilot-instructions.md +0 -0
  21. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/.github/workflows/ci.yml +0 -0
  22. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/.github/workflows/docs-deploy.yml +0 -0
  23. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/.github/workflows/release.yml +0 -0
  24. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/.gitignore +0 -0
  25. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/LICENSE +0 -0
  26. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/explanation/architecture.md +0 -0
  27. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/explanation/authentication.md +0 -0
  28. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/explanation/grpc-and-protobuf.md +0 -0
  29. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/explanation/snapshot-and-stream.md +0 -0
  30. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/explanation/streaming-protocol.md +0 -0
  31. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/authenticate.md +0 -0
  32. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/automation-daemon.md +0 -0
  33. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/cli-scripting.md +0 -0
  34. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/configure-comfort-settings.md +0 -0
  35. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/configure-schedules.md +0 -0
  36. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/contribute.md +0 -0
  37. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/control-spaces.md +0 -0
  38. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/home-assistant.md +0 -0
  39. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/regenerate-protos.md +0 -0
  40. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/stream-updates.md +0 -0
  41. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/how-to/tui-app.md +0 -0
  42. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/index.md +0 -0
  43. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/reference/api-reference.md +0 -0
  44. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/reference/documentation-standards.md +0 -0
  45. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/reference/grpc-services-matrix.md +0 -0
  46. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/reference/hds-entities.md +0 -0
  47. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/reference/models.md +0 -0
  48. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/reference/token-management.md +0 -0
  49. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/docs/tutorial/get-started.md +0 -0
  50. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/mkdocs.yml +0 -0
  51. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/proto/cleaned/quilt_device_pairing.proto +0 -0
  52. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/proto/cleaned/quilt_hds.proto +0 -0
  53. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/proto/cleaned/quilt_notifier.proto +0 -0
  54. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/proto/cleaned/quilt_services.proto +0 -0
  55. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/proto/cleaned/quilt_system.proto +0 -0
  56. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/scripts/bump_version.py +0 -0
  57. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/scripts/check_docs_nav.py +0 -0
  58. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/scripts/generate_public_api_reference.py +0 -0
  59. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/scripts/regen_protos.sh +0 -0
  60. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_paths.py +0 -0
  61. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/__init__.py +0 -0
  62. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2.py +0 -0
  63. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2.pyi +0 -0
  64. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +0 -0
  65. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_hds_pb2.py +0 -0
  66. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_hds_pb2.pyi +0 -0
  67. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_hds_pb2_grpc.py +0 -0
  68. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_notifier_pb2.py +0 -0
  69. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_notifier_pb2.pyi +0 -0
  70. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_notifier_pb2_grpc.py +0 -0
  71. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_services_pb2.py +0 -0
  72. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_services_pb2.pyi +0 -0
  73. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_services_pb2_grpc.py +0 -0
  74. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_system_pb2.py +0 -0
  75. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_system_pb2.pyi +0 -0
  76. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/_proto/quilt_system_pb2_grpc.py +0 -0
  77. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/auth.py +0 -0
  78. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/cli/__init__.py +0 -0
  79. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/cli/main.py +0 -0
  80. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/cli/settings.py +0 -0
  81. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/cli/store.py +0 -0
  82. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/cli/tui.py +0 -0
  83. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/const.py +0 -0
  84. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/exceptions.py +0 -0
  85. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/__init__.py +0 -0
  86. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/comfort.py +0 -0
  87. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/controller.py +0 -0
  88. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/energy.py +0 -0
  89. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/indoor_unit.py +0 -0
  90. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/outdoor_unit.py +0 -0
  91. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/qsm.py +0 -0
  92. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/schedule.py +0 -0
  93. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/sensor.py +0 -0
  94. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/models/software_update.py +0 -0
  95. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/py.typed +0 -0
  96. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/services/__init__.py +0 -0
  97. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/services/hds.py +0 -0
  98. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/services/system.py +0 -0
  99. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/src/quilt_hp/services/user.py +0 -0
  100. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/__init__.py +0 -0
  101. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/conftest.py +0 -0
  102. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_auth.py +0 -0
  103. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_auth_store_settings_edges.py +0 -0
  104. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_cli_feature_completion.py +0 -0
  105. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_cli_login.py +0 -0
  106. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_client_cache.py +0 -0
  107. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_client_service_error_paths.py +0 -0
  108. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_grpc_retry.py +0 -0
  109. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_hds_payloads.py +0 -0
  110. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_hds_schedule_mapping.py +0 -0
  111. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_hds_service_branches.py +0 -0
  112. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_models.py +0 -0
  113. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_models_extra.py +0 -0
  114. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_models_from_proto.py +0 -0
  115. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_settings_store.py +0 -0
  116. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_streaming_concurrency.py +0 -0
  117. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_streaming_debounce.py +0 -0
  118. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_streaming_health.py +0 -0
  119. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_streaming_reconnect_dispatch_extra.py +0 -0
  120. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_tokens.py +0 -0
  121. {quilt_hp_python-0.5.0 → quilt_hp_python-0.5.1}/tests/test_tui_bindings.py +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.1] - 2026-06-30
6
+
7
+ ### Fixed
8
+ - `SpaceControls.display_setpoint_str()` — removed dead unreachable code in the
9
+ fallback branch; `temperature_setpoint_c` is typed `float` so the `None`-guard
10
+ lines were never executed (confirmed by coverage)
11
+ - `SystemSnapshot.apply_outdoor_unit()` — refactored to collect field patches into
12
+ an `updates` dict and call `dataclasses.replace()` once, consistent with all other
13
+ `apply_*` methods; previously called `replace()` twice in sequence, creating an
14
+ unnecessary intermediate object
15
+ - `QuiltClient.close()` now sets `self._token = None`; previously left a stale token
16
+ accessible via `get_current_token()` after the channel was closed
17
+
18
+ ### Changed
19
+ - `invoke_refresh_callback` (formerly `_invoke_refresh_callback`) extracted from
20
+ `transport.py` and `services/streaming.py` into a single shared implementation in
21
+ `tokens.py`; the streaming copy lacked the `WeakKeyDictionary` signature cache,
22
+ causing `inspect.signature()` to be called on every token-refresh event
23
+ - `FanSpeed.to_wire()` and `LouverAngle.to_wire()` now reference module-level
24
+ constant dicts (`_FAN_SPEED_WIRE_MAP`, `_LOUVER_ANGLE_WIRE_MAP`) instead of
25
+ re-allocating the mapping on every call
26
+ - `_id_variants()` moved from `models/system.py` into `models/_helpers.py` and
27
+ reused by `lookup_hardware()`; eliminates duplicated ID-normalisation logic
28
+ - `QuiltClient.invalidate_snapshot()` log level changed from `WARNING` to `DEBUG`
29
+
5
30
  ## [0.5.0] - 2026-06-04
6
31
 
7
32
  ### Added protocol support
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quilt-hp-python
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: Async Python client for Quilt mini-split HVAC systems
5
5
  Project-URL: Repository, https://github.com/eman/quilt-hp-python
6
6
  Project-URL: Issues, https://github.com/eman/quilt-hp-python/issues
@@ -139,6 +139,10 @@ quilt energy --period week
139
139
  quilt set "Living Room" --mode cool --cool 22
140
140
  ```
141
141
 
142
+ ## Related Projects
143
+
144
+ - **[homeassistant-quilt-hp](https://github.com/eman/homeassistant-quilt-hp)** — Home Assistant custom integration built on this library. Exposes Quilt spaces, indoor/outdoor units, sensors, and schedules as HA entities with full climate control.
145
+
142
146
  ## Development
143
147
 
144
148
  ```bash
@@ -74,6 +74,10 @@ quilt energy --period week
74
74
  quilt set "Living Room" --mode cool --cool 22
75
75
  ```
76
76
 
77
+ ## Related Projects
78
+
79
+ - **[homeassistant-quilt-hp](https://github.com/eman/homeassistant-quilt-hp)** — Home Assistant custom integration built on this library. Exposes Quilt spaces, indoor/outdoor units, sensors, and schedules as HA entities with full climate control.
80
+
77
81
  ## Development
78
82
 
79
83
  ```bash
@@ -69,7 +69,7 @@ Raised when a requested resource does not exist (gRPC `NOT_FOUND`).
69
69
  ### `__version__`
70
70
 
71
71
  ```python
72
- __version__: str # e.g. "0.5.0"
72
+ __version__: str # e.g. "0.5.1"
73
73
  ```
74
74
 
75
75
  ---
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quilt-hp-python"
7
- version = "0.5.0"
7
+ version = "0.5.1"
8
8
  description = "Async Python client for Quilt mini-split HVAC systems"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -10,7 +10,7 @@ from quilt_hp.exceptions import (
10
10
  QuiltStreamError,
11
11
  )
12
12
 
13
- __version__ = "0.5.0"
13
+ __version__ = "0.5.1"
14
14
 
15
15
  __all__ = [
16
16
  "Environment",
@@ -273,7 +273,7 @@ class QuiltClient:
273
273
 
274
274
  def invalidate_snapshot(self) -> None:
275
275
  """Discard the cached snapshot so the next call fetches fresh data."""
276
- logger.warning("Invalidating snapshot cache")
276
+ logger.debug("Invalidating snapshot cache")
277
277
  self._snapshot_cache = None
278
278
  self._snapshot_cached_at = 0.0
279
279
 
@@ -640,6 +640,7 @@ class QuiltClient:
640
640
  if self._channel is not None:
641
641
  await self._channel.close()
642
642
  self._channel = None
643
+ self._token = None
643
644
  self._hds = None
644
645
  self._sysinfo = None
645
646
  self._user_svc = None
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def _id_variant_keys(raw: str) -> tuple[str, ...]:
5
+ """Return ID variant keys in deterministic priority order (exact → tail → casefold)."""
6
+ tail_slash = raw.rsplit("/", 1)[-1]
7
+ tail_colon = raw.rsplit(":", 1)[-1]
8
+ return (
9
+ raw,
10
+ tail_slash,
11
+ tail_colon,
12
+ raw.casefold(),
13
+ tail_slash.casefold(),
14
+ tail_colon.casefold(),
15
+ )
16
+
17
+
18
+ def _id_variants(value: str | None) -> set[str]:
19
+ """Return raw and normalized ID variants for matching resource IDs."""
20
+ if not value:
21
+ return set()
22
+ raw = value.strip()
23
+ if not raw:
24
+ return set()
25
+ return {v for v in _id_variant_keys(raw) if v}
26
+
27
+
28
+ def lookup_hardware(hw_map: dict[str, object], hardware_id: str | None) -> object | None:
29
+ """Resolve hardware objects across common ID formats.
30
+
31
+ Keys are tried in deterministic priority order: exact → tail (after last
32
+ ``/`` or ``:`` separator) → casefold variants, matching the behaviour of
33
+ the original implementation.
34
+ """
35
+ if not hardware_id:
36
+ return None
37
+ raw = hardware_id.strip()
38
+ if not raw:
39
+ return None
40
+ for key in _id_variant_keys(raw):
41
+ hw = hw_map.get(key)
42
+ if hw is not None:
43
+ return hw
44
+ return None
45
+
46
+
47
+ def parse_wifi_state(proto: object) -> tuple[str | None, str | None, int | None]:
48
+ """Extract WiFi fields while preserving explicit zero signal values."""
49
+ ssid = getattr(proto, "ssid", "") or None
50
+ ip = getattr(proto, "ipv4_address", None) or None
51
+ signal = getattr(proto, "signal_level_dbm", None)
52
+ return ssid, ip, signal if signal is not None else None
@@ -59,15 +59,7 @@ class FanSpeed(IntEnum):
59
59
 
60
60
  def to_wire(self) -> tuple[int, float]:
61
61
  """Return (fan_speed_mode, fan_speed_percent) for the wire protocol."""
62
- _MAP: dict[FanSpeed, tuple[int, float]] = {
63
- FanSpeed.AUTO: (1, 0.0), # FAN_SPEED_MODE_AUTO
64
- FanSpeed.QUIET: (2, 0.20), # FAN_SPEED_MODE_SETPOINT
65
- FanSpeed.LOW: (2, 0.40),
66
- FanSpeed.MEDIUM: (2, 0.60),
67
- FanSpeed.HIGH: (2, 0.80),
68
- FanSpeed.BLAST: (2, 1.00),
69
- }
70
- return _MAP[self]
62
+ return _FAN_SPEED_WIRE_MAP[self.value]
71
63
 
72
64
  @classmethod
73
65
  def from_wire(cls, mode: int, percent: float) -> FanSpeed:
@@ -85,6 +77,16 @@ class FanSpeed(IntEnum):
85
77
  return cls.BLAST
86
78
 
87
79
 
80
+ _FAN_SPEED_WIRE_MAP: dict[int, tuple[int, float]] = {
81
+ 0: (1, 0.0), # AUTO → FAN_SPEED_MODE_AUTO
82
+ 1: (2, 0.20), # QUIET → FAN_SPEED_MODE_SETPOINT
83
+ 2: (2, 0.40), # LOW
84
+ 3: (2, 0.60), # MEDIUM
85
+ 4: (2, 0.80), # HIGH
86
+ 5: (2, 1.00), # BLAST
87
+ }
88
+
89
+
88
90
  class LouverMode(IntEnum):
89
91
  """Indoor unit louver mode."""
90
92
 
@@ -129,7 +131,7 @@ class LouverAngle(IntEnum):
129
131
 
130
132
  def to_wire(self) -> float:
131
133
  """Return the louver_fixed_position float for the wire."""
132
- return {1: 0.20, 2: 0.40, 3: 0.60, 4: 0.80, 5: 1.00}[self.value]
134
+ return _LOUVER_ANGLE_WIRE_MAP[self.value]
133
135
 
134
136
  @classmethod
135
137
  def from_wire(cls, position: float) -> LouverAngle:
@@ -145,6 +147,9 @@ class LouverAngle(IntEnum):
145
147
  return cls.ANGLE5
146
148
 
147
149
 
150
+ _LOUVER_ANGLE_WIRE_MAP: dict[int, float] = {1: 0.20, 2: 0.40, 3: 0.60, 4: 0.80, 5: 1.00}
151
+
152
+
148
153
  class LightPreset(IntEnum):
149
154
  """Built-in LED color presets (RGBW packed int32)."""
150
155
 
@@ -85,12 +85,7 @@ class SpaceControls:
85
85
  return fmt(self.heating_setpoint_c)
86
86
  if mode == HVACMode.AUTO:
87
87
  return f"{fmt(self.heating_setpoint_c)}–{fmt(self.cooling_setpoint_c)}"
88
- best = self.temperature_setpoint_c
89
- if best is None:
90
- best = self.cooling_setpoint_c
91
- if best is None:
92
- best = self.heating_setpoint_c
93
- return fmt(best) if best is not None else "--"
88
+ return fmt(self.temperature_setpoint_c)
94
89
 
95
90
  @property
96
91
  def has_standby_sentinel_setpoints(self) -> bool:
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
  from typing import Any, cast
7
7
 
8
+ from quilt_hp.models._helpers import _id_variants
8
9
  from quilt_hp.models.comfort import ComfortSetting
9
10
  from quilt_hp.models.controller import Controller
10
11
  from quilt_hp.models.enums import (
@@ -24,21 +25,6 @@ from quilt_hp.models.software_update import SoftwareUpdateInfo
24
25
  from quilt_hp.models.space import Space
25
26
 
26
27
 
27
- def _id_variants(value: str | None) -> set[str]:
28
- """Return raw and normalized ID variants for matching resource IDs."""
29
- if not value:
30
- return set()
31
- raw = value.strip()
32
- if not raw:
33
- return set()
34
- tail_slash = raw.rsplit("/", 1)[-1]
35
- tail_colon = raw.rsplit(":", 1)[-1]
36
- variants = {raw, tail_slash, tail_colon, raw.casefold()}
37
- variants.add(tail_slash.casefold())
38
- variants.add(tail_colon.casefold())
39
- return {v for v in variants if v}
40
-
41
-
42
28
  @dataclass(slots=True)
43
29
  class Location:
44
30
  """A Quilt location with global settings like schedule execution state."""
@@ -283,17 +269,17 @@ class SystemSnapshot:
283
269
 
284
270
  for i, u in enumerate(self.outdoor_units):
285
271
  if u.id == odu.id:
272
+ updates: dict[str, Any] = {}
286
273
  # Preserve hvac_state when stream diff has a default-zero state
287
274
  if not odu.hvac_state and u.hvac_state:
288
- odu = replace(odu, hvac_state=u.hvac_state)
275
+ updates["hvac_state"] = u.hvac_state
289
276
  # Preserve hardware info — stream diffs are parsed without hw_map
290
277
  if odu.model_sku is None and u.model_sku is not None:
291
- odu = replace(
292
- odu,
293
- model_sku=u.model_sku,
294
- serial_number=u.serial_number,
295
- firmware_version=u.firmware_version,
296
- )
278
+ updates["model_sku"] = u.model_sku
279
+ updates["serial_number"] = u.serial_number
280
+ updates["firmware_version"] = u.firmware_version
281
+ if updates:
282
+ odu = replace(odu, **updates)
297
283
  self.outdoor_units[i] = odu
298
284
  return odu
299
285
  self.outdoor_units.append(odu)
@@ -9,7 +9,6 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import contextlib
12
- import inspect
13
12
  import logging
14
13
  import time
15
14
  from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
@@ -30,7 +29,7 @@ from quilt_hp.models.qsm import QuiltSmartModule
30
29
  from quilt_hp.models.sensor import ControllerRemoteSensor, RemoteSensor
31
30
  from quilt_hp.models.software_update import SoftwareUpdateInfo
32
31
  from quilt_hp.models.space import Space
33
- from quilt_hp.tokens import TokenRefreshContext, TokenRefreshReason
32
+ from quilt_hp.tokens import TokenRefreshContext, TokenRefreshReason, invoke_refresh_callback
34
33
 
35
34
  logger = logging.getLogger(__name__)
36
35
 
@@ -60,21 +59,6 @@ type _EventKey = tuple[str, str]
60
59
  type _AnyCallback = Callable[[Any], Awaitable[None] | None]
61
60
 
62
61
 
63
- async def _invoke_refresh_callback(
64
- refresh_callback: RefreshCallback, context: TokenRefreshContext
65
- ) -> None:
66
- try:
67
- has_params = bool(inspect.signature(refresh_callback).parameters)
68
- except TypeError:
69
- has_params = False
70
- except ValueError:
71
- has_params = False
72
- if has_params:
73
- await cast("Callable[[TokenRefreshContext], Awaitable[None]]", refresh_callback)(context)
74
- return
75
- await cast("Callable[[], Awaitable[None]]", refresh_callback)()
76
-
77
-
78
62
  def _parse_varint(data: bytes, pos: int) -> tuple[int, int]:
79
63
  """Parse a protobuf varint from raw bytes."""
80
64
  result, shift = 0, 0
@@ -624,7 +608,7 @@ class NotifierStream:
624
608
  source="streaming",
625
609
  attempt=attempt + 1,
626
610
  )
627
- await _invoke_refresh_callback(self._authenticate, context)
611
+ await invoke_refresh_callback(self._authenticate, context)
628
612
  except Exception:
629
613
  logger.exception("Token refresh failed; giving up stream")
630
614
  self._error = exc
@@ -7,13 +7,20 @@ protocol. Persistence is the caller's responsibility — the CLI provides
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import inspect
10
11
  import time
12
+ import weakref
13
+ from collections.abc import Awaitable, Callable
11
14
  from dataclasses import dataclass
12
15
  from enum import StrEnum
13
- from typing import Protocol
16
+ from typing import Protocol, cast
14
17
 
15
18
  _TOKEN_BUFFER_S = 300 # treat tokens as expired 5 min before actual expiry
16
19
 
20
+ # Cache whether a refresh callback accepts a TokenRefreshContext argument,
21
+ # so inspect.signature is only called once per unique callable.
22
+ _REFRESH_CALLBACK_HAS_PARAMS: weakref.WeakKeyDictionary[object, bool] = weakref.WeakKeyDictionary()
23
+
17
24
 
18
25
  @dataclass(slots=True)
19
26
  class CachedTokens:
@@ -117,3 +124,36 @@ class TokenRefreshPolicy(Protocol):
117
124
  ) -> RefreshFailureAction:
118
125
  """Return fallback strategy when refresh fails."""
119
126
  ...
127
+
128
+
129
+ type _RefreshCallback = (
130
+ Callable[[], Awaitable[None]] | Callable[[TokenRefreshContext], Awaitable[None]]
131
+ )
132
+
133
+
134
+ async def invoke_refresh_callback(
135
+ refresh_callback: _RefreshCallback, context: TokenRefreshContext
136
+ ) -> None:
137
+ """Invoke a refresh callback, passing context only if it accepts a parameter.
138
+
139
+ Whether each callback accepts a ``TokenRefreshContext`` argument is cached
140
+ per-callable in a WeakKeyDictionary so that ``inspect.signature`` is only
141
+ called once per unique callback object.
142
+ """
143
+ try:
144
+ has_params = _REFRESH_CALLBACK_HAS_PARAMS.get(refresh_callback)
145
+ except TypeError:
146
+ has_params = None # non-weakrefable callable — skip cache
147
+ if has_params is None:
148
+ try:
149
+ has_params = bool(inspect.signature(refresh_callback).parameters)
150
+ except TypeError, ValueError:
151
+ has_params = False
152
+ try:
153
+ _REFRESH_CALLBACK_HAS_PARAMS[refresh_callback] = has_params
154
+ except TypeError:
155
+ pass # non-weakrefable callable — skip caching
156
+ if has_params:
157
+ await cast("Callable[[TokenRefreshContext], Awaitable[None]]", refresh_callback)(context)
158
+ return
159
+ await cast("Callable[[], Awaitable[None]]", refresh_callback)()
@@ -2,11 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import inspect
6
5
  import logging
7
- import weakref
8
6
  from collections.abc import Awaitable, Callable
9
- from typing import cast
10
7
 
11
8
  import grpc
12
9
  import grpc.aio
@@ -18,7 +15,12 @@ from quilt_hp.const import (
18
15
  grpc_host,
19
16
  )
20
17
  from quilt_hp.exceptions import QuiltAuthError
21
- from quilt_hp.tokens import CurrentTokenProvider, TokenRefreshContext, TokenRefreshReason
18
+ from quilt_hp.tokens import (
19
+ CurrentTokenProvider,
20
+ TokenRefreshContext,
21
+ TokenRefreshReason,
22
+ invoke_refresh_callback,
23
+ )
22
24
 
23
25
  type RefreshCallback = (
24
26
  Callable[[], Awaitable[None]] | Callable[[TokenRefreshContext], Awaitable[None]]
@@ -26,7 +28,6 @@ type RefreshCallback = (
26
28
  type TokenProviderLike = Callable[[], str] | CurrentTokenProvider
27
29
 
28
30
  logger = logging.getLogger(__name__)
29
- _REFRESH_CALLBACK_HAS_PARAMS: weakref.WeakKeyDictionary[object, bool] = weakref.WeakKeyDictionary()
30
31
 
31
32
 
32
33
  def _resolve_token_provider(token_provider: TokenProviderLike) -> Callable[[], str]:
@@ -35,28 +36,6 @@ def _resolve_token_provider(token_provider: TokenProviderLike) -> Callable[[], s
35
36
  return token_provider.get_current_token
36
37
 
37
38
 
38
- async def _invoke_refresh_callback(
39
- refresh_callback: RefreshCallback, context: TokenRefreshContext
40
- ) -> None:
41
- try:
42
- has_params = _REFRESH_CALLBACK_HAS_PARAMS.get(refresh_callback)
43
- except TypeError:
44
- has_params = None # non-weakrefable callable — skip cache
45
- if has_params is None:
46
- try:
47
- has_params = bool(inspect.signature(refresh_callback).parameters)
48
- except TypeError, ValueError:
49
- has_params = False
50
- try:
51
- _REFRESH_CALLBACK_HAS_PARAMS[refresh_callback] = has_params
52
- except TypeError:
53
- pass # non-weakrefable callable — skip caching
54
- if has_params:
55
- await cast("Callable[[TokenRefreshContext], Awaitable[None]]", refresh_callback)(context)
56
- return
57
- await cast("Callable[[], Awaitable[None]]", refresh_callback)()
58
-
59
-
60
39
  class _AuthInterceptor(
61
40
  grpc.aio.UnaryUnaryClientInterceptor, # type: ignore[misc]
62
41
  grpc.aio.UnaryStreamClientInterceptor, # type: ignore[misc]
@@ -109,7 +88,7 @@ class _AuthInterceptor(
109
88
  or the credentials are otherwise invalid.
110
89
  """
111
90
  if self._refresh_callback is not None:
112
- await _invoke_refresh_callback(
91
+ await invoke_refresh_callback(
113
92
  self._refresh_callback,
114
93
  TokenRefreshContext(
115
94
  reason=TokenRefreshReason.TRANSPORT_UNAUTHENTICATED,
@@ -16,7 +16,7 @@ runner = CliRunner()
16
16
  def test_version_option_outputs_package_version() -> None:
17
17
  result = runner.invoke(cli_main.app, ["--version"])
18
18
  assert result.exit_code == 0
19
- assert result.stdout.strip() == "0.5.0"
19
+ assert result.stdout.strip() == "0.5.1"
20
20
 
21
21
 
22
22
  class _FakeClient:
@@ -12,10 +12,10 @@ from quilt_hp.services.streaming import (
12
12
  NotifierStream,
13
13
  _dispatch,
14
14
  _get_len_field,
15
- _invoke_refresh_callback,
16
15
  _parse_varint,
17
16
  )
18
17
  from quilt_hp.tokens import TokenRefreshContext, TokenRefreshReason
18
+ from quilt_hp.tokens import invoke_refresh_callback as _invoke_refresh_callback
19
19
 
20
20
 
21
21
  class _FakeRpcError(grpc.aio.AioRpcError):
@@ -6,7 +6,7 @@ import pytest
6
6
 
7
7
  from quilt_hp import transport
8
8
  from quilt_hp.const import APP_VERSION, Environment, grpc_host
9
- from quilt_hp.tokens import TokenRefreshContext, TokenRefreshReason
9
+ from quilt_hp.tokens import TokenRefreshContext, TokenRefreshReason, invoke_refresh_callback
10
10
 
11
11
 
12
12
  def test_grpc_host_prod() -> None:
@@ -45,7 +45,7 @@ async def test_invoke_refresh_callback_passes_context() -> None:
45
45
  reason=TokenRefreshReason.TRANSPORT_UNAUTHENTICATED,
46
46
  source="test",
47
47
  )
48
- await transport._invoke_refresh_callback(_with_context, context)
48
+ await invoke_refresh_callback(_with_context, context)
49
49
  assert captured == [context]
50
50
 
51
51
 
@@ -60,5 +60,5 @@ async def test_invoke_refresh_callback_supports_legacy_signature() -> None:
60
60
  reason=TokenRefreshReason.TRANSPORT_UNAUTHENTICATED,
61
61
  source="test",
62
62
  )
63
- await transport._invoke_refresh_callback(_legacy, context)
63
+ await invoke_refresh_callback(_legacy, context)
64
64
  assert calls == ["called"]
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock
6
6
  import grpc
7
7
  import pytest
8
8
 
9
- from quilt_hp import transport
9
+ from quilt_hp import tokens, transport
10
10
  from quilt_hp.const import Environment
11
11
  from quilt_hp.exceptions import QuiltAuthError
12
12
 
@@ -32,12 +32,12 @@ async def test_invoke_refresh_callback_handles_signature_fallback(
32
32
  async def _legacy() -> None:
33
33
  called.append("legacy")
34
34
 
35
- monkeypatch.setattr(transport.inspect, "signature", MagicMock(side_effect=TypeError("bad")))
35
+ monkeypatch.setattr(tokens.inspect, "signature", MagicMock(side_effect=TypeError("bad")))
36
36
 
37
- await transport._invoke_refresh_callback(
37
+ await tokens.invoke_refresh_callback(
38
38
  _legacy,
39
- transport.TokenRefreshContext(
40
- reason=transport.TokenRefreshReason.TRANSPORT_UNAUTHENTICATED,
39
+ tokens.TokenRefreshContext(
40
+ reason=tokens.TokenRefreshReason.TRANSPORT_UNAUTHENTICATED,
41
41
  source="test",
42
42
  ),
43
43
  )
@@ -48,10 +48,10 @@ async def test_invoke_refresh_callback_handles_signature_fallback(
48
48
  async def test_invoke_refresh_callback_caches_signature(
49
49
  monkeypatch: pytest.MonkeyPatch,
50
50
  ) -> None:
51
- transport._REFRESH_CALLBACK_HAS_PARAMS.clear()
52
- called: list[transport.TokenRefreshContext] = []
51
+ tokens._REFRESH_CALLBACK_HAS_PARAMS.clear()
52
+ called: list[tokens.TokenRefreshContext] = []
53
53
 
54
- async def _with_context(context: transport.TokenRefreshContext) -> None:
54
+ async def _with_context(context: tokens.TokenRefreshContext) -> None:
55
55
  called.append(context)
56
56
 
57
57
  signature = inspect.Signature(
@@ -63,14 +63,14 @@ async def test_invoke_refresh_callback_caches_signature(
63
63
  ]
64
64
  )
65
65
  inspect_signature = MagicMock(return_value=signature)
66
- monkeypatch.setattr(transport.inspect, "signature", inspect_signature)
66
+ monkeypatch.setattr(tokens.inspect, "signature", inspect_signature)
67
67
 
68
- context = transport.TokenRefreshContext(
69
- reason=transport.TokenRefreshReason.TRANSPORT_UNAUTHENTICATED,
68
+ context = tokens.TokenRefreshContext(
69
+ reason=tokens.TokenRefreshReason.TRANSPORT_UNAUTHENTICATED,
70
70
  source="test",
71
71
  )
72
- await transport._invoke_refresh_callback(_with_context, context)
73
- await transport._invoke_refresh_callback(_with_context, context)
72
+ await tokens.invoke_refresh_callback(_with_context, context)
73
+ await tokens.invoke_refresh_callback(_with_context, context)
74
74
 
75
75
  assert called == [context, context]
76
76
  assert inspect_signature.call_count == 1
@@ -1,31 +0,0 @@
1
- from __future__ import annotations
2
-
3
-
4
- def lookup_hardware(hw_map: dict[str, object], hardware_id: str | None) -> object | None:
5
- """Resolve hardware objects across common ID formats."""
6
- if not hardware_id:
7
- return None
8
- raw = hardware_id.strip()
9
- if not raw:
10
- return None
11
- keys = (
12
- raw,
13
- raw.rsplit("/", 1)[-1],
14
- raw.rsplit(":", 1)[-1],
15
- raw.casefold(),
16
- raw.rsplit("/", 1)[-1].casefold(),
17
- raw.rsplit(":", 1)[-1].casefold(),
18
- )
19
- for key in keys:
20
- hw = hw_map.get(key)
21
- if hw is not None:
22
- return hw
23
- return None
24
-
25
-
26
- def parse_wifi_state(proto: object) -> tuple[str | None, str | None, int | None]:
27
- """Extract WiFi fields while preserving explicit zero signal values."""
28
- ssid = getattr(proto, "ssid", "") or None
29
- ip = getattr(proto, "ipv4_address", None) or None
30
- signal = getattr(proto, "signal_level_dbm", None)
31
- return ssid, ip, signal if signal is not None else None
File without changes