From 3fdebc6f200533576ad7ff7874a06d5da0774613 Mon Sep 17 00:00:00 2001 From: SchiZzA Date: Sun, 21 Jun 2026 22:55:58 +0200 Subject: [PATCH] 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) --- custom_components/sws12500/ecowitt.py | 82 ++++++++++++++++++- custom_components/sws12500/strings.json | 45 ++++++++++ .../sws12500/translations/cs.json | 45 ++++++++++ .../sws12500/translations/en.json | 45 ++++++++++ tests/test_ecowitt_bridge.py | 43 ++++++++++ 5 files changed, 258 insertions(+), 2 deletions(-) diff --git a/custom_components/sws12500/ecowitt.py b/custom_components/sws12500/ecowitt.py index 2646192..ae16952 100644 --- a/custom_components/sws12500/ecowitt.py +++ b/custom_components/sws12500/ecowitt.py @@ -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). diff --git a/custom_components/sws12500/strings.json b/custom_components/sws12500/strings.json index 4923b6b..87ac5cc 100644 --- a/custom_components/sws12500/strings.json +++ b/custom_components/sws12500/strings.json @@ -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": { diff --git a/custom_components/sws12500/translations/cs.json b/custom_components/sws12500/translations/cs.json index fc629ec..da6e4c1 100644 --- a/custom_components/sws12500/translations/cs.json +++ b/custom_components/sws12500/translations/cs.json @@ -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": { diff --git a/custom_components/sws12500/translations/en.json b/custom_components/sws12500/translations/en.json index 4923b6b..87ac5cc 100644 --- a/custom_components/sws12500/translations/en.json +++ b/custom_components/sws12500/translations/en.json @@ -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": { diff --git a/tests/test_ecowitt_bridge.py b/tests/test_ecowitt_bridge.py index 71d7ec9..baef99e 100644 --- a/tests/test_ecowitt_bridge.py +++ b/tests/test_ecowitt_bridge.py @@ -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