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>
SchiZzA 2026-06-21 22:55:58 +02:00
parent 72c383ffba
commit 3fdebc6f20
No known key found for this signature in database
5 changed files with 258 additions and 2 deletions

View File

@ -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).

View File

@ -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": {

View File

@ -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": {

View File

@ -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": {

View File

@ -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