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.
- quilt_hp_python-0.2.1/.github/copilot-instructions.md +145 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/CHANGELOG.md +8 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/PKG-INFO +1 -1
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/home-assistant.md +236 -20
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/client.md +1 -1
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/hds-entities.md +18 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/pyproject.toml +2 -2
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/scripts/bump_version.py +24 -13
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/__init__.py +1 -1
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/tui.py +7 -7
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/const.py +19 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/comfort.py +37 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/controller.py +3 -2
- quilt_hp_python-0.2.1/src/quilt_hp/models/energy.py +47 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/indoor_unit.py +21 -1
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/schedule.py +21 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/space.py +68 -1
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/system.py +38 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/transport.py +16 -2
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_cli_surfaces_extra.py +1 -1
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_models_from_proto.py +244 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_transport_interceptor_extra.py +29 -0
- quilt_hp_python-0.2.1/tests/test_tui_bindings.py +23 -0
- quilt_hp_python-0.1.4/src/quilt_hp/models/energy.py +0 -31
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/.github/workflows/ci.yml +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/.github/workflows/docs-deploy.yml +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/.github/workflows/release.yml +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/.gitignore +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/LICENSE +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/README.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/architecture.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/authentication.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/grpc-and-protobuf.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/snapshot-and-stream.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/explanation/streaming-protocol.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/authenticate.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/automation-daemon.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/cli-scripting.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/configure-comfort-settings.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/configure-schedules.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/contribute.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/control-spaces.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/regenerate-protos.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/stream-updates.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/how-to/tui-app.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/index.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/api-reference.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/documentation-standards.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/grpc-services-matrix.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/models.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/reference/token-management.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/docs/tutorial/get-started.md +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/mkdocs.yml +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_device_pairing.proto +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_hds.proto +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_notifier.proto +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_services.proto +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/proto/cleaned/quilt_system.proto +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/scripts/check_docs_nav.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/scripts/generate_public_api_reference.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/scripts/regen_protos.sh +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_paths.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/__init__.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2.pyi +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_device_pairing_pb2_grpc.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_hds_pb2.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_hds_pb2.pyi +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_hds_pb2_grpc.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_notifier_pb2.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_notifier_pb2.pyi +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_notifier_pb2_grpc.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_services_pb2.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_services_pb2.pyi +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_services_pb2_grpc.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_system_pb2.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_system_pb2.pyi +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/_proto/quilt_system_pb2_grpc.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/auth.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/__init__.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/main.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/settings.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/cli/store.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/client.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/exceptions.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/__init__.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/enums.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/outdoor_unit.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/qsm.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/sensor.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/models/software_update.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/py.typed +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/__init__.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/hds.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/streaming.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/system.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/services/user.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/src/quilt_hp/tokens.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/__init__.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/conftest.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_auth.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_auth_store_settings_edges.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_cli_feature_completion.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_cli_login.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_client_cache.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_client_service_error_paths.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_hds_schedule_mapping.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_hds_service_branches.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_models.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_settings_store.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_streaming.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_streaming_reconnect_dispatch_extra.py +0 -0
- {quilt_hp_python-0.1.4 → quilt_hp_python-0.2.1}/tests/test_tokens.py +0 -0
- {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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
337
|
+
mode = self.space.controls.hvac_mode
|
|
200
338
|
if mode == QHVACMode.COOL:
|
|
201
|
-
return self.space.controls.
|
|
339
|
+
return self.space.controls.cooling_setpoint_c
|
|
202
340
|
if mode == QHVACMode.HEAT:
|
|
203
|
-
return self.space.controls.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
478
|
+
return self.controller.is_online
|
|
263
479
|
```
|
|
264
480
|
|
|
265
481
|
---
|
|
@@ -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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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(
|
|
48
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
120
|
+
update_files(new)
|
|
110
121
|
update_changelog(old, new)
|
|
111
|
-
print(
|
|
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}")
|