Skip to content

Commit 04774a6

Browse files
authored
Merge pull request #200 from plugwise/fix_cooling
Improve support for Anna + Elga (cooling), fix Anna + cooling for Loria/Thermastage
2 parents 1a00be0 + 73629d9 commit 04774a6

File tree

23 files changed

+12256
-211
lines changed

23 files changed

+12256
-211
lines changed

.github/workflows/verify.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
name: Latest commit
55

66
env:
7-
CACHE_VERSION: 10
7+
CACHE_VERSION: 2
88
DEFAULT_PYTHON: "3.9"
99
PRE_COMMIT_HOME: ~/.cache/pre-commit
1010

plugwise/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Plugwise module."""
22

3-
__version__ = "0.20.0"
3+
__version__ = "0.20.1"
44

55
from plugwise.smile import Smile
66
from plugwise.stick import Stick

plugwise/constants.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,9 +381,16 @@
381381
DEFAULT_PORT: Final = 80
382382
NONE: Final = "None"
383383
FAKE_LOC: Final = "0000aaaa0000aaaa0000aaaa0000aa00"
384+
MAX_SETPOINT: Final = 40
385+
MIN_SETPOINT: Final = 0
384386
SEVERITIES: Final[list[str]] = ["other", "info", "warning", "error"]
385387
SPECIAL_FORMAT: Final[list[str]] = [ENERGY_KILO_WATT_HOUR, VOLUME_CUBIC_METERS]
386388
SWITCH_GROUP_TYPES: Final[list[str]] = ["switching", "report"]
389+
ZONE_THERMOSTATS: Final[list[str]] = [
390+
"thermostat",
391+
"zone_thermometer",
392+
"zone_thermostat",
393+
]
387394
THERMOSTAT_CLASSES: Final[list[str]] = [
388395
"thermostat",
389396
"zone_thermometer",
@@ -448,7 +455,6 @@
448455
"upper_bound": {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS},
449456
"resolution": {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS},
450457
"regulation_mode": {ATTR_UNIT_OF_MEASUREMENT: NONE},
451-
"maximum_boiler_temperature": {ATTR_UNIT_OF_MEASUREMENT: NONE},
452458
}
453459

454460
# Heater Central related measurements
@@ -466,6 +472,7 @@
466472
ATTR_NAME: "dhw_state",
467473
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
468474
},
475+
"elga_status_code": {ATTR_UNIT_OF_MEASUREMENT: NONE},
469476
"intended_boiler_temperature": {
470477
ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS
471478
}, # Non-zero when heating, zero when dhw-heating
@@ -477,6 +484,7 @@
477484
ATTR_NAME: "heating_state",
478485
ATTR_UNIT_OF_MEASUREMENT: NONE,
479486
}, # This key shows in general the heating-behavior better than c-h_state. except when connected to a heatpump
487+
"maximum_boiler_temperature": {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS},
480488
"modulation_level": {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE},
481489
"return_water_temperature": {
482490
ATTR_NAME: "return_temperature",
@@ -685,6 +693,7 @@ class SmileSensors(TypedDict, total=False):
685693
electricity_produced_peak_interval: int
686694
electricity_produced_peak_point: int
687695
electricity_produced_point: float
696+
elga_status_code: int
688697
gas_consumed_cumulative: float
689698
gas_consumed_interval: float
690699
humidity: float

plugwise/helper.py

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,6 @@ def __init__(self) -> None:
295295
self._appliances: etree
296296
self._allowed_modes: list[str] = []
297297
self._adam_cooling_enabled = False
298-
self._anna_cooling_derived = False
299298
self._anna_cooling_present = False
300299
self._cooling_activation_outdoor_temp: float
301300
self._cooling_deactivation_threshold: float
@@ -311,13 +310,29 @@ def __init__(self) -> None:
311310
self._on_off_device = False
312311
self._opentherm_device = False
313312
self._outdoor_temp: float
313+
self._sched_setpoints: list[float] | None = None
314314
self._smile_legacy = False
315315
self._stretch_v2 = False
316316
self._stretch_v3 = False
317317
self._thermo_locs: dict[str, ThermoLoc] = {}
318318

319-
self.anna_cooling_enabled = False
320-
self.anna_cool_ena_indication: bool | None = None
319+
###################################################################
320+
# 'elga_cooling_enabled' refers to the state of the Elga heatpump
321+
# connected to an Anna. For Elga, 'elga_status_code' in [8, 9]
322+
# means cooling mode is available, next to heating mode.
323+
# 'elga_status_code' = 8 means cooling is active, 9 means idle.
324+
#
325+
# 'lortherm_cooling_enabled' refers to the state of the Loria or
326+
# Thermastage heatpump connected to an Anna. For these,
327+
# 'cooling_state' = on means set to cooling mode, instead of to
328+
# heating mode.
329+
# 'modulation_level' = 100 means cooling is active, 0.0 means idle.
330+
###################################################################
331+
self._elga_cooling_active = False
332+
self.elga_cooling_enabled = False
333+
self._lortherm_cooling_active = False
334+
self.lortherm_cooling_enabled = False
335+
321336
self.gateway_id: str
322337
self.gw_data: GatewayData = {}
323338
self.gw_devices: dict[str, DeviceData] = {}
@@ -834,11 +849,8 @@ def _get_appliance_data(self, d_id: str) -> DeviceData:
834849
return data
835850

836851
measurements = DEVICE_MEASUREMENTS
837-
if self._opentherm_device or self._on_off_device:
838-
measurements = {
839-
**DEVICE_MEASUREMENTS,
840-
**HEATER_CENTRAL_MEASUREMENTS,
841-
}
852+
if d_id == self._heater_id:
853+
measurements = HEATER_CENTRAL_MEASUREMENTS
842854

843855
if (
844856
appliance := self._appliances.find(f'./appliance[@id="{d_id}"]')
@@ -847,31 +859,39 @@ def _get_appliance_data(self, d_id: str) -> DeviceData:
847859
data.update(self._get_lock_state(appliance))
848860

849861
# Remove c_heating_state from the output
850-
# Also, Elga doesn't use intended_cental_heating_state to show the generic heating state
851862
if "c_heating_state" in data:
852-
if self._anna_cooling_present and "heating_state" in data:
863+
# Anna + Elga and Adam + OnOff heater/cooler don't use intended_cental_heating_state
864+
# to show the generic heating state
865+
if (self._anna_cooling_present and "heating_state" in data) or (
866+
self.smile_name == "Adam" and self._on_off_device
867+
):
853868
if data.get("c_heating_state") and not data.get("heating_state"):
854869
data["heating_state"] = True
870+
855871
data.pop("c_heating_state")
856872

857873
# Fix for Adam + Anna: heating_state also present under Anna, remove
858874
if "temperature" in data:
859875
data.pop("heating_state", None)
860876

861-
if d_id == self._heater_id:
862-
# Use cooling_enabled point-log to set self.anna_cool_ena_indication to True, then remove
877+
if self.smile_name == "Anna" and d_id == self._heater_id:
878+
# Use elga_status_code or cooling_state to set the relevant *_cooling_enabled to True
863879
if self._anna_cooling_present:
864-
self.anna_cool_ena_indication = False
865-
if "cooling_enabled" in data:
866-
self.anna_cool_ena_indication = True
867-
self.anna_cooling_enabled = data["cooling_enabled"]
868-
data.pop("cooling_enabled", None)
869-
870-
# Create updated cooling_state based on cooling_state = on and modulation = 1.0
871-
if "cooling_state" in data:
872-
data["cooling_state"] = (
873-
data["cooling_state"] and data["modulation_level"] == 100
874-
)
880+
# Elga:
881+
if "elga_status_code" in data:
882+
self.elga_cooling_enabled = data["elga_status_code"] in [8, 9]
883+
self._elga_cooling_active = data["elga_status_code"] == 8
884+
data.pop("elga_status_code", None)
885+
# Loria/Thermastate:
886+
elif "cooling_state" in data:
887+
self.lortherm_cooling_enabled = data["cooling_state"]
888+
self._lortherm_cooling_active = False
889+
if data["modulation_level"] == 100:
890+
self._lortherm_cooling_active = True
891+
892+
# Don't show cooling_state when no cooling present
893+
if not self._cooling_present and "cooling_state" in data:
894+
data.pop("cooling_state")
875895

876896
return data
877897

plugwise/smile.py

Lines changed: 82 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
"""
44
from __future__ import annotations
55

