Skip to content

Commit eca817e

Browse files
CoMPaTechbouwew
authored andcommitted
Initial typed xml reading attempt (incomplete)
1 parent 9d07e66 commit eca817e

5 files changed

Lines changed: 296 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Remove biome (as prettier was reinstated)
1515
- Replace node-based markdownlint with pythonic library
1616

17+
- Attempt to ditch untyped Munch for the existing TypedDicts by leveraging pydantic to type xmltodict XML conversion
1718
## v1.11.2
1819

1920
- Add/update model-data for Jip, Tom and Floor via PR [#842](https://github.com/plugwise/python-plugwise/pull/842)

plugwise/helper.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,8 @@
6060
def extend_plug_device_class(appl: Munch, appliance: etree.Element) -> None:
6161
"""Extend device_class name of Plugs (Plugwise and Aqara) - Pw-Beta Issue #739."""
6262

63-
if (
64-
(search := appliance.find("description")) is not None
65-
and (description := search.text) is not None
66-
and ("ZigBee protocol" in description or "smart plug" in description)
63+
if (description := appliance.description) is not None and (
64+
"ZigBee protocol" in description or "smart plug" in description
6765
):
6866
appl.pwclass = f"{appl.pwclass}_plug"
6967

@@ -114,19 +112,19 @@ def _get_appliances(self) -> None:
114112
self._count = 0
115113
self._get_locations()
116114

117-
for appliance in self._domain_objects.findall("./appliance"):
115+
for appliance in self._domain_objects.appliance:
118116
appl = Munch()
119117
appl.available = None
120-
appl.entity_id = appliance.get("id")
118+
appl.entity_id = appliance.id
121119
appl.firmware = None
122120
appl.hardware = None
123121
appl.location = None
124122
appl.mac = None
125123
appl.model = None
126124
appl.model_id = None
127125
appl.module_id = None
128-
appl.name = appliance.find("name").text
129-
appl.pwclass = appliance.find("type").text
126+
appl.name = appliance.name
127+
appl.pwclass = appliance.type
130128
appl.zigbee_mac = None
131129
appl.vendor_name = None
132130

@@ -138,7 +136,7 @@ def _get_appliances(self) -> None:
138136
):
139137
continue
140138

141-
if (appl_loc := appliance.find("location")) is not None:
139+
if (appl_loc := appliance.location) is not None:
142140
appl.location = appl_loc.get("id")
143141
# Set location to the _home_loc_id when the appliance-location is not found,
144142
# except for thermostat-devices without a location, they are not active
@@ -204,21 +202,22 @@ def _get_locations(self) -> None:
204202
"""Collect all locations."""
205203
counter = 0
206204
loc = Munch()
207-
locations = self._domain_objects.findall("./location")
205+
locations = self._domain_objects.location
208206
if not locations:
209207
raise KeyError("No location data present!")
210208

211209
for location in locations:
212-
loc.loc_id = location.get("id")
213-
loc.name = location.find("name").text
214-
loc._type = location.find("type").text
210+
loc.loc_id = location.id
211+
loc.name = location.name
212+
loc._type = location.type
215213
self._loc_data[loc.loc_id] = {"name": loc.name}
216214
# Home location is of type building
217215
if loc._type == "building":
218216
counter += 1
219217
self._home_loc_id = loc.loc_id
220-
self._home_location = self._domain_objects.find(
221-
f"./location[@id='{loc.loc_id}']"
218+
self._home_location = next(
219+
(l for l in self._domain_objects.location if l.id == loc.loc_id),
220+
None,
222221
)
223222

224223
if counter == 0:
@@ -488,11 +487,11 @@ def _get_toggle_state(
488487
def _get_plugwise_notifications(self) -> None:
489488
"""Collect the Plugwise notifications."""
490489
self._notifications = {}
491-
for notification in self._domain_objects.findall("./notification"):
490+
for notification in self._domain_objects.notification:
492491
try:
493-
msg_id = notification.get("id")
494-
msg_type = notification.find("type").text
495-
msg = notification.find("message").text
492+
msg_id = notification.id
493+
msg_type = notification.type
494+
msg = notification.message
496495
self._notifications[msg_id] = {msg_type: msg}
497496
LOGGER.debug("Plugwise notifications: %s", self._notifications)
498497
except AttributeError: # pragma: no cover

plugwise/model.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""Plugwise models."""
2+
3+
from typing import Any
4+
5+
from pydantic import BaseModel, ConfigDict, Field
6+
7+
8+
class PWBase(BaseModel):
9+
"""Base / common Plugwise class."""
10+
11+
# Allow additional struct (ignored)
12+
model_config = ConfigDict(extra="ignore")
13+
14+
15+
class WithID(PWBase):
16+
"""Class for Plugwise ID base XML elements.
17+
18+
Takes id from the xml definition.
19+
"""
20+
21+
id: str = Field(alias="@id")
22+
model_config = ConfigDict(extra="allow")
23+
24+
25+
# Period and measurements
26+
class Measurement(PWBase):
27+
"""Plugwise Measurement."""
28+
29+
log_date: str = Field(alias="@log_date")
30+
value: str = Field(alias="#text")
31+
32+
33+
class Period(PWBase):
34+
"""Plugwise period of time."""
35+
36+
start_date: str = Field(alias="@start_date")
37+
end_date: str = Field(alias="@end_date")
38+
interval: str | None = Field(default=None, alias="@interval")
39+
measurement: Measurement | None = None
40+
41+
42+
# Notification
43+
class Notification(WithID):
44+
"""Plugwise notification.
45+
46+
Our examples only show single optional notification being present
47+
"""
48+
49+
type: str
50+
origin: str | None = None
51+
title: str | None = None
52+
message: str | None = None
53+
54+
created_date: str
55+
modified_date: str | list[str] | None = None
56+
deleted_date: str | None = None
57+
58+
valid_from: str | list[str] | None = None
59+
valid_to: str | list[str] | None = None
60+
read_date: str | list[str] | None = None
61+
62+
63+
# Logging
64+
class BaseLog(WithID):
65+
"""Plugwise mapping for point_log and interval_log constructs."""
66+
67+
type: str
68+
unit: str | None = None
69+
updated_date: str | None = None
70+
last_consecutive_log_date: str | None = None
71+
interval: str | None = None
72+
period: Period | None = None
73+
74+
75+
class PointLog(BaseLog):
76+
"""Plugwise class ofr specific point_logs.
77+
78+
i.e. <relay id="..."/>
79+
"""
80+
81+
relay: WithID | None = None
82+
thermo_meter: WithID | None = None
83+
thermostat: WithID | None = None
84+
battery_meter: WithID | None = None
85+
temperature_offset: WithID | None = None
86+
weather_descriptor: WithID | None = None
87+
irradiance_meter: WithID | None = None
88+
wind_vector: WithID | None = None
89+
hygro_meter: WithID | None = None
90+
91+
92+
class IntervalLog(BaseLog):
93+
"""Plugwise class ofr specific interval_logs."""
94+
95+
electricity_interval_meter: WithID | None = (
96+
None # references only, still to type if we need this
97+
)
98+
99+
100+
# Functionality
101+
class BaseFunctionality(WithID):
102+
"""Plugwise functionality."""
103+
104+
updated_date: str | None = None
105+
106+
107+
class RelayFunctionality(BaseFunctionality):
108+
"""Relay functionality."""
109+
110+
lock: bool | None = None
111+
state: str | None = None
112+
relay: WithID | None = None
113+
114+
115+
class ThermostatFunctionality(BaseFunctionality):
116+
"""Thermostat functionality."""
117+
118+
type: str
119+
lower_bound: float
120+
upper_bound: float
121+
resolution: float
122+
setpoint: float
123+
thermostat: WithID | None = None
124+
125+
126+
class OffsetFunctionality(BaseFunctionality):
127+
"""Offset functionality."""
128+
129+
type: str
130+
offset: float
131+
temperature_offset: WithID | None = None
132+
133+
134+
# Services
135+
class ServiceBase(WithID):
136+
"""Plugwise Services."""
137+
138+
log_type: str | None = Field(default=None, alias="@log_type")
139+
endpoint: str | None = Field(default=None, alias="@endpoint")
140+
functionalities: dict[str, WithID | list[WithID]] | None = (
141+
None # references only, still to type if we need this
142+
)
143+
144+
145+
# Protocols
146+
class Neighbor(PWBase):
147+
"""Neighbor definition."""
148+
149+
mac_address: str = Field(alias="@mac_address")
150+
lqi: int | None = None
151+
depth: int | None = None
152+
relationship: str | None = None
153+
154+
155+
class ZigBeeNode(WithID):
156+
"""ZigBee node definition."""
157+
158+
mac_address: str
159+
type: str
160+
reachable: bool
161+
power_source: str | None = None
162+
battery_type: str | None = None
163+
zig_bee_coordinator: WithID | None = None
164+
neighbors: list[Neighbor]
165+
last_neighbor_table_received: str | None = None
166+
neighbor_table_support: bool | None = None
167+
168+
169+
# Appliance
170+
class Appliance(WithID):
171+
"""Plugwise Appliance."""
172+
173+
name: str
174+
description: str | None = None
175+
type: str
176+
created_date: str
177+
modified_date: str | list[str] | None = None
178+
deleted_date: str | None = None
179+
180+
location: dict[str, Any] | None = None
181+
groups: dict[str, WithID | list[WithID]] | None = None
182+
logs: dict[str, BaseLog | list[BaseLog]] | None = None
183+
actuator_functionalities: (
184+
dict[str, BaseFunctionality | list[BaseFunctionality]] | None
185+
) = None
186+
187+
188+
# Module
189+
class Module(WithID):
190+
"""Plugwise Module."""
191+
192+
vendor_name: str | None = None
193+
vendor_model: str | None = None
194+
hardware_version: str | None = None
195+
firmware_version: str | None = None
196+
created_date: str
197+
modified_date: str | list[str] | None = None
198+
deleted_date: str | None = None
199+
200+
# This is too much :) shorted to Any, but we should still look at this
201+
# services: dict[str, ServiceBase | list[ServiceBase]] | list[dict[str, Any]] | None = None
202+
services: dict[str, Any] | list[Any] | None = None
203+
204+
protocols: dict[str, Any] | None = None # ZigBeeNode, WLAN, LAN
205+
206+
207+
# Location
208+
class Location(WithID):
209+
"""Plugwise Location."""
210+
211+
name: str
212+
description: str | None = None
213+
type: str
214+
created_date: str
215+
modified_date: str | list[str] | None = None
216+
deleted_date: str | None = None
217+
preset: str | None = None
218+
appliances: list[WithID]
219+
logs: dict[str, BaseLog | list[BaseLog]] | list[BaseLog] | None
220+
appliances: dict[str, WithID | list[WithID]] | None = None
221+
actuator_functionalities: dict[str, BaseFunctionality] | None = None
222+
223+
224+
# Root objects
225+
class DomainObjects(PWBase):
226+
"""Plugwise Domain Objects."""
227+
228+
appliance: list[Appliance] = []
229+
module: list[Module] = []
230+
location: list[Location] = []
231+
notification: Notification | list[Notification] | None = None
232+
rule: list[dict] = []
233+
template: list[dict] = []
234+
235+
236+
class Root(PWBase):
237+
"""Main XML definition."""
238+
239+
domain_objects: DomainObjects

plugwise/smile.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from collections.abc import Awaitable, Callable
99
import datetime as dt
10+
import json
1011
from typing import Any, cast
1112

1213
from plugwise.constants import (
@@ -35,6 +36,9 @@
3536

3637
# Dict as class
3738
from munch import Munch
39+
import xmltodict
40+
41+
from .model import Appliance, Root
3842

3943

4044
def model_to_switch_items(model: str, state: str, switch: Munch) -> tuple[str, Munch]:
@@ -93,10 +97,32 @@ def cooling_present(self) -> bool:
9397
"""Return the cooling capability."""
9498
return self._cooling_present
9599

100+
def parse_xml(self, xml: str) -> dict:
101+
# Safely parse XML
102+
element = etree.fromstring(xml)
103+
xml_dict = xmltodict.parse(etree.tostring(element))
104+
print(f"HOI1 {xml_dict.keys()}")
105+
print(
106+
f"HOI2 {json.dumps(xmltodict.parse(xml, process_namespaces=True), indent=2)}"
107+
)
108+
appliance_in = xml_dict["domain_objects"]["appliance"][0]
109+
print(f"HOI4a1 {json.dumps(appliance_in, indent=2)}")
110+
appliance_in = xml_dict["domain_objects"]["appliance"][5]
111+
print(f"HOI4a1 {json.dumps(appliance_in, indent=2)}")
112+
appliance = Appliance.model_validate(appliance_in)
113+
print(f"HOI4a2 {appliance}")
114+
115+
return Root.model_validate(xml_dict)
116+
96117
async def full_xml_update(self) -> None:
97118
"""Perform a first fetch of the Plugwise server XML data."""
98-
self._domain_objects = await self._request(DOMAIN_OBJECTS)
99-
self._get_plugwise_notifications()
119+
self._domain_objects = await self._request(DOMAIN_OBJECTS, new=True)
120+
root = self.parse_xml(self._domain_objects)
121+
self._domain_objects = root.domain_objects
122+
print(f"HOI3a {self._domain_objects}")
123+
print(f"HOI3b {self._domain_objects.notification}")
124+
if self._domain_objects.notification is not None:
125+
self._get_plugwise_notifications()
100126

101127
def get_all_gateway_entities(self) -> None:
102128
"""Collect the Plugwise gateway entities and their data and states from the received raw XML-data.

0 commit comments

Comments
 (0)