Skip to content

Commit 71c5a02

Browse files
authored
Merge pull request #670 from plugwise/try-something
Improve P1 fault-handling
2 parents 3801070 + 5bcc778 commit 71c5a02

File tree

9 files changed

+116
-60
lines changed

9 files changed

+116
-60
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Rework tooling [#664](https://github.com/plugwise/python-plugwise/pull/664)
77
- Archive p1v4 userdata [#666](https://github.com/plugwise/python-plugwise/pull/666)
88
- Correct manual_fixtures script [#668](https://github.com/plugwise/python-plugwise/pull/668)
9+
- Improve P1 fault-handling, continuous improvements [#670](https://github.com/plugwise/python-plugwise/pull/670)
910

1011
## v1.6.3
1112

plugwise/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
MODULE_LOCATOR: Final = "./logs/point_log/*[@id]"
8686
NONE: Final = "None"
8787
OFF: Final = "off"
88+
PRIORITY_DEVICE_CLASSES = ("heater_central", "gateway")
8889

8990
# XML data paths
9091
APPLIANCES: Final = "/core/appliances"

plugwise/helper.py

Lines changed: 58 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
NONE,
3030
OFF,
3131
P1_MEASUREMENTS,
32+
PRIORITY_DEVICE_CLASSES,
3233
TEMP_CELSIUS,
3334
THERMOSTAT_CLASSES,
3435
TOGGLES,
@@ -278,7 +279,11 @@ def __init__(self) -> None:
278279
SmileCommon.__init__(self)
279280

280281
def _all_appliances(self) -> None:
281-
"""Collect all appliances with relevant info."""
282+
"""Collect all appliances with relevant info.
283+
284+
Also, collect the P1 smartmeter info from a location
285+
as this one is not available as an appliance.
286+
"""
282287
self._count = 0
283288
self._all_locations()
284289

@@ -330,55 +335,33 @@ def _all_appliances(self) -> None:
330335
if not (appl := self._appliance_info_finder(appl, appliance)):
331336
continue
332337

333-
# P1: for gateway and smartmeter switch entity_id - part 1
334-
# This is done to avoid breakage in HA Core
335-
if appl.pwclass == "gateway" and self.smile_type == "power":
336-
appl.entity_id = appl.location
337-
338338
self._create_gw_entities(appl)
339339

340-
# For P1 collect the connected SmartMeter info
341340
if self.smile_type == "power":
342-
self._p1_smartmeter_info_finder(appl)
343-
# P1: for gateway and smartmeter switch entity_id - part 2
344-
for item in self.gw_entities:
345-
if item != self.gateway_id:
346-
self.gateway_id = item
347-
# Leave for-loop to avoid a 2nd device_id switch
348-
break
349-
350-
# Place the gateway and optional heater_central devices as 1st and 2nd
351-
for dev_class in ("heater_central", "gateway"):
352-
for entity_id, entity in dict(self.gw_entities).items():
353-
if entity["dev_class"] == dev_class:
354-
tmp_entity = entity
355-
self.gw_entities.pop(entity_id)
356-
cleared_dict = self.gw_entities
357-
add_to_front = {entity_id: tmp_entity}
358-
self.gw_entities = {**add_to_front, **cleared_dict}
341+
self._get_p1_smartmeter_info()
359342

360-
def _all_locations(self) -> None:
361-
"""Collect all locations."""
362-
loc = Munch()
363-
locations = self._domain_objects.findall("./location")
364-
for location in locations:
365-
loc.name = location.find("name").text
366-
loc.loc_id = location.attrib["id"]
367-
if loc.name == "Home":
368-
self._home_location = loc.loc_id
343+
# Sort the gw_entities
344+
self._sort_gw_entities()
369345

370-
self._loc_data[loc.loc_id] = {"name": loc.name}
346+
def _get_p1_smartmeter_info(self) -> None:
347+
"""For P1 collect the connected SmartMeter info from the Home/building location.
371348
372-
def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
373-
"""Collect P1 DSMR SmartMeter info."""
349+
Note: For P1, the entity_id for the gateway and smartmeter are
350+
switched to maintain backward compatibility with existing implementations.
351+
"""
352+
appl = Munch()
374353
loc_id = next(iter(self._loc_data.keys()))
375-
location = self._domain_objects.find(f'./location[@id="{loc_id}"]')
354+
if (
355+
location := self._domain_objects.find(f'./location[@id="{loc_id}"]')
356+
) is None:
357+
return
358+
376359
locator = MODULE_LOCATOR
377360
module_data = self._get_module_data(location, locator)
378361
if not module_data["contents"]:
379362
LOGGER.error("No module data found for SmartMeter") # pragma: no cover
380-
return None # pragma: no cover
381-
363+
return # pragma: no cover
364+
appl.available = None
382365
appl.entity_id = self.gateway_id
383366
appl.firmware = module_data["firmware_version"]
384367
appl.hardware = module_data["hardware_version"]
@@ -391,22 +374,49 @@ def _p1_smartmeter_info_finder(self, appl: Munch) -> None:
391374
appl.vendor_name = module_data["vendor_name"]
392375
appl.zigbee_mac = None
393376

377+
# Replace the entity_id of the gateway by the smartmeter location_id
378+
self.gw_entities[loc_id] = self.gw_entities.pop(self.gateway_id)
379+
self.gateway_id = loc_id
380+
394381
self._create_gw_entities(appl)
395382

383+
def _sort_gw_entities(self) -> None:
384+
"""Place the gateway and optional heater_central entities as 1st and 2nd."""
385+
for dev_class in PRIORITY_DEVICE_CLASSES:
386+
for entity_id, entity in dict(self.gw_entities).items():
387+
if entity["dev_class"] == dev_class:
388+
priority_entity = entity
389+
self.gw_entities.pop(entity_id)
390+
other_entities = self.gw_entities
391+
priority_entities = {entity_id: priority_entity}
392+
self.gw_entities = {**priority_entities, **other_entities}
393+
394+
def _all_locations(self) -> None:
395+
"""Collect all locations."""
396+
loc = Munch()
397+
locations = self._domain_objects.findall("./location")
398+
for location in locations:
399+
loc.name = location.find("name").text
400+
loc.loc_id = location.attrib["id"]
401+
if loc.name == "Home":
402+
self._home_location = loc.loc_id
403+
404+
self._loc_data[loc.loc_id] = {"name": loc.name}
405+
396406
def _appliance_info_finder(self, appl: Munch, appliance: etree) -> Munch:
397407
"""Collect info for all appliances found."""
398408
match appl.pwclass:
399409
case "gateway":
400-
# Collect gateway device info
410+
# Collect gateway entity info
401411
return self._appl_gateway_info(appl, appliance)
402412
case _ as dev_class if dev_class in THERMOSTAT_CLASSES:
403-
# Collect thermostat device info
413+
# Collect thermostat entity info
404414
return self._appl_thermostat_info(appl, appliance)
405415
case "heater_central":
406-
# Collect heater_central device info
416+
# Collect heater_central entity info
407417
self._appl_heater_central_info(
408418
appl, appliance, False
409-
) # False means non-legacy device
419+
) # False means non-legacy entity
410420
self._dhw_allowed_modes = self._get_appl_actuator_modes(
411421
appliance, "domestic_hot_water_mode_control_functionality"
412422
)
@@ -801,19 +811,23 @@ def _process_on_off_device_c_heating_state(self, data: GwEntityData) -> None:
801811
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
802812

803813
if self.smile(ADAM):
814+
# First count when not present, then create and init to False.
815+
# When present init to False
804816
if "heating_state" not in data["binary_sensors"]:
805817
self._count += 1
806818
data["binary_sensors"]["heating_state"] = False
819+
807820
if "cooling_state" not in data["binary_sensors"]:
808821
self._count += 1
809822
data["binary_sensors"]["cooling_state"] = False
823+
810824
if self._cooling_enabled:
811825
data["binary_sensors"]["cooling_state"] = data["c_heating_state"]
812826
else:
813827
data["binary_sensors"]["heating_state"] = data["c_heating_state"]
814828

815829
def _update_anna_cooling(self, entity_id: str, data: GwEntityData) -> None:
816-
"""Update the Anna heater_central device for cooling.
830+
"""Update the Anna heater_central entity for cooling.
817831
818832
Support added for Techneco Elga and Thercon Loria/Thermastage.
819833
"""

plugwise/legacy/smile.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
PlugwiseData,
2626
ThermoLoc,
2727
)
28-
from plugwise.exceptions import ConnectionFailedError, PlugwiseError
28+
from plugwise.exceptions import ConnectionFailedError, DataMissingError, PlugwiseError
2929
from plugwise.legacy.data import SmileLegacyData
3030

3131
import aiohttp
@@ -120,18 +120,30 @@ async def async_update(self) -> PlugwiseData:
120120
)
121121
self.gw_data: GatewayData = {}
122122
self.gw_entities: dict[str, GwEntityData] = {}
123-
await self.full_xml_update()
124-
self.get_all_gateway_entities()
123+
try:
124+
await self.full_xml_update()
125+
self.get_all_gateway_entities()
126+
# Detect failed data-retrieval
127+
_ = self.gw_entities[self.gateway_id]["location"]
128+
except KeyError as err: # pragma: no cover
129+
raise DataMissingError(
130+
"No (full) Plugwise legacy data received"
131+
) from err
125132
# Otherwise perform an incremental update
126133
else:
127-
self._domain_objects = await self.request(DOMAIN_OBJECTS)
128-
match self._target_smile:
129-
case "smile_v2":
130-
self._modules = await self.request(MODULES)
131-
case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
132-
self._appliances = await self.request(APPLIANCES)
133-
134-
self._update_gw_entities()
134+
try:
135+
self._domain_objects = await self.request(DOMAIN_OBJECTS)
136+
match self._target_smile:
137+
case "smile_v2":
138+
self._modules = await self.request(MODULES)
139+
case self._target_smile if self._target_smile in REQUIRE_APPLIANCES:
140+
self._appliances = await self.request(APPLIANCES)
141+
142+
self._update_gw_entities()
143+
# Detect failed data-retrieval
144+
_ = self.gw_entities[self.gateway_id]["location"]
145+
except KeyError as err: # pragma: no cover
146+
raise DataMissingError("No legacy Plugwise data received") from err
135147

136148
self._previous_day_number = day_number
137149
return PlugwiseData(

plugwise/smile.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ async def async_update(self) -> PlugwiseData:
131131
try:
132132
await self.full_xml_update()
133133
self.get_all_gateway_entities()
134-
# Set self._cooling_enabled -required for set_temperature,
134+
# Set self._cooling_enabled - required for set_temperature,
135135
# also, check for a failed data-retrieval
136136
if "heater_id" in self.gw_data:
137137
heat_cooler = self.gw_entities[self.gw_data["heater_id"]]
@@ -142,8 +142,10 @@ async def async_update(self) -> PlugwiseData:
142142
self._cooling_enabled = heat_cooler["binary_sensors"][
143143
"cooling_enabled"
144144
]
145+
else: # cover failed data-retrieval for P1
146+
_ = self.gw_entities[self.gateway_id]["location"]
145147
except KeyError as err:
146-
raise DataMissingError("No Plugwise data received") from err
148+
raise DataMissingError("No Plugwise actual data received") from err
147149

148150
return PlugwiseData(
149151
devices=self.gw_entities,

tests/test_adam.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,10 @@ async def test_connect_adam_plus_anna_new(self):
137137
self.smile_setup = "reboot/adam_plus_anna_new"
138138
try:
139139
await self.device_test(smile, initialize=False)
140-
except pw_exceptions.PlugwiseError:
141-
_LOGGER.debug("Receiving no data after a reboot is properly handled")
140+
except pw_exceptions.PlugwiseError as err:
141+
_LOGGER.debug(
142+
f"Receiving no data after a reboot is properly handled: {err}"
143+
)
142144

143145
# Simulate receiving xml-data with <error>
144146
self.smile_setup = "error/adam_plus_anna_new"

tests/test_p1.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pytest
44

5-
from .test_init import _LOGGER, TestPlugwise
5+
from .test_init import _LOGGER, TestPlugwise, pw_exceptions
66

77
SMILE_TYPE = "p1"
88

@@ -41,6 +41,22 @@ async def test_connect_p1v4_442_single(self):
4141
smile, "2022-05-16 00:00:01", testdata_updated, initialize=False
4242
)
4343

44+
# Simulate receiving no xml-data after a requesting a reboot of the gateway
45+
self.smile_setup = "reboot/p1v4_442_single"
46+
try:
47+
await self.device_test(smile, initialize=False)
48+
except pw_exceptions.PlugwiseError as err:
49+
_LOGGER.debug(
50+
f"Receiving no data after a reboot is properly handled: {err}"
51+
)
52+
53+
# Simulate receiving xml-data with <error>
54+
self.smile_setup = "error/p1v4_442_single"
55+
try:
56+
await self.device_test(smile, initialize=False)
57+
except pw_exceptions.ResponseError:
58+
_LOGGER.debug("Receiving error-data from the Gateway")
59+
4460
await smile.close_connection()
4561
await self.disconnect(server, client)
4662

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<domain_objects>
3+
<error>
4+
</error>
5+
</domain_objects>
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<domain_objects>
3+
</domain_objects>

0 commit comments

Comments
 (0)