Skip to content

Commit 3d0fdc2

Browse files
authored
Merge pull request #217 from plugwise/device_available
Add available status for non-legacy Smiles
2 parents 3b93002 + 0fd03d2 commit 3d0fdc2

File tree

23 files changed

+383
-169
lines changed

23 files changed

+383
-169
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: 4
7+
CACHE_VERSION: 5
88
DEFAULT_PYTHON: "3.9"
99
PRE_COMMIT_HOME: ~/.cache/pre-commit
1010

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Changelog
22

3+
# v0.23.0: Add device availability for non-legacy Smiles
4+
- Add back Adam vacation preset, fixing reopened issue #185
5+
36
# v0.22.1: Improve solution for issue #213
47

58
# v0.22.0: Smile P1 - add a P1 smartmeter device

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.22.1"
3+
__version__ = "0.23.0a7"
44

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

plugwise/constants.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,7 @@ class GatewayData(TypedDict, total=False):
594594
gateway_id: str | None
595595
heater_id: str | None
596596
cooling_present: bool
597-
notifications: dict[str, str]
597+
notifications: dict[str, dict[str, str]]
598598

599599

600600
class ModelData(TypedDict):
@@ -606,6 +606,7 @@ class ModelData(TypedDict):
606606
hardware_version: str | None
607607
firmware_version: str | None
608608
zigbee_mac_address: str | None
609+
available: bool | None
609610

610611

