diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 3dc8f878791..b5aab53e684 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -344,6 +344,7 @@ class SensorDeviceClass(StrEnum): - SI /metric: `mm/d`, `mm/h`, `m/s`, `km/h` - USCS / imperial: `in/d`, `in/h`, `ft/s`, `mph` - Nautical: `kn` + - Beaufort: `Beaufort` """ SULPHUR_DIOXIDE = "sulphur_dioxide" @@ -431,6 +432,7 @@ class SensorDeviceClass(StrEnum): - SI /metric: `m/s`, `km/h` - USCS / imperial: `ft/s`, `mph` - Nautical: `kn` + - Beaufort: `Beaufort` """ diff --git a/homeassistant/const.py b/homeassistant/const.py index 31b08becee6..3a8d1a09e85 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1214,6 +1214,7 @@ CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" class UnitOfSpeed(StrEnum): """Speed units.""" + BEAUFORT = "Beaufort" FEET_PER_SECOND = "ft/s" METERS_PER_SECOND = "m/s" KILOMETERS_PER_HOUR = "km/h" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index fe1974f2bee..18318f89da4 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -334,6 +334,7 @@ class SpeedConverter(BaseUnitConverter): UnitOfSpeed.KNOTS: _HRS_TO_SECS / _NAUTICAL_MILE_TO_M, UnitOfSpeed.METERS_PER_SECOND: 1, UnitOfSpeed.MILES_PER_HOUR: _HRS_TO_SECS / _MILE_TO_M, + UnitOfSpeed.BEAUFORT: 1, } VALID_UNITS = { UnitOfVolumetricFlux.INCHES_PER_DAY, @@ -345,8 +346,73 @@ class SpeedConverter(BaseUnitConverter): UnitOfSpeed.KNOTS, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.MILES_PER_HOUR, + UnitOfSpeed.BEAUFORT, } + @classmethod + @lru_cache + def converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: + """Return a function to convert a speed from one unit to another.""" + if from_unit == to_unit: + # Return a function that does nothing. This is not + # in _converter_factory because we do not want to wrap + # it with the None check in converter_factory_allow_none. + return lambda value: value + + return cls._converter_factory(from_unit, to_unit) + + @classmethod + @lru_cache + def converter_factory_allow_none( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float | None], float | None]: + """Return a function to convert a speed from one unit to another which allows None.""" + if from_unit == to_unit: + # Return a function that does nothing. This is not + # in _converter_factory because we do not want to wrap + # it with the None check in this case. + return lambda value: value + + convert = cls._converter_factory(from_unit, to_unit) + return lambda value: None if value is None else convert(value) + + @classmethod + def _converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: + """Convert a speed from one unit to another, eg. 14m/s will return 7Bft.""" + # We cannot use the implementation from BaseUnitConverter here because the + # Beaufort scale is not a constant value to divide or multiply with. + if ( + from_unit not in SpeedConverter.VALID_UNITS + or to_unit not in SpeedConverter.VALID_UNITS + ): + raise HomeAssistantError( + UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) + ) + + if from_unit == UnitOfSpeed.BEAUFORT: + to_ratio = cls._UNIT_CONVERSION[to_unit] + return lambda val: cls._beaufort_to_ms(val) * to_ratio + if to_unit == UnitOfSpeed.BEAUFORT: + from_ratio = cls._UNIT_CONVERSION[from_unit] + return lambda val: cls._ms_to_beaufort(val / from_ratio) + + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return lambda val: (val / from_ratio) * to_ratio + + @classmethod + def _ms_to_beaufort(cls, ms: float) -> float: + """Convert a speed in m/s to Beaufort.""" + return float(round(((ms / 0.836) ** 2) ** (1 / 3))) + + @classmethod + def _beaufort_to_ms(cls, beaufort: float) -> float: + """Convert a speed in Beaufort to m/s.""" + return float(0.836 * beaufort ** (3 / 2)) + class TemperatureConverter(BaseUnitConverter): """Utility to convert temperature values.""" diff --git a/tests/components/sensor/test_websocket_api.py b/tests/components/sensor/test_websocket_api.py index bd0a68598e1..98d07b599fe 100644 --- a/tests/components/sensor/test_websocket_api.py +++ b/tests/components/sensor/test_websocket_api.py @@ -30,7 +30,18 @@ async def test_device_class_units( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "units": ["ft/s", "in/d", "in/h", "km/h", "kn", "m/s", "mm/d", "mm/h", "mph"] + "units": [ + "Beaufort", + "ft/s", + "in/d", + "in/h", + "km/h", + "kn", + "m/s", + "mm/d", + "mm/h", + "mph", + ] } # Device class with units which include `None` diff --git a/tests/test_const.py b/tests/test_const.py index 7ca4812ca8e..b43f677ba8f 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -131,7 +131,16 @@ def test_all() -> None: ], "PRECIPITATION_", ) - + _create_tuples(const.UnitOfSpeed, "SPEED_") + + _create_tuples( + [ + const.UnitOfSpeed.FEET_PER_SECOND, + const.UnitOfSpeed.METERS_PER_SECOND, + const.UnitOfSpeed.KILOMETERS_PER_HOUR, + const.UnitOfSpeed.KNOTS, + const.UnitOfSpeed.MILES_PER_HOUR, + ], + "SPEED_", + ) + _create_tuples( [ const.UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index d4649671f47..be8f51af6f9 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -413,6 +413,8 @@ _CONVERTED_VALUE: dict[ (5, UnitOfSpeed.KNOTS, 2.57222, UnitOfSpeed.METERS_PER_SECOND), # 5 ft/s * 0.3048 m/ft = 1.524 m/s (5, UnitOfSpeed.FEET_PER_SECOND, 1.524, UnitOfSpeed.METERS_PER_SECOND), + # float(round(((20.7 m/s / 0.836) ** 2) ** (1 / 3))) = 8.0Bft + (20.7, UnitOfSpeed.METERS_PER_SECOND, 8.0, UnitOfSpeed.BEAUFORT), ], TemperatureConverter: [ (100, UnitOfTemperature.CELSIUS, 212, UnitOfTemperature.FAHRENHEIT), diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index 44b287bd05d..5a199783346 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -515,6 +515,7 @@ UNCONVERTED_UNITS_METRIC_SYSTEM = { UnitOfPressure.PA, ), SensorDeviceClass.SPEED: ( + UnitOfSpeed.BEAUFORT, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS, UnitOfSpeed.METERS_PER_SECOND, @@ -723,6 +724,7 @@ UNCONVERTED_UNITS_US_SYSTEM = { ), SensorDeviceClass.PRESSURE: (UnitOfPressure.INHG, UnitOfPressure.PSI), SensorDeviceClass.SPEED: ( + UnitOfSpeed.BEAUFORT, UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR,