From d8549409f79aeebe6ae8650849deb219b4cf6461 Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Fri, 22 Nov 2024 04:10:44 +1300 Subject: [PATCH] Area units and conversion between metric and US (#123563) * area conversions * start work on tests * add number device class * update unit conversions to utilise distance constants * add area unit * update test unit system * update device condition and trigger * update statistic unit converters * further tests work WIP * update test unit system * add missing string translations * fix websocket tests * add deprecated notice * add more missing strings and missing initialisation of unit system * adjust icon and remove strings from scrape and random * Fix acre to meters conversion Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Tidy up valid units Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * fix ordering of area * update order alphabetically * fix broken test * update test_init * Update homeassistant/const.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * remove deprecated unit and fix alphabetical order * change deprecation and add tests, change to millimeter conversion for inches * fix order * re-order defs alphabetically * add measurement as well * update icons * fix up Deprecation of area square meters * Update core integrations to UnitOfArea * update test recorder tests * unit system tests in alphabetical * update snapshot * rebuild * revert alphabetization of functions * other revert of alphabetical order --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/ecovacs/sensor.py | 6 +- homeassistant/components/number/const.py | 8 ++ homeassistant/components/number/icons.json | 3 + homeassistant/components/number/strings.json | 3 + .../components/recorder/statistics.py | 2 + .../components/recorder/websocket_api.py | 2 + .../rituals_perfume_genie/select.py | 4 +- homeassistant/components/roborock/sensor.py | 11 +-- homeassistant/components/romy/sensor.py | 4 +- homeassistant/components/roomba/sensor.py | 9 +- homeassistant/components/sensor/const.py | 11 +++ .../components/sensor/device_condition.py | 3 + .../components/sensor/device_trigger.py | 3 + homeassistant/components/sensor/icons.json | 3 + homeassistant/components/sensor/strings.json | 5 + .../components/smartthings/sensor.py | 4 +- .../components/xiaomi_miio/sensor.py | 8 +- homeassistant/const.py | 24 ++++- homeassistant/util/unit_conversion.py | 33 +++++++ homeassistant/util/unit_system.py | 36 +++++++- .../ecovacs/snapshots/test_sensor.ambr | 16 ++-- .../components/recorder/test_websocket_api.py | 23 +++++ .../rituals_perfume_genie/test_select.py | 4 +- tests/components/sensor/test_init.py | 30 ++++++ tests/components/sensor/test_recorder.py | 14 +++ tests/helpers/test_template.py | 2 + tests/test_const.py | 18 ++-- tests/util/test_unit_conversion.py | 60 ++++++++++++ tests/util/test_unit_system.py | 92 +++++++++++++++++++ 29 files changed, 394 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 28c4efbd0c6341..7c190d27775c83 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -26,11 +26,11 @@ SensorStateClass, ) from homeassistant.const import ( - AREA_SQUARE_METERS, ATTR_BATTERY_LEVEL, CONF_DESCRIPTION, PERCENTAGE, EntityCategory, + UnitOfArea, UnitOfTime, ) from homeassistant.core import HomeAssistant @@ -67,7 +67,7 @@ class EcovacsSensorEntityDescription( capability_fn=lambda caps: caps.stats.clean, value_fn=lambda e: e.area, translation_key="stats_area", - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, ), EcovacsSensorEntityDescription[StatsEvent]( key="stats_time", @@ -84,7 +84,7 @@ class EcovacsSensorEntityDescription( value_fn=lambda e: e.area, key="total_stats_area", translation_key="total_stats_area", - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), EcovacsSensorEntityDescription[TotalStatsEvent]( diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 374a69dedc8e72..3f29dd0416601d 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -17,6 +17,7 @@ SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, @@ -98,6 +99,12 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `None` """ + AREA = "area" + """Area + + Unit of measurement: `UnitOfArea` units + """ + ATMOSPHERIC_PRESSURE = "atmospheric_pressure" """Atmospheric pressure. @@ -434,6 +441,7 @@ class NumberDeviceClass(StrEnum): DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), NumberDeviceClass.AQI: {None}, + NumberDeviceClass.AREA: set(UnitOfArea), NumberDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), NumberDeviceClass.BATTERY: {PERCENTAGE}, NumberDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index 5e0fc6e44d2612..636fa0a7751e83 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -9,6 +9,9 @@ "aqi": { "default": "mdi:air-filter" }, + "area": { + "default": "mdi:texture-box" + }, "atmospheric_pressure": { "default": "mdi:thermometer-lines" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index b9aec880ecc22f..cc77d224d72af1 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -37,6 +37,9 @@ "aqi": { "name": "[%key:component::sensor::entity_component::aqi::name%]" }, + "area": { + "name": "[%key:component::sensor::entity_component::area::name%]" + }, "atmospheric_pressure": { "name": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]" }, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7243af9d4d548f..9f01fd0399c69d 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -27,6 +27,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -129,6 +130,7 @@ STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = { + **{unit: AreaConverter for unit in AreaConverter.VALID_UNITS}, **{ unit: BloodGlucoseConcentrationConverter for unit in BloodGlucoseConcentrationConverter.VALID_UNITS diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index f4dce73fa47395..ee5c5dd6d75b14 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import ( + AreaConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, @@ -55,6 +56,7 @@ UNIT_SCHEMA = vol.Schema( { + vol.Optional("area"): vol.In(AreaConverter.VALID_UNITS), vol.Optional("blood_glucose_concentration"): vol.In( BloodGlucoseConcentrationConverter.VALID_UNITS ), diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index e93d6ae03ef20a..27aff70649bf6e 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -9,7 +9,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import AREA_SQUARE_METERS, EntityCategory +from homeassistant.const import EntityCategory, UnitOfArea from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,7 +30,7 @@ class RitualsSelectEntityDescription(SelectEntityDescription): RitualsSelectEntityDescription( key="room_size_square_meter", translation_key="room_size_square_meter", - unit_of_measurement=AREA_SQUARE_METERS, + unit_of_measurement=UnitOfArea.SQUARE_METERS, entity_category=EntityCategory.CONFIG, options=["15", "30", "60", "100"], current_fn=lambda diffuser: str(diffuser.room_size_square_meter), diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 33ce6be5a6814b..47849ed5cc53ce 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -25,12 +25,7 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.const import ( - AREA_SQUARE_METERS, - PERCENTAGE, - EntityCategory, - UnitOfTime, -) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -131,14 +126,14 @@ def _dock_error_value_fn(properties: DeviceProp) -> str | None: translation_key="cleaning_area", value_fn=lambda data: data.status.square_meter_clean_area, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, ), RoborockSensorDescription( key="total_cleaning_area", translation_key="total_cleaning_area", value_fn=lambda data: data.clean_summary.square_meter_clean_area, entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, ), RoborockSensorDescription( key="vacuum_error", diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py index bdd486c4f8f84b..341125b86ba1d5 100644 --- a/homeassistant/components/romy/sensor.py +++ b/homeassistant/components/romy/sensor.py @@ -8,10 +8,10 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - AREA_SQUARE_METERS, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfArea, UnitOfLength, UnitOfTime, ) @@ -61,7 +61,7 @@ key="total_area_cleaned", translation_key="total_area_cleaned", state_class=SensorStateClass.TOTAL, - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 87e97fdb76085d..d358dcb428c24c 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -12,12 +12,7 @@ SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - AREA_SQUARE_METERS, - PERCENTAGE, - EntityCategory, - UnitOfTime, -) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -108,7 +103,7 @@ class RoombaSensorEntityDescription(SensorEntityDescription): RoombaSensorEntityDescription( key="total_cleaned_area", translation_key="total_cleaned_area", - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda self: ( None if (sqft := self.run_stats.get("sqft")) is None else sqft * 9.29 diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index b06353272f87e9..c290a627b0700a 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -17,6 +17,7 @@ SIGNAL_STRENGTH_DECIBELS, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfApparentPower, + UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, @@ -47,6 +48,7 @@ dir_with_deprecated_constants, ) from homeassistant.util.unit_conversion import ( + AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -117,6 +119,12 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `None` """ + AREA = "area" + """Area + + Unit of measurement: `UnitOfArea` units + """ + ATMOSPHERIC_PRESSURE = "atmospheric_pressure" """Atmospheric pressure. @@ -500,6 +508,7 @@ class SensorStateClass(StrEnum): STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, SensorDeviceClass.CONDUCTIVITY: ConductivityConverter, @@ -531,6 +540,7 @@ class SensorStateClass(StrEnum): DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), SensorDeviceClass.AQI: {None}, + SensorDeviceClass.AREA: set(UnitOfArea), SensorDeviceClass.ATMOSPHERIC_PRESSURE: set(UnitOfPressure), SensorDeviceClass.BATTERY: {PERCENTAGE}, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: set(UnitOfBloodGlucoseConcentration), @@ -607,6 +617,7 @@ class SensorStateClass(StrEnum): DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.AREA: set(SensorStateClass), SensorDeviceClass.ATMOSPHERIC_PRESSURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.BATTERY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: {SensorStateClass.MEASUREMENT}, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 56ecb36adb3b1d..fc25dce18fce9e 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -35,6 +35,7 @@ CONF_IS_APPARENT_POWER = "is_apparent_power" CONF_IS_AQI = "is_aqi" +CONF_IS_AREA = "is_area" CONF_IS_ATMOSPHERIC_PRESSURE = "is_atmospheric_pressure" CONF_IS_BATTERY_LEVEL = "is_battery_level" CONF_IS_BLOOD_GLUCOSE_CONCENTRATION = "is_blood_glucose_concentration" @@ -86,6 +87,7 @@ ENTITY_CONDITIONS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], + SensorDeviceClass.AREA: [{CONF_TYPE: CONF_IS_AREA}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_IS_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}], SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [ @@ -153,6 +155,7 @@ [ CONF_IS_APPARENT_POWER, CONF_IS_AQI, + CONF_IS_AREA, CONF_IS_ATMOSPHERIC_PRESSURE, CONF_IS_BATTERY_LEVEL, CONF_IS_BLOOD_GLUCOSE_CONCENTRATION, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index ffee10d9f401b4..d75b3aa6e41a29 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -34,6 +34,7 @@ CONF_APPARENT_POWER = "apparent_power" CONF_AQI = "aqi" +CONF_AREA = "area" CONF_ATMOSPHERIC_PRESSURE = "atmospheric_pressure" CONF_BATTERY_LEVEL = "battery_level" CONF_BLOOD_GLUCOSE_CONCENTRATION = "blood_glucose_concentration" @@ -85,6 +86,7 @@ ENTITY_TRIGGERS = { SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], + SensorDeviceClass.AREA: [{CONF_TYPE: CONF_AREA}], SensorDeviceClass.ATMOSPHERIC_PRESSURE: [{CONF_TYPE: CONF_ATMOSPHERIC_PRESSURE}], SensorDeviceClass.BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}], SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: [ @@ -153,6 +155,7 @@ [ CONF_APPARENT_POWER, CONF_AQI, + CONF_AREA, CONF_ATMOSPHERIC_PRESSURE, CONF_BATTERY_LEVEL, CONF_BLOOD_GLUCOSE_CONCENTRATION, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index ea4c902e665735..5f770765ee3df5 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -9,6 +9,9 @@ "aqi": { "default": "mdi:air-filter" }, + "area": { + "default": "mdi:texture-box" + }, "atmospheric_pressure": { "default": "mdi:thermometer-lines" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 6d529e72c3b2bb..0bc370398b5134 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -4,6 +4,7 @@ "condition_type": { "is_apparent_power": "Current {entity_name} apparent power", "is_aqi": "Current {entity_name} air quality index", + "is_area": "Current {entity_name} area", "is_atmospheric_pressure": "Current {entity_name} atmospheric pressure", "is_battery_level": "Current {entity_name} battery level", "is_blood_glucose_concentration": "Current {entity_name} blood glucose concentration", @@ -55,6 +56,7 @@ "trigger_type": { "apparent_power": "{entity_name} apparent power changes", "aqi": "{entity_name} air quality index changes", + "area": "{entity_name} area changes", "atmospheric_pressure": "{entity_name} atmospheric pressure changes", "battery_level": "{entity_name} battery level changes", "blood_glucose_concentration": "{entity_name} blood glucose concentration changes", @@ -145,6 +147,9 @@ "aqi": { "name": "Air quality index" }, + "area": { + "name": "Area" + }, "atmospheric_pressure": { "name": "Atmospheric pressure" }, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index b73d3b43764ebb..8bd0421d2bcf3e 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -15,11 +15,11 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - AREA_SQUARE_METERS, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, + UnitOfArea, UnitOfElectricPotential, UnitOfEnergy, UnitOfMass, @@ -95,7 +95,7 @@ class Map(NamedTuple): Map( Attribute.bmi_measurement, "Body Mass Index", - f"{UnitOfMass.KILOGRAMS}/{AREA_SQUARE_METERS}", + f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}", None, SensorStateClass.MEASUREMENT, None, diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py index 3f6f4e9b50bf6e..aafcba974875e5 100644 --- a/homeassistant/components/xiaomi_miio/sensor.py +++ b/homeassistant/components/xiaomi_miio/sensor.py @@ -24,7 +24,6 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - AREA_SQUARE_METERS, ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, @@ -37,6 +36,7 @@ PERCENTAGE, REVOLUTIONS_PER_MINUTE, EntityCategory, + UnitOfArea, UnitOfPower, UnitOfPressure, UnitOfTemperature, @@ -622,7 +622,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), f"last_clean_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, icon="mdi:texture-box", key=ATTR_LAST_CLEAN_AREA, parent_key=VacuumCoordinatorDataAttributes.last_clean_details, @@ -639,7 +639,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), f"current_{ATTR_LAST_CLEAN_AREA}": XiaomiMiioSensorDescription( - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, icon="mdi:texture-box", key=ATTR_STATUS_CLEAN_AREA, parent_key=VacuumCoordinatorDataAttributes.status, @@ -657,7 +657,7 @@ class XiaomiMiioSensorDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, ), f"clean_history_{ATTR_CLEAN_HISTORY_TOTAL_AREA}": XiaomiMiioSensorDescription( - native_unit_of_measurement=AREA_SQUARE_METERS, + native_unit_of_measurement=UnitOfArea.SQUARE_METERS, icon="mdi:texture-box", key=ATTR_CLEAN_HISTORY_TOTAL_AREA, parent_key=VacuumCoordinatorDataAttributes.clean_history_status, diff --git a/homeassistant/const.py b/homeassistant/const.py index 61b60fc3cf3561..5a3a3f292ffe76 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1179,8 +1179,27 @@ class UnitOfVolumeFlowRate(StrEnum): ) """Deprecated: please use UnitOfVolumeFlowRate.CUBIC_FEET_PER_MINUTE""" -# Area units -AREA_SQUARE_METERS: Final = "m²" + +class UnitOfArea(StrEnum): + """Area units.""" + + SQUARE_METERS = "m²" + SQUARE_CENTIMETERS = "cm²" + SQUARE_KILOMETERS = "km²" + SQUARE_MILLIMETERS = "mm²" + SQUARE_INCHES = "in²" + SQUARE_FEET = "ft²" + SQUARE_YARDS = "yd²" + SQUARE_MILES = "mi²" + ACRES = "ac" + HECTARES = "ha" + + +_DEPRECATED_AREA_SQUARE_METERS: Final = DeprecatedConstantEnum( + UnitOfArea.SQUARE_METERS, + "2025.12", +) +"""Deprecated: please use UnitOfArea.SQUARE_METERS""" # Mass units @@ -1704,6 +1723,7 @@ class UnitOfDataRate(StrEnum): UNIT_NOT_RECOGNIZED_TEMPLATE: Final = "{} is not a recognized {} unit." LENGTH: Final = "length" +AREA: Final = "area" MASS: Final = "mass" PRESSURE: Final = "pressure" VOLUME: Final = "volume" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index a4c35d67ab75d0..b700c66f248d4a 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -10,6 +10,7 @@ CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, + UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, @@ -42,6 +43,19 @@ _NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m +# Area constants to square meters +_CM2_TO_M2 = _CM_TO_M**2 # 1 cm² = 0.0001 m² +_MM2_TO_M2 = _MM_TO_M**2 # 1 mm² = 0.000001 m² +_KM2_TO_M2 = _KM_TO_M**2 # 1 km² = 1,000,000 m² + +_IN2_TO_M2 = _IN_TO_M**2 # 1 in² = 0.00064516 m² +_FT2_TO_M2 = _FOOT_TO_M**2 # 1 ft² = 0.092903 m² +_YD2_TO_M2 = _YARD_TO_M**2 # 1 yd² = 0.836127 m² +_MI2_TO_M2 = _MILE_TO_M**2 # 1 mi² = 2,590,000 m² + +_ACRE_TO_M2 = 66 * 660 * _FT2_TO_M2 # 1 acre = 4,046.86 m² +_HECTARE_TO_M2 = 100 * 100 # 1 hectare = 10,000 m² + # Duration conversion constants _MIN_TO_SEC = 60 # 1 min = 60 seconds _HRS_TO_MINUTES = 60 # 1 hr = 60 minutes @@ -146,6 +160,25 @@ class DataRateConverter(BaseUnitConverter): VALID_UNITS = set(UnitOfDataRate) +class AreaConverter(BaseUnitConverter): + """Utility to convert area values.""" + + UNIT_CLASS = "area" + _UNIT_CONVERSION: dict[str | None, float] = { + UnitOfArea.SQUARE_METERS: 1, + UnitOfArea.SQUARE_CENTIMETERS: 1 / _CM2_TO_M2, + UnitOfArea.SQUARE_MILLIMETERS: 1 / _MM2_TO_M2, + UnitOfArea.SQUARE_KILOMETERS: 1 / _KM2_TO_M2, + UnitOfArea.SQUARE_INCHES: 1 / _IN2_TO_M2, + UnitOfArea.SQUARE_FEET: 1 / _FT2_TO_M2, + UnitOfArea.SQUARE_YARDS: 1 / _YD2_TO_M2, + UnitOfArea.SQUARE_MILES: 1 / _MI2_TO_M2, + UnitOfArea.ACRES: 1 / _ACRE_TO_M2, + UnitOfArea.HECTARES: 1 / _HECTARE_TO_M2, + } + VALID_UNITS = set(UnitOfArea) + + class DistanceConverter(BaseUnitConverter): """Utility to convert distance values.""" diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index 7f7c7f2b5fdb67..c812dd38230c2a 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -9,6 +9,7 @@ from homeassistant.const import ( ACCUMULATED_PRECIPITATION, + AREA, LENGTH, MASS, PRESSURE, @@ -16,6 +17,7 @@ UNIT_NOT_RECOGNIZED_TEMPLATE, VOLUME, WIND_SPEED, + UnitOfArea, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, @@ -27,6 +29,7 @@ ) from .unit_conversion import ( + AreaConverter, DistanceConverter, PressureConverter, SpeedConverter, @@ -41,6 +44,8 @@ _CONF_UNIT_SYSTEM_METRIC: Final = "metric" _CONF_UNIT_SYSTEM_US_CUSTOMARY: Final = "us_customary" +AREA_UNITS = AreaConverter.VALID_UNITS + LENGTH_UNITS = DistanceConverter.VALID_UNITS MASS_UNITS: set[str] = { @@ -66,6 +71,7 @@ MASS: MASS_UNITS, VOLUME: VOLUME_UNITS, PRESSURE: PRESSURE_UNITS, + AREA: AREA_UNITS, } @@ -84,6 +90,7 @@ def __init__( name: str, *, accumulated_precipitation: UnitOfPrecipitationDepth, + area: UnitOfArea, conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str], length: UnitOfLength, mass: UnitOfMass, @@ -97,6 +104,7 @@ def __init__( UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type) for unit, unit_type in ( (accumulated_precipitation, ACCUMULATED_PRECIPITATION), + (area, AREA), (temperature, TEMPERATURE), (length, LENGTH), (wind_speed, WIND_SPEED), @@ -112,10 +120,11 @@ def __init__( self._name = name self.accumulated_precipitation_unit = accumulated_precipitation - self.temperature_unit = temperature + self.area_unit = area self.length_unit = length self.mass_unit = mass self.pressure_unit = pressure + self.temperature_unit = temperature self.volume_unit = volume self.wind_speed_unit = wind_speed self._conversions = conversions @@ -149,6 +158,16 @@ def accumulated_precipitation(self, precip: float | None, from_unit: str) -> flo precip, from_unit, self.accumulated_precipitation_unit ) + def area(self, area: float | None, from_unit: str) -> float: + """Convert the given area to this unit system.""" + if not isinstance(area, Number): + raise TypeError(f"{area!s} is not a numeric value.") + + # type ignore: https://github.com/python/mypy/issues/7207 + return AreaConverter.convert( # type: ignore[unreachable] + area, from_unit, self.area_unit + ) + def pressure(self, pressure: float | None, from_unit: str) -> float: """Convert the given pressure to this unit system.""" if not isinstance(pressure, Number): @@ -184,6 +203,7 @@ def as_dict(self) -> dict[str, str]: return { LENGTH: self.length_unit, ACCUMULATED_PRECIPITATION: self.accumulated_precipitation_unit, + AREA: self.area_unit, MASS: self.mass_unit, PRESSURE: self.pressure_unit, TEMPERATURE: self.temperature_unit, @@ -234,6 +254,12 @@ def _deprecated_unit_system(value: str) -> str: for unit in UnitOfPressure if unit != UnitOfPressure.HPA }, + # Convert non-metric area + ("area", UnitOfArea.SQUARE_INCHES): UnitOfArea.SQUARE_CENTIMETERS, + ("area", UnitOfArea.SQUARE_FEET): UnitOfArea.SQUARE_METERS, + ("area", UnitOfArea.SQUARE_MILES): UnitOfArea.SQUARE_KILOMETERS, + ("area", UnitOfArea.SQUARE_YARDS): UnitOfArea.SQUARE_METERS, + ("area", UnitOfArea.ACRES): UnitOfArea.HECTARES, # Convert non-metric distances ("distance", UnitOfLength.FEET): UnitOfLength.METERS, ("distance", UnitOfLength.INCHES): UnitOfLength.MILLIMETERS, @@ -285,6 +311,7 @@ def _deprecated_unit_system(value: str) -> str: if unit not in (UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS) }, }, + area=UnitOfArea.SQUARE_METERS, length=UnitOfLength.KILOMETERS, mass=UnitOfMass.GRAMS, pressure=UnitOfPressure.PA, @@ -303,6 +330,12 @@ def _deprecated_unit_system(value: str) -> str: for unit in UnitOfPressure if unit != UnitOfPressure.INHG }, + # Convert non-USCS areas + ("area", UnitOfArea.SQUARE_METERS): UnitOfArea.SQUARE_FEET, + ("area", UnitOfArea.SQUARE_CENTIMETERS): UnitOfArea.SQUARE_INCHES, + ("area", UnitOfArea.SQUARE_MILLIMETERS): UnitOfArea.SQUARE_INCHES, + ("area", UnitOfArea.SQUARE_KILOMETERS): UnitOfArea.SQUARE_MILES, + ("area", UnitOfArea.HECTARES): UnitOfArea.ACRES, # Convert non-USCS distances ("distance", UnitOfLength.CENTIMETERS): UnitOfLength.INCHES, ("distance", UnitOfLength.KILOMETERS): UnitOfLength.MILES, @@ -356,6 +389,7 @@ def _deprecated_unit_system(value: str) -> str: if unit not in (UnitOfSpeed.KNOTS, UnitOfSpeed.MILES_PER_HOUR) }, }, + area=UnitOfArea.SQUARE_FEET, length=UnitOfLength.MILES, mass=UnitOfMass.POUNDS, pressure=UnitOfPressure.PSI, diff --git a/tests/components/ecovacs/snapshots/test_sensor.ambr b/tests/components/ecovacs/snapshots/test_sensor.ambr index 659edfde2cf937..9c76c00b5b7dbc 100644 --- a/tests/components/ecovacs/snapshots/test_sensor.ambr +++ b/tests/components/ecovacs/snapshots/test_sensor.ambr @@ -177,14 +177,14 @@ 'supported_features': 0, 'translation_key': 'stats_area', 'unique_id': '8516fbb1-17f1-4194-0000000_stats_area', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }) # --- # name: test_sensors[5xu9h3][sensor.goat_g1_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Goat G1 Area cleaned', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.goat_g1_area_cleaned', @@ -512,7 +512,7 @@ 'supported_features': 0, 'translation_key': 'total_stats_area', 'unique_id': '8516fbb1-17f1-4194-0000000_total_stats_area', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }) # --- # name: test_sensors[5xu9h3][sensor.goat_g1_total_area_cleaned:state] @@ -520,7 +520,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Goat G1 Total area cleaned', 'state_class': , - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.goat_g1_total_area_cleaned', @@ -755,14 +755,14 @@ 'supported_features': 0, 'translation_key': 'stats_area', 'unique_id': 'E1234567890000000001_stats_area', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }) # --- # name: test_sensors[yna5x1][sensor.ozmo_950_area_cleaned:state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Ozmo 950 Area cleaned', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ozmo_950_area_cleaned', @@ -1137,7 +1137,7 @@ 'supported_features': 0, 'translation_key': 'total_stats_area', 'unique_id': 'E1234567890000000001_total_stats_area', - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }) # --- # name: test_sensors[yna5x1][sensor.ozmo_950_total_area_cleaned:state] @@ -1145,7 +1145,7 @@ 'attributes': ReadOnlyDict({ 'friendly_name': 'Ozmo 950 Total area cleaned', 'state_class': , - 'unit_of_measurement': 'm²', + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.ozmo_950_total_area_cleaned', diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 547288d1cc3f32..403384aee9f3b6 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -51,6 +51,16 @@ async def mock_recorder_before_hass( """Set up recorder.""" +AREA_SENSOR_FT_ATTRIBUTES = { + "device_class": "area", + "state_class": "measurement", + "unit_of_measurement": "ft²", +} +AREA_SENSOR_M_ATTRIBUTES = { + "device_class": "area", + "state_class": "measurement", + "unit_of_measurement": "m²", +} DISTANCE_SENSOR_FT_ATTRIBUTES = { "device_class": "distance", "state_class": "measurement", @@ -1247,6 +1257,9 @@ async def test_statistic_during_period_calendar( @pytest.mark.parametrize( ("attributes", "state", "value", "custom_units", "converted_value"), [ + (AREA_SENSOR_M_ATTRIBUTES, 10, 10, {"area": "cm²"}, 100000), + (AREA_SENSOR_M_ATTRIBUTES, 10, 10, {"area": "m²"}, 10), + (AREA_SENSOR_M_ATTRIBUTES, 10, 10, {"area": "ft²"}, 107.639), (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "cm"}, 1000), (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "m"}, 10), (DISTANCE_SENSOR_M_ATTRIBUTES, 10, 10, {"distance": "in"}, 10 / 0.0254), @@ -1434,6 +1447,7 @@ async def test_sum_statistics_during_period_unit_conversion( "custom_units", [ {"distance": "L"}, + {"area": "L"}, {"energy": "W"}, {"power": "Pa"}, {"pressure": "K"}, @@ -1678,6 +1692,8 @@ async def test_statistics_during_period_empty_statistic_ids( @pytest.mark.parametrize( ("units", "attributes", "display_unit", "statistics_unit", "unit_class"), [ + (US_CUSTOMARY_SYSTEM, AREA_SENSOR_M_ATTRIBUTES, "m²", "m²", "area"), + (METRIC_SYSTEM, AREA_SENSOR_M_ATTRIBUTES, "m²", "m²", "area"), (US_CUSTOMARY_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), (METRIC_SYSTEM, DISTANCE_SENSOR_M_ATTRIBUTES, "m", "m", "distance"), ( @@ -1852,6 +1868,13 @@ async def test_list_statistic_ids( @pytest.mark.parametrize( ("attributes", "attributes2", "display_unit", "statistics_unit", "unit_class"), [ + ( + AREA_SENSOR_M_ATTRIBUTES, + AREA_SENSOR_FT_ATTRIBUTES, + "ft²", + "m²", + "area", + ), ( DISTANCE_SENSOR_M_ATTRIBUTES, DISTANCE_SENSOR_FT_ATTRIBUTES, diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index 17612edfd9795e..a4d97ab83fd31b 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -9,10 +9,10 @@ DOMAIN as SELECT_DOMAIN, ) from homeassistant.const import ( - AREA_SQUARE_METERS, ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, EntityCategory, + UnitOfArea, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -38,7 +38,7 @@ async def test_select_entity( entry = entity_registry.async_get("select.genie_room_size") assert entry assert entry.unique_id == f"{diffuser.hublot}-room_size_square_meter" - assert entry.unit_of_measurement == AREA_SQUARE_METERS + assert entry.unit_of_measurement == UnitOfArea.SQUARE_METERS assert entry.entity_category == EntityCategory.CONFIG diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 2504ea80d84437..3893a089b81278 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -30,6 +30,7 @@ PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfArea, UnitOfDataRate, UnitOfEnergy, UnitOfLength, @@ -651,6 +652,34 @@ async def test_custom_unit( "device_class", ), [ + # Area + ( + UnitOfArea.SQUARE_KILOMETERS, + UnitOfArea.SQUARE_MILES, + UnitOfArea.SQUARE_MILES, + 1000, + "1000", + "386", + SensorDeviceClass.AREA, + ), + ( + UnitOfArea.SQUARE_CENTIMETERS, + UnitOfArea.SQUARE_INCHES, + UnitOfArea.SQUARE_INCHES, + 7.24, + "7.24", + "1.12", + SensorDeviceClass.AREA, + ), + ( + UnitOfArea.SQUARE_KILOMETERS, + "peer_distance", + UnitOfArea.SQUARE_KILOMETERS, + 1000, + "1000", + "1000", + SensorDeviceClass.AREA, + ), # Distance ( UnitOfLength.KILOMETERS, @@ -1834,6 +1863,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( [ SensorDeviceClass.APPARENT_POWER, SensorDeviceClass.AQI, + SensorDeviceClass.AREA, SensorDeviceClass.ATMOSPHERIC_PRESSURE, SensorDeviceClass.BATTERY, SensorDeviceClass.CO, diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index aec6ec84f1b032..44eaa9fde0d218 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -227,6 +227,8 @@ async def assert_validation_result( ), [ (None, "%", "%", "%", "unitless", 13.050847, -10, 30), + ("area", "m²", "m²", "m²", "area", 13.050847, -10, 30), + ("area", "mi²", "mi²", "mi²", "area", 13.050847, -10, 30), ("battery", "%", "%", "%", "unitless", 13.050847, -10, 30), ("battery", None, None, None, "unitless", 13.050847, -10, 30), ("distance", "m", "m", "m", "distance", 13.050847, -10, 30), @@ -914,6 +916,8 @@ async def test_compile_hourly_statistics_wrong_unit( "factor", ), [ + (US_CUSTOMARY_SYSTEM, "area", "m²", "m²", "m²", "area", 1), + (US_CUSTOMARY_SYSTEM, "area", "mi²", "mi²", "mi²", "area", 1), (US_CUSTOMARY_SYSTEM, "distance", "m", "m", "m", "distance", 1), (US_CUSTOMARY_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), (US_CUSTOMARY_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), @@ -926,6 +930,8 @@ async def test_compile_hourly_statistics_wrong_unit( (US_CUSTOMARY_SYSTEM, "volume", "ft³", "ft³", "ft³", "volume", 1), (US_CUSTOMARY_SYSTEM, "weight", "g", "g", "g", "mass", 1), (US_CUSTOMARY_SYSTEM, "weight", "oz", "oz", "oz", "mass", 1), + (METRIC_SYSTEM, "area", "m²", "m²", "m²", "area", 1), + (METRIC_SYSTEM, "area", "mi²", "mi²", "mi²", "area", 1), (METRIC_SYSTEM, "distance", "m", "m", "m", "distance", 1), (METRIC_SYSTEM, "distance", "mi", "mi", "mi", "distance", 1), (METRIC_SYSTEM, "energy", "kWh", "kWh", "kWh", "energy", 1), @@ -2228,6 +2234,8 @@ async def test_compile_hourly_energy_statistics_multiple( [ ("battery", "%", 30), ("battery", None, 30), + ("area", "m²", 30), + ("area", "mi²", 30), ("distance", "m", 30), ("distance", "mi", 30), ("humidity", "%", 30), @@ -2336,6 +2344,8 @@ async def test_compile_hourly_statistics_partially_unavailable( [ ("battery", "%", 30), ("battery", None, 30), + ("area", "m²", 30), + ("area", "mi²", 30), ("distance", "m", 30), ("distance", "mi", 30), ("humidity", "%", 30), @@ -2438,6 +2448,10 @@ async def test_compile_hourly_statistics_fails( "statistic_type", ), [ + ("measurement", "area", "m²", "m²", "m²", "area", "mean"), + ("measurement", "area", "mi²", "mi²", "mi²", "area", "mean"), + ("total", "area", "m²", "m²", "m²", "area", "sum"), + ("total", "area", "mi²", "mi²", "mi²", "area", "sum"), ("measurement", "battery", "%", "%", "%", "unitless", "mean"), ("measurement", "battery", None, None, None, "unitless", "mean"), ("measurement", "distance", "m", "m", "m", "distance", "mean"), diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index b8c6b5a25af373..628aea20900ba2 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -24,6 +24,7 @@ ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_UNAVAILABLE, + UnitOfArea, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, @@ -61,6 +62,7 @@ def _set_up_units(hass: HomeAssistant) -> None: hass.config.units = UnitSystem( "custom", accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, diff --git a/tests/test_const.py b/tests/test_const.py index 73636b9910798d..ca598de39e14fd 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -177,18 +177,24 @@ def test_deprecated_constants( @pytest.mark.parametrize( - ("replacement", "constant_name"), + ("replacement", "constant_name", "breaks_in_version"), [ - (const.UnitOfLength.YARDS, "LENGTH_YARD"), - (const.UnitOfSoundPressure.DECIBEL, "SOUND_PRESSURE_DB"), - (const.UnitOfSoundPressure.WEIGHTED_DECIBEL_A, "SOUND_PRESSURE_WEIGHTED_DBA"), - (const.UnitOfVolume.FLUID_OUNCES, "VOLUME_FLUID_OUNCE"), + (const.UnitOfLength.YARDS, "LENGTH_YARD", "2025.1"), + (const.UnitOfSoundPressure.DECIBEL, "SOUND_PRESSURE_DB", "2025.1"), + ( + const.UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + "SOUND_PRESSURE_WEIGHTED_DBA", + "2025.1", + ), + (const.UnitOfVolume.FLUID_OUNCES, "VOLUME_FLUID_OUNCE", "2025.1"), + (const.UnitOfArea.SQUARE_METERS, "AREA_SQUARE_METERS", "2025.12"), ], ) def test_deprecated_constant_name_changes( caplog: pytest.LogCaptureFixture, replacement: Enum, constant_name: str, + breaks_in_version: str, ) -> None: """Test deprecated constants, where the name is not the same as the enum value.""" import_and_test_deprecated_constant( @@ -197,7 +203,7 @@ def test_deprecated_constant_name_changes( constant_name, f"{replacement.__class__.__name__}.{replacement.name}", replacement, - "2025.1", + breaks_in_version, ) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index c2c05e76ab5a42..60144f817149d9 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -11,6 +11,7 @@ CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + UnitOfArea, UnitOfBloodGlucoseConcentration, UnitOfConductivity, UnitOfDataRate, @@ -32,6 +33,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.util import unit_conversion from homeassistant.util.unit_conversion import ( + AreaConverter, BaseUnitConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, @@ -61,6 +63,7 @@ _ALL_CONVERTERS: dict[type[BaseUnitConverter], list[str | None]] = { converter: sorted(converter.VALID_UNITS, key=lambda x: (x is None, x)) for converter in ( + AreaConverter, BloodGlucoseConcentrationConverter, ConductivityConverter, DataRateConverter, @@ -83,6 +86,7 @@ # Dict containing all converters with a corresponding unit ratio. _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, float]] = { + AreaConverter: (UnitOfArea.SQUARE_KILOMETERS, UnitOfArea.SQUARE_METERS, 0.000001), BloodGlucoseConcentrationConverter: ( UnitOfBloodGlucoseConcentration.MILLIGRAMS_PER_DECILITER, UnitOfBloodGlucoseConcentration.MILLIMOLE_PER_LITER, @@ -138,6 +142,62 @@ _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { + AreaConverter: [ + # Square Meters to other units + (5, UnitOfArea.SQUARE_METERS, 50000, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.SQUARE_METERS, 5000000, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.SQUARE_METERS, 0.000005, UnitOfArea.SQUARE_KILOMETERS), + (5, UnitOfArea.SQUARE_METERS, 7750.015500031001, UnitOfArea.SQUARE_INCHES), + (5, UnitOfArea.SQUARE_METERS, 53.81955, UnitOfArea.SQUARE_FEET), + (5, UnitOfArea.SQUARE_METERS, 5.979950231505403, UnitOfArea.SQUARE_YARDS), + (5, UnitOfArea.SQUARE_METERS, 1.9305107927122295e-06, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.SQUARE_METERS, 0.0012355269073358272, UnitOfArea.ACRES), + (5, UnitOfArea.SQUARE_METERS, 0.0005, UnitOfArea.HECTARES), + # Square Kilometers to other units + (1, UnitOfArea.SQUARE_KILOMETERS, 1000000, UnitOfArea.SQUARE_METERS), + (1, UnitOfArea.SQUARE_KILOMETERS, 1e10, UnitOfArea.SQUARE_CENTIMETERS), + (1, UnitOfArea.SQUARE_KILOMETERS, 1e12, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.SQUARE_KILOMETERS, 1.9305107927122296, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.SQUARE_KILOMETERS, 1235.5269073358272, UnitOfArea.ACRES), + (5, UnitOfArea.SQUARE_KILOMETERS, 500, UnitOfArea.HECTARES), + # Acres to other units + (5, UnitOfArea.ACRES, 20234.3, UnitOfArea.SQUARE_METERS), + (5, UnitOfArea.ACRES, 202342821.11999995, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.ACRES, 20234282111.999992, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.ACRES, 0.0202343, UnitOfArea.SQUARE_KILOMETERS), + (5, UnitOfArea.ACRES, 217800, UnitOfArea.SQUARE_FEET), + (5, UnitOfArea.ACRES, 24200.0, UnitOfArea.SQUARE_YARDS), + (5, UnitOfArea.ACRES, 0.0078125, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.ACRES, 2.02343, UnitOfArea.HECTARES), + # Hectares to other units + (5, UnitOfArea.HECTARES, 50000, UnitOfArea.SQUARE_METERS), + (5, UnitOfArea.HECTARES, 500000000, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.HECTARES, 50000000000.0, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.HECTARES, 0.019305107927122298, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.HECTARES, 538195.5, UnitOfArea.SQUARE_FEET), + (5, UnitOfArea.HECTARES, 59799.50231505403, UnitOfArea.SQUARE_YARDS), + (5, UnitOfArea.HECTARES, 12.355269073358272, UnitOfArea.ACRES), + # Square Miles to other units + (5, UnitOfArea.SQUARE_MILES, 12949940.551679997, UnitOfArea.SQUARE_METERS), + (5, UnitOfArea.SQUARE_MILES, 129499405516.79997, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.SQUARE_MILES, 12949940551679.996, UnitOfArea.SQUARE_MILLIMETERS), + (5, UnitOfArea.SQUARE_MILES, 1294.9940551679997, UnitOfArea.HECTARES), + (5, UnitOfArea.SQUARE_MILES, 3200, UnitOfArea.ACRES), + # Square Yards to other units + (5, UnitOfArea.SQUARE_YARDS, 4.1806367999999985, UnitOfArea.SQUARE_METERS), + (5, UnitOfArea.SQUARE_YARDS, 41806.4, UnitOfArea.SQUARE_CENTIMETERS), + (5, UnitOfArea.SQUARE_YARDS, 4180636.7999999984, UnitOfArea.SQUARE_MILLIMETERS), + ( + 5, + UnitOfArea.SQUARE_YARDS, + 4.180636799999998e-06, + UnitOfArea.SQUARE_KILOMETERS, + ), + (5, UnitOfArea.SQUARE_YARDS, 45.0, UnitOfArea.SQUARE_FEET), + (5, UnitOfArea.SQUARE_YARDS, 6479.999999999998, UnitOfArea.SQUARE_INCHES), + (5, UnitOfArea.SQUARE_YARDS, 1.6141528925619832e-06, UnitOfArea.SQUARE_MILES), + (5, UnitOfArea.SQUARE_YARDS, 0.0010330578512396695, UnitOfArea.ACRES), + ], BloodGlucoseConcentrationConverter: [ ( 90, diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index c08555840bb073..b2c604acbcf6af 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -7,12 +7,14 @@ from homeassistant.components.sensor import DEVICE_CLASS_UNITS, SensorDeviceClass from homeassistant.const import ( ACCUMULATED_PRECIPITATION, + AREA, LENGTH, MASS, PRESSURE, TEMPERATURE, VOLUME, WIND_SPEED, + UnitOfArea, UnitOfLength, UnitOfMass, UnitOfPrecipitationDepth, @@ -44,6 +46,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -57,6 +60,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=INVALID_UNIT, mass=UnitOfMass.GRAMS, @@ -70,6 +74,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -83,6 +88,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -96,6 +102,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=INVALID_UNIT, @@ -109,6 +116,7 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=UnitOfArea.SQUARE_METERS, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -122,6 +130,21 @@ def test_invalid_units() -> None: UnitSystem( SYSTEM_NAME, accumulated_precipitation=INVALID_UNIT, + area=UnitOfArea.SQUARE_METERS, + conversions={}, + length=UnitOfLength.METERS, + mass=UnitOfMass.GRAMS, + pressure=UnitOfPressure.PA, + temperature=UnitOfTemperature.CELSIUS, + volume=UnitOfVolume.LITERS, + wind_speed=UnitOfSpeed.METERS_PER_SECOND, + ) + + with pytest.raises(ValueError): + UnitSystem( + SYSTEM_NAME, + accumulated_precipitation=UnitOfPrecipitationDepth.MILLIMETERS, + area=INVALID_UNIT, conversions={}, length=UnitOfLength.METERS, mass=UnitOfMass.GRAMS, @@ -146,6 +169,8 @@ def test_invalid_value() -> None: METRIC_SYSTEM.pressure("50Pa", UnitOfPressure.PA) with pytest.raises(TypeError): METRIC_SYSTEM.accumulated_precipitation("50mm", UnitOfLength.MILLIMETERS) + with pytest.raises(TypeError): + METRIC_SYSTEM.area("2m²", UnitOfArea.SQUARE_METERS) def test_as_dict() -> None: @@ -158,6 +183,7 @@ def test_as_dict() -> None: MASS: UnitOfMass.GRAMS, PRESSURE: UnitOfPressure.PA, ACCUMULATED_PRECIPITATION: UnitOfLength.MILLIMETERS, + AREA: UnitOfArea.SQUARE_METERS, } assert expected == METRIC_SYSTEM.as_dict() @@ -303,6 +329,29 @@ def test_accumulated_precipitation_to_imperial() -> None: ) == pytest.approx(10, abs=1e-4) +def test_area_same_unit() -> None: + """Test no conversion happens if to unit is same as from unit.""" + assert METRIC_SYSTEM.area(5, METRIC_SYSTEM.area_unit) == 5 + + +def test_area_unknown_unit() -> None: + """Test no conversion happens if unknown unit.""" + with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): + METRIC_SYSTEM.area(5, "abc") + + +def test_area_to_metric() -> None: + """Test area conversion to metric system.""" + assert METRIC_SYSTEM.area(25, METRIC_SYSTEM.area_unit) == 25 + assert round(METRIC_SYSTEM.area(10, IMPERIAL_SYSTEM.area_unit), 1) == 0.9 + + +def test_area_to_imperial() -> None: + """Test area conversion to imperial system.""" + assert IMPERIAL_SYSTEM.area(77, IMPERIAL_SYSTEM.area_unit) == 77 + assert IMPERIAL_SYSTEM.area(25, METRIC_SYSTEM.area_unit) == 269.09776041774313 + + def test_properties() -> None: """Test the unit properties are returned as expected.""" assert METRIC_SYSTEM.length_unit == UnitOfLength.KILOMETERS @@ -312,6 +361,7 @@ def test_properties() -> None: assert METRIC_SYSTEM.volume_unit == UnitOfVolume.LITERS assert METRIC_SYSTEM.pressure_unit == UnitOfPressure.PA assert METRIC_SYSTEM.accumulated_precipitation_unit == UnitOfLength.MILLIMETERS + assert METRIC_SYSTEM.area_unit == UnitOfArea.SQUARE_METERS @pytest.mark.parametrize( @@ -338,6 +388,18 @@ def test_get_unit_system_invalid(key: str) -> None: @pytest.mark.parametrize( ("device_class", "original_unit", "state_unit"), [ + # Test area conversion + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_FEET, UnitOfArea.SQUARE_METERS), + ( + SensorDeviceClass.AREA, + UnitOfArea.SQUARE_INCHES, + UnitOfArea.SQUARE_CENTIMETERS, + ), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_MILES, UnitOfArea.SQUARE_KILOMETERS), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_YARDS, UnitOfArea.SQUARE_METERS), + (SensorDeviceClass.AREA, UnitOfArea.ACRES, UnitOfArea.HECTARES), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_KILOMETERS, None), + (SensorDeviceClass.AREA, "very_long", None), # Test atmospheric pressure ( SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -495,6 +557,13 @@ def test_get_metric_converted_unit_( UNCONVERTED_UNITS_METRIC_SYSTEM = { + SensorDeviceClass.AREA: ( + UnitOfArea.SQUARE_MILLIMETERS, + UnitOfArea.SQUARE_CENTIMETERS, + UnitOfArea.SQUARE_METERS, + UnitOfArea.SQUARE_KILOMETERS, + UnitOfArea.HECTARES, + ), SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.HPA,), SensorDeviceClass.DISTANCE: ( UnitOfLength.CENTIMETERS, @@ -544,6 +613,7 @@ def test_get_metric_converted_unit_( @pytest.mark.parametrize( "device_class", [ + SensorDeviceClass.AREA, SensorDeviceClass.ATMOSPHERIC_PRESSURE, SensorDeviceClass.DISTANCE, SensorDeviceClass.GAS, @@ -572,6 +642,21 @@ def test_metric_converted_units(device_class: SensorDeviceClass) -> None: @pytest.mark.parametrize( ("device_class", "original_unit", "state_unit"), [ + # Test area conversion + ( + SensorDeviceClass.AREA, + UnitOfArea.SQUARE_MILLIMETERS, + UnitOfArea.SQUARE_INCHES, + ), + ( + SensorDeviceClass.AREA, + UnitOfArea.SQUARE_CENTIMETERS, + UnitOfArea.SQUARE_INCHES, + ), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_METERS, UnitOfArea.SQUARE_FEET), + (SensorDeviceClass.AREA, UnitOfArea.SQUARE_KILOMETERS, UnitOfArea.SQUARE_MILES), + (SensorDeviceClass.AREA, UnitOfArea.HECTARES, UnitOfArea.ACRES), + (SensorDeviceClass.AREA, "very_area", None), # Test atmospheric pressure ( SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -721,6 +806,13 @@ def test_get_us_converted_unit( UNCONVERTED_UNITS_US_SYSTEM = { + SensorDeviceClass.AREA: ( + UnitOfArea.SQUARE_FEET, + UnitOfArea.SQUARE_INCHES, + UnitOfArea.SQUARE_MILES, + UnitOfArea.SQUARE_YARDS, + UnitOfArea.ACRES, + ), SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.INHG,), SensorDeviceClass.DISTANCE: ( UnitOfLength.FEET,