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.
@@ -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