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

View File

@ -198,6 +198,11 @@ class WeatherDataUpdateCoordinator(DataUpdateCoordinator):
post_data = await webdata.post()
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)
if mapped_data:

View File

@ -11,10 +11,16 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from py_typecheck import checked_or
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.util import dt as dt_util
from .const import DOMAIN, ECOWITT_ENABLED, WSLINK
if TYPE_CHECKING:
from homeassistant.components.binary_sensor import BinarySensorEntityDescription
@ -51,6 +57,38 @@ class SWSRuntimeData:
started_at: datetime = field(default_factory=dt_util.utcnow)
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 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 homeassistant.components.sensor import SensorDeviceClass, SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
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.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__)
@ -216,7 +214,7 @@ class EcowittBridge:
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."""
self.hass = hass
@ -308,7 +306,7 @@ class EcowittBridge:
return
self._know_native_keys.add(sensor.key)
entity = EcoWittNativeSensor(sensor)
entity = EcoWittNativeSensor(sensor, self.config)
self._add_entities_cb([entity])
_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_should_poll = False
def __init__(self, sensor: EcoWittSensor) -> None:
def __init__(self, sensor: EcoWittSensor, config: SWSConfigEntry) -> None:
"""Initialize native EcoWittSensor."""
self._ecowitt_sensor = sensor
@ -358,17 +356,9 @@ class EcoWittNativeSensor(SensorEntity):
self._attr_native_unit_of_measurement = unit
self._attr_state_class = state_class
# Always attach device info so the entity is grouped under the Ecowitt
# station device even when its sensor type has no HA mapping.
station = sensor.station
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,
)
# Share the single integration device (see data.build_device_info); the station
# type is reflected in the device model rather than a separate Ecowitt device.
self._attr_device_info = build_device_info(config)
@property
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.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
from .const import DOMAIN
from .data import SWSConfigEntry
from .data import SWSConfigEntry, build_device_info
from .health_coordinator import HealthCoordinator, public_health_snapshot
if TYPE_CHECKING:
@ -265,12 +263,5 @@ class HealthDiagnosticSensor( # pyright: ignore[reportIncompatibleVariableOverr
@cached_property
def device_info(self) -> DeviceInfo:
"""Device info."""
return DeviceInfo(
connections=set(),
name="Weather Station SWS 12500",
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN,)}, # type: ignore[arg-type]
manufacturer="Schizza",
model="Weather Station SWS 12500",
)
"""Device info (single shared device for the whole integration)."""
return build_device_info(self.coordinator.config)

View File

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

View File

@ -25,6 +25,8 @@ class _CoordinatorStub:
def __init__(self, data: dict[str, Any] | None = None) -> None:
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(
@ -213,7 +215,7 @@ def test_device_info():
info = sensor.device_info
assert info["name"] == "Weather Station SWS 12500"
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,)}

View File

@ -2,7 +2,14 @@
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():
@ -44,3 +51,64 @@ def test_runtime_data_collections_are_independent_per_instance():
assert b.sensor_descriptions == {}
assert b.added_binary_keys == set()
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
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
# 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
# station extraction. Contains both internally mapped fields (tempf, humidity,
# 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)
entity = EcoWittNativeSensor(sensor)
entity = _native(sensor, _ECOWITT_CONFIG)
assert entity._attr_unique_id == "ecowitt_pm25_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_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
assert info["name"] == "Ecowitt GW1000"
assert info["model"] == "GW1000"
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"]
assert info["manufacturer"] == "Ecowitt impl. from Schizza for SWS12500"
assert info["name"] == "Weather Station SWS 12500"
assert info["model"] == "Ecowitt GW1000"
assert info["identifiers"] == {(DOMAIN,)}
assert info["manufacturer"] == "Schizza"
def test_native_sensor_init_with_unmapped_stype() -> None:
@ -274,37 +290,40 @@ def test_native_sensor_init_with_unmapped_stype() -> None:
value="1000",
)
entity = EcoWittNativeSensor(sensor)
entity = _native(sensor, _ECOWITT_CONFIG)
# No HA metadata set for unknown types.
assert getattr(entity, "_attr_device_class", None) is None
assert getattr(entity, "_attr_native_unit_of_measurement", 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
assert info["name"] == "Ecowitt GW1000"
assert (DOMAIN, "ecowitt_ABC123") in info["identifiers"]
assert info["name"] == "Weather Station SWS 12500"
assert info["identifiers"] == {(DOMAIN,)}
def test_native_sensor_init_without_station() -> None:
"""A sensor with no station falls back to generic device info."""
def test_native_sensor_device_model_follows_config_not_station() -> None:
"""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)
# Force the no-station branch.
sensor.station = None # type: ignore[assignment]
entity = EcoWittNativeSensor(sensor)
entity = _native(sensor) # default PWS config
info = entity._attr_device_info
assert info["name"] == "Ecowitt station"
assert info["model"] is None
assert (DOMAIN, "ecowitt") in info["identifiers"]
assert info["name"] == "Weather Station SWS 12500"
assert info["model"] == "PWS"
assert info["identifiers"] == {(DOMAIN,)}
def test_native_value_returns_value() -> None:
"""native_value returns the underlying sensor value."""
sensor = _make_sensor(value=42.0)
entity = EcoWittNativeSensor(sensor)
entity = _native(sensor)
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:
"""native_value maps None and "" to None."""
sensor = _make_sensor(value=empty)
entity = EcoWittNativeSensor(sensor)
entity = _native(sensor)
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_added/async_will_remove register and unregister the update cb."""
sensor = _make_sensor()
entity = EcoWittNativeSensor(sensor)
entity = _native(sensor)
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_will_remove is safe when the callback was never registered."""
sensor = _make_sensor()
entity = EcoWittNativeSensor(sensor)
entity = _native(sensor)
# Not added; removal must not raise and must not touch the list.
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:
"""_handle_update forwards to async_write_ha_state."""
sensor = _make_sensor()
entity = EcoWittNativeSensor(sensor)
entity = _native(sensor)
entity.async_write_ha_state = MagicMock() # type: ignore[method-assign]
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:
ent = EcoWittNativeSensor(
ent = _native(
_make_sensor(key="baromabsin", name="Absolute Pressure", stype=EcoWittSensorTypes.PRESSURE_INHG)
)
assert ent._attr_translation_key == "ecowitt_absolute_pressure"
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)
)
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:
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices."""
return SimpleNamespace(data=data)
"""CoordinatorEntity.__init__ only stores the coordinator; a stub suffices.
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:
@ -745,7 +748,7 @@ def test_sensor_device_info() -> None:
info = sensor.device_info
assert info["name"] == "Weather Station SWS 12500"
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:

View File

@ -240,7 +240,7 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
coordinator.async_set_updated_data = MagicMock()
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]
@ -248,6 +248,9 @@ async def test_received_ecowitt_success_full_pipeline_with_health_autodiscovery_
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.
update_options.assert_awaited_once()
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.
assert info.get("name") == "Weather Station SWS 12500"
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")
assert isinstance(identifiers, set)