6+
from typing import Any
7+
68
import aiohttp
79
from defusedxml import ElementTree as etree
810

@@ -20,6 +22,8 @@
2022
DOMAIN_OBJECTS,
2123
LOCATIONS,
2224
LOGGER,
25+
MAX_SETPOINT,
26+
MIN_SETPOINT,
2327
MODULES,
2428
NOTIFICATIONS,
2529
RULES,
@@ -28,6 +32,7 @@
2832
SWITCH_GROUP_TYPES,
2933
SYSTEM,
3034
THERMOSTAT_CLASSES,
35+
ZONE_THERMOSTATS,
3136
ApplianceData,
3237
DeviceData,
3338
GatewayData,
@@ -47,6 +52,43 @@
4752
class SmileData(SmileHelper):
4853
"""The Plugwise Smile main class."""
4954

55+
def update_for_cooling(self, devices: dict[str, DeviceData]) -> None:
56+
"""Helper-function for adding/updating various cooling-related values."""
57+
for _, device in devices.items():
58+
# For Anna + cooling, modify cooling_state based on provided info by Plugwise
59+
if self.smile_name == "Anna":
60+
if device["dev_class"] == "heater_central" and self._cooling_present:
61+
device["binary_sensors"]["cooling_state"] = False
62+
if self._elga_cooling_active or self._lortherm_cooling_active:
63+
device["binary_sensors"]["cooling_state"] = True
64+
65+
# Add setpoint_low and setpoint_high when cooling is enabled
66+
if device["dev_class"] not in ZONE_THERMOSTATS:
67+
continue
68+
69+
if self.elga_cooling_enabled:
70+
sensors = device["sensors"]
71+
sensors["setpoint_low"] = sensors["setpoint"]
72+
sensors["setpoint_high"] = MAX_SETPOINT
73+
if self._elga_cooling_active:
74+
sensors["setpoint_low"] = MIN_SETPOINT
75+
sensors["setpoint_high"] = sensors["setpoint"]
76+
if self._sched_setpoints is not None:
77+
sensors["setpoint_low"] = self._sched_setpoints[0]
78+
sensors["setpoint_high"] = self._sched_setpoints[1]
79+
80+
# For Adam + on/off cooling, modify heating_state and cooling_state
81+
# based on provided info by Plugwise
82+
if (
83+
self.smile_name == "Adam"
84+
and device["dev_class"] == "heater_central"
85+
and self._on_off_device
86+
and self._adam_cooling_enabled
87+
and device["binary_sensors"]["heating_state"]
88+
):
89+
device["binary_sensors"]["cooling_state"] = True
90+
device["binary_sensors"]["heating_state"] = False
91+
5092
def _all_device_data(self) -> None:
5193
"""Helper-function for get_all_devices().
5294
Collect initial data for each device and add to self.gw_data and self.gw_devices.
@@ -60,6 +102,9 @@ def _all_device_data(self) -> None:
60102
device_id, data, device, bs_dict, s_dict, sw_dict
61103
)
62104

105+
# After all device data has been determined, add/update for cooling
106+
self.update_for_cooling(self.gw_devices)
107+
63108
self.gw_data.update(
64109
{"smile_name": self.smile_name, "gateway_id": self.gateway_id}
65110
)
@@ -87,17 +132,24 @@ def get_all_devices(self) -> None:
87132
self._opentherm_device = open_therm_boiler is not None
88133

89134
# Determine if the Adam or Anna has cooling capability
90-
locator = "./gateway/features/cooling"
91-
anna_cooling_present_1 = adam_cooling_present = (
92-
self._domain_objects.find(locator) is not None
93-
)
94-
# Alternative method for the Anna with Elga
135+
locator_1 = "./gateway/features/cooling"
95136
locator_2 = "./gateway/features/elga_support"
96-
anna_cooling_present_2 = self._domain_objects.find(locator_2) is not None
97-
if self.smile_name == "Anna":
98-
self._anna_cooling_present = (
99-
anna_cooling_present_1 or anna_cooling_present_2
100-
)
137+
locator_3 = "./module[vendor_name='Atlantic']"
138+
locator_4 = "./module[vendor_model='Loria']"
139+
search = self._domain_objects
140+
self._anna_cooling_present = adam_cooling_present = False
141+
if search.find(locator_1) is not None:
142+
if self.smile_name == "Anna":
143+
self._anna_cooling_present = True
144+
else:
145+
adam_cooling_present = True
146+
# Alternative method for the Anna with Elga, or alternative method for the Anna with Loria/Thermastage
147+
elif search.find(locator_2) is not None or (
148+
search.find(locator_3) is not None
149+
and search.find(locator_4) is not None
150+
):
151+
self._anna_cooling_present = True
152+
101153
self._cooling_present = self._anna_cooling_present or adam_cooling_present
102154

103155
# Gather all the device and initial data
@@ -109,29 +161,6 @@ def get_all_devices(self) -> None:
109161
# Collect data for each device via helper function
110162
self._all_device_data()
111163

112-
# Anna: indicate possible active heating/cooling operation-mode
113-
# Actual ongoing heating/cooling is shown via heating_state/cooling_state
114-
if self._anna_cooling_present and not self.anna_cool_ena_indication:
115-
if (
116-
not self._anna_cooling_derived
117-
and self._outdoor_temp > self._cooling_activation_outdoor_temp
118-
):
119-
self._anna_cooling_derived = True
120-
if (
121-
self._anna_cooling_derived
122-
and self._outdoor_temp < self._cooling_deactivation_threshold
123-
):
124-
self._anna_cooling_derived = False
125-
126-
# Don't show cooling_state when no cooling present
127-
for _, device in self.gw_devices.items():
128-
if (
129-
not self._cooling_present
130-
and "binary_sensors" in device
131-
and "cooling_state" in device["binary_sensors"]
132-
):
133-
device["binary_sensors"].pop("cooling_state")
134-
135164
def _device_data_switching_group(
136165
self, details: ApplianceData, device_data: DeviceData
137166
) -> DeviceData:
@@ -181,27 +210,18 @@ def _device_data_climate(
181210
device_data["active_preset"] = self._preset(loc_id)
182211

183212
# Schedule
184-
avail_schedules, sel_schedule, sched_setpoints, last_active = self._schedules(
185-
loc_id
186-
)
213+
(
214+
avail_schedules,
215+
sel_schedule,
216+
self._sched_setpoints,
217+
last_active,
218+
) = self._schedules(loc_id)
187219
device_data["available_schedules"] = avail_schedules
188220
device_data["selected_schedule"] = sel_schedule
189221
if self._smile_legacy:
190222
device_data["last_used"] = "".join(map(str, avail_schedules))
191223
else:
192224
device_data["last_used"] = last_active
193-
if self._anna_cooling_present:
194-
if sched_setpoints is None:
195-
device_data["setpoint_low"] = device_data["setpoint"]
196-
device_data["setpoint_high"] = float(40)
197-
if self._anna_cooling_derived or self.anna_cooling_enabled:
198-
device_data["setpoint_low"] = float(0)
199-
device_data["setpoint_high"] = device_data["setpoint"]
200-
else:
201-
device_data["setpoint_low"] = sched_setpoints[0]
202-
device_data["setpoint_high"] = sched_setpoints[1]
203-
204-
device_data.pop("setpoint")
205225

206226
# Control_state, only for Adam master thermostats
207227
if ctrl_state := self._control_state(loc_id):
@@ -211,9 +231,9 @@ def _device_data_climate(
211231
device_data["mode"] = "auto"
212232
if sel_schedule == "None":
213233
device_data["mode"] = "heat"
214-
if self._anna_cooling_present:
234+
if self.elga_cooling_enabled:
215235
device_data["mode"] = "heat_cool"
216-
if self.smile_name == "Adam" and self._adam_cooling_enabled:
236+
if self._adam_cooling_enabled or self.lortherm_cooling_enabled:
217237
device_data["mode"] = "cool"
218238

219239
return device_data
@@ -495,6 +515,9 @@ async def async_update(self) -> list[GatewayData | dict[str, DeviceData]]:
495515
notifs,
496516
)
497517

518+
# After all device data has been determined, add/update for cooling
519+
self.update_for_cooling(self.gw_devices)
520+
498521
return [self.gw_data, self.gw_devices]
499522

500523
async def _set_schedule_state_legacy(self, name: str, status: str) -> None:
@@ -616,9 +639,16 @@ async def set_preset(self, loc_id: str, preset: str) -> None:
616639

617640
await self._request(uri, method="put", data=data)
618641

619-
async def set_temperature(self, loc_id: str, temperature: float) -> None:
642+
async def set_temperature(self, loc_id: str, temps: dict[str, Any]) -> None:
620643
"""Set the given Temperature on the relevant Thermostat."""
621-
temp = str(temperature)
644+
if "setpoint" in temps:
645+
setpoint = temps["setpoint"]
646+
elif self._elga_cooling_active:
647+
setpoint = temps["setpoint_high"]
648+
else:
649+
setpoint = temps["setpoint_low"]
650+
651+
temp = str(setpoint)
622652
uri = self._thermostat_uri(loc_id)
623653
data = (
624654
"<thermostat_functionality><setpoint>"

0 commit comments

Comments
 (0)