Skip to content
Open
37 changes: 3 additions & 34 deletions src/pyatmo/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ async def update_devices(
module_data["home_id"] = home_id
module_data["id"] = module_data["_id"]
module_data["name"] = module_data.get("module_name")
modules_data.append(normalize_weather_attributes(module_data))
modules_data.append(normalize_weather_attributes(device_data))
modules_data.append(module_data)
modules_data.append(device_data)

self.homes[home_id] = Home(
self.auth,
Expand All @@ -247,7 +247,7 @@ async def update_devices(
},
)
await self.homes[home_id].update(
{HOME: {"modules": [normalize_weather_attributes(device_data)]}},
{HOME: {"modules": [device_data]}},
)
else:
LOG.debug("No home %s (%s) found.", home_id, home_id)
Expand All @@ -264,7 +264,6 @@ async def update_devices(
"station_name",
device_data.get("module_name", "Unknown"),
)
device_data = normalize_weather_attributes(device_data)
if device_data["id"] not in self.modules:
self.modules[device_data["id"]] = getattr(
modules,
Expand Down Expand Up @@ -293,33 +292,3 @@ def find_home_of_device(self, device_data: dict[str, Any]) -> str | None:
),
None,
)


ATTRIBUTES_TO_FIX: dict[str, str] = {
"_id": "id",
"firmware": "firmware_revision",
"wifi_status": "wifi_strength",
"rf_status": "rf_strength",
"Temperature": "temperature",
"Humidity": "humidity",
"Pressure": "pressure",
"CO2": "co2",
"AbsolutePressure": "absolute_pressure",
"Noise": "noise",
"Rain": "rain",
"WindStrength": "wind_strength",
"WindAngle": "wind_angle",
"GustStrength": "gust_strength",
"GustAngle": "gust_angle",
}


def normalize_weather_attributes(raw_data: RawData) -> dict[str, Any]:
"""Normalize weather attributes."""
result: dict[str, Any] = {}
for attribute, value in raw_data.items():
if attribute == "dashboard_data":
result.update(**normalize_weather_attributes(value))
else:
result[ATTRIBUTES_TO_FIX.get(attribute, attribute)] = value
return result
62 changes: 58 additions & 4 deletions src/pyatmo/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,57 @@

LOG: logging.Logger = logging.getLogger(__name__)

# types for data that can be normalized (recursive structures) - to satisfy mypy
NormalizableData = dict[str, Any] | list[Any] | str | int | float | bool | None

ATTRIBUTES_TO_FIX: dict[str, str] = {
"firmware": "firmware_revision",
"firmware_revision": "firmware_revision",
"firmware_name": "firmware_name",
"wifi_status": "wifi_strength",
"rf_status": "rf_strength",
"Temperature": "temperature",
"Humidity": "humidity",
"Pressure": "pressure",
"CO2": "co2",
"AbsolutePressure": "absolute_pressure",
"Noise": "noise",
"Rain": "rain",
"WindStrength": "wind_strength",
"WindAngle": "wind_angle",
"GustStrength": "gust_strength",
"GustAngle": "gust_angle",
"wind_gust": "gust_strength",
"wind_gust_angle": "gust_angle",
}


def normalize_weather_attributes(raw_data: NormalizableData) -> NormalizableData:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are you sure about the return type?

Copy link
Author

Choose a reason for hiding this comment

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

I thought making a return type would satisfy mypy. Otherwise, it would block since the return type wasn't explicitly specified, and "any" would also block it. At least in local.

Copy link
Collaborator

Choose a reason for hiding this comment

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

What type does normalize_weather_attributes return?

Copy link
Author

Choose a reason for hiding this comment

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

From this logic, it returns the same type that it received in input. If we call normalize_weather_attributes with a dict in input, it returns a dict. If it's a list in input, it returns a list.

That's why I declare the type NormalizableData, so mypy is satisfied with the different possibilities.

Copy link
Collaborator

@cgtobi cgtobi Jan 12, 2026

Choose a reason for hiding this comment

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

Take a look at the original function and compare how you changed the types and the internal behaviour.

Copy link
Collaborator

@cgtobi cgtobi Jan 12, 2026

Choose a reason for hiding this comment

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

Suggested change
def normalize_weather_attributes(raw_data: NormalizableData) -> NormalizableData:
T = TypeVar('T')
def normalize_weather_attributes(raw_data: T) -> T:

Also the normalize function needs to be split so that it uses your extended version when calling from within.

"""Normalize weather-related attributes recursively."""

if isinstance(raw_data, list):
return [normalize_weather_attributes(item) for item in raw_data]

if not isinstance(raw_data, dict):
return raw_data

normalized: dict[str, Any] = {}
for key, value in raw_data.items():
if key == "_id":
normalized["_id"] = value
continue
if key == "dashboard_data" and isinstance(value, dict):
# useless cast here to satisfy mypy
Copy link
Collaborator

Choose a reason for hiding this comment

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

This hints at some type mismatch. A cast should not be necessary.

Copy link
Author

@tiimjcb tiimjcb Jan 12, 2026

Choose a reason for hiding this comment

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

I agree with you that the cast is completly unnecessary. But as said, it's only to satisfy mypy. Without this cast, it blocks. I don't know what else to do in that case 😬

I'll check if there seems to be a sort of mismatch in the types.

Copy link
Author

@tiimjcb tiimjcb Jan 12, 2026

Choose a reason for hiding this comment

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

Checked it again, and I really don't see how I can do it in another way. From what I understand, it seems that mypy expects an explicit type because it needs to guarantee that the passed object (the result of normalize_weather_attributes) is of the expected type (dict). But since that function can return different types, I placed a cast there to explicitly tell mypy that, in this specific context, the type is indeed a dict, even though it's completely unnecessary 🤷🏼‍♂️

normalized |= cast("dict[str, Any]", normalize_weather_attributes(value))
continue

mapped_key = ATTRIBUTES_TO_FIX[key] if key in ATTRIBUTES_TO_FIX else key # noqa: SIM401
normalized[mapped_key] = normalize_weather_attributes(value)

if "_id" in normalized and "id" not in normalized:
normalized["id"] = normalized["_id"]
return normalized


def fix_id(raw_data: list[RawData | str]) -> list[RawData | str]:
"""Fix known errors in station ids like superfluous spaces."""
Expand Down Expand Up @@ -43,20 +94,23 @@ def extract_raw_data(resp: RawData, tag: str) -> RawData:
msg = "No device found, errors in response"
raise NoDeviceError(msg)

# useless cast here again to satisfy mypy
Copy link
Collaborator

Choose a reason for hiding this comment

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

This hints at some type mismatch. A cast should not be necessary.

body = cast("dict[str, Any]", normalize_weather_attributes(resp["body"]))

if tag == "homes":
homes: list[dict[str, Any] | str] = fix_id(resp["body"].get(tag))
homes: list[dict[str, Any] | str] = fix_id(body.get(tag, []))
if not homes:
LOG.debug("Server response (tag: %s): %s", tag, resp)
msg = "No homes found"
raise NoDeviceError(msg)
return {
tag: homes,
"errors": resp["body"].get("errors", []),
"errors": body.get("errors", []),
}

if not (raw_data := fix_id(resp["body"].get(tag))):
if not (raw_data := fix_id(body.get(tag, []))):
LOG.debug("Server response (tag: %s): %s", tag, resp)
msg = "No device data available"
raise NoDeviceError(msg)

return {tag: raw_data, "errors": resp["body"].get("errors", [])}
return {tag: raw_data, "errors": body.get("errors", [])}