diff --git a/.idea/custom_homematic.iml b/.idea/custom_homematic.iml index 8b8c3954..c54ed29b 100644 --- a/.idea/custom_homematic.iml +++ b/.idea/custom_homematic.iml @@ -1,7 +1,9 @@ - + + + diff --git a/custom_components/changelog.txt b/custom_components/changelog.txt index 9e994390..c7d1e3bb 100644 --- a/custom_components/changelog.txt +++ b/custom_components/changelog.txt @@ -1,3 +1,9 @@ +Version 0.1.0 (2021-12-XX) +- Bump version to 0.1.0 +- Update EntityDescriptions +- Add initial tests for config_flow +- Add Sensor Descriptions + Version 0.0.22.2 (2021-12-16) - Add DE translation - Update NL translation diff --git a/custom_components/hahm/__init__.py b/custom_components/hahm/__init__.py index 0dac4c4d..17045874 100644 --- a/custom_components/hahm/__init__.py +++ b/custom_components/hahm/__init__.py @@ -1,6 +1,6 @@ """ hahomematic is a Python 3 module for Home Assistant to interact with -HomeMatic and homematic IP devices. +Homematic and Homematic IP devices. https://github.com/danielperna84/hahomematic """ from __future__ import annotations diff --git a/custom_components/hahm/control_unit.py b/custom_components/hahm/control_unit.py index 36ccfde9..08d4e418 100644 --- a/custom_components/hahm/control_unit.py +++ b/custom_components/hahm/control_unit.py @@ -111,7 +111,7 @@ async def init_hub(self) -> None: await self._central.init_hub() if not self._central.hub: return None - self._hub = HaHub(self._hass, cu=self, hm_hub=self._central.hub) + self._hub = HaHub(self._hass, control_unit=self, hm_hub=self._central.hub) await self._hub.init() hm_entities = ( [self._central.hub.hub_entities.values()] if self._central.hub else [] @@ -377,13 +377,13 @@ class HaHub(Entity): """The HomeMatic hub. (CCU2/HomeGear).""" def __init__( - self, hass: HomeAssistant, cu: ControlUnit, hm_hub: HmHub | HmDummyHub + self, hass: HomeAssistant, control_unit: ControlUnit, hm_hub: HmHub | HmDummyHub ) -> None: """Initialize HomeMatic hub.""" self.hass = hass - self._cu: ControlUnit = cu + self._control: ControlUnit = control_unit self._hm_hub: HmHub | HmDummyHub = hm_hub - self._name: str = self._cu.central.instance_name + self._name: str = self._control.central.instance_name self.entity_id = f"{DOMAIN}.{slugify(self._name.lower())}" self._hm_hub.register_update_callback(self._update_hub) diff --git a/custom_components/hahm/entity_helpers.py b/custom_components/hahm/entity_helpers.py index 1632bc5e..5c7a5e11 100644 --- a/custom_components/hahm/entity_helpers.py +++ b/custom_components/hahm/entity_helpers.py @@ -36,6 +36,7 @@ SIGNAL_STRENGTH_DECIBELS, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + TIME_MINUTES, VOLUME_CUBIC_METERS, ) from homeassistant.helpers.entity import EntityDescription @@ -44,22 +45,6 @@ _LOGGER = logging.getLogger(__name__) -HM_STATE_HA_CAST = { - "IPGarage": {0: "closed", 1: "open", 2: "ventilation", 3: None}, - "RotaryHandleSensor": {0: "closed", 1: "tilted", 2: "open"}, - "RotaryHandleSensorIP": {0: "closed", 1: "tilted", 2: "open"}, - "WaterSensor": {0: "dry", 1: "wet", 2: "water"}, - "CO2Sensor": {0: "normal", 1: "added", 2: "strong"}, - "IPSmoke": {0: "off", 1: "primary", 2: "intrusion", 3: "secondary"}, - "RFSiren": { - 0: "disarmed", - 1: "extsens_armed", - 2: "allsens_armed", - 3: "alarm_blocked", - }, - "IPLockDLD": {0: None, 1: "locked", 2: "unlocked"}, -} - _SENSOR_DESCRIPTIONS_BY_PARAM: dict[str, SensorEntityDescription] = { "HUMIDITY": SensorEntityDescription( @@ -180,23 +165,32 @@ "RAIN_COUNTER": SensorEntityDescription( key="RAIN_COUNTER", native_unit_of_measurement=LENGTH_MILLIMETERS, + icon="mdi:weather-rainy", + state_class=SensorStateClass.TOTAL_INCREASING, ), "WIND_SPEED": SensorEntityDescription( key="WIND_SPEED", native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, icon="mdi:weather-windy", + state_class=SensorStateClass.MEASUREMENT, ), "WIND_DIR": SensorEntityDescription( key="WIND_DIR", native_unit_of_measurement=DEGREE, + icon="mdi:windsock", + state_class=SensorStateClass.MEASUREMENT, ), "WIND_DIR_RANGE": SensorEntityDescription( key="WIND_DIR_RANGE", native_unit_of_measurement=DEGREE, + icon="mdi:windsock", + state_class=SensorStateClass.MEASUREMENT, ), "SUNSHINEDURATION": SensorEntityDescription( key="SUNSHINEDURATION", - native_unit_of_measurement="#", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:weather-sunny", + state_class=SensorStateClass.TOTAL_INCREASING, ), "AIR_PRESSURE": SensorEntityDescription( key="AIR_PRESSURE", @@ -207,42 +201,69 @@ "FREQUENCY": SensorEntityDescription( key="FREQUENCY", native_unit_of_measurement=FREQUENCY_HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + ), + "LEVEL": SensorEntityDescription( + key="LEVEL", + native_unit_of_measurement="#", + state_class=SensorStateClass.MEASUREMENT, ), "VALUE": SensorEntityDescription( key="VALUE", native_unit_of_measurement="#", + state_class=SensorStateClass.MEASUREMENT, ), "VALVE_STATE": SensorEntityDescription( key="VALVE_STATE", native_unit_of_measurement=PERCENTAGE, + icon="mdi:pipe-valve", + state_class=SensorStateClass.MEASUREMENT, ), "CARRIER_SENSE_LEVEL": SensorEntityDescription( key="CARRIER_SENSE_LEVEL", native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), "DUTY_CYCLE_LEVEL": SensorEntityDescription( key="DUTY_CYCLE_LEVEL", native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), "BRIGHTNESS": SensorEntityDescription( key="BRIGHTNESS", native_unit_of_measurement="#", + state_class=SensorStateClass.MEASUREMENT, icon="mdi:invert-colors", ), "RSSI_DEVICE": SensorEntityDescription( key="RSSI_DEVICE", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + state_class=SensorStateClass.MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), "RSSI_PEER": SensorEntityDescription( key="RSSI_PEER", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + state_class=SensorStateClass.MEASUREMENT, entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), "IP_ADDRESS": SensorEntityDescription( key="IP_ADDRESS", entity_category=ENTITY_CATEGORY_DIAGNOSTIC, ), + "SMOKE_DETECTOR_ALARM_STATUS": SensorEntityDescription( + key="SMOKE_DETECTOR_ALARM_STATUS", + icon="mdi:smoke-detector", + device_class="hahm__smoke_detector_alarm_status", + ), + "LOCK_STATE": SensorEntityDescription( + key="LOCK_STATE", + icon="mdi:lock", + device_class="hahm__lock_state", + ), } _SENSOR_DESCRIPTIONS_BY_DEVICE_PARAM: dict[tuple[str, str], SensorEntityDescription] = { diff --git a/custom_components/hahm/manifest.json b/custom_components/hahm/manifest.json index cb062a3b..475e0022 100644 --- a/custom_components/hahm/manifest.json +++ b/custom_components/hahm/manifest.json @@ -4,12 +4,12 @@ "config_flow": true, "documentation": "https://github.com/danielperna84/custom_homematic", "issue_tracker": "https://github.com/danielperna84/custom_homematic/issues", - "requirements": ["hahomematic==0.0.22"], + "requirements": ["hahomematic==0.1.0"], "ssdp": [], "zeroconf": [], "homekit": {}, "dependencies": [], "codeowners": ["@danielperna84", "@SukramJ"], "iot_class": "local_push", - "version": "0.0.22.2" + "version": "0.1.0" } diff --git a/custom_components/hahm/strings.sensor.json b/custom_components/hahm/strings.sensor.json index 9c84afaf..1416f56e 100644 --- a/custom_components/hahm/strings.sensor.json +++ b/custom_components/hahm/strings.sensor.json @@ -4,6 +4,17 @@ "CLOSED": "Closed", "TILTED": "Tilted", "OPEN": "Open" + }, + "hahm__lock_state": { + "UNKNOWN": "Unknown", + "LOCKED": "Locked", + "UNLOCKED": "Unlocked" + }, + "hahm__smoke_detector_alarm_status": { + "IDLE_OFF": "idle off", + "PRIMARY_ALARM": "Primary Alarm", + "INTRUSION_ALARM": "Intrusion Alarm", + "SECONDARY_ALARM": "Secondary Alarm" } } } \ No newline at end of file diff --git a/custom_components/hahm/translations/sensor.de.json b/custom_components/hahm/translations/sensor.de.json index fd1e6ce6..1d2d3d17 100644 --- a/custom_components/hahm/translations/sensor.de.json +++ b/custom_components/hahm/translations/sensor.de.json @@ -4,6 +4,17 @@ "CLOSED": "Geschlossen", "TILTED": "Gekippt", "OPEN": "Offen" + }, + "hahm__lock_state": { + "UNKNOWN": "Unbekannt", + "LOCKED": "Verriegelt", + "UNLOCKED": "Unverriegelt" + }, + "hahm__smoke_detector_alarm_status": { + "IDLE_OFF": "untätig", + "PRIMARY_ALARM": "Primär-Alarm", + "INTRUSION_ALARM": "Einbruch-Alarm", + "SECONDARY_ALARM": "Sekundär-Alarm" } } } \ No newline at end of file diff --git a/custom_components/hahm/translations/sensor.en.json b/custom_components/hahm/translations/sensor.en.json index 9c84afaf..1416f56e 100644 --- a/custom_components/hahm/translations/sensor.en.json +++ b/custom_components/hahm/translations/sensor.en.json @@ -4,6 +4,17 @@ "CLOSED": "Closed", "TILTED": "Tilted", "OPEN": "Open" + }, + "hahm__lock_state": { + "UNKNOWN": "Unknown", + "LOCKED": "Locked", + "UNLOCKED": "Unlocked" + }, + "hahm__smoke_detector_alarm_status": { + "IDLE_OFF": "idle off", + "PRIMARY_ALARM": "Primary Alarm", + "INTRUSION_ALARM": "Intrusion Alarm", + "SECONDARY_ALARM": "Secondary Alarm" } } } \ No newline at end of file diff --git a/custom_components/hahm/translations/sensor.nl.json b/custom_components/hahm/translations/sensor.nl.json index b9ad9d97..3a32d1a4 100644 --- a/custom_components/hahm/translations/sensor.nl.json +++ b/custom_components/hahm/translations/sensor.nl.json @@ -4,6 +4,17 @@ "CLOSED": "Gesloten", "TILTED": "Gekanteld", "OPEN": "Geopend" + }, + "hahm__lock_state": { + "UNKNOWN": "Unknown", + "LOCKED": "Locked", + "UNLOCKED": "Unlocked" + }, + "hahm__smoke_detector_alarm_status": { + "IDLE_OFF": "idle off", + "PRIMARY_ALARM": "Primary Alarm", + "INTRUSION_ALARM": "Intrusion Alarm", + "SECONDARY_ALARM": "Secondary Alarm" } } } \ No newline at end of file diff --git a/tests/hahm/__init__.py b/tests/hahm/__init__.py new file mode 100644 index 00000000..74df806e --- /dev/null +++ b/tests/hahm/__init__.py @@ -0,0 +1,5 @@ +""" +hahomematic is a Python 3 module for Home Assistant to interact with +Homematic and Homematic IP devices. +https://github.com/danielperna84/hahomematic +""" diff --git a/tests/hahm/test_config_flow.py b/tests/hahm/test_config_flow.py new file mode 100644 index 00000000..b29ea8bb --- /dev/null +++ b/tests/hahm/test_config_flow.py @@ -0,0 +1,243 @@ +"""Test the HaHomematic config flow.""" +from typing import Any +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.hahm.config_flow import ( + ATTR_BICDOS_RF_ENABLED, + ATTR_BICDOS_RF_PORT, + ATTR_HMIP_RF_ENABLED, + ATTR_HOST, + ATTR_HS485D_ENABLED, + ATTR_INSTANCE_NAME, + ATTR_PASSWORD, + ATTR_PORT, + ATTR_TLS, + ATTR_USERNAME, + ATTR_VIRTUAL_DEVICES_ENABLED, + IF_BIDCOS_RF_NAME, + IF_HMIP_RF_NAME, + IF_HS485D_NAME, + IF_VIRTUAL_DEVICES_NAME, + CannotConnect, + InvalidAuth, +) +from homeassistant.components.hahm.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +TEST_INSTANCE_NAME = "pytest" +TEST_HOST = "1.1.1.1" +TEST_USERNAME = "test-username" +TEST_PASSWORD = "test-password" + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + interface = await check_form(hass, interface_data={}) + + if_hmip_rf = interface[IF_HMIP_RF_NAME] + assert if_hmip_rf[ATTR_PORT] == 2010 + if_bidcos_rf = interface[IF_BIDCOS_RF_NAME] + assert if_bidcos_rf[ATTR_PORT] == 2001 + if_virtual_devices = interface[IF_VIRTUAL_DEVICES_NAME] + assert if_virtual_devices[ATTR_PORT] == 9292 + assert interface.get(IF_HS485D_NAME) is None + + +async def test_form_no_hmip_other_bidcos_port(hass: HomeAssistant) -> None: + """Test we get the form.""" + interface_data = {ATTR_HMIP_RF_ENABLED: False, ATTR_BICDOS_RF_PORT: 5555} + interface = await check_form(hass, interface_data=interface_data) + + assert interface.get(IF_HMIP_RF_NAME) is None + if_bidcos_rf = interface[IF_BIDCOS_RF_NAME] + assert if_bidcos_rf[ATTR_PORT] == 5555 + if_virtual_devices = interface[IF_VIRTUAL_DEVICES_NAME] + assert if_virtual_devices[ATTR_PORT] == 9292 + assert interface.get(IF_HS485D_NAME) is None + + +async def test_form_only_hs485(hass: HomeAssistant) -> None: + """Test we get the form.""" + interface_data = { + ATTR_HMIP_RF_ENABLED: False, + ATTR_BICDOS_RF_ENABLED: False, + ATTR_VIRTUAL_DEVICES_ENABLED: False, + ATTR_HS485D_ENABLED: True, + } + interface = await check_form(hass, interface_data=interface_data) + + assert interface.get(IF_HMIP_RF_NAME) is None + assert interface.get(IF_BIDCOS_RF_NAME) is None + assert interface.get(IF_VIRTUAL_DEVICES_NAME) is None + + if_hs485d = interface[IF_HS485D_NAME] + assert if_hs485d[ATTR_PORT] == 2000 + + +async def test_form_tls(hass: HomeAssistant) -> None: + """Test we get the form with tls.""" + interface = await check_form(hass, interface_data={}, tls=True) + + if_hmip_rf = interface[IF_HMIP_RF_NAME] + assert if_hmip_rf[ATTR_PORT] == 42010 + if_bidcos_rf = interface[IF_BIDCOS_RF_NAME] + assert if_bidcos_rf[ATTR_PORT] == 42001 + if_virtual_devices = interface[IF_VIRTUAL_DEVICES_NAME] + assert if_virtual_devices[ATTR_PORT] == 49292 + assert interface.get(IF_HS485D_NAME) is None + + +async def check_form( + hass: HomeAssistant, interface_data: dict[str, Any], tls: bool = False +) -> dict[str, Any]: + """Test we get the form.""" + if interface_data is None: + interface_data = {} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.hahm.config_flow.validate_input", + return_value=True, + ), patch( + "homeassistant.components.hahm.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + ATTR_INSTANCE_NAME: TEST_INSTANCE_NAME, + ATTR_HOST: TEST_HOST, + ATTR_USERNAME: TEST_USERNAME, + ATTR_PASSWORD: TEST_PASSWORD, + ATTR_TLS: tls, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["handler"] == DOMAIN + assert result2["step_id"] == "interface" + + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert flow["context"]["unique_id"] == "pytest" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + interface_data, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["handler"] == DOMAIN + assert result3["title"] == TEST_INSTANCE_NAME + data = result3["data"] + assert data[ATTR_INSTANCE_NAME] == TEST_INSTANCE_NAME + assert data[ATTR_HOST] == TEST_HOST + assert data[ATTR_USERNAME] == TEST_USERNAME + assert data[ATTR_PASSWORD] == TEST_PASSWORD + return data["interface"] + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.hahm.config_flow.validate_input", + side_effect=InvalidAuth, + ), patch( + "homeassistant.components.hahm.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + ATTR_INSTANCE_NAME: TEST_INSTANCE_NAME, + ATTR_HOST: TEST_HOST, + ATTR_USERNAME: TEST_USERNAME, + ATTR_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["handler"] == DOMAIN + assert result2["step_id"] == "interface" + + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert flow["context"]["unique_id"] == "pytest" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.hahm.config_flow.validate_input", + side_effect=CannotConnect, + ), patch( + "homeassistant.components.hahm.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + ATTR_INSTANCE_NAME: TEST_INSTANCE_NAME, + ATTR_HOST: TEST_HOST, + ATTR_USERNAME: TEST_USERNAME, + ATTR_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["handler"] == DOMAIN + assert result2["step_id"] == "interface" + + flow = next( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) + assert flow["context"]["unique_id"] == "pytest" + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_FORM + assert result3["errors"] == {"base": "cannot_connect"}