Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
eca817e
Initial typed xml reading attempt (incomplete)
CoMPaTech Dec 28, 2025
408682b
Further progress
CoMPaTech Dec 29, 2025
6c6b3f2
Further progress - appliance typos
CoMPaTech Dec 29, 2025
709e84c
Some progress
CoMPaTech Dec 29, 2025
660acd8
More progress
CoMPaTech Dec 29, 2025
826c387
Further cleaning
CoMPaTech Dec 29, 2025
baf3419
Gateway progress
CoMPaTech Dec 31, 2025
9cd1b54
Fix after rebase
bouwew Jan 24, 2026
91b6877
Contents -> content
bouwew Jan 24, 2026
e2ad185
Fix module_data
bouwew Jan 24, 2026
9c011df
Fix another contents
bouwew Jan 24, 2026
6d09ec6
Add xmltodict to requirements
bouwew Apr 5, 2026
04698e7
Change index from 5 to 4
bouwew Apr 5, 2026
7e393f5
Update _appl_heater_central_info()
bouwew Apr 6, 2026
1d36e07
Update check_heater_central()
bouwew Apr 7, 2026
7c84129
And use
bouwew Apr 7, 2026
39ab2b3
Fixes
bouwew Apr 7, 2026
d7fa9f7
More _appl_heater_central_info() updating
bouwew Apr 13, 2026
85bfdda
Fix contents
bouwew Apr 13, 2026
613cbb3
Update _appl_thermostat_info()
bouwew Apr 13, 2026
aeee690
Update _get_appliances_with_offset_functionality()
bouwew Apr 13, 2026
8e3c64c
get_groups()
bouwew Apr 13, 2026
a4a8822
Disable prints, add logger
bouwew Apr 17, 2026
6728175
Disable initial Adam testcase
bouwew Apr 17, 2026
bbe0e9d
Add _create_gw_entities() call
bouwew Apr 17, 2026
a998ae0
Extend Appliance class
bouwew Apr 18, 2026
51f9db4
Correct to appl.id
bouwew Apr 18, 2026
17e6798
Various improvements
bouwew Apr 18, 2026
e9d0d42
fixup: typing_1_b Python code fixed using ruff
Apr 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Gateway progress
  • Loading branch information
CoMPaTech authored and bouwew committed Apr 5, 2026
commit baf34190c5bb08619cd4c744b607af542deb60a1
70 changes: 41 additions & 29 deletions plugwise/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,24 +23,34 @@
from defusedxml import ElementTree as etree
from munch import Munch

from .model import ModuleData
from .model import Module, ModuleData


def get_zigbee_data(
module: etree.Element, module_data: ModuleData, legacy: bool
) -> None:
def get_zigbee_data(module: Module, module_data: ModuleData, legacy: bool) -> None:
"""Helper-function for _get_module_data()."""
if not module.protocols:
return

if legacy:
# Stretches
if (router := module.find("./protocols/network_router")) is not None:
module_data["zigbee_mac_address"] = router.find("mac_address").text
# Also look for the Circle+/Stealth M+
if (coord := module.find("./protocols/network_coordinator")) is not None:
module_data["zigbee_mac_address"] = coord.find("mac_address").text
if legacy:
if module.protocols.network_router:
module_data.zigbee_mac_address = module.protocols.network_router.mac_address
if module.protocols.network_coordinator:
module_data.zigbee_mac_address = (
module.protocols.network_coordinator.mac_address
)
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Adam
elif (zb_node := module.find("./protocols/zig_bee_node")) is not None:
module_data["zigbee_mac_address"] = zb_node.find("mac_address").text
module_data["reachable"] = zb_node.find("reachable").text == "true"
if module.protocols.zig_bee_node:
zb = module.protocols.zig_bee_node
module_data.zigbee_mac_address = zb.mac_address
module_data.reachable = zb.reachable


class SmileCommon:
Expand Down Expand Up @@ -194,12 +204,8 @@ def _get_groups(self) -> None:
if self.smile.type == "power" or self.check_name(ANNA):
return

for group in self._domain_objects.group:
group_id = group.get("id")
if group_id is None:
continue # pragma: no cover

if not (members := self._collect_members(group)):
for group in self.data.group:
if not group.appliances:
continue

