vtherm-api 0.1.0__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.
- vtherm_api-0.1.0/LICENSE +21 -0
- vtherm_api-0.1.0/PKG-INFO +575 -0
- vtherm_api-0.1.0/README.md +555 -0
- vtherm_api-0.1.0/pyproject.toml +45 -0
- vtherm_api-0.1.0/setup.cfg +4 -0
- vtherm_api-0.1.0/src/vtherm_api/__init__.py +7 -0
- vtherm_api-0.1.0/src/vtherm_api/commons_type.py +4 -0
- vtherm_api-0.1.0/src/vtherm_api/const.py +46 -0
- vtherm_api-0.1.0/src/vtherm_api/interfaces.py +83 -0
- vtherm_api-0.1.0/src/vtherm_api/log_collector.py +533 -0
- vtherm_api-0.1.0/src/vtherm_api/plugin_climate.py +154 -0
- vtherm_api-0.1.0/src/vtherm_api/vtherm_api.py +115 -0
- vtherm_api-0.1.0/src/vtherm_api.egg-info/PKG-INFO +575 -0
- vtherm_api-0.1.0/src/vtherm_api.egg-info/SOURCES.txt +20 -0
- vtherm_api-0.1.0/src/vtherm_api.egg-info/dependency_links.txt +1 -0
- vtherm_api-0.1.0/src/vtherm_api.egg-info/requires.txt +4 -0
- vtherm_api-0.1.0/src/vtherm_api.egg-info/top_level.txt +1 -0
- vtherm_api-0.1.0/tests/test_log_collector.py +640 -0
- vtherm_api-0.1.0/tests/test_log_download_endpoint.py +192 -0
- vtherm_api-0.1.0/tests/test_package.py +7 -0
- vtherm_api-0.1.0/tests/test_plugin_climate.py +207 -0
- vtherm_api-0.1.0/tests/test_plugin_feature_manager.py +138 -0
vtherm_api-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jean-Marc Collin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vtherm_api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The API of the Versatile Thermostat integration for Home Assistant
|
|
5
|
+
Author: Jean-Marc Collin
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: home-assistant,thermostat,hvac,versatile-thermostat
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Topic :: Home Automation
|
|
13
|
+
Requires-Python: >=3.14
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# vtherm_api
|
|
22
|
+
|
|
23
|
+
Developer-facing API for integrating with Versatile Thermostat inside Home Assistant.
|
|
24
|
+
|
|
25
|
+
This package currently exposes two main building blocks:
|
|
26
|
+
|
|
27
|
+
- `VThermAPI`: a singleton stored in `hass.data` that lets an integration register config entries, access the Home Assistant runtime, and link a plugin climate to an existing Versatile Thermostat entity.
|
|
28
|
+
- `PluginClimate`: an event-driven helper that subscribes to Versatile Thermostat events for one linked thermostat and forwards service calls back to that thermostat.
|
|
29
|
+
|
|
30
|
+
The package is designed for Home Assistant integration code, not as a standalone HTTP or REST API.
|
|
31
|
+
|
|
32
|
+
## Table of contents
|
|
33
|
+
|
|
34
|
+
- [What this package solves](#what-this-package-solves)
|
|
35
|
+
- [Requirements](#requirements)
|
|
36
|
+
- [Installation for development](#installation-for-development)
|
|
37
|
+
- [Architecture](#architecture)
|
|
38
|
+
- [Public imports](#public-imports)
|
|
39
|
+
- [Using VThermAPI](#using-vthermapi)
|
|
40
|
+
- [Creating a FeatureManager](#creating-a-featuremanager)
|
|
41
|
+
- [Using PluginClimate](#using-pluginclimate)
|
|
42
|
+
- [Supported VTherm events](#supported-vtherm-events)
|
|
43
|
+
- [Practical patterns](#practical-patterns)
|
|
44
|
+
- [Testing your integration](#testing-your-integration)
|
|
45
|
+
|
|
46
|
+
## What this package solves
|
|
47
|
+
|
|
48
|
+
When you build custom Home Assistant code around Versatile Thermostat, you usually need to:
|
|
49
|
+
|
|
50
|
+
1. Keep a stable reference to the integration runtime.
|
|
51
|
+
2. Track one specific VTherm climate entity.
|
|
52
|
+
3. Listen only to that thermostat's events.
|
|
53
|
+
4. Relay actions such as HVAC mode or target temperature changes back to the linked thermostat.
|
|
54
|
+
|
|
55
|
+
This package provides those responsibilities out of the box.
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- Python 3.14+
|
|
60
|
+
- Home Assistant runtime objects such as `HomeAssistant`, `ConfigEntry`, `Event`, and the service bus
|
|
61
|
+
- A Versatile Thermostat entity already registered in Home Assistant
|
|
62
|
+
|
|
63
|
+
For local development, the repository uses Home Assistant `2026.3.1`.
|
|
64
|
+
|
|
65
|
+
## Installation for development
|
|
66
|
+
|
|
67
|
+
Install the local development dependencies:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
python -m pip install --upgrade pip
|
|
71
|
+
python -m pip install -r requirements-dev.txt
|
|
72
|
+
python -m pip install -e .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run the test suite:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pytest
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Architecture
|
|
82
|
+
|
|
83
|
+
```mermaid
|
|
84
|
+
flowchart LR
|
|
85
|
+
CE[ConfigEntry] --> API[VThermAPI singleton]
|
|
86
|
+
API --> HD[hass.data]
|
|
87
|
+
VT[Versatile Thermostat entity] -->|emits HA events| BUS[Home Assistant event bus]
|
|
88
|
+
BUS --> PC[PluginClimate]
|
|
89
|
+
PC -->|stores latest payload by event type| STATE[In-memory event cache]
|
|
90
|
+
PC -->|calls services on domain versatile_thermostat| SVC[Home Assistant service registry]
|
|
91
|
+
SVC --> VT
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
```mermaid
|
|
95
|
+
sequenceDiagram
|
|
96
|
+
participant VT as Linked VTherm entity
|
|
97
|
+
participant BUS as HA event bus
|
|
98
|
+
participant PC as PluginClimate
|
|
99
|
+
participant HA as HA services
|
|
100
|
+
|
|
101
|
+
VT->>BUS: versatile_thermostat_temperature_event
|
|
102
|
+
BUS->>PC: event with entity_id
|
|
103
|
+
PC->>PC: ignore if entity_id does not match
|
|
104
|
+
PC->>PC: store payload in get_event_data cache
|
|
105
|
+
PC->>PC: call matching handle_* method
|
|
106
|
+
PC->>HA: async_call(versatile_thermostat, action, target)
|
|
107
|
+
HA->>VT: execute service for linked entity
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Public imports
|
|
111
|
+
|
|
112
|
+
The package root exports `PluginClimate` and `__version__`.
|
|
113
|
+
|
|
114
|
+
Use these imports in integration code:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from vtherm_api import PluginClimate
|
|
118
|
+
from vtherm_api.const import DOMAIN, EventType
|
|
119
|
+
from vtherm_api.vtherm_api import VThermAPI
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Using VThermAPI
|
|
123
|
+
|
|
124
|
+
`VThermAPI` is a singleton attached to the Home Assistant runtime through `hass.data[DOMAIN][VTHERM_API_NAME]`.
|
|
125
|
+
|
|
126
|
+
### Main responsibilities
|
|
127
|
+
|
|
128
|
+
- attach the API instance to a `HomeAssistant` object
|
|
129
|
+
- register and remove `ConfigEntry` instances
|
|
130
|
+
- expose the active `HomeAssistant` object through `api.hass`
|
|
131
|
+
- expose a timezone-aware `api.now`
|
|
132
|
+
- optionally link a plugin climate entity to a VTherm climate entity
|
|
133
|
+
|
|
134
|
+
### Create or retrieve the singleton
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from homeassistant.config_entries import ConfigEntry
|
|
138
|
+
from homeassistant.core import HomeAssistant
|
|
139
|
+
|
|
140
|
+
from vtherm_api.vtherm_api import VThermAPI
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
144
|
+
api = VThermAPI.get_vtherm_api(hass)
|
|
145
|
+
api.add_entry(entry)
|
|
146
|
+
|
|
147
|
+
print(api.name) # VThermAPI
|
|
148
|
+
print(api.hass is hass) # True
|
|
149
|
+
print(api.now) # timezone-aware datetime from HA
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
154
|
+
api = VThermAPI.get_vtherm_api()
|
|
155
|
+
if api is not None:
|
|
156
|
+
api.remove_entry(entry)
|
|
157
|
+
return True
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Reset the singleton
|
|
161
|
+
|
|
162
|
+
This is mainly useful in tests:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
from vtherm_api.vtherm_api import VThermAPI
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def teardown_function() -> None:
|
|
169
|
+
VThermAPI.reset_vtherm_api()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Link a plugin climate through the API helper
|
|
173
|
+
|
|
174
|
+
`VThermAPI.link_to_vtherm(vtherm, plugin_vtherm_entity_id)` searches registered climate entities and links the matching plugin entity to the provided VTherm object.
|
|
175
|
+
|
|
176
|
+
Use this helper only if your plugin climate is already registered as a Home Assistant climate entity and can be found in the climate component.
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from types import SimpleNamespace
|
|
180
|
+
|
|
181
|
+
from vtherm_api.vtherm_api import VThermAPI
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def async_bind_plugin(hass):
|
|
185
|
+
api = VThermAPI.get_vtherm_api(hass)
|
|
186
|
+
|
|
187
|
+
linked_vtherm = SimpleNamespace(entity_id="climate.living_room")
|
|
188
|
+
api.link_to_vtherm(
|
|
189
|
+
linked_vtherm,
|
|
190
|
+
plugin_vtherm_entity_id="climate.living_room_plugin",
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
If you are working with a subclass of `PluginClimate`, direct linking is usually simpler and more explicit. That pattern is shown below.
|
|
195
|
+
|
|
196
|
+
## Creating a FeatureManager
|
|
197
|
+
|
|
198
|
+
`InterfaceFeatureManager` defines the contract expected by `VThermAPI.register_manager(...)`.
|
|
199
|
+
|
|
200
|
+
When you call `api.register_manager(manager)`, the API scans the Home Assistant climate component and forwards the manager to every entity that:
|
|
201
|
+
|
|
202
|
+
- exposes `device_info["model"] == DOMAIN`
|
|
203
|
+
- implements `InterfaceThermostat`
|
|
204
|
+
|
|
205
|
+
In practice, your custom manager only needs to implement the protocol and then be registered once.
|
|
206
|
+
|
|
207
|
+
### Example: OddMinuteFeatureManager
|
|
208
|
+
|
|
209
|
+
The example below toggles `is_detected` to `True` when the current minute is even, and to `False` when it is odd.
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
from typing import Any
|
|
213
|
+
|
|
214
|
+
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
|
215
|
+
|
|
216
|
+
from vtherm_api.interfaces import InterfaceFeatureManager
|
|
217
|
+
from vtherm_api.vtherm_api import VThermAPI
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class OddMinuteFeatureManager(InterfaceFeatureManager):
|
|
221
|
+
def __init__(self, hass: HomeAssistant) -> None:
|
|
222
|
+
self._hass = hass
|
|
223
|
+
self._is_configured = False
|
|
224
|
+
self._is_detected = False
|
|
225
|
+
self._listeners: list[CALLBACK_TYPE] = []
|
|
226
|
+
|
|
227
|
+
def post_init(self, entry_infos: dict[str, Any]) -> None:
|
|
228
|
+
self._is_configured = bool(entry_infos)
|
|
229
|
+
|
|
230
|
+
async def start_listening(self, force: bool = False) -> None:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def stop_listening(self) -> bool:
|
|
234
|
+
for listener in list(self._listeners):
|
|
235
|
+
listener()
|
|
236
|
+
self._listeners.clear()
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
async def refresh_state(self) -> bool:
|
|
240
|
+
api = VThermAPI.get_vtherm_api(self._hass)
|
|
241
|
+
self._is_detected = bool(api and api.now.minute % 2 == 0)
|
|
242
|
+
return self._is_detected
|
|
243
|
+
|
|
244
|
+
def restore_state(self, old_state: Any) -> None:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def add_listener(self, func: CALLBACK_TYPE) -> None:
|
|
248
|
+
self._listeners.append(func)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def is_configured(self) -> bool:
|
|
252
|
+
return self._is_configured
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def is_detected(self) -> bool:
|
|
256
|
+
return self._is_detected
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def name(self) -> str:
|
|
260
|
+
return "OddMinuteFeatureManager"
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def hass(self) -> HomeAssistant:
|
|
264
|
+
return self._hass
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Register the manager through VThermAPI
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
from homeassistant.core import HomeAssistant
|
|
271
|
+
|
|
272
|
+
from vtherm_api.vtherm_api import VThermAPI
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def async_setup_entry(hass: HomeAssistant) -> None:
|
|
276
|
+
api = VThermAPI.get_vtherm_api(hass)
|
|
277
|
+
|
|
278
|
+
manager = OddMinuteFeatureManager(hass)
|
|
279
|
+
manager.post_init({"enabled": True})
|
|
280
|
+
await manager.refresh_state()
|
|
281
|
+
|
|
282
|
+
api.register_manager(manager)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
### Minimal thermostat side contract
|
|
286
|
+
|
|
287
|
+
The thermostat receiving the manager must implement `InterfaceThermostat` and expose a `register_manager(...)` method. A minimal test double looks like this:
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from vtherm_api.const import DOMAIN
|
|
291
|
+
from vtherm_api.interfaces import InterfaceFeatureManager, InterfaceThermostat
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class FakeVTherm(InterfaceThermostat):
|
|
295
|
+
def __init__(self) -> None:
|
|
296
|
+
self._registered_managers: list[InterfaceFeatureManager] = []
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def name(self) -> str:
|
|
300
|
+
return "FakeVTherm"
|
|
301
|
+
|
|
302
|
+
def register_manager(self, manager: InterfaceFeatureManager) -> None:
|
|
303
|
+
self._registered_managers.append(manager)
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def unique_id(self) -> str:
|
|
307
|
+
return "fake-vtherm"
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def device_info(self) -> dict[str, str]:
|
|
311
|
+
return {"model": DOMAIN}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
This is the same registration flow used in the test suite to validate manager instantiation and registration.
|
|
315
|
+
|
|
316
|
+
## Using PluginClimate
|
|
317
|
+
|
|
318
|
+
`PluginClimate` is an event listener and service forwarder bound to one linked thermostat.
|
|
319
|
+
|
|
320
|
+
### What happens when you link it
|
|
321
|
+
|
|
322
|
+
When you call `plugin.link_to_vtherm(vtherm)`:
|
|
323
|
+
|
|
324
|
+
1. Existing listeners are removed.
|
|
325
|
+
2. The new thermostat reference is stored in `plugin.linked_vtherm`.
|
|
326
|
+
3. One Home Assistant listener is registered for each `EventType`.
|
|
327
|
+
|
|
328
|
+
Only events whose `entity_id` matches `linked_vtherm.entity_id` are handled.
|
|
329
|
+
|
|
330
|
+
### Basic usage
|
|
331
|
+
|
|
332
|
+
```python
|
|
333
|
+
from types import SimpleNamespace
|
|
334
|
+
|
|
335
|
+
from homeassistant.core import HomeAssistant
|
|
336
|
+
|
|
337
|
+
from vtherm_api import PluginClimate
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def create_plugin(hass: HomeAssistant) -> PluginClimate:
|
|
341
|
+
plugin = PluginClimate(hass)
|
|
342
|
+
plugin.link_to_vtherm(SimpleNamespace(entity_id="climate.living_room"))
|
|
343
|
+
return plugin
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Subclass PluginClimate to react to events
|
|
347
|
+
|
|
348
|
+
The default handler methods are intentionally empty. Override the handlers you need.
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
from typing import Any
|
|
352
|
+
|
|
353
|
+
from homeassistant.core import Event, HomeAssistant
|
|
354
|
+
|
|
355
|
+
from vtherm_api.const import EventType
|
|
356
|
+
from vtherm_api.plugin_climate import PluginClimate
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
class RecordingPluginClimate(PluginClimate):
|
|
360
|
+
def __init__(self, hass: HomeAssistant) -> None:
|
|
361
|
+
super().__init__(hass)
|
|
362
|
+
self.temperature_updates: list[dict[str, Any]] = []
|
|
363
|
+
self.mode_updates: list[dict[str, Any]] = []
|
|
364
|
+
|
|
365
|
+
def handle_temperature_event(self, event: Event) -> None:
|
|
366
|
+
payload = self.get_event_data(EventType.TEMPERATURE_EVENT)
|
|
367
|
+
self.temperature_updates.append(payload)
|
|
368
|
+
|
|
369
|
+
def handle_hvac_mode_event(self, event: Event) -> None:
|
|
370
|
+
payload = self.get_event_data(EventType.HVAC_MODE_EVENT)
|
|
371
|
+
self.mode_updates.append(payload)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Then link it explicitly:
|
|
375
|
+
|
|
376
|
+
```python
|
|
377
|
+
from types import SimpleNamespace
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
plugin = RecordingPluginClimate(hass)
|
|
381
|
+
plugin.link_to_vtherm(SimpleNamespace(entity_id="climate.living_room"))
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Simulate a VTherm event
|
|
385
|
+
|
|
386
|
+
This is useful in tests or when validating your integration wiring.
|
|
387
|
+
|
|
388
|
+
```python
|
|
389
|
+
from vtherm_api.const import EventType
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
hass.bus.async_fire(
|
|
393
|
+
EventType.TEMPERATURE_EVENT.value,
|
|
394
|
+
{
|
|
395
|
+
"entity_id": "climate.living_room",
|
|
396
|
+
"current_temperature": 20.5,
|
|
397
|
+
"target_temperature": 21.0,
|
|
398
|
+
},
|
|
399
|
+
)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
After the event is handled:
|
|
403
|
+
|
|
404
|
+
- `plugin.last_event_type` becomes `EventType.TEMPERATURE_EVENT`
|
|
405
|
+
- `plugin.get_event_data(EventType.TEMPERATURE_EVENT)` returns the last payload for that event type
|
|
406
|
+
|
|
407
|
+
### Forward an action to the linked thermostat
|
|
408
|
+
|
|
409
|
+
`PluginClimate.call_linked_vtherm_action(...)` delegates a Home Assistant service call to the `versatile_thermostat` domain and automatically targets the linked thermostat entity.
|
|
410
|
+
|
|
411
|
+
```python
|
|
412
|
+
await plugin.call_linked_vtherm_action(
|
|
413
|
+
"set_hvac_mode",
|
|
414
|
+
action_data={"hvac_mode": "heat"},
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
await plugin.call_linked_vtherm_action(
|
|
418
|
+
"set_target_temperature",
|
|
419
|
+
action_data={"temperature": 19.5},
|
|
420
|
+
blocking=True,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
await plugin.call_linked_vtherm_action(
|
|
424
|
+
"set_preset_mode",
|
|
425
|
+
action_data={"preset_mode": "eco"},
|
|
426
|
+
return_response=True,
|
|
427
|
+
)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Under the hood, the service call is equivalent to:
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
await hass.services.async_call(
|
|
434
|
+
DOMAIN,
|
|
435
|
+
action_name,
|
|
436
|
+
action_data,
|
|
437
|
+
blocking,
|
|
438
|
+
context,
|
|
439
|
+
{"entity_id": linked_vtherm.entity_id},
|
|
440
|
+
return_response,
|
|
441
|
+
)
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
If no thermostat is linked, the method raises `RuntimeError`.
|
|
445
|
+
|
|
446
|
+
### Remove listeners on unload
|
|
447
|
+
|
|
448
|
+
If your integration unloads a plugin climate, remove the listeners explicitly:
|
|
449
|
+
|
|
450
|
+
```python
|
|
451
|
+
plugin.remove_listeners()
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## Supported VTherm events
|
|
455
|
+
|
|
456
|
+
The plugin listens to every event defined by `EventType`.
|
|
457
|
+
|
|
458
|
+
| Event type | Home Assistant event name | Handler method |
|
|
459
|
+
| --------------------------------- | -------------------------------------------- | ------------------------------ |
|
|
460
|
+
| `EventType.SAFETY_EVENT` | `versatile_thermostat_safety_event` | `handle_safety_event` |
|
|
461
|
+
| `EventType.POWER_EVENT` | `versatile_thermostat_power_event` | `handle_power_event` |
|
|
462
|
+
| `EventType.TEMPERATURE_EVENT` | `versatile_thermostat_temperature_event` | `handle_temperature_event` |
|
|
463
|
+
| `EventType.HVAC_MODE_EVENT` | `versatile_thermostat_hvac_mode_event` | `handle_hvac_mode_event` |
|
|
464
|
+
| `EventType.CENTRAL_BOILER_EVENT` | `versatile_thermostat_central_boiler_event` | `handle_central_boiler_event` |
|
|
465
|
+
| `EventType.PRESET_EVENT` | `versatile_thermostat_preset_event` | `handle_preset_event` |
|
|
466
|
+
| `EventType.WINDOW_AUTO_EVENT` | `versatile_thermostat_window_auto_event` | `handle_window_auto_event` |
|
|
467
|
+
| `EventType.AUTO_START_STOP_EVENT` | `versatile_thermostat_auto_start_stop_event` | `handle_auto_start_stop_event` |
|
|
468
|
+
| `EventType.TIMED_PRESET_EVENT` | `versatile_thermostat_timed_preset_event` | `handle_timed_preset_event` |
|
|
469
|
+
| `EventType.HEATING_FAILURE_EVENT` | `versatile_thermostat_heating_failure_event` | `handle_heating_failure_event` |
|
|
470
|
+
|
|
471
|
+
## Practical patterns
|
|
472
|
+
|
|
473
|
+
### Pattern 1: register the API in your custom integration
|
|
474
|
+
|
|
475
|
+
```python
|
|
476
|
+
from homeassistant.config_entries import ConfigEntry
|
|
477
|
+
from homeassistant.core import HomeAssistant
|
|
478
|
+
|
|
479
|
+
from vtherm_api.vtherm_api import VThermAPI
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
483
|
+
VThermAPI.get_vtherm_api(hass).add_entry(entry)
|
|
484
|
+
return True
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|
488
|
+
api = VThermAPI.get_vtherm_api()
|
|
489
|
+
if api is not None:
|
|
490
|
+
api.remove_entry(entry)
|
|
491
|
+
return True
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
### Pattern 2: build a plugin that mirrors temperature updates
|
|
495
|
+
|
|
496
|
+
```python
|
|
497
|
+
from types import SimpleNamespace
|
|
498
|
+
|
|
499
|
+
from homeassistant.core import Event
|
|
500
|
+
|
|
501
|
+
from vtherm_api.const import EventType
|
|
502
|
+
from vtherm_api.plugin_climate import PluginClimate
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class MirrorPluginClimate(PluginClimate):
|
|
506
|
+
def __init__(self, hass):
|
|
507
|
+
super().__init__(hass)
|
|
508
|
+
self.last_target_temperature = None
|
|
509
|
+
|
|
510
|
+
def handle_temperature_event(self, event: Event) -> None:
|
|
511
|
+
payload = self.get_event_data(EventType.TEMPERATURE_EVENT)
|
|
512
|
+
self.last_target_temperature = payload.get("target_temperature")
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
plugin = MirrorPluginClimate(hass)
|
|
516
|
+
plugin.link_to_vtherm(SimpleNamespace(entity_id="climate.office"))
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Pattern 3: expose a command through your own integration code
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
async def async_set_eco(plugin: PluginClimate) -> None:
|
|
523
|
+
await plugin.call_linked_vtherm_action(
|
|
524
|
+
"set_preset_mode",
|
|
525
|
+
action_data={"preset_mode": "eco"},
|
|
526
|
+
blocking=True,
|
|
527
|
+
)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
## Testing your integration
|
|
531
|
+
|
|
532
|
+
The repository tests show the intended behavior clearly:
|
|
533
|
+
|
|
534
|
+
- `PluginClimate.link_to_vtherm(...)` registers one listener per event type
|
|
535
|
+
- unrelated thermostat events are ignored
|
|
536
|
+
- the last payload is stored per event type
|
|
537
|
+
- `call_linked_vtherm_action(...)` forwards parameters to `hass.services.async_call(...)`
|
|
538
|
+
|
|
539
|
+
Minimal async test example:
|
|
540
|
+
|
|
541
|
+
```python
|
|
542
|
+
from types import SimpleNamespace
|
|
543
|
+
from unittest.mock import AsyncMock, MagicMock
|
|
544
|
+
|
|
545
|
+
import pytest
|
|
546
|
+
|
|
547
|
+
from vtherm_api.plugin_climate import PluginClimate
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@pytest.mark.asyncio
|
|
551
|
+
async def test_plugin_forwards_hvac_mode() -> None:
|
|
552
|
+
hass = MagicMock()
|
|
553
|
+
hass.services.async_call = AsyncMock(return_value="ok")
|
|
554
|
+
|
|
555
|
+
plugin = PluginClimate(hass)
|
|
556
|
+
plugin.link_to_vtherm(SimpleNamespace(entity_id="climate.salon"))
|
|
557
|
+
|
|
558
|
+
result = await plugin.call_linked_vtherm_action(
|
|
559
|
+
"set_hvac_mode",
|
|
560
|
+
action_data={"hvac_mode": "heat"},
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
assert result == "ok"
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
## Summary
|
|
567
|
+
|
|
568
|
+
Use `VThermAPI` when you need a stable Home Assistant-scoped singleton for Versatile Thermostat integration state.
|
|
569
|
+
|
|
570
|
+
Use `PluginClimate` when you need to:
|
|
571
|
+
|
|
572
|
+
- subscribe to VTherm events for one thermostat
|
|
573
|
+
- keep the latest event payloads in memory
|
|
574
|
+
- override dedicated handlers for domain-specific behavior
|
|
575
|
+
- forward service calls to the linked thermostat without rebuilding the Home Assistant target payload yourself
|