Skip to content

Commit aff06e9

Browse files
authored
feat: add viaGateway mode for shared per-gateway service (#777)
* refactor: move roles + isGateway from Service to Device/DeviceConfig Preparation for the viaGateway shared-service mode (next commit): when one ViCareServiceViaGateway instance serves all devices on a gateway, the service cannot answer per-device role questions because its roles list would be whichever device was processed first. Move role-based decisions onto the device-identity side of the architecture, where they belong. - DeviceConfig.hasRoles(roles) + DeviceConfig.isGateway() use self.roles instead of self.service.hasRoles / self.service._isGateway. - Device base class now takes optional roles list; all DeviceConfig.as* factories pass it through. Device.isLegacyDevice / isE3Device use self.roles. - HeatingDevice.get_heat_curve_formular uses self.roles for the heatpump/E3 check. - Service-side hasRoles / _isGateway are kept untouched for backwards compatibility (still used internally by the per-device service for URL shape selection). No public API removals. Test mocks updated to pass roles via the PyViCareDeviceConfig constructor instead of patching service.hasRoles. * feat: add viaGateway mode for bulk per-gateway feature fetching Adds an opt-in service mode where ONE ViCareCachedServiceViaGateway instance serves all devices on a gateway from a single bulk API call, instead of N per-device calls per refresh. Endpoint used (already verified byte-identical for overlapping features): GET /features/installations/{id}/gateways/{serial}/features?includeDevicesFeatures=true Public API: vicare = PyViCare() vicare.loadViaGateway(True) # default False; must precede initWith* vicare.initWithCredentials(...) Per-device entry points (device.getProperty, DeviceConfig.as*) are unchanged. The shared service is wired in PyViCare.__extract_all_devices: one service per gateway in viaGateway mode, one per device otherwise. Architecture: - ViCareServiceViaGateway: transport, hits the bulk URL for fetch_all_features and getProperty; setProperty still uses the per-device URL (writes target one specific feature on one specific device). - ViCareCachedServiceViaGateway: caches both getProperty and fetch_all_features against the same cached payload. The fetch_all_features cache is the key optimization for HA Core's DataUpdateCoordinator pattern, where each per-device coordinator calls service.clear_cache + service.fetch_all_features per refresh -- without it, N coordinators on a shared service would still trigger N bulk fetches per cycle. - Defensive error handling (PACKAGE_NOT_PAID_FOR, DeviceCommunicationError, InternalServerError) mirrors ViCareCachedService: serve stale cache on transient failure, raise on first-fetch failure. Carries the design intent of CFenner's stalled #626 forward. The bulk response test fixtures (heatbox1/heatbox2/tcu1) are lifted from that draft. Implementation is fresh on current master because #774 made the previous sketch incompatible (accessor lives on Device now, service is stateless), and the original draft did not actually share the service instance across devices. Tests: 20 new ones across three files covering URL building, per-device filtering of the bulk response, shared-cache behavior across devices, TTL respect, setProperty cache invalidation, stale-cache fallback on transient errors, and end-to-end PyViCare wiring with mocked OAuth. Refs: #626 (CFenner draft) Refs: home-assistant/core#173776 (raised the question) * test(via-gateway): move ViCareCachedService import to module top CI pylint failed with C0415 (import-outside-toplevel). * refactor: dedupe cached-service caching and gateway role check - extract ViCareCachedServiceBase (TTL, locking, stale-cache fallback, write invalidation) shared by ViCareCachedService and ViCareCachedServiceViaGateway via _fetch_uncached/_extract_entities hooks - replace ViCareService._isGateway with module-level GATEWAY_ROLES / is_gateway_role, also used by PyViCareDeviceConfig.isGateway - move PyViCare.viaGateway to a class attribute - annotate filter_features_for_device Addresses review feedback on #777. * fix: make cached base a ViCareService so setProperty resolves cleanly Avoids the mixin super() lint/type noise; the concrete gateway __init__ keeps its explicit parent init (pylint super-init-not-called silenced).
1 parent f70c749 commit aff06e9

17 files changed

Lines changed: 6054 additions & 150 deletions

PyViCare/PyViCare.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from PyViCare.PyViCareAbstractOAuthManager import AbstractViCareOAuthManager
55
from PyViCare.PyViCareBrowserOAuthManager import ViCareBrowserOAuthManager
66
from PyViCare.PyViCareCachedService import ViCareCachedService
7+
from PyViCare.PyViCareCachedServiceViaGateway import ViCareCachedServiceViaGateway
78
from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
89
from PyViCare.PyViCareOAuthManager import ViCareOAuthManager
910
from PyViCare.PyViCareService import ViCareDeviceAccessor, ViCareService
11+
from PyViCare.PyViCareServiceViaGateway import ViCareServiceViaGateway
1012
from PyViCare.PyViCareUtils import PyViCareInvalidDataError
1113

1214
logger = logging.getLogger(__name__)
@@ -15,12 +17,25 @@
1517

1618
class PyViCare:
1719
""""Viessmann ViCare API Python tools"""
20+
viaGateway = False
21+
1822
def __init__(self) -> None:
1923
self.cacheDuration = 60
2024

2125
def setCacheDuration(self, cache_duration):
2226
self.cacheDuration = int(cache_duration)
2327

28+
def loadViaGateway(self, via_gateway: bool = True) -> None:
29+
"""Opt in to per-gateway bulk fetching.
30+
31+
When enabled, ONE service instance per gateway serves all devices on
32+
that gateway via the bulk endpoint
33+
`/features/installations/{id}/gateways/{serial}/features?includeDevicesFeatures=true`,
34+
instead of one HTTP call per device per refresh. Must be called
35+
before initWith* (services are wired during __loadInstallations).
36+
"""
37+
self.viaGateway = via_gateway
38+
2439
def initWithCredentials(self, username: str, password: str, client_id: str, token_file: str):
2540
self.initWithExternalOAuth(ViCareOAuthManager(
2641
username, password, client_id, token_file))
@@ -37,6 +52,11 @@ def __buildService(self, roles):
3752
return ViCareCachedService(self.oauth_manager, roles, self.cacheDuration)
3853
return ViCareService(self.oauth_manager, roles)
3954

55+
def __buildGatewayService(self):
56+
if self.cacheDuration > 0:
57+
return ViCareCachedServiceViaGateway(self.oauth_manager, self.cacheDuration)
58+
return ViCareServiceViaGateway(self.oauth_manager)
59+
4060
def __loadInstallations(self):
4161
installations = self.oauth_manager.get(
4262
"/equipment/installations?includeGateways=true")
@@ -58,10 +78,11 @@ def __loadInstallations(self):
5878
def __extract_all_devices(self):
5979
for installation in self.installations:
6080
for gateway in installation.gateways:
81+
shared_service = self.__buildGatewayService() if self.viaGateway else None
6182
for device in gateway.devices:
6283
accessor = ViCareDeviceAccessor(
6384
installation.id, gateway.serial, device.id)
64-
service = self.__buildService(device.roles)
85+
service = shared_service if shared_service is not None else self.__buildService(device.roles)
6586

6687
logger.info("Device found: %s (type=%s)", device.modelId, device.deviceType)
6788

PyViCare/PyViCareCachedService.py

Lines changed: 10 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,23 @@
11
import logging
2-
import threading
3-
from datetime import datetime
4-
from typing import Any, List, Optional
2+
from typing import Any, List
53

64
from PyViCare.PyViCareAbstractOAuthManager import AbstractViCareOAuthManager
7-
from PyViCare.PyViCareService import (ViCareDeviceAccessor, ViCareService,
8-
readFeature)
9-
from PyViCare.PyViCareUtils import (PyViCareDeviceCommunicationError,
10-
PyViCareInvalidDataError,
11-
PyViCareInternalServerError,
12-
PyViCareNotPaidForError,
13-
PyViCareNotSupportedFeatureError,
14-
ViCareTimer)
5+
from PyViCare.PyViCareCachedServiceBase import ViCareCachedServiceBase
6+
from PyViCare.PyViCareService import ViCareDeviceAccessor, ViCareService
157

168
logger = logging.getLogger(__name__)
179
logger.addHandler(logging.NullHandler())
1810

1911

20-
class ViCareCachedService(ViCareService):
12+
class ViCareCachedService(ViCareCachedServiceBase, ViCareService):
2113

2214
def __init__(self, oauth_manager: AbstractViCareOAuthManager, roles: List[str], cacheDuration: int) -> None:
2315
ViCareService.__init__(self, oauth_manager, roles)
24-
self.__cacheDuration = cacheDuration
25-
self.__cache: Optional[dict] = None
26-
self.__cacheTime: Optional[datetime] = None
27-
self.__lock = threading.Lock()
16+
self._init_cache(cacheDuration)
2817

29-
def getProperty(self, accessor: ViCareDeviceAccessor, property_name: str) -> Any:
30-
data = self.__get_or_update_cache(accessor)
31-
entities = data["data"]
32-
return readFeature(entities, property_name)
18+
def _fetch_uncached(self, accessor: ViCareDeviceAccessor) -> Any:
19+
return ViCareService.fetch_all_features(self, accessor)
3320

34-
def setProperty(self, accessor: ViCareDeviceAccessor, property_name: str, action: str, data: Any) -> Any:
35-
response = super().setProperty(accessor, property_name, action, data)
36-
self.clear_cache()
37-
return response
38-
39-
def __get_or_update_cache(self, accessor: ViCareDeviceAccessor):
40-
with self.__lock:
41-
if self.is_cache_invalid():
42-
# we always set the cache time before we fetch the data
43-
# to avoid consuming all the api calls if the api is down
44-
# see https://github.com/home-assistant/core/issues/67052
45-
# we simply return the old cache in this case
46-
self.__cacheTime = ViCareTimer().now()
47-
48-
try:
49-
data = self.fetch_all_features(accessor)
50-
except PyViCareNotPaidForError as e:
51-
logger.error("Viessmann API denied access (PACKAGE_NOT_PAID_FOR). Features unavailable: %s", e)
52-
if self.__cache is not None:
53-
return self.__cache
54-
raise PyViCareNotSupportedFeatureError("PACKAGE_NOT_PAID_FOR")
55-
except (PyViCareDeviceCommunicationError, PyViCareInternalServerError) as e:
56-
if self.__cache is not None:
57-
logger.warning("API error, returning stale cache: %s", e)
58-
return self.__cache
59-
raise
60-
61-
if "data" not in data:
62-
logger.error("Missing 'data' property when fetching data.")
63-
raise PyViCareInvalidDataError(data)
64-
self.__cache = data
65-
return self.__cache
66-
67-
def is_cache_invalid(self) -> bool:
68-
return self.__cache is None or self.__cacheTime is None or (ViCareTimer().now() - self.__cacheTime).seconds > self.__cacheDuration
69-
70-
def clear_cache(self):
71-
with self.__lock:
72-
self.__cache = None
73-
self.__cacheTime = None
21+
def _extract_entities(self, data: dict, accessor: ViCareDeviceAccessor) -> list[dict[str, Any]]:
22+
entities: list[dict[str, Any]] = data["data"]
23+
return entities
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import logging
2+
import threading
3+
from datetime import datetime
4+
from typing import Any, Optional
5+
6+
from PyViCare.PyViCareService import (ViCareDeviceAccessor, ViCareService,
7+
readFeature)
8+
from PyViCare.PyViCareUtils import (PyViCareDeviceCommunicationError,
9+
PyViCareInternalServerError,
10+
PyViCareInvalidDataError,
11+
PyViCareNotPaidForError,
12+
PyViCareNotSupportedFeatureError,
13+
ViCareTimer)
14+
15+
logger = logging.getLogger(__name__)
16+
logger.addHandler(logging.NullHandler())
17+
18+
19+
class ViCareCachedServiceBase(ViCareService):
20+
"""Time-based caching shared by the per-device and per-gateway services.
21+
22+
Subclasses provide `_fetch_uncached` (the HTTP fetch) and `_extract_entities`
23+
(pick this device's features out of the cached payload).
24+
"""
25+
26+
def _init_cache(self, cacheDuration: int) -> None:
27+
self._cacheDuration = cacheDuration
28+
self._cache: Optional[dict] = None
29+
self._cacheTime: Optional[datetime] = None
30+
self._cacheLock = threading.Lock()
31+
32+
def getProperty(self, accessor: ViCareDeviceAccessor, property_name: str) -> Any:
33+
data = self._get_or_update_cache(accessor)
34+
entities = self._extract_entities(data, accessor)
35+
return readFeature(entities, property_name)
36+
37+
def setProperty(self, accessor: ViCareDeviceAccessor, property_name: str, action: str, data: Any) -> Any:
38+
response = super().setProperty(accessor, property_name, action, data)
39+
self.clear_cache()
40+
return response
41+
42+
def _get_or_update_cache(self, accessor: ViCareDeviceAccessor):
43+
with self._cacheLock:
44+
if self.is_cache_invalid():
45+
# we always set the cache time before we fetch the data
46+
# to avoid consuming all the api calls if the api is down
47+
# see https://github.com/home-assistant/core/issues/67052
48+
# we simply return the old cache in this case
49+
self._cacheTime = ViCareTimer().now()
50+
51+
try:
52+
data = self._fetch_uncached(accessor)
53+
except PyViCareNotPaidForError as e:
54+
logger.error("Viessmann API denied access (PACKAGE_NOT_PAID_FOR). Features unavailable: %s", e)
55+
if self._cache is not None:
56+
return self._cache
57+
raise PyViCareNotSupportedFeatureError("PACKAGE_NOT_PAID_FOR")
58+
except (PyViCareDeviceCommunicationError, PyViCareInternalServerError) as e:
59+
if self._cache is not None:
60+
logger.warning("API error, returning stale cache: %s", e)
61+
return self._cache
62+
raise
63+
64+
if "data" not in data:
65+
logger.error("Missing 'data' property when fetching data.")
66+
raise PyViCareInvalidDataError(data)
67+
self._cache = data
68+
return self._cache
69+
70+
def is_cache_invalid(self) -> bool:
71+
return self._cache is None or self._cacheTime is None or (ViCareTimer().now() - self._cacheTime).seconds > self._cacheDuration
72+
73+
def clear_cache(self):
74+
with self._cacheLock:
75+
self._cache = None
76+
self._cacheTime = None
77+
78+
def _fetch_uncached(self, accessor: ViCareDeviceAccessor) -> Any:
79+
raise NotImplementedError
80+
81+
def _extract_entities(self, data: dict, accessor: ViCareDeviceAccessor) -> list[dict[str, Any]]:
82+
raise NotImplementedError
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import logging
2+
from typing import Any
3+
4+
from PyViCare.PyViCareAbstractOAuthManager import AbstractViCareOAuthManager
5+
from PyViCare.PyViCareCachedServiceBase import ViCareCachedServiceBase
6+
from PyViCare.PyViCareService import ViCareDeviceAccessor
7+
from PyViCare.PyViCareServiceViaGateway import (
8+
ViCareServiceViaGateway, filter_features_for_device)
9+
10+
logger = logging.getLogger(__name__)
11+
logger.addHandler(logging.NullHandler())
12+
13+
14+
class ViCareCachedServiceViaGateway(ViCareCachedServiceBase, ViCareServiceViaGateway):
15+
"""Cached variant of the gateway bulk-fetch service.
16+
17+
One instance is shared across all devices on the gateway; every device's
18+
getProperty reads from the same cached bulk payload. setProperty clears the
19+
whole cache.
20+
"""
21+
22+
def __init__(self, oauth_manager: AbstractViCareOAuthManager, cacheDuration: int) -> None: # pylint: disable=super-init-not-called
23+
ViCareServiceViaGateway.__init__(self, oauth_manager)
24+
self._init_cache(cacheDuration)
25+
26+
def _fetch_uncached(self, accessor: ViCareDeviceAccessor) -> Any:
27+
return ViCareServiceViaGateway.fetch_all_features(self, accessor)
28+
29+
def _extract_entities(self, data: dict, accessor: ViCareDeviceAccessor) -> list[dict[str, Any]]:
30+
return filter_features_for_device(data["data"], accessor.device_id)
31+
32+
def fetch_all_features(self, accessor: ViCareDeviceAccessor) -> Any:
33+
# cached too, so per-device coordinators share one bulk fetch
34+
return self._get_or_update_cache(accessor)

PyViCare/PyViCareDevice.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any
22

3-
from PyViCare.PyViCareService import ViCareDeviceAccessor, ViCareService
3+
from PyViCare.PyViCareService import ViCareDeviceAccessor, ViCareService, hasRoles
44
from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError, handleAPICommandErrors, handleNotSupported
55

66

@@ -11,9 +11,10 @@ class Device:
1111
Note that currently, a new token is generated for each run.
1212
"""
1313

14-
def __init__(self, accessor: ViCareDeviceAccessor, service: ViCareService) -> None:
14+
def __init__(self, accessor: ViCareDeviceAccessor, service: ViCareService, roles: list[str] | None = None) -> None:
1515
self.accessor = accessor
1616
self.service = service
17+
self.roles = roles if roles is not None else []
1718

1819
def getProperty(self, property_name: str) -> Any:
1920
return self.service.getProperty(self.accessor, property_name)
@@ -30,10 +31,10 @@ def getDeviceErrors(self) -> list[Any]:
3031
return list[Any](self.getProperty("device.messages.errors.raw")["properties"]["entries"]["value"])
3132

3233
def isLegacyDevice(self) -> bool:
33-
return self.service.hasRoles(["type:legacy"])
34+
return hasRoles(["type:legacy"], self.roles)
3435

3536
def isE3Device(self) -> bool:
36-
return self.service.hasRoles(["type:E3"])
37+
return hasRoles(["type:E3"], self.roles)
3738

3839
def isDomesticHotWaterDevice(self):
3940
return self._isTypeDevice("heating.dhw")

PyViCare/PyViCareDeviceConfig.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from PyViCare.PyViCareRepeater import Repeater
1717
from PyViCare.PyViCareElectricalEnergySystem import ElectricalEnergySystem
1818
from PyViCare.PyViCareGateway import Gateway
19-
from PyViCare.PyViCareService import ViCareDeviceAccessor, ViCareService
19+
from PyViCare.PyViCareService import (ViCareDeviceAccessor, ViCareService,
20+
hasRoles, is_gateway_role)
2021
from PyViCare.PyViCareUtils import PyViCareNotPaidForError
2122
from PyViCare.PyViCareVentilationDevice import VentilationDevice
2223

@@ -36,52 +37,52 @@ def __init__(self, accessor: ViCareDeviceAccessor, service: ViCareService, devic
3637
self.roles = roles if roles is not None else []
3738

3839
def asGeneric(self):
39-
return HeatingDevice(self.accessor, self.service)
40+
return HeatingDevice(self.accessor, self.service, self.roles)
4041

4142
def asGazBoiler(self):
42-
return GazBoiler(self.accessor, self.service)
43+
return GazBoiler(self.accessor, self.service, self.roles)
4344

4445
def asFuelCell(self):
45-
return FuelCell(self.accessor, self.service)
46+
return FuelCell(self.accessor, self.service, self.roles)
4647

4748
def asHeatPump(self):
48-
return HeatPump(self.accessor, self.service)
49+
return HeatPump(self.accessor, self.service, self.roles)
4950

5051
def asOilBoiler(self):
51-
return OilBoiler(self.accessor, self.service)
52+
return OilBoiler(self.accessor, self.service, self.roles)
5253

5354
def asPelletsBoiler(self):
54-
return PelletsBoiler(self.accessor, self.service)
55+
return PelletsBoiler(self.accessor, self.service, self.roles)
5556

5657
def asHybridDevice(self):
57-
return Hybrid(self.accessor, self.service)
58+
return Hybrid(self.accessor, self.service, self.roles)
5859

5960
def asRadiatorActuator(self):
60-
return RadiatorActuator(self.accessor, self.service)
61+
return RadiatorActuator(self.accessor, self.service, self.roles)
6162

6263
def asFloorHeating(self):
63-
return FloorHeating(self.accessor, self.service)
64+
return FloorHeating(self.accessor, self.service, self.roles)
6465

6566
def asFloorHeatingChannel(self):
66-
return FloorHeatingChannel(self.accessor, self.service)
67+
return FloorHeatingChannel(self.accessor, self.service, self.roles)
6768

6869
def asRoomSensor(self):
69-
return RoomSensor(self.accessor, self.service)
70+
return RoomSensor(self.accessor, self.service, self.roles)
7071

7172
def asRoomControl(self):
72-
return RoomControl(self.accessor, self.service)
73+
return RoomControl(self.accessor, self.service, self.roles)
7374

7475
def asRepeater(self):
75-
return Repeater(self.accessor, self.service)
76+
return Repeater(self.accessor, self.service, self.roles)
7677

7778
def asElectricalEnergySystem(self):
78-
return ElectricalEnergySystem(self.accessor, self.service)
79+
return ElectricalEnergySystem(self.accessor, self.service, self.roles)
7980

8081
def asGateway(self):
81-
return Gateway(self.accessor, self.service)
82+
return Gateway(self.accessor, self.service, self.roles)
8283

8384
def asVentilation(self):
84-
return VentilationDevice(self.accessor, self.service)
85+
return VentilationDevice(self.accessor, self.service, self.roles)
8586

8687
def getConfig(self):
8788
return self.accessor
@@ -101,8 +102,11 @@ def getDeviceType(self):
101102
def getRoles(self):
102103
return self.roles
103104

105+
def hasRoles(self, requested_roles):
106+
return hasRoles(requested_roles, self.roles)
107+
104108
def isGateway(self):
105-
return self.service._isGateway() # pylint: disable=protected-access
109+
return is_gateway_role(self.roles)
106110

107111
# see: https://vitodata300.viessmann.com/vd300/ApplicationHelp/VD300/1031_de_DE/Ger%C3%A4teliste.html
108112
def asAutoDetectDevice(self):
@@ -131,7 +135,7 @@ def asAutoDetectDevice(self):
131135
]
132136

133137
for (creator_method, type_name, roles) in device_types:
134-
if re.search(type_name, self.device_model) or self.service.hasRoles(roles):
138+
if re.search(type_name, self.device_model) or self.hasRoles(roles):
135139
logger.info("detected %s %s", self.device_model, creator_method.__name__)
136140
device = creator_method()
137141
if isinstance(device, (GazBoiler, HeatPump)) and not isinstance(device, Hybrid):

0 commit comments

Comments
 (0)