diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index 0b583d73886..c7a7d68d959 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -11,7 +11,7 @@ from .subscriber import AugustSubscriberMixin _LOGGER = logging.getLogger(__name__) ACTIVITY_STREAM_FETCH_LIMIT = 10 -ACTIVITY_CATCH_UP_FETCH_LIMIT = 200 +ACTIVITY_CATCH_UP_FETCH_LIMIT = 1000 class ActivityStream(AugustSubscriberMixin): diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 923f90c331e..e8b8637b6cb 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -20,6 +20,16 @@ DATA_AUGUST = "data_august" DEFAULT_NAME = "August" DOMAIN = "august" +OPERATION_METHOD_AUTORELOCK = "autorelock" +OPERATION_METHOD_REMOTE = "remote" +OPERATION_METHOD_KEYPAD = "keypad" +OPERATION_METHOD_MOBILE_DEVICE = "mobile" + +ATTR_OPERATION_AUTORELOCK = "autorelock" +ATTR_OPERATION_METHOD = "method" +ATTR_OPERATION_REMOTE = "remote" +ATTR_OPERATION_KEYPAD = "keypad" + # Limit battery, online, and hardware updates to hourly # in order to reduce the number of api requests and # avoid hitting rate limits diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 6e8571c343a..018837a81dc 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -1,12 +1,26 @@ """Support for August sensors.""" import logging +from august.activity import ActivityType + from homeassistant.components.sensor import DEVICE_CLASS_BATTERY -from homeassistant.const import UNIT_PERCENTAGE +from homeassistant.const import ATTR_ENTITY_PICTURE, UNIT_PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.entity import Entity +from homeassistant.helpers.restore_state import RestoreEntity -from .const import DATA_AUGUST, DOMAIN +from .const import ( + ATTR_OPERATION_AUTORELOCK, + ATTR_OPERATION_KEYPAD, + ATTR_OPERATION_METHOD, + ATTR_OPERATION_REMOTE, + DATA_AUGUST, + DOMAIN, + OPERATION_METHOD_AUTORELOCK, + OPERATION_METHOD_KEYPAD, + OPERATION_METHOD_MOBILE_DEVICE, + OPERATION_METHOD_REMOTE, +) from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) @@ -42,6 +56,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] + operation_sensors = [] batteries = { "device_battery": [], "linked_keypad_battery": [], @@ -51,6 +66,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in data.locks: batteries["device_battery"].append(device) batteries["linked_keypad_battery"].append(device) + operation_sensors.append(device) for sensor_type in SENSOR_TYPES_BATTERY: for device in batteries[sensor_type]: @@ -70,9 +86,111 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) devices.append(AugustBatterySensor(data, sensor_type, device)) + for device in operation_sensors: + devices.append(AugustOperatorSensor(data, device)) + async_add_entities(devices, True) +class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, Entity): + """Representation of an August lock operation sensor.""" + + def __init__(self, data, device): + """Initialize the sensor.""" + super().__init__(data, device) + self._data = data + self._device = device + self._state = None + self._operated_remote = None + self._operated_keypad = None + self._operated_autorelock = None + self._operated_time = None + self._available = False + self._entity_picture = None + self._update_from_data() + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._device.device_name} Operator" + + @callback + def _update_from_data(self): + """Get the latest state of the sensor and update activity.""" + lock_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.LOCK_OPERATION] + ) + + if lock_activity is not None: + self._available = True + self._state = lock_activity.operated_by + self._operated_remote = lock_activity.operated_remote + self._operated_keypad = lock_activity.operated_keypad + self._operated_autorelock = lock_activity.operated_autorelock + self._entity_picture = lock_activity.operator_thumbnail_url + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + attributes = {} + + if self._operated_remote is not None: + attributes[ATTR_OPERATION_REMOTE] = self._operated_remote + if self._operated_keypad is not None: + attributes[ATTR_OPERATION_KEYPAD] = self._operated_keypad + if self._operated_autorelock is not None: + attributes[ATTR_OPERATION_AUTORELOCK] = self._operated_autorelock + + if self._operated_remote: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_REMOTE + elif self._operated_keypad: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_KEYPAD + elif self._operated_autorelock: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_AUTORELOCK + else: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_MOBILE_DEVICE + + return attributes + + async def async_added_to_hass(self): + """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if not last_state: + return + + self._state = last_state.state + if ATTR_ENTITY_PICTURE in last_state.attributes: + self._entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] + if ATTR_OPERATION_REMOTE in last_state.attributes: + self._operated_remote = last_state.attributes[ATTR_OPERATION_REMOTE] + if ATTR_OPERATION_KEYPAD in last_state.attributes: + self._operated_keypad = last_state.attributes[ATTR_OPERATION_KEYPAD] + if ATTR_OPERATION_AUTORELOCK in last_state.attributes: + self._operated_autorelock = last_state.attributes[ATTR_OPERATION_AUTORELOCK] + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self._entity_picture + + @property + def unique_id(self) -> str: + """Get the unique id of the device sensor.""" + return f"{self._device_id}_lock_operator" + + class AugustBatterySensor(AugustEntityMixin, Entity): """Representation of an August sensor.""" diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index ef8518e0bbc..4bd5509a216 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,6 +6,7 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, + STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, ) @@ -87,6 +88,17 @@ async def test_one_lock_operation(hass): lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") assert lock_online_with_doorsense_name.state == STATE_LOCKED + # No activity means it will be unavailable until the activity feed has data + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == STATE_UNAVAILABLE + ) + async def test_one_lock_unknown_state(hass): """Test creation of a lock with doorsense and bridge.""" diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index dfcae6dd362..8c52d80c337 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -1,8 +1,12 @@ """The sensor tests for the august platform.""" +from homeassistant.const import STATE_UNAVAILABLE + from tests.components.august.mocks import ( _create_august_with_devices, + _mock_activities_from_fixture, _mock_doorbell_from_fixture, + _mock_doorsense_enabled_august_lock_detail, _mock_lock_from_fixture, ) @@ -121,3 +125,190 @@ async def test_create_lock_with_low_battery_linked_keypad(hass): ) assert entry assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery" + + # No activity means it will be unavailable until someone unlocks/locks it + lock_operator_sensor = entity_registry.async_get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_operator" + ) + assert ( + lock_operator_sensor.unique_id + == "A6697750D607098BAE8D6BAA11EF8063_lock_operator" + ) + assert ( + hass.states.get("sensor.a6697750d607098bae8d6baa11ef8063_name_operator").state + == STATE_UNAVAILABLE + ) + + +async def test_lock_operator_bluetooth(hass): + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.lock_from_bluetooth.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == "Your favorite elven princess" + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "remote" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "keypad" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "autorelock" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "method" + ] + == "mobile" + ) + + +async def test_lock_operator_keypad(hass): + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.lock_from_keypad.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == "Your favorite elven princess" + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "remote" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "keypad" + ] + is True + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "autorelock" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "method" + ] + == "keypad" + ) + + +async def test_lock_operator_remote(hass): + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == "Your favorite elven princess" + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "remote" + ] + is True + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "keypad" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "autorelock" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "method" + ] + == "remote" + ) + + +async def test_lock_operator_autorelock(hass): + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.lock_from_autorelock.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == "Auto Relock" + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "remote" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "keypad" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "autorelock" + ] + is True + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "method" + ] + == "autorelock" + ) diff --git a/tests/fixtures/august/get_activity.lock_from_autorelock.json b/tests/fixtures/august/get_activity.lock_from_autorelock.json new file mode 100644 index 00000000000..1c5d19344dc --- /dev/null +++ b/tests/fixtures/august/get_activity.lock_from_autorelock.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "Relock", + "UserID" : "automaticrelock", + "FirstName" : "Auto" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "lock", + "dateTime" : 1582007218000, + "info" : { + "remote" : false, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/august/get_activity.lock_from_bluetooth.json b/tests/fixtures/august/get_activity.lock_from_bluetooth.json new file mode 100644 index 00000000000..f48d8da1319 --- /dev/null +++ b/tests/fixtures/august/get_activity.lock_from_bluetooth.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "lock", + "dateTime" : 1582007218000, + "info" : { + "remote" : false, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/august/get_activity.lock_from_keypad.json b/tests/fixtures/august/get_activity.lock_from_keypad.json new file mode 100644 index 00000000000..4c76fc46cd8 --- /dev/null +++ b/tests/fixtures/august/get_activity.lock_from_keypad.json @@ -0,0 +1,35 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "lock", + "dateTime" : 1582007218000, + "info" : { + "remote" : false, + "keypad" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}]