PyViCare 2.60.2__tar.gz → 2.62.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.
Files changed (37) hide show
  1. {pyvicare-2.60.2 → pyvicare-2.62.0}/PKG-INFO +2 -2
  2. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCare.py +26 -36
  3. pyvicare-2.62.0/PyViCare/PyViCareCachedService.py +23 -0
  4. pyvicare-2.62.0/PyViCare/PyViCareCachedServiceBase.py +87 -0
  5. pyvicare-2.62.0/PyViCare/PyViCareCachedServiceViaGateway.py +30 -0
  6. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareDevice.py +8 -6
  7. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareDeviceConfig.py +55 -35
  8. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareFuelCell.py +1 -1
  9. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareGazBoiler.py +1 -5
  10. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareHeatingDevice.py +19 -4
  11. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareOilBoiler.py +1 -1
  12. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCarePelletsBoiler.py +1 -1
  13. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareRoomControl.py +22 -38
  14. pyvicare-2.62.0/PyViCare/PyViCareRoomSensor.py +17 -0
  15. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareService.py +26 -20
  16. pyvicare-2.62.0/PyViCare/PyViCareServiceViaGateway.py +38 -0
  17. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareVentilationDevice.py +29 -0
  18. pyvicare-2.62.0/PyViCare/PyViCareWaterTreatment.py +141 -0
  19. {pyvicare-2.60.2 → pyvicare-2.62.0}/pyproject.toml +8 -8
  20. pyvicare-2.60.2/PyViCare/PyViCareCachedService.py +0 -72
  21. pyvicare-2.60.2/PyViCare/PyViCareRoomSensor.py +0 -132
  22. {pyvicare-2.60.2 → pyvicare-2.62.0}/LICENSE +0 -0
  23. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/Feature.py +0 -0
  24. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareAbstractOAuthManager.py +0 -0
  25. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareBrowserOAuthManager.py +0 -0
  26. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareElectricalEnergySystem.py +0 -0
  27. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareFloorHeating.py +0 -0
  28. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareGateway.py +0 -0
  29. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareHeatCurveCalculation.py +0 -0
  30. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareHeatPump.py +0 -0
  31. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareHybrid.py +0 -0
  32. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareOAuthManager.py +0 -0
  33. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareRadiatorActuator.py +0 -0
  34. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareRepeater.py +0 -0
  35. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/PyViCareUtils.py +0 -0
  36. {pyvicare-2.60.2 → pyvicare-2.62.0}/PyViCare/__init__.py +0 -0
  37. {pyvicare-2.60.2 → pyvicare-2.62.0}/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyViCare
3
- Version: 2.60.2
3
+ Version: 2.62.0
4
4
  Summary: Library to communicate with the Viessmann ViCare API.
5
5
  License: Apache-2.0
6
6
  License-File: LICENSE
@@ -22,7 +22,7 @@ Classifier: Programming Language :: Python :: 3.13
22
22
  Classifier: Programming Language :: Python :: 3.14
23
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
24
  Requires-Dist: authlib (>1.2.0)
25
- Requires-Dist: deprecated (>=1.2.15,<2.0.0)
25
+ Requires-Dist: deprecated (>=1.3.0,<2.0.0)
26
26
  Requires-Dist: requests (>=2.31.0)
27
27
  Project-URL: Bug Tracker, https://github.com/openviess/PyViCare/issues
28
28
  Project-URL: Changelog, https://github.com/openviess/PyViCare/releases
@@ -4,10 +4,11 @@ from datetime import datetime
4
4
  from PyViCare.PyViCareAbstractOAuthManager import AbstractViCareOAuthManager
5
5
  from PyViCare.PyViCareBrowserOAuthManager import ViCareBrowserOAuthManager
6
6
  from PyViCare.PyViCareCachedService import ViCareCachedService
7
+ from PyViCare.PyViCareCachedServiceViaGateway import ViCareCachedServiceViaGateway
7
8
  from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig
8
9
  from PyViCare.PyViCareOAuthManager import ViCareOAuthManager
9
- from PyViCare.PyViCareRoomControl import RoomControl
10
10
  from PyViCare.PyViCareService import ViCareDeviceAccessor, ViCareService
11
+ from PyViCare.PyViCareServiceViaGateway import ViCareServiceViaGateway
11
12
  from PyViCare.PyViCareUtils import PyViCareInvalidDataError
