From 5e595b63d25834e85c5fe44319891ea450efeb97 Mon Sep 17 00:00:00 2001 From: Simon Berger Date: Sat, 22 May 2021 15:49:49 +0200 Subject: [PATCH] more optional values, flatten conditions --- README.md | 13 +- custom_components/weatherlink/api.py | 63 +++-- custom_components/weatherlink/sensor_iss.py | 34 +-- custom_components/weatherlink/weather.py | 20 +- tests/weatherlink/api/test_weatherlink.py | 273 ++++++++++++++------ 5 files changed, 285 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 2852b37..e06a770 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,21 @@ A custom component for Davis Instruments' [WeatherLink](https://www.davisinstrum 2. Install the "WeatherLink" integration in HACS 3. Head over to the Home Assistant configuration and set up the integration there -## AirLink +## Limitations + +WeatherLink groups data into multiple data structures. For instance, all the data reported by the ISS outdoor station (temperature, wind, rain, solar, etc.) is reported in a single data structure. +When the integration polls the API, it receives a list of these data structures. +It's possible for WeatherLink to report multiple instances of the same data structure. This happens, for instance, when you physically separate parts of the ISS and use multiple channels. +This integration doesn't handle this case very well. If it receives multiple instances of the same data structure, +it primarily uses the first one and only uses the subsequent ones to fill holes in the first one. +If you're running into a problem that's caused by this behaviour, please open an issue. + +### AirLink Older versions of the AirLink firmware use a different data structure format when transmitting updates. This integration currently doesn't support this so in case AirLink isn't working, try updating the firmware. -### AQI +#### AQI The calculation of AQI varies from country to country and may require inputs that are not available to a single sensor. For AQI calculations using the latest AQI models. diff --git a/custom_components/weatherlink/api.py b/custom_components/weatherlink/api.py index 09e8a3d..25fd0f2 100644 --- a/custom_components/weatherlink/api.py +++ b/custom_components/weatherlink/api.py @@ -98,51 +98,51 @@ class IssCondition(ConditionRecord): rx_state: Optional[ReceiverState] """configured radio receiver state""" - temp: float + temp: Optional[float] """most recent valid temperature""" - hum: float + hum: Optional[float] """most recent valid humidity **(%RH)**""" - dew_point: float + dew_point: Optional[float] """""" wet_bulb: Optional[float] """""" - heat_index: float + heat_index: Optional[float] """""" wind_chill: Optional[float] """""" - thw_index: float + thw_index: Optional[float] """""" - thsw_index: float + thsw_index: Optional[float] """""" - wind_speed_last: float + wind_speed_last: Optional[float] """most recent valid wind speed **(km/h)**""" wind_dir_last: Optional[int] """most recent valid wind direction **(°degree)**""" - wind_speed_avg_last_1_min: float + wind_speed_avg_last_1_min: Optional[float] """average wind speed over last 1 min **(km/h)**""" - wind_dir_scalar_avg_last_1_min: int + wind_dir_scalar_avg_last_1_min: Optional[int] """scalar average wind direction over last 1 min **(°degree)**""" - wind_speed_avg_last_2_min: float + wind_speed_avg_last_2_min: Optional[float] """average wind speed over last 2 min **(km/h)**""" - wind_dir_scalar_avg_last_2_min: float + wind_dir_scalar_avg_last_2_min: Optional[int] """scalar average wind direction over last 2 min **(°degree)**""" wind_speed_hi_last_2_min: Optional[float] """maximum wind speed over last 2 min **(km/h)**""" - wind_dir_at_hi_speed_last_2_min: Optional[float] + wind_dir_at_hi_speed_last_2_min: Optional[int] """gust wind direction over last 2 min **(°degree)**""" - wind_speed_avg_last_10_min: float + wind_speed_avg_last_10_min: Optional[float] """average wind speed over last 10 min **(km/h)**""" - wind_dir_scalar_avg_last_10_min: Optional[float] + wind_dir_scalar_avg_last_10_min: Optional[int] """scalar average wind direction over last 10 min **(°degree)**""" - wind_speed_hi_last_10_min: float + wind_speed_hi_last_10_min: Optional[float] """maximum wind speed over last 10 min **(km/h)**""" - wind_dir_at_hi_speed_last_10_min: float + wind_dir_at_hi_speed_last_10_min: Optional[int] """gust wind direction over last 10 min **(°degree)**""" rain_size: CollectorSize @@ -175,9 +175,9 @@ class IssCondition(ConditionRecord): rain_storm_start_at: Optional[datetime] """timestamp of current rain storm start""" - solar_rad: int + solar_rad: Optional[int] """most recent solar radiation **(W/m²)**""" - uv_index: float + uv_index: Optional[float] """most recent UV index **(Index)**""" trans_battery_flag: int @@ -384,6 +384,8 @@ def _from_json(cls, data: JsonObject, **kwargs): return cls(**data) +_STRUCTURE_TYPE_KEY = "data_structure_type" + _COND2CLS = { ConditionType.Iss: IssCondition, ConditionType.Moisture: MoistureCondition, @@ -394,11 +396,25 @@ def _from_json(cls, data: JsonObject, **kwargs): def condition_from_json(data: JsonObject, **kwargs) -> ConditionRecord: - cond_ty = ConditionType(data.pop("data_structure_type")) + cond_ty = ConditionType(data.pop(_STRUCTURE_TYPE_KEY)) cls = cond_ty.record_class() return cls.from_json(data, **kwargs) +def flatten_conditions(conditions: Iterable[JsonObject]) -> List[JsonObject]: + cond_by_type = {} + for cond in conditions: + cond_type = cond[_STRUCTURE_TYPE_KEY] + try: + existing = cond_by_type[cond_type] + except KeyError: + cond_by_type[cond_type] = cond + else: + update_dict_where_none(existing, cond) + + return list(cond_by_type.values()) + + class DeviceType(enum.Enum): WeatherLink = "WeatherLink" AirLink = "AirLink" @@ -426,7 +442,8 @@ class CurrentConditions(FromJson, Mapping[Type[RecordT], RecordT]): @classmethod def _from_json(cls, data: JsonObject, **kwargs): conditions = [] - for i, cond_data in enumerate(data["conditions"]): + raw_conditions = flatten_conditions(data["conditions"]) + for i, cond_data in enumerate(raw_conditions): try: cond = condition_from_json(cond_data, **kwargs) except Exception: @@ -576,3 +593,9 @@ def json_set_default_none(d: JsonObject, *keys: str) -> None: for key in keys: if key not in d: d[key] = None + + +def update_dict_where_none(d: JsonObject, updates: JsonObject) -> None: + for key, value in updates.items(): + if d.get(key) is None: + d[key] = value diff --git a/custom_components/weatherlink/sensor_iss.py b/custom_components/weatherlink/sensor_iss.py index e3b0d74..68ea99b 100644 --- a/custom_components/weatherlink/sensor_iss.py +++ b/custom_components/weatherlink/sensor_iss.py @@ -78,18 +78,18 @@ class Temperature( ): @property def state(self): - return round(self._iss_condition.temp, DECIMALS_TEMPERATURE) + return round_optional(self._iss_condition.temp, DECIMALS_TEMPERATURE) @property def device_state_attributes(self): c = self._iss_condition return { - "dew_point": round(c.dew_point, DECIMALS_TEMPERATURE), + "dew_point": round_optional(c.dew_point, DECIMALS_TEMPERATURE), "wet_bulb": round_optional(c.wet_bulb, DECIMALS_TEMPERATURE), - "heat_index": round(c.heat_index, DECIMALS_TEMPERATURE), + "heat_index": round_optional(c.heat_index, DECIMALS_TEMPERATURE), "wind_chill": round_optional(c.wind_chill, DECIMALS_TEMPERATURE), - "thw_index": round(c.thw_index, DECIMALS_TEMPERATURE), - "thsw_index": round(c.thsw_index, DECIMALS_TEMPERATURE), + "thw_index": round_optional(c.thw_index, DECIMALS_TEMPERATURE), + "thsw_index": round_optional(c.thsw_index, DECIMALS_TEMPERATURE), } @@ -101,7 +101,7 @@ class ThswIndex( ): @property def state(self): - return round(self._iss_condition.thsw_index, DECIMALS_TEMPERATURE) + return round_optional(self._iss_condition.thsw_index, DECIMALS_TEMPERATURE) class Humidity( @@ -112,7 +112,7 @@ class Humidity( ): @property def state(self): - return round(self._iss_condition.hum, DECIMALS_HUMIDITY) + return round_optional(self._iss_condition.hum, DECIMALS_HUMIDITY) class WindSpeed( @@ -127,13 +127,15 @@ def icon(self): @property def state(self): - return round(self._iss_condition.wind_speed_avg_last_2_min, DECIMALS_SPEED) + return round_optional( + self._iss_condition.wind_speed_avg_last_2_min, DECIMALS_SPEED + ) @property def device_state_attributes(self): c = self._iss_condition return { - "10_min": round(c.wind_speed_avg_last_10_min, DECIMALS_SPEED), + "10_min": round_optional(c.wind_speed_avg_last_10_min, DECIMALS_SPEED), } @@ -157,7 +159,7 @@ def state(self): def device_state_attributes(self): c = self._iss_condition return { - "10_min": round(c.wind_speed_hi_last_10_min, DECIMALS_SPEED), + "10_min": round_optional(c.wind_speed_hi_last_10_min, DECIMALS_SPEED), } @@ -173,7 +175,7 @@ def icon(self): @property def state(self): - return round( + return round_optional( self._iss_condition.wind_dir_scalar_avg_last_2_min, DECIMALS_DIRECTION ) @@ -181,11 +183,13 @@ def state(self): def device_state_attributes(self): c = self._iss_condition return { - "high": round_optional(c.wind_dir_at_hi_speed_last_2_min, DECIMALS_DIRECTION), + "high": round_optional( + c.wind_dir_at_hi_speed_last_2_min, DECIMALS_DIRECTION + ), "10_min": round_optional( c.wind_dir_scalar_avg_last_10_min, DECIMALS_DIRECTION ), - "10_min_high": round( + "10_min_high": round_optional( c.wind_dir_at_hi_speed_last_10_min, DECIMALS_DIRECTION ), } @@ -203,7 +207,7 @@ def icon(self): @property def state(self): - return round(self._iss_condition.solar_rad, DECIMALS_RADIATION) + return round_optional(self._iss_condition.solar_rad, DECIMALS_RADIATION) class UvIndex( @@ -218,7 +222,7 @@ def icon(self): @property def state(self): - return round(self._iss_condition.uv_index, DECIMALS_UV) + return round_optional(self._iss_condition.uv_index, DECIMALS_UV) class RainRate( diff --git a/custom_components/weatherlink/weather.py b/custom_components/weatherlink/weather.py index 9ea8953..4e6e85a 100644 --- a/custom_components/weatherlink/weather.py +++ b/custom_components/weatherlink/weather.py @@ -5,6 +5,7 @@ from . import WeatherLinkCoordinator, WeatherLinkEntity from .api import IssCondition, LssBarCondition from .const import DECIMALS_DIRECTION, DECIMALS_PRESSURE, DECIMALS_SPEED, DOMAIN +from .sensor_common import round_optional logger = logging.getLogger(__name__) @@ -49,11 +50,13 @@ def humidity(self): @property def wind_speed(self): - return round(self._iss_condition.wind_speed_avg_last_2_min, DECIMALS_SPEED) + return round_optional( + self._iss_condition.wind_speed_avg_last_2_min, DECIMALS_SPEED + ) @property def wind_bearing(self): - return round( + return round_optional( self._iss_condition.wind_dir_scalar_avg_last_2_min, DECIMALS_DIRECTION ) @@ -63,24 +66,25 @@ def condition(self): rain_rate = c.rain_rate_hi or 0.0 if rain_rate > 0.25: - if c.temp <= 0: - return "snowy" - elif 0 < c.temp < 5: - return "snowy-rainy" + if temp := c.temp: + if temp <= 0: + return "snowy" + elif 0 < temp < 5: + return "snowy-rainy" if rain_rate > 4.0: return "pouring" return "rainy" - if c.wind_speed_avg_last_2_min > 20: + if (c.wind_speed_avg_last_2_min or 0.0) > 20: return "windy" if state := self.hass.states.get("sun.sun"): if state.state == "below_horizon": return "clear-night" - if c.solar_rad > 500: + if (c.solar_rad or 0) > 500: return "sunny" return "partlycloudy" diff --git a/tests/weatherlink/api/test_weatherlink.py b/tests/weatherlink/api/test_weatherlink.py index 5899c0e..fc7e3e2 100644 --- a/tests/weatherlink/api/test_weatherlink.py +++ b/tests/weatherlink/api/test_weatherlink.py @@ -8,82 +8,209 @@ get_data_from_body, ) -SAMPLE_RESPONSE = json.loads( - """ -{ - "data": { - "did": "001D0A7139D6", - "ts": 1610810640, - "conditions": [ - { - "lsid": 380030, - "data_structure_type": 1, - "txid": 1, - "temp": 26.6, - "hum": 96.9, - "dew_point": 25.8, - "wet_bulb": 26.3, - "heat_index": 26.6, - "wind_chill": 22.9, - "thw_index": 22.9, - "thsw_index": 20.9, - "wind_speed_last": 5.00, - "wind_dir_last": 254, - "wind_speed_avg_last_1_min": 3.25, - "wind_dir_scalar_avg_last_1_min": 243, - "wind_speed_avg_last_2_min": 3.56, - "wind_dir_scalar_avg_last_2_min": 245, - "wind_speed_hi_last_2_min": 5.00, - "wind_dir_at_hi_speed_last_2_min": 246, - "wind_speed_avg_last_10_min": 3.18, - "wind_dir_scalar_avg_last_10_min": 240, - "wind_speed_hi_last_10_min": 7.00, - "wind_dir_at_hi_speed_last_10_min": 257, - "rain_size": 2, - "rain_rate_last": 0, - "rain_rate_hi": 0, - "rainfall_last_15_min": 0, - "rain_rate_hi_last_15_min": 0, - "rainfall_last_60_min": 0, - "rainfall_last_24_hr": 0, - "rain_storm": 0, - "rain_storm_start_at": null, - "solar_rad": 23, - "uv_index": 0.0, - "rx_state": 0, - "trans_battery_flag": 1, - "rainfall_daily": 0, - "rainfall_monthly": 276, - "rainfall_year": 276, - "rain_storm_last": 271, - "rain_storm_last_start_at": 1610489461, - "rain_storm_last_end_at": 1610809260 - }, - { - "lsid": 380025, - "data_structure_type": 4, - "temp_in": 69.7, - "hum_in": 24.9, - "dew_point_in": 32.2, - "heat_index_in": 65.7 + +def test_parse(): + payload = json.loads( + """ + { + "data": { + "did": "001D0A7139D6", + "ts": 1610810640, + "conditions": [ + { + "lsid": 380030, + "data_structure_type": 1, + "txid": 1, + "temp": 26.6, + "hum": 96.9, + "dew_point": 25.8, + "wet_bulb": 26.3, + "heat_index": 26.6, + "wind_chill": 22.9, + "thw_index": 22.9, + "thsw_index": 20.9, + "wind_speed_last": 5.00, + "wind_dir_last": 254, + "wind_speed_avg_last_1_min": 3.25, + "wind_dir_scalar_avg_last_1_min": 243, + "wind_speed_avg_last_2_min": 3.56, + "wind_dir_scalar_avg_last_2_min": 245, + "wind_speed_hi_last_2_min": 5.00, + "wind_dir_at_hi_speed_last_2_min": 246, + "wind_speed_avg_last_10_min": 3.18, + "wind_dir_scalar_avg_last_10_min": 240, + "wind_speed_hi_last_10_min": 7.00, + "wind_dir_at_hi_speed_last_10_min": 257, + "rain_size": 2, + "rain_rate_last": 0, + "rain_rate_hi": 0, + "rainfall_last_15_min": 0, + "rain_rate_hi_last_15_min": 0, + "rainfall_last_60_min": 0, + "rainfall_last_24_hr": 0, + "rain_storm": 0, + "rain_storm_start_at": null, + "solar_rad": 23, + "uv_index": 0.0, + "rx_state": 0, + "trans_battery_flag": 1, + "rainfall_daily": 0, + "rainfall_monthly": 276, + "rainfall_year": 276, + "rain_storm_last": 271, + "rain_storm_last_start_at": 1610489461, + "rain_storm_last_end_at": 1610809260 + }, + { + "lsid": 380025, + "data_structure_type": 4, + "temp_in": 69.7, + "hum_in": 24.9, + "dew_point_in": 32.2, + "heat_index_in": 65.7 + }, + { + "lsid": 380024, + "data_structure_type": 3, + "bar_sea_level": 30.239, + "bar_trend": -0.028, + "bar_absolute": 28.629 + } + ] }, - { - "lsid": 380024, - "data_structure_type": 3, - "bar_sea_level": 30.239, - "bar_trend": -0.028, - "bar_absolute": 28.629 - } - ] - }, - "error": null -} -""" -) + "error": null + } + """ + ) + + data = CurrentConditions.from_json(get_data_from_body(payload), strict=True) + assert data[IssCondition] + assert data[LssTempHumCondition] + assert data[LssBarCondition] -def test_parse(): - data = CurrentConditions.from_json(get_data_from_body(SAMPLE_RESPONSE), strict=True) +# see +def test_parse_02(): + payload = json.loads( + """ + { + "data": { + "did": "001D0A71472B", + "ts": 1621686244, + "conditions": [ + { + "lsid": 418773, + "data_structure_type": 1, + "txid": 1, + "temp": 52.1, + "hum": 59.1, + "dew_point": 38.2, + "wet_bulb": 43.9, + "heat_index": 51.1, + "wind_chill": null, + "thw_index": null, + "thsw_index": null, + "wind_speed_last": null, + "wind_dir_last": null, + "wind_speed_avg_last_1_min": null, + "wind_dir_scalar_avg_last_1_min": null, + "wind_speed_avg_last_2_min": null, + "wind_dir_scalar_avg_last_2_min": null, + "wind_speed_hi_last_2_min": null, + "wind_dir_at_hi_speed_last_2_min": null, + "wind_speed_avg_last_10_min": null, + "wind_dir_scalar_avg_last_10_min": null, + "wind_speed_hi_last_10_min": null, + "wind_dir_at_hi_speed_last_10_min": null, + "rain_size": 2, + "rain_rate_last": 0, + "rain_rate_hi": 0, + "rainfall_last_15_min": 0, + "rain_rate_hi_last_15_min": 0, + "rainfall_last_60_min": 0, + "rainfall_last_24_hr": 54, + "rain_storm": 213, + "rain_storm_start_at": 1621054081, + "solar_rad": 283, + "uv_index": null, + "rx_state": 0, + "trans_battery_flag": 0, + "rainfall_daily": 30, + "rainfall_monthly": 251, + "rainfall_year": 251, + "rain_storm_last": 38, + "rain_storm_last_start_at": 1620901021, + "rain_storm_last_end_at": 1621044061 + }, + { + "lsid": 418774, + "data_structure_type": 1, + "txid": 2, + "temp": null, + "hum": null, + "dew_point": null, + "wet_bulb": null, + "heat_index": null, + "wind_chill": null, + "thw_index": null, + "thsw_index": null, + "wind_speed_last": 3.00, + "wind_dir_last": 252, + "wind_speed_avg_last_1_min": 1.18, + "wind_dir_scalar_avg_last_1_min": 251, + "wind_speed_avg_last_2_min": 1.00, + "wind_dir_scalar_avg_last_2_min": 251, + "wind_speed_hi_last_2_min": 4.00, + "wind_dir_at_hi_speed_last_2_min": 250, + "wind_speed_avg_last_10_min": 2.56, + "wind_dir_scalar_avg_last_10_min": 254, + "wind_speed_hi_last_10_min": 10.00, + "wind_dir_at_hi_speed_last_10_min": 266, + "rain_size": 1, + "rain_rate_last": 0, + "rain_rate_hi": 0, + "rainfall_last_15_min": 0, + "rain_rate_hi_last_15_min": 0, + "rainfall_last_60_min": 0, + "rainfall_last_24_hr": 0, + "rain_storm": null, + "rain_storm_start_at": null, + "solar_rad": null, + "uv_index": null, + "rx_state": 0, + "trans_battery_flag": 0, + "rainfall_daily": 0, + "rainfall_monthly": 0, + "rainfall_year": 0, + "rain_storm_last": null, + "rain_storm_last_start_at": null, + "rain_storm_last_end_at": null + }, + { + "lsid": 418764, + "data_structure_type": 4, + "temp_in": 74.4, + "hum_in": 41.0, + "dew_point_in": 49.2, + "heat_index_in": 73.5 + }, + { + "lsid": 418763, + "data_structure_type": 3, + "bar_sea_level": 29.660, + "bar_trend": 0.054, + "bar_absolute": 29.259 + } + ] + }, + "error": null + } + """ + ) + + data = CurrentConditions.from_json(get_data_from_body(payload), strict=True) assert data[IssCondition] assert data[LssTempHumCondition] assert data[LssBarCondition] + + assert data[IssCondition].wind_dir_last == 252