diff --git a/.coveragerc b/.coveragerc index 14a731498b9..851922e4f3a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -442,7 +442,6 @@ omit = homeassistant/components/mychevy/* homeassistant/components/mycroft/* homeassistant/components/mycroft/notify.py - homeassistant/components/myq/cover.py homeassistant/components/mysensors/* homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py diff --git a/homeassistant/components/myq/binary_sensor.py b/homeassistant/components/myq/binary_sensor.py new file mode 100644 index 00000000000..7ce303e5d19 --- /dev/null +++ b/homeassistant/components/myq/binary_sensor.py @@ -0,0 +1,108 @@ +"""Support for MyQ gateways.""" +import logging + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorDevice, +) +from homeassistant.core import callback + +from .const import ( + DOMAIN, + KNOWN_MODELS, + MANUFACTURER, + MYQ_COORDINATOR, + MYQ_DEVICE_FAMILY, + MYQ_DEVICE_FAMILY_GATEWAY, + MYQ_DEVICE_STATE, + MYQ_DEVICE_STATE_ONLINE, + MYQ_GATEWAY, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up mysq covers.""" + data = hass.data[DOMAIN][config_entry.entry_id] + myq = data[MYQ_GATEWAY] + coordinator = data[MYQ_COORDINATOR] + + entities = [] + + for device in myq.devices.values(): + if device.device_json[MYQ_DEVICE_FAMILY] == MYQ_DEVICE_FAMILY_GATEWAY: + entities.append(MyQBinarySensorDevice(coordinator, device)) + + async_add_entities(entities, True) + + +class MyQBinarySensorDevice(BinarySensorDevice): + """Representation of a MyQ gateway.""" + + def __init__(self, coordinator, device): + """Initialize with API object, device id.""" + self._coordinator = coordinator + self._device = device + + @property + def device_class(self): + """We track connectivity for gateways.""" + return DEVICE_CLASS_CONNECTIVITY + + @property + def name(self): + """Return the name of the garage door if any.""" + return f"{self._device.name} MyQ Gateway" + + @property + def is_on(self): + """Return if the device is online.""" + if not self._coordinator.last_update_success: + return False + + # Not all devices report online so assume True if its missing + return self._device.device_json[MYQ_DEVICE_STATE].get( + MYQ_DEVICE_STATE_ONLINE, True + ) + + @property + def unique_id(self): + """Return a unique, Home Assistant friendly identifier for this entity.""" + return self._device.device_id + + async def async_update(self): + """Update status of cover.""" + await self._coordinator.async_request_refresh() + + @property + def device_info(self): + """Return the device_info of the device.""" + device_info = { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self.name, + "manufacturer": MANUFACTURER, + "sw_version": self._device.firmware_version, + } + model = KNOWN_MODELS.get(self._device.device_id[2:4]) + if model: + device_info["model"] = model + + return device_info + + @property + def should_poll(self): + """Return False, updates are controlled via coordinator.""" + return False + + @callback + def _async_consume_update(self): + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self._coordinator.async_add_listener(self._async_consume_update) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + self._coordinator.async_remove_listener(self._async_consume_update) diff --git a/homeassistant/components/myq/const.py b/homeassistant/components/myq/const.py index dcae53bd080..352c19ebd24 100644 --- a/homeassistant/components/myq/const.py +++ b/homeassistant/components/myq/const.py @@ -10,10 +10,14 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_O DOMAIN = "myq" -PLATFORMS = ["cover"] +PLATFORMS = ["cover", "binary_sensor"] MYQ_DEVICE_TYPE = "device_type" MYQ_DEVICE_TYPE_GATE = "gate" + +MYQ_DEVICE_FAMILY = "device_family" +MYQ_DEVICE_FAMILY_GATEWAY = "gateway" + MYQ_DEVICE_STATE = "state" MYQ_DEVICE_STATE_ONLINE = "online" @@ -39,3 +43,36 @@ TRANSITION_START_DURATION = 7 # Estimated time it takes myq to complete a transition # from one state to another TRANSITION_COMPLETE_DURATION = 37 + +MANUFACTURER = "The Chamberlain Group Inc." + +KNOWN_MODELS = { + "00": "Chamberlain Ethernet Gateway", + "01": "LiftMaster Ethernet Gateway", + "02": "Craftsman Ethernet Gateway", + "03": "Chamberlain Wi-Fi hub", + "04": "LiftMaster Wi-Fi hub", + "05": "Craftsman Wi-Fi hub", + "08": "LiftMaster Wi-Fi GDO DC w/Battery Backup", + "09": "Chamberlain Wi-Fi GDO DC w/Battery Backup", + "10": "Craftsman Wi-Fi GDO DC 3/4HP", + "11": "MyQ Replacement Logic Board Wi-Fi GDO DC 3/4HP", + "12": "Chamberlain Wi-Fi GDO DC 1.25HP", + "13": "LiftMaster Wi-Fi GDO DC 1.25HP", + "14": "Craftsman Wi-Fi GDO DC 1.25HP", + "15": "MyQ Replacement Logic Board Wi-Fi GDO DC 1.25HP", + "0A": "Chamberlain Wi-Fi GDO or Gate Operator AC", + "0B": "LiftMaster Wi-Fi GDO or Gate Operator AC", + "0C": "Craftsman Wi-Fi GDO or Gate Operator AC", + "0D": "MyQ Replacement Logic Board Wi-Fi GDO or Gate Operator AC", + "0E": "Chamberlain Wi-Fi GDO DC 3/4HP", + "0F": "LiftMaster Wi-Fi GDO DC 3/4HP", + "20": "Chamberlain MyQ Home Bridge", + "21": "LiftMaster MyQ Home Bridge", + "23": "Chamberlain Smart Garage Hub", + "24": "LiftMaster Smart Garage Hub", + "27": "LiftMaster Wi-Fi Wall Mount opener", + "28": "LiftMaster Commercial Wi-Fi Wall Mount operator", + "80": "EU LiftMaster Ethernet Gateway", + "81": "EU Chamberlain Ethernet Gateway", +} diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 21eca6179dd..57308a778a5 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -27,6 +27,8 @@ from homeassistant.helpers.event import async_call_later from .const import ( DOMAIN, + KNOWN_MODELS, + MANUFACTURER, MYQ_COORDINATOR, MYQ_DEVICE_STATE, MYQ_DEVICE_STATE_ONLINE, @@ -181,9 +183,12 @@ class MyQDevice(CoverDevice): device_info = { "identifiers": {(DOMAIN, self._device.device_id)}, "name": self._device.name, - "manufacturer": "The Chamberlain Group Inc.", + "manufacturer": MANUFACTURER, "sw_version": self._device.firmware_version, } + model = KNOWN_MODELS.get(self._device.device_id[2:4]) + if model: + device_info["model"] = model if self._device.parent_device_id: device_info["via_device"] = (DOMAIN, self._device.parent_device_id) return device_info diff --git a/tests/components/myq/test_binary_sensor.py b/tests/components/myq/test_binary_sensor.py new file mode 100644 index 00000000000..cef1f2e2409 --- /dev/null +++ b/tests/components/myq/test_binary_sensor.py @@ -0,0 +1,20 @@ +"""The scene tests for the myq platform.""" + +from homeassistant.const import STATE_ON + +from .util import async_init_integration + + +async def test_create_binary_sensors(hass): + """Test creation of binary_sensors.""" + + await async_init_integration(hass) + + state = hass.states.get("binary_sensor.happy_place_myq_gateway") + assert state.state == STATE_ON + expected_attributes = {"device_class": "connectivity"} + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) diff --git a/tests/components/myq/test_cover.py b/tests/components/myq/test_cover.py new file mode 100644 index 00000000000..5029c4f6b0b --- /dev/null +++ b/tests/components/myq/test_cover.py @@ -0,0 +1,50 @@ +"""The scene tests for the myq platform.""" + +from homeassistant.const import STATE_CLOSED + +from .util import async_init_integration + + +async def test_create_covers(hass): + """Test creation of covers.""" + + await async_init_integration(hass) + + state = hass.states.get("cover.large_garage_door") + assert state.state == STATE_CLOSED + expected_attributes = { + "device_class": "garage", + "friendly_name": "Large Garage Door", + "supported_features": 3, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) + + state = hass.states.get("cover.small_garage_door") + assert state.state == STATE_CLOSED + expected_attributes = { + "device_class": "garage", + "friendly_name": "Small Garage Door", + "supported_features": 3, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) + + state = hass.states.get("cover.gate") + assert state.state == STATE_CLOSED + expected_attributes = { + "device_class": "gate", + "friendly_name": "Gate", + "supported_features": 3, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all( + state.attributes[key] == expected_attributes[key] for key in expected_attributes + ) diff --git a/tests/components/myq/util.py b/tests/components/myq/util.py new file mode 100644 index 00000000000..48af17188eb --- /dev/null +++ b/tests/components/myq/util.py @@ -0,0 +1,42 @@ +"""Tests for the myq integration.""" + +import json + +from asynctest import patch + +from homeassistant.components.myq.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + + +async def async_init_integration( + hass: HomeAssistant, skip_setup: bool = False, +) -> MockConfigEntry: + """Set up the myq integration in Home Assistant.""" + + devices_fixture = "myq/devices.json" + devices_json = load_fixture(devices_fixture) + devices_dict = json.loads(devices_json) + + def _handle_mock_api_request(method, endpoint, **kwargs): + if endpoint == "Login": + return {"SecurityToken": 1234} + elif endpoint == "My": + return {"Account": {"Id": 1}} + elif endpoint == "Accounts/1/Devices": + return devices_dict + return {} + + with patch("pymyq.api.API.request", side_effect=_handle_mock_api_request): + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_USERNAME: "mock", CONF_PASSWORD: "mock"} + ) + entry.add_to_hass(hass) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/fixtures/myq/devices.json b/tests/fixtures/myq/devices.json new file mode 100644 index 00000000000..f7c65c6bb20 --- /dev/null +++ b/tests/fixtures/myq/devices.json @@ -0,0 +1,133 @@ +{ + "count" : 4, + "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices", + "items" : [ + { + "device_type" : "ethernetgateway", + "created_date" : "2020-02-10T22:54:58.423", + "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", + "device_family" : "gateway", + "name" : "Happy place", + "device_platform" : "myq", + "state" : { + "homekit_enabled" : false, + "pending_bootload_abandoned" : false, + "online" : true, + "last_status" : "2020-03-30T02:49:46.4121303Z", + "physical_devices" : [], + "firmware_version" : "1.6", + "learn_mode" : false, + "learn" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial/learn", + "homekit_capable" : false, + "updated_date" : "2020-03-30T02:49:46.4171299Z" + }, + "serial_number" : "gateway_serial" + }, + { + "serial_number" : "gate_serial", + "state" : { + "report_ajar" : false, + "aux_relay_delay" : "00:00:00", + "is_unattended_close_allowed" : true, + "door_ajar_interval" : "00:00:00", + "aux_relay_behavior" : "None", + "last_status" : "2020-03-30T02:47:40.2794038Z", + "online" : true, + "rex_fires_door" : false, + "close" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial/close", + "invalid_shutout_period" : "00:00:00", + "invalid_credential_window" : "00:00:00", + "use_aux_relay" : false, + "command_channel_report_status" : false, + "last_update" : "2020-03-28T23:07:39.5611776Z", + "door_state" : "closed", + "max_invalid_attempts" : 0, + "open" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial/open", + "passthrough_interval" : "00:00:00", + "control_from_browser" : false, + "report_forced" : false, + "is_unattended_open_allowed" : true + }, + "parent_device_id" : "gateway_serial", + "name" : "Gate", + "device_platform" : "myq", + "device_family" : "garagedoor", + "parent_device" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", + "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gate_serial", + "device_type" : "gate", + "created_date" : "2020-02-10T22:54:58.423" + }, + { + "parent_device" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", + "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial", + "device_type" : "wifigaragedooropener", + "created_date" : "2020-02-10T22:55:25.863", + "device_platform" : "myq", + "name" : "Large Garage Door", + "device_family" : "garagedoor", + "serial_number" : "large_garage_serial", + "state" : { + "report_forced" : false, + "is_unattended_open_allowed" : true, + "passthrough_interval" : "00:00:00", + "control_from_browser" : false, + "attached_work_light_error_present" : false, + "max_invalid_attempts" : 0, + "open" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial/open", + "command_channel_report_status" : false, + "last_update" : "2020-03-28T23:58:55.5906643Z", + "door_state" : "closed", + "invalid_shutout_period" : "00:00:00", + "use_aux_relay" : false, + "invalid_credential_window" : "00:00:00", + "rex_fires_door" : false, + "close" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/large_garage_serial/close", + "online" : true, + "last_status" : "2020-03-30T02:49:46.4121303Z", + "aux_relay_behavior" : "None", + "door_ajar_interval" : "00:00:00", + "gdo_lock_connected" : false, + "report_ajar" : false, + "aux_relay_delay" : "00:00:00", + "is_unattended_close_allowed" : true + }, + "parent_device_id" : "gateway_serial" + }, + { + "serial_number" : "small_garage_serial", + "state" : { + "last_status" : "2020-03-30T02:48:45.7501595Z", + "online" : true, + "report_ajar" : false, + "aux_relay_delay" : "00:00:00", + "is_unattended_close_allowed" : true, + "gdo_lock_connected" : false, + "door_ajar_interval" : "00:00:00", + "aux_relay_behavior" : "None", + "attached_work_light_error_present" : false, + "control_from_browser" : false, + "passthrough_interval" : "00:00:00", + "is_unattended_open_allowed" : true, + "report_forced" : false, + "close" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial/close", + "rex_fires_door" : false, + "invalid_credential_window" : "00:00:00", + "use_aux_relay" : false, + "invalid_shutout_period" : "00:00:00", + "door_state" : "closed", + "last_update" : "2020-03-26T15:45:31.4713796Z", + "command_channel_report_status" : false, + "open" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial/open", + "max_invalid_attempts" : 0 + }, + "parent_device_id" : "gateway_serial", + "device_platform" : "myq", + "name" : "Small Garage Door", + "device_family" : "garagedoor", + "parent_device" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/gateway_serial", + "href" : "http://api.myqdevice.com/api/v5/accounts/account_id/devices/small_garage_serial", + "device_type" : "wifigaragedooropener", + "created_date" : "2020-02-10T23:11:47.487" + } + ] +}