quilt-hp-python 0.1.4__tar.gz → 0.2.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 (114) hide show
  1. quilt_hp_python-0.2.1/.github/copilot-instructions.md +145 -0
  2. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/CHANGELOG.md +8 -0
  3. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/PKG-INFO +1 -1
  4. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/home-assistant.md +236 -20
  5. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/client.md +1 -1
  6. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/hds-entities.md +18 -0
  7. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/pyproject.toml +2 -2
  8. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/scripts/bump_version.py +24 -13
  9. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/__init__.py +1 -1
  10. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/tui.py +7 -7
  11. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/const.py +19 -0
  12. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/comfort.py +37 -0
  13. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/controller.py +3 -2
  14. quilt_hp_python-0.2.1/src/quilt_hp/models/energy.py +47 -0
  15. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/indoor_unit.py +21 -1
  16. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/schedule.py +21 -0
  17. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/space.py +68 -1
  18. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/system.py +38 -0
  19. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/transport.py +16 -2
  20. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_cli_surfaces_extra.py +1 -1
  21. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_models_from_proto.py +244 -0
  22. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_transport_interceptor_extra.py +29 -0
  23. quilt_hp_python-0.2.1/tests/test_tui_bindings.py +23 -0
  24. quilt_hp_python-0.1.4/src/quilt_hp/models/energy.py +0 -31
  25. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/.github/workflows/ci.yml +0 -0
  26. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/.github/workflows/docs-deploy.yml +0 -0
  27. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/.github/workflows/release.yml +0 -0
  28. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/.gitignore +0 -0
  29. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/LICENSE +0 -0
  30. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/README.md +0 -0
  31. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/architecture.md +0 -0
  32. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/authentication.md +0 -0
  33. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/grpc-and-protobuf.md +0 -0
  34. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/snapshot-and-stream.md +0 -0
  35. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/streaming-protocol.md +0 -0
  36. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/authenticate.md +0 -0
  37. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/automation-daemon.md +0 -0
  38. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/cli-scripting.md +0 -0
  39. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/configure-comfort-settings.md +0 -0
  40. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/configure-schedules.md +0 -0
  41. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/contribute.md +0 -0
  42. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/control-spaces.md +0 -0
  43. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/regenerate-protos.md +0 -0
  44. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/stream-updates.md +0 -0
  45. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/tui-app.md +0 -0
  46. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/index.md +0 -0
  47. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/api-reference.md +0 -0
  48. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/documentation-standards.md +0 -0
  49. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/grpc-services-matrix.md +0 -0
  50. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/models.md +0 -0
  51. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/token-management.md +0 -0
  52. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/tutorial/get-started.md +0 -0
  53. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/mkdocs.yml +0 -0
  54. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_device_pairing.proto +0 -0
  55. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_hds.proto +0 -0
  56. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_notifier.proto +0 -0
  57. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_services.proto +0 -0
  58. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_system.proto +0 -0
  59. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/scripts/check_docs_nav.py +0 -0
  60. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/scripts/generate_public_api_reference.py +0 -0
  61. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/scripts/regen_protos.sh +0 -0
  62. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_paths.py +0 -0
  63. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/__init__.py +0 -0
  64. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2.py +0 -0
  65. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2.pyi +0 -0
  66. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +0 -0
  67. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_hds_pb2.py +0 -0
  68. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_hds_pb2.pyi +0 -0
  69. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_hds_pb2_grpc.py +0 -0
  70. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_notifier_pb2.py +0 -0
  71. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_notifier_pb2.pyi +0 -0
  72. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_notifier_pb2_grpc.py +0 -0
  73. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_services_pb2.py +0 -0
  74. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_services_pb2.pyi +0 -0
  75. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_services_pb2_grpc.py +0 -0
  76. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_system_pb2.py +0 -0
  77. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_system_pb2.pyi +0 -0
  78. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_system_pb2_grpc.py +0 -0
  79. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/auth.py +0 -0
  80. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/__init__.py +0 -0
  81. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/main.py +0 -0
  82. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/settings.py +0 -0
  83. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/store.py +0 -0
  84. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/client.py +0 -0
  85. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/exceptions.py +0 -0
  86. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/__init__.py +0 -0
  87. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/enums.py +0 -0
  88. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/outdoor_unit.py +0 -0
  89. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/qsm.py +0 -0
  90. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/sensor.py +0 -0
  91. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/software_update.py +0 -0
  92. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/py.typed +0 -0
  93. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/__init__.py +0 -0
  94. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/hds.py +0 -0
  95. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/streaming.py +0 -0
  96. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/system.py +0 -0
  97. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/user.py +0 -0
  98. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/tokens.py +0 -0
  99. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/__init__.py +0 -0
  100. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/conftest.py +0 -0
  101. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_auth.py +0 -0
  102. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_auth_store_settings_edges.py +0 -0
  103. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_cli_feature_completion.py +0 -0
  104. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_cli_login.py +0 -0
  105. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_client_cache.py +0 -0
  106. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_client_service_error_paths.py +0 -0
  107. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_hds_schedule_mapping.py +0 -0
  108. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_hds_service_branches.py +0 -0
  109. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_models.py +0 -0
  110. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_settings_store.py +0 -0
  111. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_streaming.py +0 -0
  112. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_streaming_reconnect_dispatch_extra.py +0 -0
  113. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_tokens.py +0 -0
  114. {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_transport.py +0 -0
@@ -0,0 +1,145 @@
1
+ # Copilot Instructions
2
+
3
+ ## What this library is
4
+
5
+ `quilt-hp-python` is an async Python client library for [Quilt](https://www.quilt.com/) mini-split HVAC systems. It communicates with the Quilt cloud API over gRPC to control and monitor Quilt installations.
6
+
7
+ The main entry point is `QuiltClient`. Consumers use it to:
8
+
9
+ - **Authenticate** — email OTP via AWS Cognito; tokens are cached automatically.
10
+ - **Read state** — fetch a full `SystemSnapshot` containing spaces (rooms), indoor units (wall-mounted heads), outdoor units, controllers, sensors, comfort presets, and schedules.
11
+ - **Control devices** — set HVAC mode, temperature setpoints, fan speed, louver position, comfort preset, boost mode, schedules, and LED settings on spaces and indoor units.
12
+ - **Stream real-time updates** — subscribe to the Quilt `NotifierService` bidirectional gRPC stream and receive push notifications within milliseconds of any state change.
13
+
14
+ Typical consumers: Home Assistant custom integrations, CLI tools, automation daemons, and dashboards.
15
+
16
+ ### Domain model overview
17
+
18
+ | Entity | Description |
19
+ |---|---|
20
+ | `Space` | A room or zone. Has HVAC controls (mode, setpoints), state (ambient temp, hvac state), comfort preset, and occupancy automation. |
21
+ | `IndoorUnit` | A wall-mounted mini-split head. Controls: fan speed, louver, LED. |
22
+ | `OutdoorUnit` | The outdoor compressor unit. Mostly read-only telemetry. |
23
+ | `Controller` | The Quilt Dial in-room controller. Read-only hardware info + remote sensor. |
24
+ | `ComfortSetting` | A named preset (Active, Sleep, Away, Standby, Custom) with mode + setpoints. |
25
+ | `ScheduleWeek` / `ScheduleDay` | Weekly HVAC schedules for a space. |
26
+ | `SystemSnapshot` | The full state of one Quilt installation at a point in time, returned by a single `GetHomeDatastoreSystem` RPC. |
27
+
28
+ ### Snapshot + stream pattern
29
+
30
+ The correct pattern for live integrations is **snapshot once, stream updates**:
31
+
32
+ ```python
33
+ snapshot = await client.get_snapshot() # full state at time T
34
+
35
+ async with client.stream(snapshot.stream_topics()) as stream:
36
+ stream.on_space_update(
37
+ lambda space: snapshot.apply_space(space) # merge sparse diff
38
+ )
39
+ await asyncio.sleep(...)
40
+ ```
41
+
42
+ Stream events are **sparse diffs** (proto3 — only changed fields are set; absent fields default to zero/`UNSPECIFIED`). Never use a raw stream `Space` directly — always merge via `snapshot.apply_space()` (and the corresponding `apply_*` methods for other entity types), otherwise controls, setpoints, and names will read as zero/empty.
43
+
44
+ ## Commands
45
+
46
+ ```bash
47
+ # Install dev dependencies
48
+ pip install -e ".[dev,cli]"
49
+
50
+ # Lint
51
+ ruff check src/ tests/
52
+ ruff format --check src/ tests/
53
+
54
+ # Type check
55
+ mypy src/quilt_hp/
56
+
57
+ # Tests (full suite)
58
+ pytest
59
+
60
+ # Run a single test file
61
+ pytest tests/test_models.py
62
+
63
+ # Run a single test by name
64
+ pytest tests/test_models.py -k "test_space_is_off"
65
+
66
+ # Docs
67
+ python scripts/check_docs_nav.py
68
+ mkdocs build --strict
69
+
70
+ # Regenerate protobuf stubs (after editing proto/cleaned/*.proto)
71
+ ./scripts/regen_protos.sh
72
+ ```
73
+
74
+ Requires Python 3.14. Run checks in order: lint → type → tests → docs.
75
+
76
+ ## Architecture
77
+
78
+ Five layers, each with a single responsibility:
79
+
80
+ ```
81
+ CLI/TUI (src/quilt_hp/cli/)
82
+ └─► QuiltClient facade (client.py)
83
+ ├─► Service layer (src/quilt_hp/services/)
84
+ │ ├── HomeDatastoreService — spaces, IDUs, comfort, schedules
85
+ │ ├── SystemInformationService — system listing, energy
86
+ │ ├── UserService — user attributes
87
+ │ └── NotifierStream — real-time push stream (bidirectional gRPC)
88
+ └─► Transport/auth (transport.py · auth.py · tokens.py)
89
+ └─► Wire stubs (src/quilt_hp/_proto/) ← generated, never edit by hand
90
+ ```
91
+
92
+ **QuiltClient** is the only public API surface. It owns the single `grpc.aio.Channel`, stores the current JWT, and implements `CurrentTokenProvider`. Service classes receive the channel and translate Python objects ↔ proto messages.
93
+
94
+ **Auth** flow (in `auth.py`): cache → Cognito `REFRESH_TOKEN_AUTH` → email OTP (`CUSTOM_AUTH`). The `TokenStore` protocol is injectable; the CLI's implementation is `cli/store.py:FileStore`. The OTP prompt is an injectable `otp_callback`.
95
+
96
+ **Transport** (`transport.py`): TLS `grpc.aio.secure_channel` with `_AuthInterceptor` attached. The interceptor patches `authorization` and `x-quilt-app-version` metadata on every call and retries once on `UNAUTHENTICATED` for unary RPCs. Streaming RPCs handle reconnect in `NotifierStream`.
97
+
98
+ **Wire stubs** in `_proto/` are generated by `./scripts/regen_protos.sh` from `proto/cleaned/`. They are committed so the package installs without `protoc`. They are excluded from ruff and mypy entirely (`pyproject.toml` has the relevant overrides).
99
+
100
+ The library is **async-only** (`grpc.aio` throughout). There is no sync wrapper and no global state — all channel/token/cache state lives on the `QuiltClient` instance.
101
+
102
+ ## Key Conventions
103
+
104
+ ### Models
105
+
106
+ All domain models are `@dataclass(slots=True)`. Each model has a `@classmethod from_proto(cls, proto: object) -> ModelType` that accepts the proto object typed as `object` (not the generated proto type) to keep mypy happy while the proto stubs are ignored.
107
+
108
+ ```python
109
+ @dataclass(slots=True)
110
+ class Space:
111
+ id: str
112
+ ...
113
+
114
+ @classmethod
115
+ def from_proto(cls, proto: object) -> Space:
116
+ return _space_from_proto(proto)
117
+ ```
118
+
119
+ ### Enums
120
+
121
+ All enums live in `models/enums.py` and subclass `IntEnum`, mirroring proto integer values exactly. Enums that require non-trivial wire encoding (e.g. `FanSpeed`, `LouverAngle`) carry `to_wire()` / `from_wire()` methods — do not inline this logic elsewhere.
122
+
123
+ ### Imports
124
+
125
+ Every source file starts with `from __future__ import annotations`. Type-only imports go under `if TYPE_CHECKING:`.
126
+
127
+ ### Callbacks
128
+
129
+ Public APIs that accept callbacks (OTP, stream events, token refresh) support both sync and async callables. Async-ness is detected with `inspect.iscoroutinefunction` or by inspecting `inspect.signature` parameters.
130
+
131
+ ### Tests
132
+
133
+ Tests use `types.SimpleNamespace` (aliased as `_ns(...)`) as a lightweight proto stub — no real gRPC connections are made. `asyncio_mode = "auto"` is set in `pyproject.toml` so async test functions need no decorator.
134
+
135
+ ### Proto changes
136
+
137
+ When editing `.proto` files in `proto/cleaned/`:
138
+ 1. Run `./scripts/regen_protos.sh`
139
+ 2. Update the service wrapper in `src/quilt_hp/services/`
140
+ 3. Update `QuiltClient` if the change is part of the public API
141
+ 4. Commit proto sources, regenerated stubs, and source changes together
142
+
143
+ ### Documentation
144
+
145
+ `docs/` uses MkDocs Material + mkdocstrings. When changing source, update the corresponding reference doc in the same PR (see the table in `docs/how-to/contribute.md`). Validate with `python scripts/check_docs_nav.py` before `mkdocs build --strict`.
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-05-10
11
+
12
+ ### Fixed
13
+ - Restored CI/release quality-gate stability by applying required `ruff format`
14
+ updates in model files.
15
+
16
+ ## [0.2.0] - 2026-05-10
17
+
10
18
  ## [0.1.4] - 2026-05-08
11
19
 
12
20
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quilt-hp-python
3
- Version: 0.1.4
3
+ Version: 0.2.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
@@ -19,7 +19,8 @@ HA event loop
19
19
  ├── QuiltClient (gRPC channel + token store)
20
20
  ├── SystemSnapshot ← full state in-memory
21
21
  └── NotifierStream ← real-time diffs → async_set_updated_data()
22
- └── on_space_update, on_indoor_unit_update
22
+ └── on_space_update, on_indoor_unit_update,
23
+ on_controller_update, on_qsm_update
23
24
  ```
24
25
 
25
26
  ---
@@ -74,6 +75,108 @@ For the `TokenStore` protocol definition, see [Token management reference](../re
74
75
 
75
76
  ---
76
77
 
78
+ ## Device and entity modeling
79
+
80
+ Before writing entity classes, you need a clear mental model of which API objects become HA *devices* and which become *entities* under those devices.
81
+
82
+ ### Why Spaces are not HA devices
83
+
84
+ A `Space` is a logical zone (room) in the Quilt data model. It carries the writable HVAC state — mode, setpoints, occupancy timeouts — but it has no serial number, no hardware model identifier, and no physical form. Home Assistant's device registry is designed for physical hardware that can be identified by manufacturer, model, and serial number.
85
+
86
+ The Quilt mobile app does not differentiate between a room and its indoor unit: controlling the room and controlling the head unit are the same action. Use `snapshot.rooms` (which filters to leaf spaces only — those with a `parent_space_id`) when setting up entities. Never create entities for floor-level or home-level parent spaces.
87
+
88
+ ### IDU and QSM share one HA device
89
+
90
+ The `QuiltSmartModule` (QSM) is embedded inside the `IndoorUnit` enclosure — users never see it as a separate piece of hardware. Because the Quilt app presents them as one unit and the QSM has no user-visible serial number, map them to a **single HA device** identified by the IDU's hardware identifiers.
91
+
92
+ Use `snapshot.qsm_for_idu(idu)` to resolve the QSM for a given IDU when you need radar or ambient-light sensor data.
93
+
94
+ ```python
95
+ from homeassistant.helpers.entity import DeviceInfo
96
+
97
+ DOMAIN = "quilt_hp"
98
+
99
+ def idu_device_info(idu, snapshot) -> DeviceInfo:
100
+ """Build HA DeviceInfo for an IDU (and its embedded QSM)."""
101
+ space = next(
102
+ s for s in snapshot.rooms if s.id == idu.space_id
103
+ )
104
+ return DeviceInfo(
105
+ identifiers={(DOMAIN, idu.id)},
106
+ name=space.name, # room name, e.g. "Living Room"
107
+ manufacturer="Quilt",
108
+ model=idu.settings.name or "Indoor Unit",
109
+ serial_number=None, # serial_number not currently exposed via API
110
+ )
111
+ ```
112
+
113
+ The `climate` entity for the room belongs to this device. It reads setpoint and mode from `Space.controls` and writes via `client.set_space()`, but it is registered as a child of the IDU device because the IDU is the physical hardware.
114
+
115
+ ### Controller (Quilt Dial) as a separate linked device
116
+
117
+ The Quilt Dial (`Controller`) is physically separate from the IDU — it is a standalone circular thermostat with its own Wi-Fi radio, temperature sensor, and display. It is associated with the same `space_id` as the IDU it works with, but it has its own serial number and model SKU.
118
+
119
+ Create a **separate HA device** for each Dial, linked to the IDU device for the same space using `via_device`:
120
+
121
+ ```python
122
+ def controller_device_info(ctrl, snapshot) -> DeviceInfo:
123
+ """Build HA DeviceInfo for a Quilt Dial (Controller)."""
124
+ # Find the IDU in the same space so we can set via_device.
125
+ idu = next(
126
+ (u for u in snapshot.indoor_units if u.space_id == ctrl.space_id),
127
+ None,
128
+ )
129
+ return DeviceInfo(
130
+ identifiers={(DOMAIN, ctrl.id)},
131
+ name=ctrl.name or "Quilt Dial",
132
+ manufacturer="Quilt",
133
+ model=ctrl.model_sku or "Dial",
134
+ serial_number=ctrl.serial_number,
135
+ via_device=(DOMAIN, idu.id) if idu else None,
136
+ )
137
+ ```
138
+
139
+ `via_device` tells HA that the Dial is physically associated with (and accessed through) the IDU device, which correctly groups them in the HA UI by room.
140
+
141
+ ### Entity-to-object mapping
142
+
143
+ | HA platform | Entity name example | Source model | Key fields | Writable? |
144
+ |-------------|---------------------|--------------|------------|-----------|
145
+ | `climate` | Living Room | `Space.controls` + `IndoorUnit.controls` | `hvac_mode`, `heating_setpoint_c`, `cooling_setpoint_c`, `fan_speed` | Yes — `client.set_space()` / `client.set_indoor_unit()` |
146
+ | `sensor` (temperature) | Living Room Temperature | `IndoorUnit.state` | `ambient_temperature_c` | No |
147
+ | `sensor` (humidity) | Living Room Humidity | `IndoorUnit.state` | `ambient_humidity_percent` | No |
148
+ | `binary_sensor` (presence) | Living Room Presence | `IndoorUnit.effective_occupancy_state` | `occupancy_state != 0` | No |
149
+ | `light` | Living Room Light | `IndoorUnit.controls` | `led_state`, `led_brightness`, `led_color_code` | Yes — `client.set_indoor_unit()` |
150
+ | `select` (fan speed) | Living Room Fan Speed | `IndoorUnit.controls` | `fan_speed` | Yes — `client.set_indoor_unit()` |
151
+ | `select` (louver) | Living Room Louver | `IndoorUnit.controls` | `louver_mode` | Yes — `client.set_indoor_unit()` |
152
+ | `sensor` (Dial temperature) | Living Room Dial Temperature | `Controller` | `ambient_temperature_c` | No |
153
+
154
+ > **Presence note**: Use `idu.effective_occupancy_state` rather than reading `idu.occupancy` directly. The property returns `None` when the IDU is offline, avoiding stale occupancy data being presented as current.
155
+
156
+ ### Resolving IDUs and Controllers for a space
157
+
158
+ A space always has at most one IDU in a typical Quilt installation. Use these patterns when setting up platforms:
159
+
160
+ ```python
161
+ # All rooms (leaf spaces only — no floor/home-level spaces)
162
+ rooms = snapshot.rooms
163
+
164
+ # Map space_id → IndoorUnit (for the common single-IDU-per-room case)
165
+ idu_by_space: dict[str, IndoorUnit] = {
166
+ idu.space_id: idu for idu in snapshot.indoor_units
167
+ }
168
+
169
+ # Map space_id → Controller (Dial), if one is installed in that room
170
+ ctrl_by_space: dict[str, Controller] = {
171
+ ctrl.space_id: ctrl for ctrl in snapshot.controllers
172
+ }
173
+
174
+ # Resolve the QSM embedded in an IDU
175
+ qsm = snapshot.qsm_for_idu(idu) # returns QuiltSmartModule | None
176
+ ```
177
+
178
+ ---
179
+
77
180
  ## Step 2: Build the coordinator
78
181
 
79
182
  ```python
@@ -82,9 +185,11 @@ import logging
82
185
  from datetime import timedelta
83
186
  from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
84
187
  from quilt_hp import QuiltClient
85
- from quilt_hp.models.system import SystemSnapshot
86
- from quilt_hp.models.space import Space
188
+ from quilt_hp.models.controller import Controller
87
189
  from quilt_hp.models.indoor_unit import IndoorUnit
190
+ from quilt_hp.models.qsm import QuiltSmartModule
191
+ from quilt_hp.models.space import Space
192
+ from quilt_hp.models.system import SystemSnapshot
88
193
 
89
194
  _LOGGER = logging.getLogger(__name__)
90
195
 
@@ -110,24 +215,40 @@ class QuiltCoordinator(DataUpdateCoordinator[SystemSnapshot]):
110
215
  async def _start_stream(self, snapshot: SystemSnapshot) -> None:
111
216
  topics = snapshot.stream_topics()
112
217
  self._stream = self._client.stream(topics, max_reconnects=-1)
218
+ # Space updates drive climate entity state (mode, setpoints, occupancy).
113
219
  self._stream.on_space_update(self._on_space_update)
220
+ # IDU updates drive temperature/humidity sensors, fan speed, LED, and
221
+ # presence binary sensor.
114
222
  self._stream.on_indoor_unit_update(self._on_idu_update)
223
+ # Controller updates drive Dial temperature sensor entities.
224
+ self._stream.on_controller_update(self._on_controller_update)
225
+ # QSM updates provide raw radar and ALS diagnostic sensor data.
226
+ self._stream.on_qsm_update(self._on_qsm_update)
115
227
  self._stream.on_disconnected(lambda: _LOGGER.warning("Quilt stream disconnected"))
116
228
  await self._stream.start()
117
229
 
118
230
  def _on_space_update(self, space: Space) -> None:
119
231
  if self.data is not None:
120
- self.data.spaces[space.id] = space
232
+ self.data.apply_space(space)
121
233
  self.async_set_updated_data(self.data)
122
234
 
123
235
  def _on_idu_update(self, idu: IndoorUnit) -> None:
124
236
  if self.data is not None:
125
- self.data.indoor_units[idu.id] = idu
237
+ self.data.apply_indoor_unit(idu)
238
+ self.async_set_updated_data(self.data)
239
+
240
+ def _on_controller_update(self, ctrl: Controller) -> None:
241
+ if self.data is not None:
242
+ self.data.apply_controller(ctrl)
243
+ self.async_set_updated_data(self.data)
244
+
245
+ def _on_qsm_update(self, qsm: QuiltSmartModule) -> None:
246
+ if self.data is not None:
247
+ self.data.apply_qsm(qsm)
126
248
  self.async_set_updated_data(self.data)
127
249
 
128
250
  async def _async_update_data(self) -> SystemSnapshot:
129
251
  try:
130
- self._client.invalidate_snapshot()
131
252
  return await self._client.get_snapshot()
132
253
  except Exception as err:
133
254
  raise UpdateFailed(f"Error fetching Quilt snapshot: {err}") from err
@@ -142,6 +263,8 @@ class QuiltCoordinator(DataUpdateCoordinator[SystemSnapshot]):
142
263
 
143
264
  ## Step 3: Create the climate entity
144
265
 
266
+ The climate entity reads mode and setpoints from the `Space` but is registered under the IDU device. The `device_info` property links it to the physical hardware in the HA device registry.
267
+
145
268
  ```python
146
269
  from homeassistant.components.climate import (
147
270
  ClimateEntity,
@@ -149,8 +272,11 @@ from homeassistant.components.climate import (
149
272
  HVACMode as HAHVACMode,
150
273
  )
151
274
  from homeassistant.const import UnitOfTemperature
275
+ from homeassistant.helpers.entity import DeviceInfo
152
276
  from quilt_hp.models.enums import HVACMode as QHVACMode
153
277
 
278
+ DOMAIN = "quilt_hp"
279
+
154
280
  _MODE_MAP: dict[QHVACMode, HAHVACMode] = {
155
281
  QHVACMode.STANDBY: HAHVACMode.OFF,
156
282
  QHVACMode.COOL: HAHVACMode.COOL,
@@ -170,13 +296,23 @@ class QuiltClimateEntity(ClimateEntity):
170
296
  )
171
297
  _attr_hvac_modes = list(_MODE_MAP.values())
172
298
 
173
- def __init__(self, coordinator, space_id: str) -> None:
299
+ def __init__(self, coordinator, space_id: str, idu_id: str) -> None:
174
300
  self._coordinator = coordinator
175
301
  self._space_id = space_id
302
+ self._idu_id = idu_id
176
303
 
177
304
  @property
178
305
  def space(self):
179
- return self._coordinator.data.spaces[self._space_id]
306
+ return next(s for s in self._coordinator.data.spaces if s.id == self._space_id)
307
+
308
+ @property
309
+ def device_info(self) -> DeviceInfo:
310
+ return DeviceInfo(
311
+ identifiers={(DOMAIN, self._idu_id)},
312
+ name=self.space.name,
313
+ manufacturer="Quilt",
314
+ model="Indoor Unit",
315
+ )
180
316
 
181
317
  @property
182
318
  def name(self) -> str:
@@ -184,32 +320,34 @@ class QuiltClimateEntity(ClimateEntity):
184
320
 
185
321
  @property
186
322
  def unique_id(self) -> str:
187
- return f"quilt_space_{self._space_id}"
323
+ # Keyed on space_id because the climate entity represents the room,
324
+ # not the physical IDU.
325
+ return f"quilt_climate_{self._space_id}"
188
326
 
189
327
  @property
190
328
  def hvac_mode(self) -> HAHVACMode:
191
- return _MODE_MAP.get(self.space.controls.mode, HAHVACMode.OFF)
329
+ return _MODE_MAP.get(self.space.controls.hvac_mode, HAHVACMode.OFF)
192
330
 
193
331
  @property
194
332
  def current_temperature(self) -> float | None:
195
- return self.space.state.current_temp_c
333
+ return self.space.state.ambient_temperature_c
196
334
 
197
335
  @property
198
336
  def target_temperature(self) -> float | None:
199
- mode = self.space.controls.mode
337
+ mode = self.space.controls.hvac_mode
200
338
  if mode == QHVACMode.COOL:
201
- return self.space.controls.cool_setpoint_c
339
+ return self.space.controls.cooling_setpoint_c
202
340
  if mode == QHVACMode.HEAT:
203
- return self.space.controls.heat_setpoint_c
341
+ return self.space.controls.heating_setpoint_c
204
342
  return None
205
343
 
206
344
  @property
207
345
  def target_temperature_high(self) -> float | None:
208
- return self.space.controls.cool_setpoint_c
346
+ return self.space.controls.cooling_setpoint_c
209
347
 
210
348
  @property
211
349
  def target_temperature_low(self) -> float | None:
212
- return self.space.controls.heat_setpoint_c
350
+ return self.space.controls.heating_setpoint_c
213
351
 
214
352
  async def async_set_hvac_mode(self, hvac_mode: HAHVACMode) -> None:
215
353
  mode = _HA_TO_QUILT[hvac_mode]
@@ -228,9 +366,14 @@ class QuiltClimateEntity(ClimateEntity):
228
366
 
229
367
  ## Step 4: Create a temperature sensor entity
230
368
 
369
+ The IDU temperature sensor entity is also registered under the IDU device using the same `device_info` identifiers:
370
+
231
371
  ```python
232
372
  from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
233
373
  from homeassistant.const import UnitOfTemperature
374
+ from homeassistant.helpers.entity import DeviceInfo
375
+
376
+ DOMAIN = "quilt_hp"
234
377
 
235
378
 
236
379
  class QuiltIndoorTempSensor(SensorEntity):
@@ -243,11 +386,26 @@ class QuiltIndoorTempSensor(SensorEntity):
243
386
 
244
387
  @property
245
388
  def idu(self):
246
- return self._coordinator.data.indoor_units[self._idu_id]
389
+ return next(u for u in self._coordinator.data.indoor_units if u.id == self._idu_id)
390
+
391
+ @property
392
+ def device_info(self) -> DeviceInfo:
393
+ space = next(
394
+ (s for s in self._coordinator.data.spaces if s.id == self.idu.space_id), None
395
+ )
396
+ return DeviceInfo(
397
+ identifiers={(DOMAIN, self._idu_id)},
398
+ name=space.name if space else self._idu_id,
399
+ manufacturer="Quilt",
400
+ model="Indoor Unit",
401
+ )
247
402
 
248
403
  @property
249
404
  def name(self) -> str:
250
- return f"IDU {self._idu_id} temperature"
405
+ space = next(
406
+ (s for s in self._coordinator.data.spaces if s.id == self.idu.space_id), None
407
+ )
408
+ return f"{space.name} Temperature" if space else f"IDU {self._idu_id} Temperature"
251
409
 
252
410
  @property
253
411
  def unique_id(self) -> str:
@@ -255,11 +413,69 @@ class QuiltIndoorTempSensor(SensorEntity):
255
413
 
256
414
  @property
257
415
  def native_value(self) -> float | None:
258
- return self.idu.state.actual_temp_c
416
+ return self.idu.state.ambient_temperature_c
417
+
418
+ @property
419
+ def available(self) -> bool:
420
+ return self.idu.is_online
421
+ ```
422
+
423
+ ### Dial temperature sensor
424
+
425
+ Create a parallel sensor class for the `Controller` (Quilt Dial). It is registered under a **separate device** linked to the IDU device via `via_device`:
426
+
427
+ ```python
428
+ from homeassistant.helpers.entity import DeviceInfo
429
+
430
+ DOMAIN = "quilt_hp"
431
+
432
+
433
+ class QuiltDialTempSensor(SensorEntity):
434
+ _attr_device_class = SensorDeviceClass.TEMPERATURE
435
+ _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
436
+
437
+ def __init__(self, coordinator, controller_id: str) -> None:
438
+ self._coordinator = coordinator
439
+ self._controller_id = controller_id
440
+
441
+ @property
442
+ def controller(self):
443
+ return next(
444
+ c for c in self._coordinator.data.controllers if c.id == self._controller_id
445
+ )
446
+
447
+ @property
448
+ def device_info(self) -> DeviceInfo:
449
+ ctrl = self.controller
450
+ # Find the IDU in the same space so we can set via_device.
451
+ idu = next(
452
+ (u for u in self._coordinator.data.indoor_units if u.space_id == ctrl.space_id),
453
+ None,
454
+ )
455
+ return DeviceInfo(
456
+ identifiers={(DOMAIN, self._controller_id)},
457
+ name=ctrl.name or "Quilt Dial",
458
+ manufacturer="Quilt",
459
+ model=ctrl.model_sku or "Dial",
460
+ serial_number=ctrl.serial_number,
461
+ via_device=(DOMAIN, idu.id) if idu else None,
462
+ )
463
+
464
+ @property
465
+ def name(self) -> str:
466
+ return f"{self.controller.name or 'Quilt Dial'} Temperature"
467
+
468
+ @property
469
+ def unique_id(self) -> str:
470
+ return f"quilt_dial_temp_{self._controller_id}"
471
+
472
+ @property
473
+ def native_value(self) -> float | None:
474
+ return self.controller.ambient_temperature_c
259
475
 
260
476
  @property
261
477
  def available(self) -> bool:
262
- return self.idu.state.is_online
478
+ return self.controller.is_online
263
479
  ```
264
480
 
265
481
  ---
@@ -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.1.2"
72
+ __version__: str # e.g. "0.2.1"
73
73
  ```
74
74
 
75
75
  ---
@@ -38,6 +38,24 @@ A Space is a zone within a Quilt installation, typically a room. Spaces are hier
38
38
 
39
39
  **Temperature setpoint routing**: The wire `temperature_setpoint_c` field is the "mode-relevant" setpoint. It is the heating setpoint when mode is HEAT and the cooling setpoint otherwise. Both `heating_temperature_setpoint_c` and `cooling_temperature_setpoint_c` are always sent.
40
40
 
41
+ ### Sentinel and placeholder values (UI handling)
42
+
43
+ The Quilt apps use several placeholder values to represent "not set", "ignored in this mode", or missing data. Treat these as metadata, not literal user targets.
44
+
45
+ | Sentinel | Where it appears | Python handling |
46
+ | --- | --- | --- |
47
+ | `heating=8.0`, `cooling=40.0` | `SpaceControls`/`ComfortSetting` for STANDBY | `SpaceControls.has_standby_sentinel_setpoints`, `ComfortSetting.has_standby_sentinel_setpoints` |
48
+ | `heating=0.0`, `cooling=0.0` | `ComfortSettingType.UNSPECIFIED` placeholder | `ComfortSetting.has_unspecified_setpoint_sentinels`, `ComfortSetting.has_placeholder_setpoints` |
49
+ | `comfort_setting_id=""` | Manual/direct-control mode (no preset bound) | `SpaceControls.has_linked_comfort_setting`, `SpaceControls.comfort_setting_id_or_none` |
50
+ | `louver_fixed_position=0.0` with non-`FIXED` mode | Louver position ignored for AUTO/SWEEP/CLOSED | `IndoorUnitControls.louver_position_is_placeholder`, `ComfortSetting.louver_position_is_placeholder` |
51
+ | `fan_speed_mode=0` | Proto3 default = fan fields absent / unknown in sparse diff | `IndoorUnitControls.fan_speed_is_placeholder` |
52
+ | `fanSpeedMaxRpm=0.0` | Indoor-unit hardware spec missing/unpopulated (KMP model) | Reserved constant: `UNSET_MAX_FAN_SPEED_RPM_SENTINEL` |
53
+ | `NaN` in temperatures/energy | Missing or invalid measurement samples | `SpaceState.has_missing_ambient_temperature`, `SpaceState.has_missing_setpoint`, `EnergyBucket.has_missing_energy_value` |
54
+ | timestamp seconds `0` | Proto timestamp unset (no check-in yet) | Parsed as `None` for model datetimes (`updated_at`, `wifi_last_seen`) |
55
+ | unknown schedule weekday | Invalid/unknown day in ordering logic | `ScheduleWeekDay.weekday_sort_order` maps to tail sentinel |
56
+
57
+ For display code, prefer the `*_or_none` helpers and sentinel predicates over direct field formatting.
58
+
41
59
  ---
42
60
 
43
61
  ## IndoorUnit
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "quilt-hp-python"
7
- version = "0.1.4"
7
+ version = "0.2.1"
8
8
  description = "Async Python client for Quilt mini-split HVAC systems"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -136,7 +136,7 @@ ignore_missing_imports = true
136
136
 
137
137
  [tool.pytest.ini_options]
138
138
  testpaths = ["tests"]
139
- addopts = "-ra -q --cov=quilt_hp --cov-report=term-missing --cov-fail-under=80"
139
+ addopts = "-ra -q --cov=quilt_hp --cov-report=term-missing --cov-fail-under=80 -p no:homeassistant"
140
140
  asyncio_mode = "auto"
141
141
 
142
142
 
@@ -10,18 +10,28 @@ Usage:
10
10
 
11
11
  import argparse
12
12
  import re
13
- import sys
14
13
  from datetime import date
15
14
  from pathlib import Path
16
15
 
17
16
  ROOT = Path(__file__).resolve().parent.parent
18
17
 
19
- # Files where the bare version string (e.g. "0.1.0") should be replaced.
20
- VERSIONED_FILES = [
21
- ROOT / "pyproject.toml",
22
- ROOT / "src/quilt_hp/__init__.py",
23
- ROOT / "docs/reference/client.md",
24
- ROOT / "tests/test_cli_surfaces_extra.py",
18
+ VERSION_TARGETS = [
19
+ (
20
+ ROOT / "pyproject.toml",
21
+ re.compile(r'(?P<pre>^version\s*=\s*")(?P<version>\d+\.\d+\.\d+)(?P<post>")', re.MULTILINE),
22
+ ),
23
+ (
24
+ ROOT / "src/quilt_hp/__init__.py",
25
+ re.compile(r'(?P<pre>^__version__\s*=\s*")(?P<version>\d+\.\d+\.\d+)(?P<post>")', re.MULTILINE),
26
+ ),
27
+ (
28
+ ROOT / "docs/reference/client.md",
29
+ re.compile(r'(?P<pre>__version__:\s*str\s*#\s*e\.g\.\s*")(?P<version>\d+\.\d+\.\d+)(?P<post>")'),
30
+ ),
31
+ (
32
+ ROOT / "tests/test_cli_surfaces_extra.py",
33
+ re.compile(r'(?P<pre>result\.stdout\.strip\(\)\s*==\s*")(?P<version>\d+\.\d+\.\d+)(?P<post>")'),
34
+ ),
25
35
  ]
26
36
 
27
37
  CHANGELOG = ROOT / "CHANGELOG.md"
@@ -44,14 +54,15 @@ def next_semver(version: str, part: str) -> str:
44
54
  return f"{major}.{minor}.{patch + 1}"
45
55
 
46
56
 
47
- def update_files(old: str, new: str) -> None:
48
- for path in VERSIONED_FILES:
57
+ def update_files(new: str) -> None:
58
+ replacement = rf"\g<pre>{new}\g<post>"
59
+ for path, pattern in VERSION_TARGETS:
49
60
  text = path.read_text(encoding="utf-8")
50
- count = text.count(old)
61
+ updated, count = pattern.subn(replacement, text)
51
62
  if count == 0:
52
63
  print(f" SKIP {path.relative_to(ROOT)} (version string not found)")
53
64
  continue
54
- path.write_text(text.replace(old, new), encoding="utf-8")
65
+ path.write_text(updated, encoding="utf-8")
55
66
  print(f" updated {path.relative_to(ROOT)} ({count} occurrence{'s' if count > 1 else ''})")
56
67
 
57
68
 
@@ -106,9 +117,9 @@ def main() -> None:
106
117
  raise SystemExit(f"ERROR: New version is the same as current ({old})")
107
118
 
108
119
  print(f"Bumping {old} → {new}\n")
109
- update_files(old, new)
120
+ update_files(new)
110
121
  update_changelog(old, new)
111
- print(f"\nNext steps:")
122
+ print("\nNext steps:")
112
123
  print(f" 1. Fill in the ## [{new}] section in CHANGELOG.md")
113
124
  print(f" 2. git add -A && git commit -m 'chore: bump version to {new}'")
114
125
  print(f" 3. git tag v{new} && git push origin main v{new}")