12
13
 
13
14
  logger = logging.getLogger(__name__)
@@ -16,12 +17,25 @@ logger.addHandler(logging.NullHandler())
16
17
 
17
18
  class PyViCare:
18
19
  """"Viessmann ViCare API Python tools"""
20
+ viaGateway = False
21
+
19
22
  def __init__(self) -> None:
20
23
  self.cacheDuration = 60
21
24
 
22
25
  def setCacheDuration(self, cache_duration):
23
26
  self.cacheDuration = int(cache_duration)
24
27
 
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
+
25
39
  def initWithCredentials(self, username: str, password: str, client_id: str, token_file: str):
26
40
  self.initWithExternalOAuth(ViCareOAuthManager(
27
41
  username, password, client_id, token_file))
@@ -33,10 +47,15 @@ class PyViCare:
33
47
  def initWithBrowserOAuth(self, client_id: str, token_file: str) -> None:
34
48
  self.initWithExternalOAuth(ViCareBrowserOAuthManager(client_id, token_file))
35
49
 
36
- def __buildService(self, accessor, roles):
50
+ def __buildService(self, roles):
37
51
  if self.cacheDuration > 0:
38
- return ViCareCachedService(self.oauth_manager, accessor, roles, self.cacheDuration)
39
- return ViCareService(self.oauth_manager, accessor, roles)
52
+ return ViCareCachedService(self.oauth_manager, roles, self.cacheDuration)
53
+ return ViCareService(self.oauth_manager, roles)
54
+
55
+ def __buildGatewayService(self):
56
+ if self.cacheDuration > 0:
57
+ return ViCareCachedServiceViaGateway(self.oauth_manager, self.cacheDuration)
58
+ return ViCareServiceViaGateway(self.oauth_manager)
40
59
 
41
60
  def __loadInstallations(self):
