diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index a1547778f81..89595fdebc4 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -381,6 +381,25 @@ class AugustData(AugustSubscriberMixin): hyper_bridge, ) + async def async_unlatch(self, device_id: str) -> list[ActivityTypes]: + """Open/unlatch the device.""" + return await self._async_call_api_op_requires_bridge( + device_id, + self._api.async_unlatch_return_activities, + self._august_gateway.access_token, + device_id, + ) + + async def async_unlatch_async(self, device_id: str, hyper_bridge: bool) -> str: + """Open/unlatch the device but do not wait for a response since it will come via pubnub.""" + return await self._async_call_api_op_requires_bridge( + device_id, + self._api.async_unlatch_async, + self._august_gateway.access_token, + device_id, + hyper_bridge, + ) + async def async_unlock(self, device_id: str) -> list[ActivityTypes]: """Unlock the device.""" return await self._async_call_api_op_requires_bridge( diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 5a07a5de272..1817319d823 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -11,7 +11,7 @@ from yalexs.activity import SOURCE_PUBNUB, ActivityType, ActivityTypes from yalexs.lock import Lock, LockStatus from yalexs.util import get_latest_activity, update_lock_detail_from_activity -from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity +from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -46,6 +46,8 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): super().__init__(data, device) self._lock_status = None self._attr_unique_id = f"{self._device_id:s}_lock" + if self._detail.unlatch_supported: + self._attr_supported_features = LockEntityFeature.OPEN self._update_from_data() async def async_lock(self, **kwargs: Any) -> None: @@ -56,6 +58,14 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): return await self._call_lock_operation(self._data.async_lock) + async def async_open(self, **kwargs: Any) -> None: + """Open/unlatch the device.""" + assert self._data.activity_stream is not None + if self._data.activity_stream.pubnub.connected: + await self._data.async_unlatch_async(self._device_id, self._hyper_bridge) + return + await self._call_lock_operation(self._data.async_unlatch) + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" assert self._data.activity_stream is not None diff --git a/tests/components/august/fixtures/get_lock.online_with_unlatch.json b/tests/components/august/fixtures/get_lock.online_with_unlatch.json new file mode 100644 index 00000000000..288ab1a2f28 --- /dev/null +++ b/tests/components/august/fixtures/get_lock.online_with_unlatch.json @@ -0,0 +1,94 @@ +{ + "LockName": "Lock online with unlatch supported", + "Type": 17, + "Created": "2024-03-14T18:03:09.003Z", + "Updated": "2024-03-14T18:03:09.003Z", + "LockID": "online_with_unlatch", + "HouseID": "mockhouseid1", + "HouseName": "Zuhause", + "Calibrated": false, + "timeZone": "Europe/Berlin", + "battery": 0.61, + "batteryInfo": { + "level": 0.61, + "warningState": "lock_state_battery_warning_none", + "infoUpdatedDate": "2024-04-30T17:55:09.045Z", + "lastChangeDate": "2024-03-15T07:04:00.000Z", + "lastChangeVoltage": 8350, + "state": "Mittel", + "icon": "https://app-resources.aaecosystem.com/images/lock_battery_state_medium.png" + }, + "hostHardwareID": "xxx", + "supportsEntryCodes": true, + "remoteOperateSecret": "xxxx", + "skuNumber": "NONE", + "macAddress": "DE:AD:BE:00:00:00", + "SerialNumber": "LPOC000000", + "LockStatus": { + "status": "locked", + "dateTime": "2024-04-30T18:41:25.673Z", + "isLockStatusChanged": false, + "valid": true, + "doorState": "init" + }, + "currentFirmwareVersion": "1.0.4", + "homeKitEnabled": false, + "zWaveEnabled": false, + "isGalileo": false, + "Bridge": { + "_id": "65f33445529187c78a100000", + "mfgBridgeID": "LPOCH0004Y", + "deviceModel": "august-lock", + "firmwareVersion": "1.0.4", + "operative": true, + "status": { + "current": "online", + "lastOnline": "2024-04-30T18:41:27.971Z", + "updated": "2024-04-30T18:41:27.971Z", + "lastOffline": "2024-04-25T14:41:40.118Z" + }, + "locks": [ + { + "_id": "656858c182e6c7c555faf758", + "LockID": "68895DD075A1444FAD4C00B273EEEF28", + "macAddress": "DE:AD:BE:EF:0B:BC" + } + ], + "hyperBridge": true + }, + "OfflineKeys": { + "created": [], + "loaded": [ + { + "created": "2024-03-14T18:03:09.034Z", + "key": "055281d4aa9bd7b68c7b7bb78e2f34ca", + "slot": 1, + "UserID": "b4b44424-0000-0000-0000-25c224dad337", + "loaded": "2024-03-14T18:03:33.470Z" + } + ], + "deleted": [] + }, + "parametersToSet": {}, + "users": { + "b4b44424-0000-0000-0000-25c224dad337": { + "UserType": "superuser", + "FirstName": "m10x", + "LastName": "m10x", + "identifiers": ["phone:+494444444", "email:m10x@example.com"] + } + }, + "pubsubChannel": "pubsub", + "ruleHash": {}, + "cameras": [], + "geofenceLimits": { + "ios": { + "debounceInterval": 90, + "gpsAccuracyMultiplier": 2.5, + "maximumGeofence": 5000, + "minimumGeofence": 100, + "minGPSAccuracyRequired": 80 + } + }, + "accessSchedulesAllowed": true +} diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index 75145df2509..e0bc67f510f 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -191,6 +191,9 @@ async def _create_august_api_with_devices( api_call_side_effects.setdefault( "unlock_return_activities", unlock_return_activities_side_effect ) + api_call_side_effects.setdefault( + "async_unlatch_return_activities", unlock_return_activities_side_effect + ) api_instance, entry = await _mock_setup_august_with_api_side_effects( hass, api_call_side_effects, pubnub, brand @@ -244,10 +247,17 @@ async def _mock_setup_august_with_api_side_effects( side_effect=api_call_side_effects["unlock_return_activities"] ) + if api_call_side_effects["async_unlatch_return_activities"]: + type(api_instance).async_unlatch_return_activities = AsyncMock( + side_effect=api_call_side_effects["async_unlatch_return_activities"] + ) + api_instance.async_unlock_async = AsyncMock() api_instance.async_lock_async = AsyncMock() api_instance.async_status_async = AsyncMock() api_instance.async_get_user = AsyncMock(return_value={"UserID": "abc"}) + api_instance.async_unlatch_async = AsyncMock() + api_instance.async_unlatch = AsyncMock() return api_instance, await _mock_setup_august( hass, api_instance, pubnub, brand=brand @@ -366,6 +376,10 @@ async def _mock_doorsense_missing_august_lock_detail(hass): return await _mock_lock_from_fixture(hass, "get_lock.online_missing_doorsense.json") +async def _mock_lock_with_unlatch(hass): + return await _mock_lock_from_fixture(hass, "get_lock.online_with_unlatch.json") + + def _mock_lock_operation_activity(lock, action, offset): return LockOperationActivity( SOURCE_LOCK_OPERATE, diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index c62a5b55ac3..8261e32d668 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, patch from aiohttp import ClientResponseError +import pytest from yalexs.authenticator_common import AuthenticationState from yalexs.exceptions import AugustApiAIOHTTPError @@ -12,6 +13,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, + SERVICE_OPEN, SERVICE_UNLOCK, STATE_LOCKED, STATE_ON, @@ -162,6 +164,17 @@ async def test_lock_throws_august_api_http_error(hass: HomeAssistant) -> None: ) +async def test_open_throws_hass_service_not_supported_error( + hass: HomeAssistant, +) -> None: + """Test open throws correct error on entity does not support this service error.""" + mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + await _create_august_with_devices(hass, [mocked_lock_detail]) + data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} + with pytest.raises(HomeAssistantError): + await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) + + async def test_inoperative_locks_are_filtered_out(hass: HomeAssistant) -> None: """Ensure inoperative locks do not get setup.""" august_operative_lock = await _mock_operative_august_lock_detail(hass) diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 4de931e6979..a0912e48378 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -18,6 +18,7 @@ from homeassistant.components.lock import ( from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, + SERVICE_OPEN, SERVICE_UNLOCK, STATE_LOCKED, STATE_UNAVAILABLE, @@ -25,6 +26,7 @@ from homeassistant.const import ( STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.util.dt as dt_util @@ -33,6 +35,8 @@ from .mocks import ( _mock_activities_from_fixture, _mock_doorsense_enabled_august_lock_detail, _mock_lock_from_fixture, + _mock_lock_with_unlatch, + _mock_operative_august_lock_detail, ) from tests.common import async_fire_time_changed @@ -156,6 +160,60 @@ async def test_one_lock_operation( ) +async def test_open_lock_operation(hass: HomeAssistant) -> None: + """Test open lock operation using the open service.""" + lock_with_unlatch = await _mock_lock_with_unlatch(hass) + await _create_august_with_devices(hass, [lock_with_unlatch]) + + lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") + assert lock_online_with_unlatch_name.state == STATE_LOCKED + + data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} + await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) + await hass.async_block_till_done() + + lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") + assert lock_online_with_unlatch_name.state == STATE_UNLOCKED + + +async def test_open_lock_operation_pubnub_connected( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test open lock operation using the open service when pubnub is connected.""" + lock_with_unlatch = await _mock_lock_with_unlatch(hass) + assert lock_with_unlatch.pubsub_channel == "pubsub" + + pubnub = AugustPubNub() + await _create_august_with_devices(hass, [lock_with_unlatch], pubnub=pubnub) + pubnub.connected = True + + lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") + assert lock_online_with_unlatch_name.state == STATE_LOCKED + + data = {ATTR_ENTITY_ID: "lock.online_with_unlatch_name"} + await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True) + await hass.async_block_till_done() + + pubnub.message( + pubnub, + Mock( + channel=lock_with_unlatch.pubsub_channel, + timetoken=(dt_util.utcnow().timestamp() + 2) * 10000000, + message={ + "status": "kAugLockState_Unlocked", + }, + ), + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + lock_online_with_unlatch_name = hass.states.get("lock.online_with_unlatch_name") + assert lock_online_with_unlatch_name.state == STATE_UNLOCKED + await hass.async_block_till_done() + + async def test_one_lock_operation_pubnub_connected( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -449,3 +507,14 @@ async def test_lock_update_via_pubnub(hass: HomeAssistant) -> None: await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() + + +async def test_open_throws_hass_service_not_supported_error( + hass: HomeAssistant, +) -> None: + """Test open throws correct error on entity does not support this service error.""" + mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + await _create_august_with_devices(hass, [mocked_lock_detail]) + data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} + with pytest.raises(HomeAssistantError, match="does not support this service"): + await hass.services.async_call(LOCK_DOMAIN, SERVICE_OPEN, data, blocking=True)