diff --git a/homeassistant/components/gogogate2/__init__.py b/homeassistant/components/gogogate2/__init__.py index 93f000e6a3a..6d0318b99ad 100644 --- a/homeassistant/components/gogogate2/__init__.py +++ b/homeassistant/components/gogogate2/__init__.py @@ -1,5 +1,8 @@ """The gogogate2 component.""" -from homeassistant.components.cover import DOMAIN as COVER_DOMAIN +import asyncio + +from homeassistant.components.cover import DOMAIN as COVER +from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE from homeassistant.core import HomeAssistant @@ -8,6 +11,8 @@ from homeassistant.exceptions import ConfigEntryNotReady from .common import get_data_update_coordinator from .const import DEVICE_TYPE_GOGOGATE2 +PLATFORMS = [COVER, SENSOR] + async def async_setup(hass: HomeAssistant, base_config: dict) -> bool: """Set up for Gogogate2 controllers.""" @@ -34,17 +39,23 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if not data_update_coordinator.last_update_success: raise ConfigEntryNotReady() - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, COVER_DOMAIN) - ) + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Gogogate2 config entry.""" - hass.async_create_task( - hass.config_entries.async_forward_entry_unload(config_entry, COVER_DOMAIN) + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, platform) + for platform in PLATFORMS + ] + ) ) - return True + return unload_ok diff --git a/homeassistant/components/gogogate2/common.py b/homeassistant/components/gogogate2/common.py index 2817c351013..761f9211921 100644 --- a/homeassistant/components/gogogate2/common.py +++ b/homeassistant/components/gogogate2/common.py @@ -4,7 +4,7 @@ import logging from typing import Awaitable, Callable, NamedTuple, Optional from gogogate2_api import AbstractGateApi, GogoGate2Api, ISmartGateApi -from gogogate2_api.common import AbstractDoor +from gogogate2_api.common import AbstractDoor, get_door_by_id from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -15,9 +15,13 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) -from .const import DATA_UPDATE_COORDINATOR, DEVICE_TYPE_ISMARTGATE, DOMAIN +from .const import DATA_UPDATE_COORDINATOR, DEVICE_TYPE_ISMARTGATE, DOMAIN, MANUFACTURER _LOGGER = logging.getLogger(__name__) @@ -57,6 +61,44 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator): self.api = api +class GoGoGate2Entity(CoordinatorEntity): + """Base class for gogogate2 entities.""" + + def __init__( + self, + config_entry: ConfigEntry, + data_update_coordinator: DeviceDataUpdateCoordinator, + door: AbstractDoor, + ) -> None: + """Initialize gogogate2 base entity.""" + super().__init__(data_update_coordinator) + self._config_entry = config_entry + self._door = door + self._unique_id = cover_unique_id(config_entry, door) + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._unique_id + + def _get_door(self) -> AbstractDoor: + door = get_door_by_id(self._door.door_id, self.coordinator.data) + self._door = door or self._door + return self._door + + @property + def device_info(self): + """Device info for the controller.""" + data = self.coordinator.data + return { + "identifiers": {(DOMAIN, self._config_entry.unique_id)}, + "name": self._config_entry.title, + "manufacturer": MANUFACTURER, + "model": data.model, + "sw_version": data.firmwareversion, + } + + def get_data_update_coordinator( hass: HomeAssistant, config_entry: ConfigEntry ) -> DeviceDataUpdateCoordinator: diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index 8b83073d0c8..f2e05b10599 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -1,12 +1,7 @@ """Support for Gogogate2 garage Doors.""" from typing import Callable, List, Optional -from gogogate2_api.common import ( - AbstractDoor, - DoorStatus, - get_configured_doors, - get_door_by_id, -) +from gogogate2_api.common import AbstractDoor, DoorStatus, get_configured_doors import voluptuous as vol from homeassistant.components.cover import ( @@ -26,14 +21,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .common import ( DeviceDataUpdateCoordinator, - cover_unique_id, + GoGoGate2Entity, get_data_update_coordinator, ) -from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN, MANUFACTURER +from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN COVER_SCHEMA = vol.Schema( { @@ -74,7 +68,7 @@ async def async_setup_entry( ) -class DeviceCover(CoordinatorEntity, CoverEntity): +class DeviceCover(GoGoGate2Entity, CoverEntity): """Cover entity for goggate2.""" def __init__( @@ -84,18 +78,10 @@ class DeviceCover(CoordinatorEntity, CoverEntity): door: AbstractDoor, ) -> None: """Initialize the object.""" - super().__init__(data_update_coordinator) - self._config_entry = config_entry - self._door = door + super().__init__(config_entry, data_update_coordinator, door) self._api = data_update_coordinator.api - self._unique_id = cover_unique_id(config_entry, door) self._is_available = True - @property - def unique_id(self) -> Optional[str]: - """Return a unique ID.""" - return self._unique_id - @property def name(self): """Return the name of the door.""" @@ -141,20 +127,3 @@ class DeviceCover(CoordinatorEntity, CoverEntity): attrs = super().state_attributes attrs["door_id"] = self._get_door().door_id return attrs - - def _get_door(self) -> AbstractDoor: - door = get_door_by_id(self._door.door_id, self.coordinator.data) - self._door = door or self._door - return self._door - - @property - def device_info(self): - """Device info for the controller.""" - data = self.coordinator.data - return { - "identifiers": {(DOMAIN, self._config_entry.unique_id)}, - "name": self._config_entry.title, - "manufacturer": MANUFACTURER, - "model": data.model, - "sw_version": data.firmwareversion, - } diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py new file mode 100644 index 00000000000..55aacd2fdbb --- /dev/null +++ b/homeassistant/components/gogogate2/sensor.py @@ -0,0 +1,59 @@ +"""Support for Gogogate2 garage Doors.""" +from typing import Callable, List, Optional + +from gogogate2_api.common import get_configured_doors + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity + +from .common import GoGoGate2Entity, get_data_update_coordinator + +SENSOR_ID_WIRED = "WIRE" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], Optional[bool]], None], +) -> None: + """Set up the config entry.""" + data_update_coordinator = get_data_update_coordinator(hass, config_entry) + + async_add_entities( + [ + DoorSensor(config_entry, data_update_coordinator, door) + for door in get_configured_doors(data_update_coordinator.data) + if door.sensorid and door.sensorid != SENSOR_ID_WIRED + ] + ) + + +class DoorSensor(GoGoGate2Entity): + """Sensor entity for goggate2.""" + + @property + def name(self): + """Return the name of the door.""" + return f"{self._get_door().name} battery" + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_BATTERY + + @property + def state(self): + """Return the state of the entity.""" + door = self._get_door() + return door.voltage # This is a percentage, not an absolute voltage + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = super().state_attributes or {} + door = self._get_door() + if door.sensorid is not None: + attrs["sensorid"] = door.door_id + return attrs diff --git a/tests/components/gogogate2/test_sensor.py b/tests/components/gogogate2/test_sensor.py new file mode 100644 index 00000000000..0bd67dfc92a --- /dev/null +++ b/tests/components/gogogate2/test_sensor.py @@ -0,0 +1,261 @@ +"""Tests for the GogoGate2 component.""" +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from gogogate2_api import GogoGate2Api, ISmartGateApi +from gogogate2_api.common import ( + DoorMode, + DoorStatus, + GogoGate2ActivateResponse, + GogoGate2Door, + GogoGate2InfoResponse, + ISmartGateDoor, + ISmartGateInfoResponse, + Network, + Outputs, + Wifi, +) + +from homeassistant.components.gogogate2.const import DEVICE_TYPE_ISMARTGATE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + CONF_DEVICE, + CONF_IP_ADDRESS, + CONF_PASSWORD, + CONF_USERNAME, + DEVICE_CLASS_BATTERY, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, async_fire_time_changed + + +def _mocked_gogogate_sensor_response(battery_level: int): + return GogoGate2InfoResponse( + user="user1", + gogogatename="gogogatename0", + model="", + apiversion="", + remoteaccessenabled=False, + remoteaccess="abc123.blah.blah", + firmwareversion="", + apicode="", + door1=GogoGate2Door( + door_id=1, + permission=True, + name="Door1", + gate=False, + mode=DoorMode.GARAGE, + status=DoorStatus.OPENED, + sensor=True, + sensorid="ABCD", + camera=False, + events=2, + temperature=None, + voltage=battery_level, + ), + door2=GogoGate2Door( + door_id=2, + permission=True, + name="Door2", + gate=True, + mode=DoorMode.GARAGE, + status=DoorStatus.UNDEFINED, + sensor=True, + sensorid="WIRE", + camera=False, + events=0, + temperature=None, + voltage=battery_level, + ), + door3=GogoGate2Door( + door_id=3, + permission=True, + name="Door3", + gate=False, + mode=DoorMode.GARAGE, + status=DoorStatus.UNDEFINED, + sensor=True, + sensorid=None, + camera=False, + events=0, + temperature=None, + voltage=battery_level, + ), + outputs=Outputs(output1=True, output2=False, output3=True), + network=Network(ip=""), + wifi=Wifi(SSID="", linkquality="", signal=""), + ) + + +def _mocked_ismartgate_sensor_response(battery_level: int): + return ISmartGateInfoResponse( + user="user1", + ismartgatename="ismartgatename0", + model="ismartgatePRO", + apiversion="", + remoteaccessenabled=False, + remoteaccess="abc321.blah.blah", + firmwareversion="555", + pin=123, + lang="en", + newfirmware=False, + door1=ISmartGateDoor( + door_id=1, + permission=True, + name="Door1", + gate=False, + mode=DoorMode.GARAGE, + status=DoorStatus.CLOSED, + sensor=True, + sensorid="ABCD", + camera=False, + events=2, + temperature=None, + enabled=True, + apicode="apicode0", + customimage=False, + voltage=battery_level, + ), + door2=ISmartGateDoor( + door_id=2, + permission=True, + name="Door2", + gate=True, + mode=DoorMode.GARAGE, + status=DoorStatus.CLOSED, + sensor=True, + sensorid="WIRE", + camera=False, + events=2, + temperature=None, + enabled=True, + apicode="apicode0", + customimage=False, + voltage=battery_level, + ), + door3=ISmartGateDoor( + door_id=3, + permission=True, + name="Door3", + gate=False, + mode=DoorMode.GARAGE, + status=DoorStatus.UNDEFINED, + sensor=True, + sensorid=None, + camera=False, + events=0, + temperature=None, + enabled=True, + apicode="apicode0", + customimage=False, + voltage=battery_level, + ), + network=Network(ip=""), + wifi=Wifi(SSID="", linkquality="", signal=""), + ) + + +@patch("homeassistant.components.gogogate2.common.GogoGate2Api") +async def test_sensor_update(gogogate2api_mock, hass: HomeAssistant) -> None: + """Test data update.""" + + api = MagicMock(GogoGate2Api) + api.async_activate.return_value = GogoGate2ActivateResponse(result=True) + api.async_info.return_value = _mocked_gogogate_sensor_response(25) + gogogate2api_mock.return_value = api + + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_IP_ADDRESS: "127.0.0.1", + CONF_USERNAME: "admin", + CONF_PASSWORD: "password", + }, + ) + config_entry.add_to_hass(hass) + + assert hass.states.get("cover.door1") is None + assert hass.states.get("cover.door2") is None + assert hass.states.get("cover.door2") is None + assert hass.states.get("sensor.door1_battery") is None + assert hass.states.get("sensor.door2_battery") is None + assert hass.states.get("sensor.door2_battery") is None + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("cover.door1") + assert hass.states.get("cover.door2") + assert hass.states.get("cover.door2") + assert hass.states.get("sensor.door1_battery").state == "25" + assert hass.states.get("sensor.door2_battery") is None + assert hass.states.get("sensor.door2_battery") is None + + api.async_info.return_value = _mocked_gogogate_sensor_response(40) + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + assert hass.states.get("sensor.door1_battery").state == "40" + + api.async_info.return_value = _mocked_gogogate_sensor_response(None) + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + assert hass.states.get("sensor.door1_battery").state == STATE_UNKNOWN + + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert not hass.states.async_entity_ids(DOMAIN) + + +@patch("homeassistant.components.gogogate2.common.ISmartGateApi") +async def test_availability(ismartgateapi_mock, hass: HomeAssistant) -> None: + """Test availability.""" + sensor_response = _mocked_ismartgate_sensor_response(35) + api = MagicMock(ISmartGateApi) + api.async_info.return_value = sensor_response + ismartgateapi_mock.return_value = api + + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={ + CONF_DEVICE: DEVICE_TYPE_ISMARTGATE, + CONF_IP_ADDRESS: "127.0.0.1", + CONF_USERNAME: "admin", + CONF_PASSWORD: "password", + }, + ) + config_entry.add_to_hass(hass) + + assert hass.states.get("cover.door1") is None + assert hass.states.get("cover.door2") is None + assert hass.states.get("cover.door2") is None + assert hass.states.get("sensor.door1_battery") is None + assert hass.states.get("sensor.door2_battery") is None + assert hass.states.get("sensor.door2_battery") is None + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("cover.door1") + assert hass.states.get("cover.door2") + assert hass.states.get("cover.door2") + assert hass.states.get("sensor.door1_battery").state == "35" + assert hass.states.get("sensor.door2_battery") is None + assert hass.states.get("sensor.door2_battery") is None + assert ( + hass.states.get("sensor.door1_battery").attributes[ATTR_DEVICE_CLASS] + == DEVICE_CLASS_BATTERY + ) + + api.async_info.side_effect = Exception("Error") + + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + assert hass.states.get("sensor.door1_battery").state == STATE_UNAVAILABLE + + api.async_info.side_effect = None + api.async_info.return_value = sensor_response + async_fire_time_changed(hass, utcnow() + timedelta(hours=2)) + await hass.async_block_till_done() + assert hass.states.get("sensor.door1_battery").state == "35"