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>
parent
3fdebc6f20
commit
80c944fc5a
|
|
@ -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",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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,)}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue