Skip to content

Commit 7145fb9

Browse files
authored
Add lock platform to Volvo integration (#154168)
1 parent 37d94ac commit 7145fb9

File tree

5 files changed

+478
-0
lines changed

5 files changed

+478
-0
lines changed

homeassistant/components/volvo/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Platform.BINARY_SENSOR,
88
Platform.BUTTON,
99
Platform.DEVICE_TRACKER,
10+
Platform.LOCK,
1011
Platform.SENSOR,
1112
]
1213

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Volvo locks."""
2+
3+
from dataclasses import dataclass
4+
import logging
5+
from typing import Any, cast
6+
7+
from volvocarsapi.models import VolvoApiException, VolvoCarsApiBaseModel, VolvoCarsValue
8+
9+
from homeassistant.components.lock import LockEntity, LockEntityDescription
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.exceptions import HomeAssistantError
12+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
13+
14+
from .const import DOMAIN
15+
from .coordinator import VolvoConfigEntry
16+
from .entity import VolvoEntity, VolvoEntityDescription
17+
18+
PARALLEL_UPDATES = 0
19+
_LOGGER = logging.getLogger(__name__)
20+
21+
22+
@dataclass(frozen=True, kw_only=True)
23+
class VolvoLockDescription(VolvoEntityDescription, LockEntityDescription):
24+
"""Describes a Volvo lock entity."""
25+
26+
api_lock_value: str = "LOCKED"
27+
api_unlock_value: str = "UNLOCKED"
28+
lock_command: str
29+
unlock_command: str
30+
required_command_key: str
31+
32+
33+
_DESCRIPTIONS: tuple[VolvoLockDescription, ...] = (
34+
VolvoLockDescription(
35+
key="lock",
36+
api_field="centralLock",
37+
lock_command="lock",
38+
unlock_command="unlock",
39+
required_command_key="LOCK",
40+
),
41+
)
42+
43+
44+
async def async_setup_entry(
45+
hass: HomeAssistant,
46+
entry: VolvoConfigEntry,
47+
async_add_entities: AddConfigEntryEntitiesCallback,
48+
) -> None:
49+
"""Set up locks."""
50+
coordinators = entry.runtime_data.interval_coordinators
51+
async_add_entities(
52+
[
53+
VolvoLock(coordinator, description)
54+
for coordinator in coordinators
55+
for description in _DESCRIPTIONS
56+
if description.required_command_key
57+
in entry.runtime_data.context.supported_commands
58+
and description.api_field in coordinator.data
59+
]
60+
)
61+
62+
63+
class VolvoLock(VolvoEntity, LockEntity):
64+
"""Volvo lock."""
65+
66+
entity_description: VolvoLockDescription
67+
68+
async def async_lock(self, **kwargs: Any) -> None:
69+
"""Lock the car."""
70+
await self._async_handle_command(self.entity_description.lock_command, True)
71+
72+
async def async_unlock(self, **kwargs: Any) -> None:
73+
"""Unlock the car."""
74+
await self._async_handle_command(self.entity_description.unlock_command, False)
75+
76+
def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
77+
"""Update the state of the entity."""
78+
assert isinstance(api_field, VolvoCarsValue)
79+
self._attr_is_locked = api_field.value == "LOCKED"
80+
81+
async def _async_handle_command(self, command: str, locked: bool) -> None:
82+
_LOGGER.debug("Lock '%s' is %s", command, "locked" if locked else "unlocked")
83+
if locked:
84+
self._attr_is_locking = True
85+
else:
86+
self._attr_is_unlocking = True
87+
self.async_write_ha_state()
88+
89+
try:
90+
result = await self.coordinator.context.api.async_execute_command(command)
91+
except VolvoApiException as ex:
92+
_LOGGER.debug("Lock '%s' error", command)
93+
error = self._reset_and_create_error(command, message=ex.message)
94+
raise error from ex
95+
96+
status = result.invoke_status if result else ""
97+
_LOGGER.debug("Lock '%s' result: %s", command, status)
98+
99+
if status.upper() not in ("COMPLETED", "DELIVERED"):
100+
error = self._reset_and_create_error(
101+
command, status=status, message=result.message if result else ""
102+
)
103+
raise error
104+
105+
api_field = cast(
106+
VolvoCarsValue,
107+
self.coordinator.get_api_field(self.entity_description.api_field),
108+
)
109+
110+
self._attr_is_locking = False
111+
self._attr_is_unlocking = False
112+
113+
if locked:
114+
api_field.value = self.entity_description.api_lock_value
115+
else:
116+
api_field.value = self.entity_description.api_unlock_value
117+
118+
self._attr_is_locked = locked
119+
self.async_write_ha_state()
120+
121+
def _reset_and_create_error(
122+
self, command: str, *, status: str = "", message: str = ""
123+
) -> HomeAssistantError:
124+
self._attr_is_locking = False
125+
self._attr_is_unlocking = False
126+
self.async_write_ha_state()
127+
128+
return HomeAssistantError(
129+
translation_domain=DOMAIN,
130+
translation_key="lock_failure",
131+
translation_placeholders={
132+
"command": command,
133+
"status": status,
134+
"message": message,
135+
},
136+
)

homeassistant/components/volvo/strings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,11 @@
210210
"name": "Honk & flash"
211211
}
212212
},
213+
"lock": {
214+
"lock": {
215+
"name": "[%key:component::lock::title%]"
216+
}
217+
},
213218
"sensor": {
214219
"availability": {
215220
"name": "Car connection",
@@ -349,6 +354,9 @@
349354
"command_failure": {
350355
"message": "Command {command} failed. Status: {status}. Message: {message}"
351356
},
357+
"lock_failure": {
358+
"message": "Failed to {command} vehicle. Status: {status}. Message: {message}"
359+
},
352360
"no_vehicle": {
353361
"message": "Unable to retrieve vehicle details."
354362
},
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# serializer version: 1
2+
# name: test_lock[ex30_2024][lock.volvo_ex30_lock-entry]
3+
EntityRegistryEntrySnapshot({
4+
'aliases': set({
5+
}),
6+
'area_id': None,
7+
'capabilities': None,
8+
'config_entry_id': <ANY>,
9+
'config_subentry_id': <ANY>,
10+
'device_class': None,
11+
'device_id': <ANY>,
12+
'disabled_by': None,
13+
'domain': 'lock',
14+
'entity_category': None,
15+
'entity_id': 'lock.volvo_ex30_lock',
16+
'has_entity_name': True,
17+
'hidden_by': None,
18+
'icon': None,
19+
'id': <ANY>,
20+
'labels': set({
21+
}),
22+
'name': None,
23+
'options': dict({
24+
}),
25+
'original_device_class': None,
26+
'original_icon': None,
27+
'original_name': 'Lock',
28+
'platform': 'volvo',
29+
'previous_unique_id': None,
30+
'suggested_object_id': None,
31+
'supported_features': 0,
32+
'translation_key': 'lock',
33+
'unique_id': 'yv1abcdefg1234567_lock',
34+
'unit_of_measurement': None,
35+
})
36+
# ---
37+
# name: test_lock[ex30_2024][lock.volvo_ex30_lock-state]
38+
StateSnapshot({
39+
'attributes': ReadOnlyDict({
40+
'friendly_name': 'Volvo EX30 Lock',
41+
'supported_features': <LockEntityFeature: 0>,
42+
}),
43+
'context': <ANY>,
44+
'entity_id': 'lock.volvo_ex30_lock',
45+
'last_changed': <ANY>,
46+
'last_reported': <ANY>,
47+
'last_updated': <ANY>,
48+
'state': 'locked',
49+
})
50+
# ---
51+
# name: test_lock[s90_diesel_2018][lock.volvo_s90_lock-entry]
52+
EntityRegistryEntrySnapshot({
53+
'aliases': set({
54+
}),
55+
'area_id': None,
56+
'capabilities': None,
57+
'config_entry_id': <ANY>,
58+
'config_subentry_id': <ANY>,
59+
'device_class': None,
60+
'device_id': <ANY>,
61+
'disabled_by': None,
62+
'domain': 'lock',
63+
'entity_category': None,
64+
'entity_id': 'lock.volvo_s90_lock',
65+
'has_entity_name': True,
66+
'hidden_by': None,
67+
'icon': None,
68+
'id': <ANY>,
69+
'labels': set({
70+
}),
71+
'name': None,
72+
'options': dict({
73+
}),
74+
'original_device_class': None,
75+
'original_icon': None,
76+
'original_name': 'Lock',
77+
'platform': 'volvo',
78+
'previous_unique_id': None,
79+
'suggested_object_id': None,
80+
'supported_features': 0,
81+
'translation_key': 'lock',
82+
'unique_id': 'yv1abcdefg1234567_lock',
83+
'unit_of_measurement': None,
84+
})
85+
# ---
86+
# name: test_lock[s90_diesel_2018][lock.volvo_s90_lock-state]
87+
StateSnapshot({
88+
'attributes': ReadOnlyDict({
89+
'friendly_name': 'Volvo S90 Lock',
90+
'supported_features': <LockEntityFeature: 0>,
91+
}),
92+
'context': <ANY>,
93+
'entity_id': 'lock.volvo_s90_lock',
94+
'last_changed': <ANY>,
95+
'last_reported': <ANY>,
96+
'last_updated': <ANY>,
97+
'state': 'locked',
98+
})
99+
# ---
100+
# name: test_lock[xc40_electric_2024][lock.volvo_xc40_lock-entry]
101+
EntityRegistryEntrySnapshot({
102+
'aliases': set({
103+
}),
104+
'area_id': None,
105+
'capabilities': None,
106+
'config_entry_id': <ANY>,
107+
'config_subentry_id': <ANY>,
108+
'device_class': None,
109+
'device_id': <ANY>,
110+
'disabled_by': None,
111+
'domain': 'lock',
112+
'entity_category': None,
113+
'entity_id': 'lock.volvo_xc40_lock',
114+
'has_entity_name': True,
115+
'hidden_by': None,
116+
'icon': None,
117+
'id': <ANY>,
118+
'labels': set({
119+
}),
120+
'name': None,
121+
'options': dict({
122+
}),
123+
'original_device_class': None,
124+
'original_icon': None,
125+
'original_name': 'Lock',
126+
'platform': 'volvo',
127+
'previous_unique_id': None,
128+
'suggested_object_id': None,
129+
'supported_features': 0,
130+
'translation_key': 'lock',
131+
'unique_id': 'yv1abcdefg1234567_lock',
132+
'unit_of_measurement': None,
133+
})
134+
# ---
135+
# name: test_lock[xc40_electric_2024][lock.volvo_xc40_lock-state]
136+
StateSnapshot({
137+
'attributes': ReadOnlyDict({
138+
'friendly_name': 'Volvo XC40 Lock',
139+
'supported_features': <LockEntityFeature: 0>,
140+
}),
141+
'context': <ANY>,
142+
'entity_id': 'lock.volvo_xc40_lock',
143+
'last_changed': <ANY>,
144+
'last_reported': <ANY>,
145+
'last_updated': <ANY>,
146+
'state': 'locked',
147+
})
148+
# ---
149+
# name: test_lock[xc90_petrol_2019][lock.volvo_xc90_lock-entry]
150+
EntityRegistryEntrySnapshot({
151+
'aliases': set({
152+
}),
153+
'area_id': None,
154+
'capabilities': None,
155+
'config_entry_id': <ANY>,
156+
'config_subentry_id': <ANY>,
157+
'device_class': None,
158+
'device_id': <ANY>,
159+
'disabled_by': None,
160+
'domain': 'lock',
161+
'entity_category': None,
162+
'entity_id': 'lock.volvo_xc90_lock',
163+
'has_entity_name': True,
164+
'hidden_by': None,
165+
'icon': None,
166+
'id': <ANY>,
167+
'labels': set({
168+
}),
169+
'name': None,
170+
'options': dict({
171+
}),
172+
'original_device_class': None,
173+
'original_icon': None,
174+
'original_name': 'Lock',
175+
'platform': 'volvo',
176+
'previous_unique_id': None,
177+
'suggested_object_id': None,
178+
'supported_features': 0,
179+
'translation_key': 'lock',
180+
'unique_id': 'yv1abcdefg1234567_lock',
181+
'unit_of_measurement': None,
182+
})
183+
# ---
184+
# name: test_lock[xc90_petrol_2019][lock.volvo_xc90_lock-state]
185+
StateSnapshot({
186+
'attributes': ReadOnlyDict({
187+
'friendly_name': 'Volvo XC90 Lock',
188+
'supported_features': <LockEntityFeature: 0>,
189+
}),
190+
'context': <ANY>,
191+
'entity_id': 'lock.volvo_xc90_lock',
192+
'last_changed': <ANY>,
193+
'last_reported': <ANY>,
194+
'last_updated': <ANY>,
195+
'state': 'locked',
196+
})
197+
# ---

0 commit comments

Comments
 (0)