42
61
  installations = self.oauth_manager.get(
@@ -50,7 +69,6 @@ class PyViCare:
50
69
  self.all_devices = list(self.__extract_all_devices())
51
70
  self.devices = [d for d in self.all_devices
52
71
  if d.device_type in self.SUPPORTED_DEVICE_TYPES]
53
- self.__enrichZigbeeDevices()
54
72
 
55
73
  SUPPORTED_DEVICE_TYPES = [
56
74
  "heating", "zigbee", "vitoconnect", "electricityStorage",
@@ -60,43 +78,15 @@ class PyViCare:
60
78
  def __extract_all_devices(self):
61
79
  for installation in self.installations:
62
80
  for gateway in installation.gateways:
81
+ shared_service = self.__buildGatewayService() if self.viaGateway else None
63
82
  for device in gateway.devices:
64
83
  accessor = ViCareDeviceAccessor(
65
84
  installation.id, gateway.serial, device.id)
66
- service = self.__buildService(accessor, device.roles)
85
+ service = shared_service if shared_service is not None else self.__buildService(device.roles)
67
86
 
68
87
  logger.info("Device found: %s (type=%s)", device.modelId, device.deviceType)
69
88
 
70
- yield PyViCareDeviceConfig(service, device.id, device.modelId, device.status, device.deviceType, device.roles)
71
-
72
- def __enrichZigbeeDevices(self):
73
- """Enrich Zigbee devices with sensor data from RoomControl.
74
-
75
- Viessmann moved temperature/humidity data from physical Zigbee
76
- sensors to the RoomControl virtual device. This reverses that
77
- mapping by cross-referencing RoomControl actors with Zigbee
78
- device IDs.
79
- """
80
- devices_by_id = {device_config.device_id: device_config for device_config in self.devices}
81
-
82
- for device_config in self.devices:
83
- if device_config.device_type != "roomControl":
84
- continue
85
-
86
- room_control = RoomControl(device_config.service)
87
- try:
88
- actor_map = room_control.buildActorRoomMap()
89
- except Exception: # pylint: disable=broad-exception-caught
90
- logger.debug("Could not build actor map for %s", device_config.getModel(), exc_info=True)
91
- continue
92
-
93
- for device_id, room_id in actor_map.items():
94
- zigbee_config = devices_by_id.get(device_id)
95
- if zigbee_config is None:
96
- continue
97
- zigbee_config.setRoomControlEnrichment(room_control, room_id)
98
- logger.info("Enriched %s with room %s data from %s",
99
- zigbee_config.device_id, room_id, device_config.getModel())
89
+ yield PyViCareDeviceConfig(accessor, service, device.modelId, device.status, device.deviceType, device.roles)
100
90
 
101
91
 
102
92
  class DictWrap(object):
@@ -0,0 +1,23 @@
1
+ import logging
2
+ from typing import Any, List
3
+
4
+ from PyViCare.PyViCareAbstractOAuthManager import AbstractViCareOAuthManager
5
+ from PyViCare.PyViCareCachedServiceBase import ViCareCachedServiceBase
6
+ from PyViCare.PyViCareService import ViCareDeviceAccessor, ViCareService
7
+
8
+ logger = logging.getLogger(__name__)
9
+ logger.addHandler(logging.NullHandler())
10
+
11
+
12
+ class ViCareCachedService(ViCareCachedServiceBase, ViCareService):
13
+
14
+ def __init__(self, oauth_manager: AbstractViCareOAuthManager, roles: List[str], cacheDuration: int) -> None:
15
+ ViCareService.__init__(self, oauth_manager, roles)
16
+ self._init_cache(cacheDuration)
17
+
18
+ def _fetch_uncached(self, accessor: ViCareDeviceAccessor) -> Any:
19
+ return ViCareService.fetch_all_features(self, accessor)
20
+
21
+ def _extract_entities(self, data: dict, accessor: ViCareDeviceAccessor) -> list[dict[str, Any]]:
22
+ entities: list[dict[str, Any]] = data["data"]
23
+ return entities
@@ -0,0 +1,87 @@
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 fetch_all_features(self, accessor: ViCareDeviceAccessor) -> Any:
38
+ # cached, so a caller refreshing with fetch_all_features() warms the
39
+ # cache the following getProperty calls read from
40
+ return self._get_or_update_cache(accessor)
41
+
42
+ def setProperty(self, accessor: ViCareDeviceAccessor, property_name: str, action: str, data: Any) -> Any:
43
+ response = super().setProperty(accessor, property_name, action, data)
44
+ self.clear_cache()
45
+ return response
46
+
47
+ def _get_or_update_cache(self, accessor: ViCareDeviceAccessor):
48
+ with self._cacheLock:
49
+ if self.is_cache_invalid():
50
+ # we always set the cache time before we fetch the data
51
+ # to avoid consuming all the api calls if the api is down
52
+ # see https://github.com/home-assistant/core/issues/67052
53
+ # we simply return the old cache in this case
54
+ self._cacheTime = ViCareTimer().now()
55
+
56
+ try:
57
+ data = self._fetch_uncached(accessor)
58
+ except PyViCareNotPaidForError as e:
59
+ logger.error("Viessmann API denied access (PACKAGE_NOT_PAID_FOR). Features unavailable: %s", e)
60
+ if self._cache is not None:
61
+ return self._cache
62
+ raise PyViCareNotSupportedFeatureError("PACKAGE_NOT_PAID_FOR")
63
+ except (PyViCareDeviceCommunicationError, PyViCareInternalServerError) as e:
64
+ if self._cache is not None:
65
+ logger.warning("API error, returning stale cache: %s", e)
66
+ return self._cache
67
+ raise
68
+
69
+ if "data" not in data:
70
+ logger.error("Missing 'data' property when fetching data.")
71
+ raise PyViCareInvalidDataError(data)
72
+ self._cache = data
73
+ return self._cache
74
+
75
+ def is_cache_invalid(self) -> bool:
76
+ return self._cache is None or self._cacheTime is None or (ViCareTimer().now() - self._cacheTime).seconds > self._cacheDuration
77
+
78
+ def clear_cache(self):
79
+ with self._cacheLock:
80
+ self._cache = None
81
+ self._cacheTime = None
82
+
83
+ def _fetch_uncached(self, accessor: ViCareDeviceAccessor) -> Any:
84
+ raise NotImplementedError
85
+
86
+ def _extract_entities(self, data: dict, accessor: ViCareDeviceAccessor) -> list[dict[str, Any]]:
87
+ raise NotImplementedError
@@ -0,0 +1,30 @@
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)
@@ -1,6 +1,6 @@
1
1
  from typing import Any
2
2
 
3
- from PyViCare.PyViCareService import ViCareService
3
+ from PyViCare.PyViCareService import ViCareDeviceAccessor, ViCareService, hasRoles
4
4
  from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError, handleAPICommandErrors, handleNotSupported
5
5
 
6
6
 
@@ -11,14 +11,16 @@ class Device:
11
11
  Note that currently, a new token is generated for each run.
12
12
  """
13
13
 
14
- def __init__(self, service: ViCareService) -> None:
14
+ def __init__(self, accessor: ViCareDeviceAccessor, service: ViCareService, roles: list[str] | None = None) -> None:
15
+ self.accessor = accessor
15
16
  self.service = service
17
+ self.roles = roles if roles is not None else []
16
18
 
17
19
  def getProperty(self, property_name: str) -> Any:
18
- return self.service.getProperty(property_name)
20
+ return self.service.getProperty(self.accessor, property_name)
19
21
 
20
22
  def setProperty(self, property_name: str, action: str, data: Any) -> Any:
21
- return self.service.setProperty(property_name, action, data)
23
+ return self.service.setProperty(self.accessor, property_name, action, data)
22
24
 
23
25
  @handleNotSupported
24
26
  def getSerial(self):
@@ -29,10 +31,10 @@ class Device:
29
31
  return list[Any](self.getProperty("device.messages.errors.raw")["properties"]["entries"]["value"])
30
32
 
31
33
  def isLegacyDevice(self) -> bool:
32
- return self.service.hasRoles(["type:legacy"])
34
+ return hasRoles(["type:legacy"], self.roles)
33
35
 
34
36
  def isE3Device(self) -> bool:
35
- return self.service.hasRoles(["type:E3"])
37
+ return hasRoles(["type:E3"], self.roles)
36
38
 
37
39
  def isDomesticHotWaterDevice(self):
38
40
  return self._isTypeDevice("heating.dhw")
@@ -16,7 +16,11 @@ from PyViCare.PyViCareRoomSensor import RoomSensor
16
16
  from PyViCare.PyViCareRepeater import Repeater
17
17
  from PyViCare.PyViCareElectricalEnergySystem import ElectricalEnergySystem
18
18
  from PyViCare.PyViCareGateway import Gateway
19
+ from PyViCare.PyViCareService import (ViCareDeviceAccessor, ViCareService,
20
+ hasRoles, is_gateway_role)
21
+ from PyViCare.PyViCareUtils import PyViCareNotPaidForError
19
22
  from PyViCare.PyViCareVentilationDevice import VentilationDevice
23
+ from PyViCare.PyViCareWaterTreatment import WaterTreatment
20
24
 
21
25
  logger = logging.getLogger(__name__)
22
26
  logger.addHandler(logging.NullHandler())
@@ -24,74 +28,68 @@ logger.addHandler(logging.NullHandler())
24
28
 
25
29
  class PyViCareDeviceConfig:
26
30
  # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-instance-attributes
27
- def __init__(self, service, device_id, device_model, status, device_type=None, roles=None):
31
+ def __init__(self, accessor: ViCareDeviceAccessor, service: ViCareService, device_model, status, device_type=None, roles=None):
32
+ self.accessor = accessor
28
33
  self.service = service
29
- self.device_id = device_id
34
+ self.device_id = accessor.device_id
30
35
  self.device_model = device_model
31
36
  self.status = status
32
37
  self.device_type = device_type
33
38
  self.roles = roles if roles is not None else []
34
- self._room_control = None
35
- self._room_id = None
36
39
 
37
40
  def asGeneric(self):
38
- return HeatingDevice(self.service)
41
+ return HeatingDevice(self.accessor, self.service, self.roles)
39
42
 
40
43
  def asGazBoiler(self):
41
- return GazBoiler(self.service)
44
+ return GazBoiler(self.accessor, self.service, self.roles)
42
45
 
43
46
  def asFuelCell(self):
44
- return FuelCell(self.service)
47
+ return FuelCell(self.accessor, self.service, self.roles)
45
48
 
46
49
  def asHeatPump(self):
47
- return HeatPump(self.service)
50
+ return HeatPump(self.accessor, self.service, self.roles)
48
51
 
49
52
  def asOilBoiler(self):
50
- return OilBoiler(self.service)
53
+ return OilBoiler(self.accessor, self.service, self.roles)
51
54
 
52
55
  def asPelletsBoiler(self):
53
- return PelletsBoiler(self.service)
56
+ return PelletsBoiler(self.accessor, self.service, self.roles)
54
57
 
55
58
  def asHybridDevice(self):
56
- return Hybrid(self.service)
59
+ return Hybrid(self.accessor, self.service, self.roles)
57
60
 
58
61
  def asRadiatorActuator(self):
59
- return RadiatorActuator(self.service)
62
+ return RadiatorActuator(self.accessor, self.service, self.roles)
60
63
 
61
64
  def asFloorHeating(self):
62
- return FloorHeating(self.service)
65
+ return FloorHeating(self.accessor, self.service, self.roles)
63
66
 
64
67
  def asFloorHeatingChannel(self):
65
- return FloorHeatingChannel(self.service)
68
+ return FloorHeatingChannel(self.accessor, self.service, self.roles)
66
69
 
67
70
  def asRoomSensor(self):
68
- sensor = RoomSensor(self.service)
69
- if self._room_control is not None:
70
- sensor.setRoomControl(self._room_control, self._room_id)
71
- return sensor
71
+ return RoomSensor(self.accessor, self.service, self.roles)
72
72
 
73
73
  def asRoomControl(self):
74
- return RoomControl(self.service)
75
-
76
- def setRoomControlEnrichment(self, room_control, room_id):
77
- """Store RoomControl enrichment data to apply when creating a RoomSensor."""
78
- self._room_control = room_control
79
- self._room_id = room_id
74
+ return RoomControl(self.accessor, self.service, self.roles)
80
75
 
81
76
  def asRepeater(self):
82
- return Repeater(self.service)
77
+ return Repeater(self.accessor, self.service, self.roles)
83
78
 
84
79
  def asElectricalEnergySystem(self):
85
- return ElectricalEnergySystem(self.service)
80
+ return ElectricalEnergySystem(self.accessor, self.service, self.roles)
86
81
 
87
82
  def asGateway(self):
88
- return Gateway(self.service)
83
+ return Gateway(self.accessor, self.service, self.roles)
89
84
 
90
85
  def asVentilation(self):
91
- return VentilationDevice(self.service)
86
+ return VentilationDevice(self.accessor, self.service, self.roles)
87
+
88
+ def asWaterTreatment(self):
89
+ return WaterTreatment(self.accessor, self.service, self.roles)
92
90
 
93
91
  def getConfig(self):
94
- return self.service.accessor
92
+ return self.accessor
95
93
 
96
94
  def getId(self):
97
95
  return self.device_id
@@ -108,8 +106,11 @@ class PyViCareDeviceConfig:
108
106
  def getRoles(self):
109
107
  return self.roles
110
108
 
109
+ def hasRoles(self, requested_roles):
110
+ return hasRoles(requested_roles, self.roles)
111
+
111
112
  def isGateway(self):
112
- return self.service._isGateway() # pylint: disable=protected-access
113
+ return is_gateway_role(self.roles)
113
114
 
114
115
  # see: https://vitodata300.viessmann.com/vd300/ApplicationHelp/VD300/1031_de_DE/Ger%C3%A4teliste.html
115
116
  def asAutoDetectDevice(self):
@@ -130,6 +131,7 @@ class PyViCareDeviceConfig:
130
131
  (self.asRoomControl, r"E3_RoomControl|Smart_RoomControl", ["type:virtual;smartRoomControl"]),
131
132
  (self.asRoomSensor, r"E3_RoomSensor", ["type:climateSensor"]),
132
133
  (self.asRepeater, r"E3_Repeater", ["type:repeater"]),
134
+ (self.asWaterTreatment, r"VitosetAqua", ["type:waterTreatment"]),
133
135
  (self.asGateway, r"E3_TCU41_x04", ["type:gateway;TCU100"]),
134
136
  (self.asGateway, r"E3_TCU19_x05", ["type:gateway;TCU200"]),
135
137
  (self.asGateway, r"E3_TCU10_x07", ["type:gateway;TCU300"]),
@@ -138,7 +140,7 @@ class PyViCareDeviceConfig:
138
140
  ]
139
141
 
140
142
  for (creator_method, type_name, roles) in device_types:
141
- if re.search(type_name, self.device_model) or self.service.hasRoles(roles):
143
+ if re.search(type_name, self.device_model) or self.hasRoles(roles):
142
144
  logger.info("detected %s %s", self.device_model, creator_method.__name__)
143
145
  device = creator_method()
144
146
  if isinstance(device, (GazBoiler, HeatPump)) and not isinstance(device, Hybrid):
@@ -153,20 +155,27 @@ class PyViCareDeviceConfig:
153
155
  def _isHybridByFeatures(self):
154
156
  """Check API features to detect hybrid devices (both burners and compressors)."""
155
157
  try:
156
- features = self.service.fetch_all_features()
158
+ features = self.service.fetch_all_features(self.accessor)
157
159
  feature_names = [f["feature"] for f in features.get("data", [])]
158
160
  has_burners = any(f.startswith("heating.burners") for f in feature_names)
159
161
  has_compressors = any(f.startswith("heating.compressors") for f in feature_names)
160
162
  return has_burners and has_compressors
163
+ except PyViCareNotPaidForError:
164
+ # Account lacks the paid feature package, so feature listing is
165
+ # unavailable. Treat as non-hybrid and let downstream feature
166
+ # access fall back to PyViCareNotSupportedFeatureError via the
167
+ # cached service. Without this, auto-detection crashes the
168
+ # integration setup before any device is created.
169
+ logger.debug("PACKAGE_NOT_PAID_FOR while detecting hybrid for %s, treating as non-hybrid", self.device_model)
170
+ return False
161
171
  except (KeyError, TypeError, AttributeError, OSError):
162
172
  logger.debug("Could not fetch features for hybrid detection of %s", self.device_model)
163
173
  return False
164
174
 
165
175
  def get_raw_json(self):
166
- return self.service.fetch_all_features()
176
+ return self.service.fetch_all_features(self.accessor)
167
177
 
168
178
  def dump_secure(self, flat=False):
169
- raw_data = self.get_raw_json()
170
179
  device_info = {
171
180
  "id": self.device_id,
172
181
  "modelId": self.device_model,
@@ -174,9 +183,20 @@ class PyViCareDeviceConfig:
174
183
  "status": self.status,
175
184
  "roles": self.roles
176
185
  }
186
+ try:
187
+ raw_data = self.get_raw_json()
188
+ data = raw_data['data']
189
+ except PyViCareNotPaidForError:
190
+ # Feature listing requires a paid package the account does not
191
+ # have. Emit a placeholder so diagnostics still produce a usable
192
+ # dump (device metadata plus an unavailable-reason marker)
193
+ # rather than crashing the diagnostics endpoint.
194
+ logger.debug("PACKAGE_NOT_PAID_FOR while dumping features for %s", self.device_model)
195
+ data = []
196
+ device_info["dataUnavailableReason"] = "PACKAGE_NOT_PAID_FOR"
177
197
  output = {
178
198
  "device": device_info,
179
- "data": raw_data['data']
199
+ "data": data
180
200
  }
181
201
 
182
202
  if flat:
@@ -17,7 +17,7 @@ class FuelCell(HeatingDevice):
17
17
 
18
18
  @handleNotSupported
19
19
  def getAvailableBurners(self):
20
- return get_available_burners(self.service)
20
+ return get_available_burners(self)
21
21
 
22
22
  @handleNotSupported
23
23
  def getReturnTemperature(self):
@@ -17,7 +17,7 @@ class GazBoiler(HeatingDevice):
17
17
 
18
18
  @handleNotSupported
19
19
  def getAvailableBurners(self):
20
- return get_available_burners(self.service)
20
+ return get_available_burners(self)
21
21
 
22
22
  @handleNotSupported
23
23
  def getGasConsumptionHeatingUnit(self):
@@ -91,10 +91,6 @@ class GazBoiler(HeatingDevice):
91
91
  def getGasConsumptionDomesticHotWaterThisYear(self):
92
92
  return self.getProperty("heating.gas.consumption.dhw")["properties"]["year"]["value"][0]
93
93
 
94
- @handleNotSupported
95
- def getBoilerTemperature(self):
96
- return self.getProperty("heating.boiler.sensors.temperature.main")["properties"]["value"]["value"]
97
-
98
94
  @handleNotSupported
99
95
  def getBoilerTargetTemperature(self):
100
96
  return self.getProperty("heating.boiler.temperature")["properties"]["value"]["value"]
@@ -2,6 +2,7 @@ from contextlib import suppress
2
2
  from typing import Any, List, Optional
3
3
 
4
4
  from PyViCare.PyViCareDevice import Device
5
+ from PyViCare.PyViCareService import hasRoles
5
6
  from PyViCare.PyViCareHeatCurveCalculation import (
6
7
  heat_curve_formular_variant1, heat_curve_formular_variant2)
7
8
  from PyViCare.PyViCareUtils import (VICARE_DAYS,
@@ -17,13 +18,13 @@ def all_set(_list: List[Any]) -> bool:
17
18
  return all(v is not None for v in _list)
18
19
 
19
20
 
20
- def get_available_burners(service):
21
+ def get_available_burners(device):
21
22
  # workaround starting from 25.01.2022
22
23
  # see: https://github.com/somm15/PyViCare/issues/243
23
24
  available_burners = []
24
25
  for burner in ['0', '1', '2', '3', '4', '5']:
25
26
  with suppress(PyViCareNotSupportedFeatureError):
26
- if service.getProperty(f"heating.burners.{burner}") is not None:
27
+ if device.getProperty(f"heating.burners.{burner}") is not None:
27
28
  available_burners.append(burner)
28
29
 
29
30
  return available_burners
@@ -48,9 +49,9 @@ class HeatingDevice(Device):
48
49
  return HeatingCircuit(self, circuit)
49
50
 
50
51
  def get_heat_curve_formular(self):
51
- if self.service.hasRoles(["type:heatpump", "type:E3"]):
52
+ if hasRoles(["type:heatpump", "type:E3"], self.roles):
52
53
  return heat_curve_formular_variant1
53
- if self.service.hasRoles(["type:heatpump"]) and len(self.getAvailableCircuits()) == 1:
54
+ if hasRoles(["type:heatpump"], self.roles) and len(self.getAvailableCircuits()) == 1:
54
55
  return heat_curve_formular_variant2
55
56
  return heat_curve_formular_variant1
56
57
 
@@ -391,6 +392,10 @@ class HeatingDevice(Device):
391
392
  def getBoilerCommonSupplyTemperature(self):
392
393
  return self.getProperty("heating.boiler.sensors.temperature.commonSupply")["properties"]["value"]["value"]
393
394
 
395
+ @handleNotSupported
396
+ def getBoilerTemperature(self):
397
+ return self.getProperty("heating.boiler.sensors.temperature.main")["properties"]["value"]["value"]
398
+
394
399
 
395
400
  class HeatingDeviceWithComponent:
396
401
  """This is the base class for all heating components"""
@@ -699,6 +704,16 @@ class HeatingCircuit(HeatingDeviceWithComponent):
699
704
  "sun": properties["entries"]["value"]["sun"]
700
705
  }
701
706
 
707
+ @handleAPICommandErrors
708
+ def setHeatingSchedule(self, schedule: dict) -> None:
709
+ self.device.setProperty(f"heating.circuits.{self.circuit}.heating.schedule",
710
+ "setSchedule", {'newSchedule': schedule})
711
+
712
+ @handleNotSupported
713
+ def getHeatingScheduleModes(self) -> list: # type: ignore[type-arg]
714
+ return list(self.getProperty(f"heating.circuits.{self.circuit}.heating.schedule"
715
+ )["commands"]["setSchedule"]["params"]["newSchedule"]["constraints"]["modes"])
716
+
702
717
  # Calculates target supply temperature based on data from Viessmann
703
718
  # See: https://www.viessmann-community.com/t5/Gas/Mathematische-Formel-fuer-Vorlauftemperatur-aus-den-vier/m-p/68890#M27556
704
719
  def getTargetSupplyTemperature(self) -> Optional[float]:
@@ -17,7 +17,7 @@ class OilBoiler(HeatingDevice):
17
17
 
18
18
  @handleNotSupported
19
19
  def getAvailableBurners(self):
20
- return get_available_burners(self.service)
20
+ return get_available_burners(self)
21
21
 
22
22
  @handleNotSupported
23
23
  def getBoilerTemperature(self):
@@ -16,7 +16,7 @@ class PelletsBoiler(HeatingDevice):
16
16
 
17
17
  @handleNotSupported
18
18
  def getAvailableBurners(self):
19
- return get_available_burners(self.service)
19
+ return get_available_burners(self)
20
20
 
21
21
  @handleNotSupported
22
22
  def getBoilerTemperature(self):