diff --git a/homeassistant/generated/device_classes.json b/homeassistant/generated/device_classes.json new file mode 100644 index 000000000000..bc96db3197a7 --- /dev/null +++ b/homeassistant/generated/device_classes.json @@ -0,0 +1,210 @@ +{ + "device_classes": { + "binary_sensor": [ + "battery", + "battery_charging", + "carbon_monoxide", + "cold", + "connectivity", + "door", + "garage_door", + "gas", + "heat", + "light", + "lock", + "moisture", + "motion", + "moving", + "occupancy", + "opening", + "plug", + "power", + "presence", + "problem", + "running", + "safety", + "smoke", + "sound", + "tamper", + "update", + "vibration", + "window" + ], + "button": [ + "identify", + "restart", + "update" + ], + "cover": [ + "awning", + "blind", + "curtain", + "damper", + "door", + "garage", + "gate", + "shade", + "shutter", + "window" + ], + "event": [ + "button", + "doorbell", + "motion" + ], + "humidifier": [ + "dehumidifier", + "humidifier" + ], + "image_processing": [ + "alpr", + "face", + "ocr" + ], + "infrared": [ + "emitter", + "receiver" + ], + "media_player": [ + "projector", + "receiver", + "speaker", + "tv" + ], + "number": [ + "absolute_humidity", + "apparent_power", + "aqi", + "area", + "atmospheric_pressure", + "battery", + "blood_glucose_concentration", + "carbon_dioxide", + "carbon_monoxide", + "conductivity", + "current", + "data_rate", + "data_size", + "distance", + "duration", + "energy", + "energy_distance", + "energy_storage", + "frequency", + "gas", + "humidity", + "illuminance", + "irradiance", + "moisture", + "monetary", + "nitrogen_dioxide", + "nitrogen_monoxide", + "nitrous_oxide", + "ozone", + "ph", + "pm1", + "pm10", + "pm25", + "pm4", + "power", + "power_factor", + "precipitation", + "precipitation_intensity", + "pressure", + "radon", + "reactive_energy", + "reactive_power", + "signal_strength", + "sound_pressure", + "speed", + "sulphur_dioxide", + "temperature", + "temperature_delta", + "volatile_organic_compounds", + "volatile_organic_compounds_parts", + "voltage", + "volume", + "volume_flow_rate", + "volume_storage", + "water", + "weight", + "wind_direction", + "wind_speed" + ], + "sensor": [ + "absolute_humidity", + "apparent_power", + "aqi", + "area", + "atmospheric_pressure", + "battery", + "blood_glucose_concentration", + "carbon_dioxide", + "carbon_monoxide", + "conductivity", + "current", + "data_rate", + "data_size", + "date", + "distance", + "duration", + "energy", + "energy_distance", + "energy_storage", + "enum", + "frequency", + "gas", + "humidity", + "illuminance", + "irradiance", + "moisture", + "monetary", + "nitrogen_dioxide", + "nitrogen_monoxide", + "nitrous_oxide", + "ozone", + "ph", + "pm1", + "pm10", + "pm25", + "pm4", + "power", + "power_factor", + "precipitation", + "precipitation_intensity", + "pressure", + "radon", + "reactive_energy", + "reactive_power", + "signal_strength", + "sound_pressure", + "speed", + "sulphur_dioxide", + "temperature", + "temperature_delta", + "timestamp", + "uptime", + "volatile_organic_compounds", + "volatile_organic_compounds_parts", + "voltage", + "volume", + "volume_flow_rate", + "volume_storage", + "water", + "weight", + "wind_direction", + "wind_speed" + ], + "switch": [ + "outlet", + "switch" + ], + "update": [ + "firmware" + ], + "valve": [ + "gas", + "water" + ] + } +} diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 4601f15defec..49ffb1f71112 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -15,6 +15,7 @@ from . import ( config_schema, core_files, dependencies, + device_classes, dhcp, docker, icons, @@ -66,6 +67,7 @@ INTEGRATION_PLUGINS = [ ] HASS_PLUGINS = [ core_files, + device_classes, docker, mdi_icons, mypy_config, diff --git a/script/hassfest/device_classes.py b/script/hassfest/device_classes.py new file mode 100644 index 000000000000..ff695f81e819 --- /dev/null +++ b/script/hassfest/device_classes.py @@ -0,0 +1,91 @@ +"""Generate the device_classes.json file.""" + +import json +import re + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.button import ButtonDeviceClass +from homeassistant.components.cover.const import CoverDeviceClass +from homeassistant.components.event import EventDeviceClass +from homeassistant.components.humidifier import HumidifierDeviceClass +from homeassistant.components.image_processing import ImageProcessingDeviceClass +from homeassistant.components.infrared.entity import InfraredDeviceClass +from homeassistant.components.media_player import MediaPlayerDeviceClass +from homeassistant.components.number.const import NumberDeviceClass +from homeassistant.components.sensor.const import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass +from homeassistant.components.update import UpdateDeviceClass +from homeassistant.components.valve.const import ValveDeviceClass + +from .model import Config, Integration + +PATH = "homeassistant/generated/device_classes.json" + +DEVICE_CLASS_ENUMS = { + "binary_sensor": BinarySensorDeviceClass, + "button": ButtonDeviceClass, + "cover": CoverDeviceClass, + "event": EventDeviceClass, + "humidifier": HumidifierDeviceClass, + "image_processing": ImageProcessingDeviceClass, + "infrared": InfraredDeviceClass, + "media_player": MediaPlayerDeviceClass, + "number": NumberDeviceClass, + "sensor": SensorDeviceClass, + "switch": SwitchDeviceClass, + "update": UpdateDeviceClass, + "valve": ValveDeviceClass, +} + +DEVICE_CLASS_ENUM_DEFINITION = re.compile( + r"^class \w*DeviceClass\(StrEnum\)", re.MULTILINE +) + + +def find_undeclared_domains(integrations: dict[str, Integration]) -> set[str]: + """Return entity domains defining a device class enum but missing above.""" + return { + domain + for domain, integration in integrations.items() + if domain not in DEVICE_CLASS_ENUMS + and integration.manifest.get("integration_type") == "entity" + and any( + DEVICE_CLASS_ENUM_DEFINITION.search(path.read_text(encoding="utf-8")) + for path in integration.path.rglob("*.py") + ) + } + + +def _generate() -> str: + """Generate the device class data.""" + device_classes = { + domain: sorted(device_class.value for device_class in enum) + for domain, enum in sorted(DEVICE_CLASS_ENUMS.items()) + } + return json.dumps({"device_classes": device_classes}, indent=2) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Validate device_classes.json.""" + if undeclared := find_undeclared_domains(integrations): + config.add_error( + "device_classes", + f"Add {', '.join(sorted(undeclared))} to DEVICE_CLASS_ENUMS in" + " script/hassfest/device_classes.py", + ) + + path = config.root / PATH + config.cache["device_classes"] = content = _generate() + + if path.read_text() != content + "\n": + config.add_error( + "device_classes", + "File device_classes.json is not up to date. Run python3 -m script.hassfest", + fixable=True, + ) + + +def generate(integrations: dict[str, Integration], config: Config) -> None: + """Generate device_classes.json.""" + path = config.root / PATH + path.write_text(f"{config.cache['device_classes']}\n") diff --git a/tests/hassfest/test_device_classes.py b/tests/hassfest/test_device_classes.py new file mode 100644 index 000000000000..4f797e75eb2d --- /dev/null +++ b/tests/hassfest/test_device_classes.py @@ -0,0 +1,49 @@ +"""Tests for hassfest device_classes generation.""" + +import json +from pathlib import Path + +import pytest + +from script.hassfest.device_classes import ( + DEVICE_CLASS_ENUMS, + PATH, + find_undeclared_domains, +) +from script.hassfest.model import Config, Integration + + +@pytest.fixture +def entity_integrations(config: Config) -> dict[str, Integration]: + """Return the entity integrations of the repository.""" + integrations = Integration.load_dir(config.core_integrations_path, config) + return { + domain: integration + for domain, integration in integrations.items() + if integration.manifest.get("integration_type") == "entity" + } + + +def test_every_domain_is_declared(entity_integrations: dict[str, Integration]) -> None: + """Test no domain defining a device class enum is missing from the generator.""" + assert find_undeclared_domains(entity_integrations) == set() + + +def test_undeclared_domain_is_reported( + entity_integrations: dict[str, Integration], +) -> None: + """Test a domain missing from the generator is reported.""" + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.delitem(DEVICE_CLASS_ENUMS, "infrared") + + assert find_undeclared_domains(entity_integrations) == {"infrared"} + + +def test_generated_file_matches_the_declared_enums() -> None: + """Test the generated file is in sync with the declared enums.""" + device_classes = json.loads(Path(PATH).read_text(encoding="utf-8")) + + assert device_classes["device_classes"] == { + domain: sorted(device_class.value for device_class in enum) + for domain, enum in sorted(DEVICE_CLASS_ENUMS.items()) + }