group_name = group.find("name").text
Expand Down Expand Up @@ -251,26 +257,32 @@ def _get_module_data(

Collect requested info from MODULES.
"""
module = self.data.get_module(link_id)
module_data = ModuleData()
if "services" not in self.data.appliance or not self.data.appliance.services:
return module_data

for service_type, services in appliance.services.iter_services():
for service_type, services in self.data.appliance.services.iter_services():
if key and key not in service_type:
continue
for service in services:
module = self.data.get_module(service.id)
if not module:
continue

return ModuleData(
contents=True,
firmware_version=None,
hardware_version=None,
reachable=None,
vendor_name=None,
vendor_model=None,
zigbee_mac_address=None,
)
return ModuleData()
# NOW correctly nested
for service in services:
module = self.data.get_module(service.id)
if not module:
continue

module_data = ModuleData(
content=True,
firmware_version=module.firmware_version,
hardware_version=module.hardware_version,
reachable=module.reachable,
vendor_name=module.vendor_name,
vendor_model=module.vendor_model,
zigbee_mac_address=module.zigbee_mac_address,
)
get_zigbee_data(module, module_data, legacy)

return module_data

Comment on lines 246 to +281

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm services typing and iter_services location
ast-grep --pattern 'class Module($_):
  $$$'
ast-grep --pattern 'class Appliance($_):
  $$$'
rg -nP 'iter_services' --type=py
rg -nP '_get_module_data\(' --type=py -C1

Repository: plugwise/python-plugwise

Length of output: 4463


🏁 Script executed:

#!/bin/bash
# 1. Show the actual _get_module_data function signature and full implementation
sed -n '246,281p' plugwise/common.py

# 2. Check DomainObjects.appliance type
rg -n 'class DomainObjects' --type=py -A 30 | head -50

# 3. Search for iter_services definition
rg -n 'def iter_services|\.iter_services' --type=py -B 2 -A 5

Repository: plugwise/python-plugwise

Length of output: 3560


🏁 Script executed:

#!/bin/bash
# 1. Check the exact calls in helper.py with more context
sed -n '145,160p' plugwise/helper.py
sed -n '225,240p' plugwise/helper.py

# 2. Check legacy/helper.py call
sed -n '205,220p' plugwise/legacy/helper.py

# 3. Search for iter_services definition
rg -n 'iter_services' --type=py -B 5 -A 10

# 4. Check if Appliance has services attribute (full class definition)
rg -n 'class Appliance' --type=py -A 40

Repository: plugwise/python-plugwise

Length of output: 8475


🏁 Script executed:

#!/bin/bash
# Check the dead code block mentioned (lines 282-299)
sed -n '282,299p' plugwise/common.py

# Check if there's any working version or if this entire function needs rewriting
rg -n 'services.iter_services\|\.iter_services' --type=py -C 3

# Check git history to see if this was recently changed
git log --oneline -20 plugwise/common.py | head -20

Repository: plugwise/python-plugwise

Length of output: 1031


_get_module_data has critical signature/logic mismatch: callers pass appliance objects, but the function signature accepts only key: str, and the implementation treats self.data.appliance (a list[Appliance]) as a single object.

From plugwise/model.py line 368: appliance: list[Appliance] = []. The function does:

  • "services" not in self.data.appliance — membership test on a list, always False, preventing the guard clause from working.
  • self.data.appliance.services.iter_services() — Appliance has no services attribute; iter_services() method does not exist anywhere in the codebase.
  • Callers (helper.py lines 152, 233; legacy/helper.py line 212) pass appliance/location objects as the first positional argument, which currently lands in the key: str | None parameter, causing a type error.

Option (a): Add appliance: Appliance parameter to the function signature and iterate its services (if it had one). Option (b): Iterate self.data.appliance and select the correct appliance by matching module services to the key filter.

Also remove the commented-out legacy code block (lines 282–299); it's unreachable dead code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugwise/common.py` around lines 246 - 281, The _get_module_data function
currently has the wrong signature and treats self.data.appliance as a single
Appliance; change its signature to accept an appliance parameter (e.g., def
_get_module_data(self, appliance: Appliance, key: str | None = None, legacy:
bool = False) -> ModuleData) and update the body to operate on that appliance
(check appliance.services, iterate appliance.services.iter_services() or the
actual service iterable, call self.data.get_module(service.id) as before,
populate ModuleData and call get_zigbee_data(module, module_data, legacy));
update callers that pass an appliance to pass it into the new first argument;
alternatively if you prefer the other approach, iterate self.data.appliance (the
list) and find the matching Appliance by service/module when key is provided;
finally remove the unreachable commented-out legacy block after the function.

# TODO legacy
"""
Expand Down
9 changes: 4 additions & 5 deletions plugwise/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
ANNA,
MAX_SETPOINT,
MIN_SETPOINT,
NONE,
OFF,
ActuatorData,
GwEntityData,
Expand Down Expand Up @@ -272,14 +271,14 @@ def _climate_data(self, location_id: str, entity: GwEntityData) -> None:
entity["select_schedule"] = None
self._count += 2
avail_schedules, sel_schedule = self._schedules(loc_id)
if avail_schedules != [NONE]:
if avail_schedules != [None]:
entity["available_schedules"] = avail_schedules
entity["select_schedule"] = sel_schedule

# Set HA climate HVACMode: auto, heat, heat_cool, cool and off
entity["climate_mode"] = "auto"
self._count += 1
if sel_schedule in (NONE, OFF):
if sel_schedule in (None, OFF):
entity["climate_mode"] = "heat"
if self._cooling_present:
entity["climate_mode"] = (
Expand All @@ -289,7 +288,7 @@ def _climate_data(self, location_id: str, entity: GwEntityData) -> None:
if self.check_reg_mode("off"):
entity["climate_mode"] = "off"

if NONE not in avail_schedules:
if None not in avail_schedules:
self._get_schedule_states_with_off(
loc_id, avail_schedules, sel_schedule, entity
)
Expand Down Expand Up @@ -320,7 +319,7 @@ def _get_schedule_states_with_off(
) -> None:
"""Collect schedules with states for each thermostat.

Also, replace NONE by OFF when none of the schedules are active.
Also, replace None by OFF when none of the schedules are active.
"""
all_off = True
self._schedule_old_states[location] = {}
Expand Down
68 changes: 35 additions & 33 deletions plugwise/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
LOCATIONS,
LOGGER,
MODULE_LOCATOR,
NONE,
OFF,
P1_MEASUREMENTS,
TEMP_CELSIUS,
Expand Down Expand Up @@ -80,7 +79,7 @@ def __init__(self) -> None:
self._is_thermostat: bool
self._loc_data: dict[str, ThermoLoc]
self._schedule_old_states: dict[str, dict[str, str]]
self._gateway_id: str = NONE
self._gateway_id: str = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

self._gateway_id: str = None — annotation doesn't permit None.

Either widen to str | None (and update accessors) or keep the old NONE sentinel string.

-        self._gateway_id: str = None
+        self._gateway_id: str | None = None

(Same pattern applies downstream — e.g. _get_actuator_mode returns None; the gateway_id property annotated -> str on line 87 will need to reflect optionality.)

🧰 Tools
🪛 GitHub Check: SonarCloud Code Analysis

[warning] 80-80: Replace the type hint "str" with "Optional[str]" or don't assign "None" to this expression

See more on https://sonarcloud.io/project/issues?id=plugwise_python-plugwise&issues=AZ1ejLoxrHfHDGLiEckG&open=AZ1ejLoxrHfHDGLiEckG&pullRequest=870

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugwise/helper.py` at line 80, The field self._gateway_id is annotated as
str but initialized to None, causing a type mismatch; change its annotation to
allow None (e.g. str | None or Optional[str]) and update any accessors and the
gateway_id property signature to reflect optionality (adjust return type from ->
str to -> str | None or Optional[str]) and handle None safely where accessed;
apply the same pattern to other members/methods mentioned in the review (for
example update the return annotation of _get_actuator_mode if it can return None
and adjust callers accordingly).

self._zones: dict[str, GwEntityData]
self.gw_entities: dict[str, GwEntityData]
self.smile: Munch = Munch()
Expand Down Expand Up @@ -131,10 +130,9 @@ def _get_appliances(self) -> None:
):
appliance.type = f"{appliance.type}_plug"

# TODO: recreate functionality
# # Collect appliance info, skip orphaned/removed devices
# if not (appl := self._appliance_info_finder(appl, appliance)):
# continue
# Collect appliance info, skip orphaned/removed devices
if not self._appliance_info_finder(appliance):
continue

# A smartmeter is not present as an appliance, add it specifically
if self.smile.type == "power" or self.smile.anna_p1:
Expand Down Expand Up @@ -182,6 +180,7 @@ def _get_locations(self) -> None:
"""Collect all locations."""
counter = 0
loc = Munch()
print(f"HOI15 {self.data}")
print(f"HOI15 {self.data.location}")
locations = self.data.location
if not locations:
Expand All @@ -206,48 +205,51 @@ def _get_locations(self) -> None:
"Error, location Home (building) not found!"
) # pragma: no cover

def _appliance_info_finder(self, appliance: Appliance) -> Appliance:
def _appliance_info_finder(self, appliance: Appliance) -> Appliance | None:
"""Collect info for all appliances found."""
print(f"HOI22 appliance type {appliance.type}!")
match appliance.type:
# No longer needed since we have a Gateway
# case "gateway":
# # Collect gateway entity info
# return self._appl_gateway_info(appl, appliance)
case "gateway":
# Collect gateway entity info
print("HOI22 gateway!")
return self._appl_gateway_info(appliance)
case _ as dev_class if dev_class in THERMOSTAT_CLASSES:
# Collect thermostat entity info
return self._appl_thermostat_info(appl, appliance)
return self._appl_thermostat_info(appliance)
case "heater_central":
# Collect heater_central entity info
# 251016: the added guarding below also solves Core Issue #104433
if not (
appl := self._appl_heater_central_info(appl, appliance, False)
appliance := self._appl_heater_central_info(appliance, False)
): # False means non-legacy entity
return Munch()
self._dhw_allowed_modes = self._get_appl_actuator_modes(
appliance, "domestic_hot_water_mode_control_functionality"
)
return appl
return appliance
case _ as s if s.endswith("_plug"):
# Collect info from plug-types (Plug, Aqara Smart Plug)
locator = MODULE_LOCATOR
module_data = self._get_module_data(appliance, locator)
# A plug without module-data is orphaned/ no present
if not module_data["contents"]:
return Munch()

appl.available = module_data["reachable"]
appl.firmware = module_data["firmware_version"]
appl.hardware = module_data["hardware_version"]
appl.model_id = module_data["vendor_model"]
appl.vendor_name = module_data["vendor_name"]
appl.model = check_model(appl.model_id, appl.vendor_name)
appl.zigbee_mac = module_data["zigbee_mac_address"]
return appl
return None

print(f"HOI24 {module_data}")
appliance.available = module_data["reachable"]
appliance.firmware_version = module_data["firmware_version"]
appliance.hardware_version = module_data["hardware_version"]
appliance.model_id = module_data["vendor_model"]
appliance.vendor_name = module_data["vendor_name"]
appliance.model = check_model(appl.model_id, appl.vendor_name)
appliance.zigbee_mac_address = module_data["zigbee_mac_address"]
return appliance
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case _: # pragma: no cover
return Munch()
return None

def _appl_gateway_info(self, appliance: Appliance) -> Appliance:
"""Helper-function for _appliance_info_finder()."""
print(f"HOI19 {appliance.id}")
self._gateway_id = appliance.id

# Adam: collect the ZigBee MAC address of the Smile
Expand All @@ -267,7 +269,7 @@ def _appl_gateway_info(self, appliance: Appliance) -> Appliance:
# Limit the possible gateway-modes
self._gw_allowed_modes = ["away", "full", "vacation"]

return appl
return appliance

def _get_appl_actuator_modes(
self, appliance: etree.Element, actuator_type: str
Expand Down Expand Up @@ -601,7 +603,7 @@ def _get_gateway_outdoor_temp(self, entity_id: str, data: GwEntityData) -> None:
if self._is_thermostat and entity_id == self._gateway_id:
locator = "./logs/point_log[type='outdoor_temperature']/period/measurement"
if (found := self._home_location.find(locator)) is not None:
value = format_measure(found.text, NONE)
value = format_measure(found.text, None)
data.update({"sensors": {"outdoor_temperature": value}})
self._count += 1

Expand Down Expand Up @@ -905,7 +907,7 @@ def _rule_ids_by_name(self, name: str, loc_id: str) -> dict[str, dict[str, str]]
}
else:
schedule_ids[rule.get("id")] = {
"location": NONE,
"location": None,
"name": name,
"active": active,
}
Expand All @@ -932,7 +934,7 @@ def _rule_ids_by_tag(self, tag: str, loc_id: str) -> dict[str, dict[str, str]]:
}
else:
schedule_ids[rule.get("id")] = {
"location": NONE,
"location": None,
"name": name,
"active": active,
}
Expand All @@ -945,9 +947,9 @@ def _schedules(self, location: str) -> tuple[list[str], str]:
Obtain the available schedules/schedules. Adam: a schedule can be connected to more than one location.
NEW: when a location_id is present then the schedule is active. Valid for both Adam and non-legacy Anna.
"""
available: list[str] = [NONE]
available: list[str] = [None]
rule_ids: dict[str, dict[str, str]] = {}
selected = NONE
selected = None
tag = "zone_preset_based_on_time_and_presence_with_override"
if not (rule_ids := self._rule_ids_by_tag(tag, location)):
return available, selected
Expand All @@ -967,9 +969,9 @@ def _schedules(self, location: str) -> tuple[list[str], str]:
schedules.append(name)

if schedules:
available.remove(NONE)
available.remove(None)
available.append(OFF)
if selected == NONE:
if selected == None:
selected = OFF

return available, selected
Expand Down
26 changes: 25 additions & 1 deletion plugwise/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ class Neighbor(PWBase):
class ZigBeeNode(WithID):
"""ZigBee node definition."""

reachable: bool | None = None
mac_address: str
type: str
reachable: bool
Comment on lines +186 to +189

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove the duplicated field declarations.

The second annotation silently overwrites the first in both classes. That makes ZigBeeNode.reachable required again on Line 163 and discards the first Location.appliances shape on Line 325.

Also applies to: 323-325

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugwise/model.py` around lines 160 - 163, Remove the duplicated attribute
declarations so the optional types are preserved: in the ZigBeeNode class keep a
single reachable declaration as "reachable: bool | None = None" (remove the
second "reachable: bool") and similarly for the Location class remove the
duplicate appliances/reachable lines so the first declaration (the
optional/typed one) remains; ensure no attribute is declared twice anywhere in
the file so the optional/default shapes are not overwritten.

Expand All @@ -168,6 +169,26 @@ class ZigBeeNode(WithID):
neighbor_table_support: bool | None = None


class NetworkRouter(BaseModel):
"""Network router."""

mac_address: str | None = None


class NetworkCoordinator(BaseModel):
"""Network coordinator."""

mac_address: str | None = None


class Protocols(BaseModel):
"""Protocol definition."""

network_router: NetworkRouter | None = None
network_coordinator: NetworkCoordinator | None = None
zig_bee_node: ZigBeeNode | None = None


# Appliance
class ApplianceType(str, Enum):
"""Define application types."""
Expand All @@ -182,6 +203,7 @@ class ApplianceType(str, Enum):
STRETCH = "stretch"
THERMO_RV = "thermostatic_radiator_valve"
VA = "valve_actuator"
VA_plug = "valve_actuator_plug"
WHV = "water_heater_vessel"
ZONETHERMOMETER = "zone_thermometer"
ZONETHERMOSTAT = "zone_thermostat"
Expand All @@ -192,6 +214,7 @@ class ApplianceType(str, Enum):
class Appliance(WithID):
"""Plugwise Appliance."""

available: bool = False
name: str
description: str | None = None
type: ApplianceType
Expand Down Expand Up @@ -226,7 +249,8 @@ class Module(WithID):
# services: dict[str, ServiceBase | list[ServiceBase]] | list[dict[str, Any]] | None = None
services: dict[str, Any] | list[Any] | None = None

protocols: dict[str, Any] | None = None # ZigBeeNode, WLAN, LAN
# protocols: dict[str, Any] | None = None # ZigBeeNode, WLAN, LAN
protocols: dict[str, Protocols] | list[Protocols] | None = None


# Gateway
Expand Down
2 changes: 1 addition & 1 deletion plugwise/smile.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def cooling_present(self) -> bool:

async def full_xml_update(self) -> None:
"""Perform a first fetch of the Plugwise server XML data."""
self.data = await self._request(DOMAIN_OBJECTS, new=True)
await self._request(DOMAIN_OBJECTS, new=True)
print(f"HOI3a {self.data}")
if "notification" in self.data and self.data.notification is not None:
print(f"HOI3b {self.data.notification}")
Expand Down