feat(device): merge all entities into one shared device with station-type model

All entities (weather, battery, health, native Ecowitt) now report through a
single device under the existing {(DOMAIN,)} identifier, removing the separate
"Ecowitt {model}" device that appeared whenever Ecowitt was enabled. This
collapses the previous two-device layout (and the duplicate/untranslated native
Ecowitt sensors living on the second device) into one.

The device model reflects the running station type via _station_model():
"Ecowitt <model>" when Ecowitt is active (model learned from the payload's
`model` field and stored on runtime_data.ecowitt_model), else "WSLink", else
"PWS". Device identity is centralised in data.build_device_info() and reused by
every platform.

No device-registry migration is required: the identifier is unchanged; only the
model/grouping change. Full device unification under a renamed hub is deferred
to the rename round.

Tests: device-info assertions updated to the shared device across the entity
suites; new _station_model/build_device_info branch coverage in test_data.py;
received_ecowitt now asserts the learned model. 320 passed, 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SchiZzA 2026-06-21 23:37:11 +02:00
parent 3fdebc6f20
commit 80c944fc5a
No known key found for this signature in database
12 changed files with 187 additions and 85 deletions

View File

@ -11,11 +11,10 @@ from typing import Any
from py_typecheck import checked_or from py_typecheck import checked_or
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorEntityDescription
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .data import build_device_info
class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride] class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride]
@ -62,12 +61,5 @@ class BatteryBinarySensor( # pyright: ignore[reportIncompatibleVariableOverride
@cached_property @cached_property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Device info.""" """Device info (single shared device for the whole integration)."""
return DeviceInfo( return build_device_info(self.coordinator.config)
connections=set(),
name="Weather Station SWS 12500",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
manufacturer="Schizza",
model="Weather Station SWS 12500",
)

View File

@ -198,6 +198,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
post_data = await webdata.post() post_data = await webdata.post()
data: dict[str, Any] = dict(post_data) data: dict[str, Any] = dict(post_data)
# Record the Ecowitt station model (used as the device model) before parsing,
# so native entities created during process_payload report it.
if model := checked(data.get("model"), str):
self.config.runtime_data.ecowitt_model = model
mapped_data = await self.ecowitt_bridge.process_payload(data) mapped_data = await self.ecowitt_bridge.process_payload(data)
if mapped_data: if mapped_data:

View File

@ -11,10 +11,16 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from py_typecheck import checked_or
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import DOMAIN, ECOWITT_ENABLED, WSLINK
if TYPE_CHECKING: if TYPE_CHECKING:
from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.binary_sensor import BinarySensorEntityDescription
@ -51,6 +57,38 @@ class SWSRuntimeData:
started_at: datetime = field(default_factory=dt_util.utcnow) started_at: datetime = field(default_factory=dt_util.utcnow)
last_seen: dict[str, datetime] = field(default_factory=dict) last_seen: dict[str, datetime] = field(default_factory=dict)
# Ecowitt station model (e.g. "GW1000"), learned from the first Ecowitt payload.
ecowitt_model: str | None = None
# Type alias for typed ConfigEntry # Type alias for typed ConfigEntry
type SWSConfigEntry = ConfigEntry[SWSRuntimeData] type SWSConfigEntry = ConfigEntry[SWSRuntimeData]
def _station_model(entry: SWSConfigEntry) -> str:
"""Return the device model label reflecting the running station type.
Ecowitt (with the learned model when available), else WSLink, else PWS.
"""
if checked_or(entry.options.get(ECOWITT_ENABLED), bool, False):
runtime = getattr(entry, "runtime_data", None)
model = getattr(runtime, "ecowitt_model", None) if runtime is not None else None
return f"Ecowitt {model}" if model else "Ecowitt"
if checked_or(entry.options.get(WSLINK), bool, False):
return "WSLink"
return "PWS"
def build_device_info(entry: SWSConfigEntry) -> DeviceInfo:
"""Single device shared by all entities (SWS, battery, health, native Ecowitt).
Keeps the existing ``{(DOMAIN,)}`` identifier so no device-registry migration is
needed; the model reflects the active station type.
"""
return DeviceInfo(
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
name="Weather Station SWS 12500",
entry_type=DeviceEntryType.SERVICE,
manufacturer="Schizza",
model=_station_model(entry),
)

View File

@ -19,14 +19,12 @@ from aioecowitt import EcoWittListener, EcoWittSensor, EcoWittSensorTypes
from aioecowitt.sensor import SENSOR_MAP from aioecowitt.sensor import SENSOR_MAP
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from .const import DOMAIN, REMAP_ECOWITT_COMPAT from .const import REMAP_ECOWITT_COMPAT
from .data import SWSConfigEntry, build_device_info
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -216,7 +214,7 @@ class EcowittBridge:
and we are just using parsing/discovery logic. and we are just using parsing/discovery logic.
""" """
def __init__(self, hass: HomeAssistant, config: ConfigEntry) -> None: def __init__(self, hass: HomeAssistant, config: SWSConfigEntry) -> None:
"""Initialize bridge.""" """Initialize bridge."""
self.hass = hass self.hass = hass
@ -308,7 +306,7 @@ class EcowittBridge:
return return
self._know_native_keys.add(sensor.key) self._know_native_keys.add(sensor.key)
entity = EcoWittNativeSensor(sensor) entity = EcoWittNativeSensor(sensor, self.config)
self._add_entities_cb([entity]) self._add_entities_cb([entity])
_LOGGER.info("New native Ecowitt sensor %s (type=%s)", sensor.name, sensor.stype.name) _LOGGER.info("New native Ecowitt sensor %s (type=%s)", sensor.name, sensor.stype.name)
@ -336,7 +334,7 @@ class EcoWittNativeSensor(SensorEntity):
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False _attr_should_poll = False
def __init__(self, sensor: EcoWittSensor) -> None: def __init__(self, sensor: EcoWittSensor, config: SWSConfigEntry) -> None:
"""Initialize native EcoWittSensor.""" """Initialize native EcoWittSensor."""
self._ecowitt_sensor = sensor self._ecowitt_sensor = sensor
@ -358,17 +356,9 @@ class EcoWittNativeSensor(SensorEntity):
self._attr_native_unit_of_measurement = unit self._attr_native_unit_of_measurement = unit
self._attr_state_class = state_class self._attr_state_class = state_class
# Always attach device info so the entity is grouped under the Ecowitt # Share the single integration device (see data.build_device_info); the station
# station device even when its sensor type has no HA mapping. # type is reflected in the device model rather than a separate Ecowitt device.
station = sensor.station self._attr_device_info = build_device_info(config)
self._attr_device_info = DeviceInfo(
connections=set(),
name=f"Ecowitt {station.model}" if station else "Ecowitt station",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, f"ecowitt_{station.key}" if station else "ecowitt")},
manufacturer="Ecowitt impl. from Schizza for SWS12500",
model=station.model if station else None,
)
@property @property
def native_value(self) -> StateType | datetime: # pyright: ignore[reportIncompatibleVariableOverride] def native_value(self) -> StateType | datetime: # pyright: ignore[reportIncompatibleVariableOverride]

View File

@ -12,14 +12,12 @@ from py_typecheck import checked, checked_or
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription from homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorEntityDescription
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import DOMAIN from .data import SWSConfigEntry, build_device_info
from .data import SWSConfigEntry
from .health_coordinator import HealthCoordinator, public_health_snapshot from .health_coordinator import HealthCoordinator, public_health_snapshot
if TYPE_CHECKING: if TYPE_CHECKING:
@ -265,12 +263,5 @@ class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverr
@cached_property @cached_property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Device info.""" """Device info (single shared device for the whole integration)."""
return DeviceInfo( return build_device_info(self.coordinator.config)
connections=set(),
name="Weather Station SWS 12500",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
manufacturer="Schizza",
model="Weather Station SWS 12500",
)

View File

@ -24,7 +24,6 @@ from py_typecheck import checked_or
from homeassistant.components.sensor import SensorEntity from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo, generate_entity_id from homeassistant.helpers.entity import DeviceInfo, generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -33,7 +32,6 @@ from . import health_sensor
from .const import ( from .const import (
CHILL_INDEX, CHILL_INDEX,
DEV_DBG, DEV_DBG,
DOMAIN,
HEAT_INDEX, HEAT_INDEX,
OUTSIDE_HUMIDITY, OUTSIDE_HUMIDITY,
OUTSIDE_TEMP, OUTSIDE_TEMP,
@ -43,7 +41,7 @@ from .const import (
WIND_SPEED, WIND_SPEED,
WSLINK, WSLINK,
) )
from .data import SWSConfigEntry from .data import SWSConfigEntry, build_device_info
from .sensors_common import WeatherSensorEntityDescription from .sensors_common import WeatherSensorEntityDescription
from .sensors_weather import SENSOR_TYPES_WEATHER_API from .sensors_weather import SENSOR_TYPES_WEATHER_API
from .sensors_wslink import SENSOR_TYPES_WSLINK from .sensors_wslink import SENSOR_TYPES_WSLINK
@ -225,12 +223,5 @@ class WeatherSensor( # pyright: ignore[reportIncompatibleVariableOverride]
@cached_property @cached_property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Device info.""" """Device info (single shared device for the whole integration)."""
return DeviceInfo( return build_device_info(self.coordinator.config)
connections=set(),
name="Weather Station SWS 12500",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
manufacturer="Schizza",
model="Weather Station SWS 12500",
)

View File

@ -25,6 +25,8 @@ class _CoordinatorStub:
def __init__(self, data: dict[str, Any] | None = None) -> None: def __init__(self, data: dict[str, Any] | None = None) -> None:
self.data: dict[str, Any] = data if data is not None else {} self.data: dict[str, Any] = data if data is not None else {}
# device_info reads coordinator.config for the shared device model.
self.config = SimpleNamespace(options={})
def _make_entry( def _make_entry(
@ -213,7 +215,7 @@ def test_device_info():
info = sensor.device_info info = sensor.device_info
assert info["name"] == "Weather Station SWS 12500" assert info["name"] == "Weather Station SWS 12500"
assert info["manufacturer"] == "Schizza" assert info["manufacturer"] == "Schizza"
assert info["model"] == "Weather Station SWS 12500" assert info["model"] == "PWS" # no ecowitt/wslink in stub options -> PWS
assert info["identifiers"] == {(DOMAIN,)} assert info["identifiers"] == {(DOMAIN,)}

View File

@ -2,7 +2,14 @@
from __future__ import annotations from __future__ import annotations
from custom_components.sws12500.data import SWSRuntimeData from types import SimpleNamespace
from custom_components.sws12500.const import DOMAIN, ECOWITT_ENABLED, WSLINK
from custom_components.sws12500.data import (
SWSRuntimeData,
_station_model,
build_device_info,
)
def test_runtime_data_defaults(): def test_runtime_data_defaults():
@ -44,3 +51,64 @@ def test_runtime_data_collections_are_independent_per_instance():
assert b.sensor_descriptions == {} assert b.sensor_descriptions == {}
assert b.added_binary_keys == set() assert b.added_binary_keys == set()
assert b.last_seen == {} assert b.last_seen == {}
# --------------------------------------------------------------------------- #
# _station_model / build_device_info (single shared device)
# --------------------------------------------------------------------------- #
def test_station_model_pws_when_no_flags():
"""No ecowitt / wslink flags -> the legacy PWS push station."""
entry = SimpleNamespace(options={})
assert _station_model(entry) == "PWS" # type: ignore[arg-type]
def test_station_model_wslink_when_flag_set():
"""The WSLINK flag (without ecowitt) yields the WSLink model."""
entry = SimpleNamespace(options={WSLINK: True})
assert _station_model(entry) == "WSLink" # type: ignore[arg-type]
def test_station_model_ecowitt_without_learned_model():
"""Ecowitt enabled but no model learned yet -> bare "Ecowitt".
Covers both the missing-runtime_data and the model-is-None paths.
"""
no_runtime = SimpleNamespace(options={ECOWITT_ENABLED: True})
assert _station_model(no_runtime) == "Ecowitt" # type: ignore[arg-type]
runtime_no_model = SimpleNamespace(
options={ECOWITT_ENABLED: True},
runtime_data=SimpleNamespace(ecowitt_model=None),
)
assert _station_model(runtime_no_model) == "Ecowitt" # type: ignore[arg-type]
def test_station_model_ecowitt_with_learned_model():
"""Ecowitt enabled with a learned model -> "Ecowitt <model>"."""
entry = SimpleNamespace(
options={ECOWITT_ENABLED: True},
runtime_data=SimpleNamespace(ecowitt_model="GW1000"),
)
assert _station_model(entry) == "Ecowitt GW1000" # type: ignore[arg-type]
def test_station_model_ecowitt_takes_precedence_over_wslink():
"""When both flags are set the ecowitt model wins."""
entry = SimpleNamespace(
options={ECOWITT_ENABLED: True, WSLINK: True},
runtime_data=SimpleNamespace(ecowitt_model="WS3900"),
)
assert _station_model(entry) == "Ecowitt WS3900" # type: ignore[arg-type]
def test_build_device_info_shared_identity():
"""All entities share one device under the legacy ``{(DOMAIN,)}`` identifier."""
entry = SimpleNamespace(options={})
info = build_device_info(entry) # type: ignore[arg-type]
assert info["identifiers"] == {(DOMAIN,)}
assert info["name"] == "Weather Station SWS 12500"
assert info["manufacturer"] == "Schizza"
assert info["model"] == "PWS"

View File

@ -20,9 +20,24 @@ from aioecowitt import EcoWittSensor, EcoWittSensorTypes
from aioecowitt.station import EcoWittStation from aioecowitt.station import EcoWittStation
import pytest import pytest
from custom_components.sws12500.const import DOMAIN, REMAP_ECOWITT_COMPAT from custom_components.sws12500.const import DOMAIN, ECOWITT_ENABLED, REMAP_ECOWITT_COMPAT
from custom_components.sws12500.ecowitt import STYPE_TO_HA, EcowittBridge, EcoWittNativeSensor from custom_components.sws12500.ecowitt import STYPE_TO_HA, EcowittBridge, EcoWittNativeSensor
# Default config stub for native entities: the integration shares a single device,
# so EcoWittNativeSensor only needs the entry to resolve the device model. A bare
# PWS config (no ecowitt/wslink flags) is enough for the non-device assertions.
_PWS_CONFIG = SimpleNamespace(options={})
# An ecowitt config that yields model "Ecowitt GW1000" for device-info assertions.
_ECOWITT_CONFIG = SimpleNamespace(
options={ECOWITT_ENABLED: True},
runtime_data=SimpleNamespace(ecowitt_model="GW1000"),
)
def _native(sensor: Any, config: Any = _PWS_CONFIG) -> EcoWittNativeSensor:
"""Build a native sensor with a default (PWS) config stub."""
return EcoWittNativeSensor(sensor, config)
# A realistic Ecowitt POST payload. `model` is required by aioecowitt's # A realistic Ecowitt POST payload. `model` is required by aioecowitt's
# station extraction. Contains both internally mapped fields (tempf, humidity, # station extraction. Contains both internally mapped fields (tempf, humidity,
# windspeedmph, baromrelin, dewpointf) and unmapped fields (pm25_ch1, co2). # windspeedmph, baromrelin, dewpointf) and unmapped fields (pm25_ch1, co2).
@ -238,7 +253,7 @@ def test_native_sensor_init_with_mapped_stype() -> None:
) )
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25, station=station) sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25, station=station)
entity = EcoWittNativeSensor(sensor) entity = _native(sensor, _ECOWITT_CONFIG)
assert entity._attr_unique_id == "ecowitt_pm25_ch1" assert entity._attr_unique_id == "ecowitt_pm25_ch1"
assert entity._attr_name == "PM2.5 CH1" assert entity._attr_name == "PM2.5 CH1"
@ -249,12 +264,13 @@ def test_native_sensor_init_with_mapped_stype() -> None:
assert entity._attr_native_unit_of_measurement == unit assert entity._attr_native_unit_of_measurement == unit
assert entity._attr_state_class == state_class assert entity._attr_state_class == state_class
# Device info groups the entity under the station device. # Native sensors join the single shared integration device; the running station
# type is reflected in the model, not in a separate Ecowitt device.
info = entity._attr_device_info info = entity._attr_device_info
assert info["name"] == "Ecowitt GW1000" assert info["name"] == "Weather Station SWS 12500"
assert info["model"] == "GW1000" assert info["model"] == "Ecowitt GW1000"
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"] assert info["identifiers"] == {(DOMAIN,)}
assert info["manufacturer"] == "Ecowitt impl. from Schizza for SWS12500" assert info["manufacturer"] == "Schizza"
def test_native_sensor_init_with_unmapped_stype() -> None: def test_native_sensor_init_with_unmapped_stype() -> None:
@ -274,37 +290,40 @@ def test_native_sensor_init_with_unmapped_stype() -> None:
value="1000", value="1000",
) )
entity = EcoWittNativeSensor(sensor) entity = _native(sensor, _ECOWITT_CONFIG)
# No HA metadata set for unknown types. # No HA metadata set for unknown types.
assert getattr(entity, "_attr_device_class", None) is None assert getattr(entity, "_attr_device_class", None) is None
assert getattr(entity, "_attr_native_unit_of_measurement", None) is None assert getattr(entity, "_attr_native_unit_of_measurement", None) is None
assert getattr(entity, "_attr_state_class", None) is None assert getattr(entity, "_attr_state_class", None) is None
# Device info is still present. # Device info is still present and points at the shared device.
info = entity._attr_device_info info = entity._attr_device_info
assert info["name"] == "Ecowitt GW1000" assert info["name"] == "Weather Station SWS 12500"
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"] assert info["identifiers"] == {(DOMAIN,)}
def test_native_sensor_init_without_station() -> None: def test_native_sensor_device_model_follows_config_not_station() -> None:
"""A sensor with no station falls back to generic device info.""" """Device model comes from the entry config, independent of the sensor station.
A native sensor whose ``station`` is missing still lands on the shared device,
and the model reflects the configured station type (here a bare PWS config).
"""
sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25) sensor = _make_sensor(key="pm25_ch1", stype=EcoWittSensorTypes.PM25)
# Force the no-station branch.
sensor.station = None # type: ignore[assignment] sensor.station = None # type: ignore[assignment]
entity = EcoWittNativeSensor(sensor) entity = _native(sensor) # default PWS config
info = entity._attr_device_info info = entity._attr_device_info
assert info["name"] == "Ecowitt station" assert info["name"] == "Weather Station SWS 12500"
assert info["model"] is None assert info["model"] == "PWS"
assert (DOMAIN, "ecowitt") in info["identifiers"] assert info["identifiers"] == {(DOMAIN,)}
def test_native_value_returns_value() -> None: def test_native_value_returns_value() -> None:
"""native_value returns the underlying sensor value.""" """native_value returns the underlying sensor value."""
sensor = _make_sensor(value=42.0) sensor = _make_sensor(value=42.0)
entity = EcoWittNativeSensor(sensor) entity = _native(sensor)
assert entity.native_value == 42.0 assert entity.native_value == 42.0
@ -312,7 +331,7 @@ def test_native_value_returns_value() -> None:
def test_native_value_none_or_empty(empty: Any) -> None: def test_native_value_none_or_empty(empty: Any) -> None:
"""native_value maps None and "" to None.""" """native_value maps None and "" to None."""
sensor = _make_sensor(value=empty) sensor = _make_sensor(value=empty)
entity = EcoWittNativeSensor(sensor) entity = _native(sensor)
assert entity.native_value is None assert entity.native_value is None
@ -320,7 +339,7 @@ def test_native_value_none_or_empty(empty: Any) -> None:
async def test_added_and_removed_callback_lifecycle() -> None: async def test_added_and_removed_callback_lifecycle() -> None:
"""async_added/async_will_remove register and unregister the update cb.""" """async_added/async_will_remove register and unregister the update cb."""
sensor = _make_sensor() sensor = _make_sensor()
entity = EcoWittNativeSensor(sensor) entity = _native(sensor)
assert entity._handle_update not in sensor.update_cb assert entity._handle_update not in sensor.update_cb
@ -335,7 +354,7 @@ async def test_added_and_removed_callback_lifecycle() -> None:
async def test_will_remove_when_callback_absent() -> None: async def test_will_remove_when_callback_absent() -> None:
"""async_will_remove is safe when the callback was never registered.""" """async_will_remove is safe when the callback was never registered."""
sensor = _make_sensor() sensor = _make_sensor()
entity = EcoWittNativeSensor(sensor) entity = _native(sensor)
# Not added; removal must not raise and must not touch the list. # Not added; removal must not raise and must not touch the list.
assert entity._handle_update not in sensor.update_cb assert entity._handle_update not in sensor.update_cb
@ -346,7 +365,7 @@ async def test_will_remove_when_callback_absent() -> None:
def test_handle_update_writes_ha_state() -> None: def test_handle_update_writes_ha_state() -> None:
"""_handle_update forwards to async_write_ha_state.""" """_handle_update forwards to async_write_ha_state."""
sensor = _make_sensor() sensor = _make_sensor()
entity = EcoWittNativeSensor(sensor) entity = _native(sensor)
entity.async_write_ha_state = MagicMock() # type: ignore[method-assign] entity.async_write_ha_state = MagicMock() # type: ignore[method-assign]
entity._handle_update() entity._handle_update()
@ -416,14 +435,14 @@ def test_on_new_sensor_skips_already_created_twin() -> None:
def test_native_sensor_translation_key_for_curated() -> None: def test_native_sensor_translation_key_for_curated() -> None:
ent = EcoWittNativeSensor( ent = _native(
_make_sensor(key="baromabsin", name="Absolute Pressure", stype=EcoWittSensorTypes.PRESSURE_INHG) _make_sensor(key="baromabsin", name="Absolute Pressure", stype=EcoWittSensorTypes.PRESSURE_INHG)
) )
assert ent._attr_translation_key == "ecowitt_absolute_pressure" assert ent._attr_translation_key == "ecowitt_absolute_pressure"
def test_native_sensor_name_fallback_for_unknown() -> None: def test_native_sensor_name_fallback_for_unknown() -> None:
ent = EcoWittNativeSensor( ent = _native(
_make_sensor(key="air_ch1", name="Air Gap 1", stype=EcoWittSensorTypes.INTERNAL) _make_sensor(key="air_ch1", name="Air Gap 1", stype=EcoWittSensorTypes.INTERNAL)
) )
assert ent._attr_name == "Air Gap 1" assert ent._attr_name == "Air Gap 1"

View File

@ -675,8 +675,11 @@ def _description(key: str) -> hs.HealthSensorEntityDescription:
def _stub_coordinator(data: dict[str, Any]) -> Any: def _stub_coordinator(data: dict[str, Any]) -> Any:
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices.""" """CoordinatorEntity.__init__ only stores the coordinator; a stub suffices.
return SimpleNamespace(data=data)
device_info reads coordinator.config for the shared device model.
"""
return SimpleNamespace(data=data, config=SimpleNamespace(options={}))
def test_sensor_native_value_without_value_fn() -> None: def test_sensor_native_value_without_value_fn() -> None:
@ -745,7 +748,7 @@ def test_sensor_device_info() -> None:
info = sensor.device_info info = sensor.device_info
assert info["name"] == "Weather Station SWS 12500" assert info["name"] == "Weather Station SWS 12500"
assert info["manufacturer"] == "Schizza" assert info["manufacturer"] == "Schizza"
assert info["model"] == "Weather Station SWS 12500" assert info["model"] == "PWS" # no ecowitt/wslink in stub options -> PWS
def test_sensor_unique_id_and_category() -> None: def test_sensor_unique_id_and_category() -> None:

View File

@ -240,7 +240,7 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
coordinator.async_set_updated_data = MagicMock() coordinator.async_set_updated_data = MagicMock()
request = _EcowittRequestStub( request = _EcowittRequestStub(
match_info={"webhook_id": "hook"}, post_data={"tempf": "68"} match_info={"webhook_id": "hook"}, post_data={"tempf": "68", "model": "GW2000A"}
) )
resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type] resp = await coordinator.received_ecowitt_data(request) # type: ignore[arg-type]
@ -248,6 +248,9 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
coordinator.ecowitt_bridge.process_payload.assert_awaited_once() coordinator.ecowitt_bridge.process_payload.assert_awaited_once()
# Station model is learned from the payload and stored for the shared device model.
assert entry.runtime_data.ecowitt_model == "GW2000A"
# Autodiscovery side-effects. # Autodiscovery side-effects.
update_options.assert_awaited_once() update_options.assert_awaited_once()
args, _kwargs = update_options.await_args args, _kwargs = update_options.await_args

View File

@ -236,7 +236,7 @@ def test_device_info_contains_expected_identifiers_and_domain():
# DeviceInfo is mapping-like; access defensively. # DeviceInfo is mapping-like; access defensively.
assert info.get("name") == "Weather Station SWS 12500" assert info.get("name") == "Weather Station SWS 12500"
assert info.get("manufacturer") == "Schizza" assert info.get("manufacturer") == "Schizza"
assert info.get("model") == "Weather Station SWS 12500" assert info.get("model") == "PWS" # no ecowitt/wslink in stub options -> PWS
identifiers = info.get("identifiers") identifiers = info.get("identifiers")
assert isinstance(identifiers, set) assert isinstance(identifiers, set)