feat(ecowitt): dedupe unit-variant sensors and translate common native ones
Two long-standing rough edges with Ecowitt running alongside the SWS device: - Dedup (#1): aioecowitt emits both metric and imperial sensors per quantity (tempc/tempf, rainratemm/rainratein, ...). Only the imperial twin is mapped to the SWS sensors, so the metric twin (and other unmapped extras) showed up as duplicate native entities. _on_new_sensor now skips a key whose unit-variant twin is already mapped or already created (twin groups derived from aioecowitt's SENSOR_MAP). HA converts units via device_class, so a single variant suffices. - Translation (#2): native sensors used the raw English aioecowitt name. Common single-instance families (rain rate / hourly / event / 24h / weekly / monthly / yearly / total rain, absolute pressure, feels-like, indoor dew point, CO2) now use curated translation_keys (added to strings/en/cs); long-tail / multi-channel sensors keep the English name fallback. - Also add the imperial RAIN_RATE_INCHES / RAIN_COUNT_INCHES to STYPE_TO_HA so those native rain sensors get a device class and unit. Device unification (one "WeatherHub" device + station-type sensor) stays for the rename round, as agreed. 314 passing, 100% coverage; ruff + basedpyright clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent
72c383ffba
commit
3fdebc6f20
|
|
@ -16,6 +16,7 @@ import logging
|
|||
from typing import Any, Final
|
||||
|
||||
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
|
||||
|
|
@ -37,6 +38,60 @@ _MAPPED_ECOWITT_KEYS: set[str] = set(REMAP_ECOWITT_COMPAT.keys())
|
|||
# from an (authenticated) sender that fabricates many distinct sensor keys.
|
||||
MAX_NATIVE_ECOWITT_SENSORS: Final = 64
|
||||
|
||||
|
||||
def _build_unit_twins() -> dict[str, frozenset[str]]:
|
||||
"""Group aioecowitt keys that are unit variants of the same quantity.
|
||||
|
||||
aioecowitt exposes both metric and imperial sensors for many readings (e.g.
|
||||
`tempc`/`tempf`, `rainratemm`/`rainratein`), recognisable by a shared display name.
|
||||
"""
|
||||
by_name: dict[str, set[str]] = {}
|
||||
for key, meta in SENSOR_MAP.items():
|
||||
by_name.setdefault(meta.name, set()).add(key)
|
||||
twins: dict[str, frozenset[str]] = {}
|
||||
for keys in by_name.values():
|
||||
if len(keys) > 1:
|
||||
group = frozenset(keys)
|
||||
for key in keys:
|
||||
twins[key] = group
|
||||
return twins
|
||||
|
||||
|
||||
# key -> set of its unit-variant twin keys (incl. itself).
|
||||
_UNIT_TWINS: dict[str, frozenset[str]] = _build_unit_twins()
|
||||
|
||||
# Curated translation keys for the common native (unmapped) Ecowitt sensors. Both unit
|
||||
# variants map to the same slug so the name is stable regardless of the station's units.
|
||||
# Long-tail / multi-channel sensors fall back to aioecowitt's English name.
|
||||
_ECOWITT_TRANSLATIONS: dict[str, str] = {
|
||||
"baromabsin": "ecowitt_absolute_pressure",
|
||||
"baromabshpa": "ecowitt_absolute_pressure",
|
||||
"rainratein": "ecowitt_rain_rate",
|
||||
"rainratemm": "ecowitt_rain_rate",
|
||||
"eventrainin": "ecowitt_event_rain",
|
||||
"eventrainmm": "ecowitt_event_rain",
|
||||
"hourlyrainin": "ecowitt_hourly_rain",
|
||||
"hourlyrainmm": "ecowitt_hourly_rain",
|
||||
"weeklyrainin": "ecowitt_weekly_rain",
|
||||
"weeklyrainmm": "ecowitt_weekly_rain",
|
||||
"monthlyrainin": "ecowitt_monthly_rain",
|
||||
"monthlyrainmm": "ecowitt_monthly_rain",
|
||||
"yearlyrainin": "ecowitt_yearly_rain",
|
||||
"yearlyrainmm": "ecowitt_yearly_rain",
|
||||
"totalrainin": "ecowitt_total_rain",
|
||||
"totalrainmm": "ecowitt_total_rain",
|
||||
"last24hrainin": "ecowitt_24h_rain",
|
||||
"last24hrainmm": "ecowitt_24h_rain",
|
||||
"tempfeelsc": "ecowitt_feels_like",
|
||||
"tempfeelsf": "ecowitt_feels_like",
|
||||
"dewpointinc": "ecowitt_indoor_dewpoint",
|
||||
"dewpointinf": "ecowitt_indoor_dewpoint",
|
||||
"co2in": "ecowitt_console_co2",
|
||||
"co2in_24h": "ecowitt_console_co2_24h",
|
||||
"co2": "ecowitt_co2",
|
||||
"co2_24h": "ecowitt_co2_24h",
|
||||
}
|
||||
|
||||
# aioecowitt sensor type to HA device class + unit
|
||||
# We cover most common types, additional will be covered later.
|
||||
STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None, SensorStateClass | None]] = {
|
||||
|
|
@ -115,6 +170,16 @@ STYPE_TO_HA: dict[EcoWittSensorTypes, tuple[SensorDeviceClass | None, str | None
|
|||
"mm",
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
EcoWittSensorTypes.RAIN_RATE_INCHES: (
|
||||
SensorDeviceClass.PRECIPITATION_INTENSITY,
|
||||
"in/h",
|
||||
SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
EcoWittSensorTypes.RAIN_COUNT_INCHES: (
|
||||
SensorDeviceClass.PRECIPITATION,
|
||||
"in",
|
||||
SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
EcoWittSensorTypes.LIGHTNING_COUNT: (
|
||||
None,
|
||||
"strikes",
|
||||
|
|
@ -222,6 +287,14 @@ class EcowittBridge:
|
|||
if sensor.key in self._know_native_keys:
|
||||
return
|
||||
|
||||
# Skip unit-variant duplicates: if a twin (e.g. the °C form of an already
|
||||
# mapped °F sensor, or the other unit of an already-created native one) is
|
||||
# handled, don't create a second entity. HA converts units via device_class.
|
||||
twins = _UNIT_TWINS.get(sensor.key, frozenset())
|
||||
if any(twin in _MAPPED_ECOWITT_KEYS or twin in self._know_native_keys for twin in twins):
|
||||
_LOGGER.debug("Ecowitt sensor %s skipped: unit-variant twin already handled", sensor.key)
|
||||
return
|
||||
|
||||
if self._add_entities_cb is None:
|
||||
_LOGGER.debug("Ecowitt sensor %s discovered but platform not ready yet", sensor.key)
|
||||
return
|
||||
|
|
@ -268,8 +341,13 @@ class EcoWittNativeSensor(SensorEntity):
|
|||
|
||||
self._ecowitt_sensor = sensor
|
||||
self._attr_unique_id = f"ecowitt_{sensor.key}"
|
||||
self._attr_translation_key = None # we do not have translation_keys for native sensors
|
||||
self._attr_name = sensor.name # default name, can be overridden by translation_key if we had one
|
||||
|
||||
# Use a curated translation_key for common native sensors; otherwise fall back
|
||||
# to aioecowitt's English name. _attr_translation_key is always set (None when
|
||||
# there is no curated translation) so the attribute is well defined.
|
||||
self._attr_translation_key = _ECOWITT_TRANSLATIONS.get(sensor.key)
|
||||
if self._attr_translation_key is None:
|
||||
self._attr_name = sensor.name
|
||||
|
||||
# set HomeAssistant metadata from aioecowitt sensor type.
|
||||
# Unknown types still get a usable entity (raw value, no device class/unit).
|
||||
|
|
|
|||
|
|
@ -186,6 +186,51 @@
|
|||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ecowitt_absolute_pressure": {
|
||||
"name": "Absolute pressure"
|
||||
},
|
||||
"ecowitt_rain_rate": {
|
||||
"name": "Rain rate"
|
||||
},
|
||||
"ecowitt_event_rain": {
|
||||
"name": "Event rain"
|
||||
},
|
||||
"ecowitt_hourly_rain": {
|
||||
"name": "Hourly rain"
|
||||
},
|
||||
"ecowitt_weekly_rain": {
|
||||
"name": "Weekly rain"
|
||||
},
|
||||
"ecowitt_monthly_rain": {
|
||||
"name": "Monthly rain"
|
||||
},
|
||||
"ecowitt_yearly_rain": {
|
||||
"name": "Yearly rain"
|
||||
},
|
||||
"ecowitt_total_rain": {
|
||||
"name": "Total rain"
|
||||
},
|
||||
"ecowitt_24h_rain": {
|
||||
"name": "24h rain"
|
||||
},
|
||||
"ecowitt_feels_like": {
|
||||
"name": "Feels like"
|
||||
},
|
||||
"ecowitt_indoor_dewpoint": {
|
||||
"name": "Indoor dew point"
|
||||
},
|
||||
"ecowitt_console_co2": {
|
||||
"name": "Console CO₂"
|
||||
},
|
||||
"ecowitt_console_co2_24h": {
|
||||
"name": "Console CO₂ (24h avg)"
|
||||
},
|
||||
"ecowitt_co2": {
|
||||
"name": "CO₂"
|
||||
},
|
||||
"ecowitt_co2_24h": {
|
||||
"name": "CO₂ (24h avg)"
|
||||
},
|
||||
"integration_health": {
|
||||
"name": "Integration status",
|
||||
"state": {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,51 @@
|
|||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ecowitt_absolute_pressure": {
|
||||
"name": "Absolutní tlak"
|
||||
},
|
||||
"ecowitt_rain_rate": {
|
||||
"name": "Intenzita srážek"
|
||||
},
|
||||
"ecowitt_event_rain": {
|
||||
"name": "Srážky (událost)"
|
||||
},
|
||||
"ecowitt_hourly_rain": {
|
||||
"name": "Hodinové srážky"
|
||||
},
|
||||
"ecowitt_weekly_rain": {
|
||||
"name": "Týdenní srážky"
|
||||
},
|
||||
"ecowitt_monthly_rain": {
|
||||
"name": "Měsíční srážky"
|
||||
},
|
||||
"ecowitt_yearly_rain": {
|
||||
"name": "Roční srážky"
|
||||
},
|
||||
"ecowitt_total_rain": {
|
||||
"name": "Celkové srážky"
|
||||
},
|
||||
"ecowitt_24h_rain": {
|
||||
"name": "Srážky za 24 h"
|
||||
},
|
||||
"ecowitt_feels_like": {
|
||||
"name": "Pocitová teplota"
|
||||
},
|
||||
"ecowitt_indoor_dewpoint": {
|
||||
"name": "Vnitřní rosný bod"
|
||||
},
|
||||
"ecowitt_console_co2": {
|
||||
"name": "CO₂ konzole"
|
||||
},
|
||||
"ecowitt_console_co2_24h": {
|
||||
"name": "CO₂ konzole (průměr 24 h)"
|
||||
},
|
||||
"ecowitt_co2": {
|
||||
"name": "CO₂"
|
||||
},
|
||||
"ecowitt_co2_24h": {
|
||||
"name": "CO₂ (průměr 24 h)"
|
||||
},
|
||||
"integration_health": {
|
||||
"name": "Stav integrace",
|
||||
"state": {
|
||||
|
|
|
|||
|
|
@ -186,6 +186,51 @@
|
|||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ecowitt_absolute_pressure": {
|
||||
"name": "Absolute pressure"
|
||||
},
|
||||
"ecowitt_rain_rate": {
|
||||
"name": "Rain rate"
|
||||
},
|
||||
"ecowitt_event_rain": {
|
||||
"name": "Event rain"
|
||||
},
|
||||
"ecowitt_hourly_rain": {
|
||||
"name": "Hourly rain"
|
||||
},
|
||||
"ecowitt_weekly_rain": {
|
||||
"name": "Weekly rain"
|
||||
},
|
||||
"ecowitt_monthly_rain": {
|
||||
"name": "Monthly rain"
|
||||
},
|
||||
"ecowitt_yearly_rain": {
|
||||
"name": "Yearly rain"
|
||||
},
|
||||
"ecowitt_total_rain": {
|
||||
"name": "Total rain"
|
||||
},
|
||||
"ecowitt_24h_rain": {
|
||||
"name": "24h rain"
|
||||
},
|
||||
"ecowitt_feels_like": {
|
||||
"name": "Feels like"
|
||||
},
|
||||
"ecowitt_indoor_dewpoint": {
|
||||
"name": "Indoor dew point"
|
||||
},
|
||||
"ecowitt_console_co2": {
|
||||
"name": "Console CO₂"
|
||||
},
|
||||
"ecowitt_console_co2_24h": {
|
||||
"name": "Console CO₂ (24h avg)"
|
||||
},
|
||||
"ecowitt_co2": {
|
||||
"name": "CO₂"
|
||||
},
|
||||
"ecowitt_co2_24h": {
|
||||
"name": "CO₂ (24h avg)"
|
||||
},
|
||||
"integration_health": {
|
||||
"name": "Integration status",
|
||||
"state": {
|
||||
|
|
|
|||
|
|
@ -385,3 +385,46 @@ def test_on_new_sensor_respects_cap() -> None:
|
|||
bridge._on_new_sensor(_make_sensor(key="pm25_ch1"))
|
||||
|
||||
cb.assert_not_called()
|
||||
|
||||
|
||||
def test_on_new_sensor_skips_unit_twin_of_mapped() -> None:
|
||||
"""The metric twin of a mapped (imperial) sensor is not created as a native entity."""
|
||||
bridge = _make_bridge()
|
||||
cb = MagicMock()
|
||||
bridge.set_add_entities(cb)
|
||||
|
||||
# tempc is the °C twin of tempf, which IS mapped into the SWS pipeline.
|
||||
bridge._on_new_sensor(
|
||||
_make_sensor(key="tempc", name="Outdoor Temperature", stype=EcoWittSensorTypes.TEMPERATURE_C)
|
||||
)
|
||||
|
||||
cb.assert_not_called()
|
||||
assert "tempc" not in bridge._know_native_keys
|
||||
|
||||
|
||||
def test_on_new_sensor_skips_already_created_twin() -> None:
|
||||
"""For an unmapped metric/imperial pair only the first-seen unit is created."""
|
||||
bridge = _make_bridge()
|
||||
created: list[Any] = []
|
||||
bridge.set_add_entities(lambda ents: created.extend(ents))
|
||||
|
||||
bridge._on_new_sensor(_make_sensor(key="rainratein", name="Rain Rate", stype=EcoWittSensorTypes.RAIN_RATE_INCHES))
|
||||
bridge._on_new_sensor(_make_sensor(key="rainratemm", name="Rain Rate", stype=EcoWittSensorTypes.RAIN_RATE_MM))
|
||||
|
||||
keys = [e._ecowitt_sensor.key for e in created]
|
||||
assert keys == ["rainratein"] # the twin rainratemm is deduplicated
|
||||
|
||||
|
||||
def test_native_sensor_translation_key_for_curated() -> None:
|
||||
ent = EcoWittNativeSensor(
|
||||
_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(
|
||||
_make_sensor(key="air_ch1", name="Air Gap 1", stype=EcoWittSensorTypes.INTERNAL)
|
||||
)
|
||||
assert ent._attr_name == "Air Gap 1"
|
||||
assert getattr(ent, "_attr_translation_key", None) is None
|
||||
|
|
|
|||
Loading…
Reference in New Issue