611612
class SmileBinarySensors(TypedDict, total=False):
@@ -714,6 +715,10 @@ class DeviceDataPoints(
714715

715716
# For temporary use
716717
c_heating_state: str
718+
modified: str
719+
720+
# Device availability
721+
available: bool | None
717722

718723

719724
class DeviceData(ApplianceData, DeviceDataPoints, TypedDict, total=False):

plugwise/helper.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def update_helper(
7575
device_id: str,
7676
bsssw_type: str,
7777
key: str,
78-
notifs: dict[str, str],
78+
notifs: dict[str, dict[str, str]],
7979
) -> None:
8080
"""Helper-function for async_update()."""
8181
for item in device_dict[bsssw_type]: # type: ignore [literal-required]
@@ -323,17 +323,21 @@ def __init__(self) -> None:
323323
self._home_location: str
324324
self._is_thermostat = False
325325
self._last_active: dict[str, str | None] = {}
326+
self._last_modified: dict[str, str] = {}
326327
self._locations: etree
327328
self._loc_data: dict[str, ThermoLoc] = {}
328329
self._modules: etree
330+
self._notifications: dict[str, dict[str, str]] = {}
329331
self._on_off_device = False
330332
self._opentherm_device = False
331333
self._outdoor_temp: float
332334
self._schedule_old_states: dict[str, dict[str, str]] = {}
333335
self._sched_setpoints: list[float] | None = None
334336
self._smile_legacy = False
337+
self._status: etree
335338
self._stretch_v2 = False
336339
self._stretch_v3 = False
340+
self._system: etree
337341
self._thermo_locs: dict[str, ThermoLoc] = {}
338342
###################################################################
339343
# '_elga_cooling_enabled' refers to the state of the Elga heatpump
@@ -375,9 +379,7 @@ def _locations_legacy(self) -> None:
375379
for appliance in self._appliances.findall("./appliance"):
376380
appliances.add(appliance.attrib["id"])
377381

378-
if self.smile_type == "thermostat":
379-
self._loc_data[FAKE_LOC] = {"name": "Home"}
380-
if self.smile_type == "stretch":
382+
if self.smile_type in ("stretch", "thermostat"):
381383
self._loc_data[FAKE_LOC] = {"name": "Home"}
382384

383385
def _locations_specials(self, loc: Munch, location: str) -> Munch:
@@ -436,6 +438,7 @@ def _get_module_data(
436438
"hardware_version": None,
437439
"firmware_version": None,
438440
"zigbee_mac_address": None,
441+
"available": None,
439442
}
440443
if (appl_search := appliance.find(locator)) is not None:
441444
link_id = appl_search.attrib["id"]
@@ -453,6 +456,7 @@ def _get_module_data(
453456
# Adam
454457
if found := module.find("./protocols/zig_bee_node"):
455458
model_data["zigbee_mac_address"] = found.find("mac_address").text
459+
model_data["available"] = found.find("reachable").text == "true"
456460
# Stretches
457461
if found := module.find("./protocols/network_router"):
458462
model_data["zigbee_mac_address"] = found.find("mac_address").text
@@ -801,10 +805,6 @@ def _presets(self, loc_id: str) -> dict[str, list[float]]:
801805
float(preset.get("cooling_setpoint")),
802806
]
803807

804-
# Adam does not show vacation preset anymore, issue #185
805-
if self.smile_name == "Adam":
806-
presets.pop("vacation")
807-
808808
return presets
809809

810810
def _rule_ids_by_name(self, name: str, loc_id: str) -> dict[str, str]:
@@ -882,6 +882,24 @@ def _appliance_measurements(
882882

883883
return data
884884

885+
def _wireless_availablity(self, appliance: etree, data: DeviceData) -> None:
886+
"""Helper-function for _get_appliance_data().
887+
Collect the availablity-status for wireless connected devices.
888+
"""
889+
if self.smile_name == "Adam":
890+
# Collect for Plugs
891+
locator = "./logs/interval_log/electricity_interval_meter"
892+
mod_type = "electricity_interval_meter"
893+
module_data = self._get_module_data(appliance, locator, mod_type)
894+
if module_data["available"] is None:
895+
# Collect for wireless thermostats
896+
locator = "./logs/point_log[type='thermostat']/thermostat"
897+
mod_type = "thermostat"
898+
module_data = self._get_module_data(appliance, locator, mod_type)
899+
900+
if module_data["available"] is not None:
901+
data["available"] = module_data["available"]
902+
885903
def _get_appliance_data(self, d_id: str) -> DeviceData:
886904
"""Helper-function for smile.py: _get_device_data().
887905
Collect the appliance-data based on device id.
@@ -899,12 +917,22 @@ def _get_appliance_data(self, d_id: str) -> DeviceData:
899917
if (
900918
appliance := self._appliances.find(f'./appliance[@id="{d_id}"]')
901919
) is not None:
920+
902921
data = self._appliance_measurements(appliance, data, measurements)
903922
data.update(self._get_lock_state(appliance))
904923
if (appl_type := appliance.find("type")) is not None:
905924
if appl_type.text in ACTUATOR_CLASSES:
906925
data.update(_get_actuator_functionalities(appliance))
907926

927+
# Collect availability-status for wireless connected devices to Adam
928+
self._wireless_availablity(appliance, data)
929+
930+
# Collect modified_date for devices without available-status
931+
if not self._smile_legacy and (
932+
d_id != self.gateway_id or "available" not in data
933+
):
934+
data["modified"] = appliance.find("modified_date").text
935+
908936
# Remove c_heating_state from the output
909937
if "c_heating_state" in data:
910938
# Anna + Elga and Adam + OnOff heater/cooler don't use intended_cental_heating_state
@@ -1069,7 +1097,7 @@ def _heating_valves(self) -> int | None:
10691097

10701098
return None if loc_found == 0 else open_valve_count
10711099

1072-
def _power_data_peak_value(self, loc: Munch) -> Munch:
1100+
def _power_data_peak_value(self, direct_data: DeviceData, loc: Munch) -> Munch:
10731101
"""Helper-function for _power_data_from_location()."""
10741102
loc.found = True
10751103
no_tariffs = False
@@ -1129,7 +1157,7 @@ def _power_data_from_location(self, loc_id: str) -> DeviceData:
11291157
f'./{loc.log_type}[type="{loc.measurement}"]/period/'
11301158
f'measurement[@{t_string}="{loc.peak_select}"]'
11311159
)
1132-
loc = self._power_data_peak_value(loc)
1160+
loc = self._power_data_peak_value(direct_data, loc)
11331161
if not loc.found:
11341162
continue
11351163

plugwise/smile.py

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
import aiohttp
7+
from dateutil.parser import parse
78
from defusedxml import ElementTree as etree
89

910
# Dict as class
@@ -227,6 +228,45 @@ def _device_data_climate(
227228

228229
return device_data
229230

231+
def _check_availability(
232+
self, details: ApplianceData, device_data: DeviceData
233+
) -> None:
234+
"""Helper-function for _get_device_data().
235+
Provide availability status for the wired-commected devices.
236+
"""
237+
# OpenTherm device
238+
if details["dev_class"] == "heater_central" and details["name"] != "OnOff":
239+
device_data["available"] = True
240+
for _, data in self._notifications.items():
241+
for _, msg in data.items():
242+
if "no OpenTherm communication" in msg:
243+
device_data["available"] = False
244+
245+
# Smartmeter
246+
if details["dev_class"] == "smartmeter":
247+
device_data["available"] = True
248+
for _, data in self._notifications.items():
249+
for _, msg in data.items():
250+
if "P1 does not seem to be connected to a smart meter" in msg:
251+
device_data["available"] = False
252+
253+
# Anna thermostat
254+
if "modified" in device_data:
255+
time_now: str | None = None
256+
if (
257+
time_now := self._domain_objects.find("./gateway/time").text
258+
) is not None:
259+
interval = (
260+
parse(time_now) - parse(device_data["modified"])
261+
).total_seconds()
262+
if interval > 0:
263+
if details["dev_class"] == "thermostat":
264+
device_data["available"] = False
265+
if interval < 90:
266+
device_data["available"] = True
267+
268+
device_data.pop("modified")
269+
230270
def _get_device_data(self, dev_id: str) -> DeviceData:
231271
"""Helper-function for _all_device_data() and async_update().
232272
Provide device-data, based on Location ID (= dev_id), from APPLIANCES.
@@ -259,6 +299,10 @@ def _get_device_data(self, dev_id: str) -> DeviceData:
259299
) is not None:
260300
device_data.update(power_data)
261301

302+
# Check availability of non-legacy wired-connected devices
303+
if not self._smile_legacy:
304+
self._check_availability(details, device_data)
305+
262306
# Switching groups data
263307
device_data = self._device_data_switching_group(details, device_data)
264308
# Specific, not generic Adam data
@@ -298,7 +342,6 @@ def __init__(
298342
)
299343
SmileData.__init__(self)
300344

301-
self._notifications: dict[str, str] = {}
302345
self.smile_hostname: str | None = None
303346

304347
async def connect(self) -> bool:
@@ -364,22 +407,22 @@ async def _smile_detect_legacy(self, result: etree, dsmrmain: etree) -> str:
364407
if result.find('./appliance[type="thermostat"]') is None:
365408
# It's a P1 legacy:
366409
if dsmrmain is not None:
367-
status = await self._request(STATUS)
368-
self.smile_fw_version = status.find("./system/version").text
369-
model = status.find("./system/product").text
370-
self.smile_hostname = status.find("./network/hostname").text
371-
self.smile_mac_address = status.find("./network/mac_address").text
410+
self._status = await self._request(STATUS)
411+
self.smile_fw_version = self._status.find("./system/version").text
412+
model = self._status.find("./system/product").text
413+
self.smile_hostname = self._status.find("./network/hostname").text
414+
self.smile_mac_address = self._status.find("./network/mac_address").text
372415

373416
# Or a legacy Stretch:
374417
elif network is not None:
375-
system = await self._request(SYSTEM)
376-
self.smile_fw_version = system.find("./gateway/firmware").text
377-
model = system.find("./gateway/product").text
378-
self.smile_hostname = system.find("./gateway/hostname").text
418+
self._system = await self._request(SYSTEM)
419+
self.smile_fw_version = self._system.find("./gateway/firmware").text
420+
model = self._system.find("./gateway/product").text
421+
self.smile_hostname = self._system.find("./gateway/hostname").text
379422
# If wlan0 contains data it's active, so eth0 should be checked last
380423
for network in ("wlan0", "eth0"):
381424
locator = f"./{network}/mac"
382-
if (net_locator := system.find(locator)) is not None:
425+
if (net_locator := self._system.find(locator)) is not None:
383426
self.smile_mac_address = net_locator.text
384427

385428
else: # pragma: no cover
@@ -444,12 +487,9 @@ async def _full_update_device(self) -> None:
444487
self._locations = await self._request(LOCATIONS)
445488
self._modules = await self._request(MODULES)
446489

447-
# P1 legacy has no appliances
490+
# P1 legacy has no appliances and nothing of interest in domain_objects
448491
if not (self.smile_type == "power" and self._smile_legacy):
449492
self._appliances = await self._request(APPLIANCES)
450-
451-
# No need to import domain_objects for P1, no useful info
452-
if self.smile_type != "power":
453493
await self._update_domain_objects()
454494

455495
async def _update_domain_objects(self) -> None:
@@ -484,6 +524,8 @@ async def async_update(self) -> list[GatewayData | dict[str, DeviceData]]:
484524
if not (self.smile_type == "power" and self._smile_legacy):
485525
self._appliances = await self._request(APPLIANCES)
486526

527+
self._modules = await self._request(MODULES)
528+
487529
self.gw_data["notifications"] = self._notifications
488530

489531
for dev_id, dev_dict in self.gw_devices.items():
@@ -493,7 +535,7 @@ async def async_update(self) -> list[GatewayData | dict[str, DeviceData]]:
493535
dev_dict[key] = value # type: ignore [literal-required]
494536

495537
for item in ("binary_sensors", "sensors", "switches"):
496-
notifs: dict[str, str] = {}
538+
notifs: dict[str, dict[str, str]] = {}
497539
if item == "binary_sensors":
498540
notifs = self._notifications
499541
if item in dev_dict:

0 commit comments

Comments
 (0)