pytest-homeassistant-custom-component 0.13.163__py3-none-any.whl → 0.13.298__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_homeassistant_custom_component/common.py +289 -46
- pytest_homeassistant_custom_component/components/__init__.py +351 -0
- pytest_homeassistant_custom_component/components/diagnostics/__init__.py +71 -0
- pytest_homeassistant_custom_component/components/recorder/common.py +143 -13
- pytest_homeassistant_custom_component/components/recorder/db_schema_0.py +1 -1
- pytest_homeassistant_custom_component/const.py +4 -3
- pytest_homeassistant_custom_component/patch_json.py +41 -0
- pytest_homeassistant_custom_component/patch_recorder.py +1 -1
- pytest_homeassistant_custom_component/patch_time.py +66 -0
- pytest_homeassistant_custom_component/plugins.py +429 -177
- pytest_homeassistant_custom_component/syrupy.py +188 -1
- pytest_homeassistant_custom_component/test_util/aiohttp.py +56 -20
- pytest_homeassistant_custom_component/typing.py +5 -0
- {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/METADATA +54 -29
- pytest_homeassistant_custom_component-0.13.298.dist-info/RECORD +28 -0
- {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/WHEEL +1 -1
- pytest_homeassistant_custom_component-0.13.163.dist-info/RECORD +0 -26
- {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/entry_points.txt +0 -0
- {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info/licenses}/LICENSE +0 -0
- {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info/licenses}/LICENSE_HA_CORE.md +0 -0
- {pytest_homeassistant_custom_component-0.13.163.dist-info → pytest_homeassistant_custom_component-0.13.298.dist-info}/top_level.txt +0 -0
|
@@ -3,3 +3,354 @@ The tests for components.
|
|
|
3
3
|
|
|
4
4
|
This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component.
|
|
5
5
|
"""
|
|
6
|
+
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
import itertools
|
|
9
|
+
from typing import TypedDict
|
|
10
|
+
|
|
11
|
+
from homeassistant.const import (
|
|
12
|
+
ATTR_AREA_ID,
|
|
13
|
+
ATTR_DEVICE_ID,
|
|
14
|
+
ATTR_FLOOR_ID,
|
|
15
|
+
ATTR_LABEL_ID,
|
|
16
|
+
CONF_ENTITY_ID,
|
|
17
|
+
CONF_OPTIONS,
|
|
18
|
+
CONF_PLATFORM,
|
|
19
|
+
CONF_TARGET,
|
|
20
|
+
STATE_UNAVAILABLE,
|
|
21
|
+
STATE_UNKNOWN,
|
|
22
|
+
)
|
|
23
|
+
from homeassistant.core import HomeAssistant
|
|
24
|
+
from homeassistant.helpers import (
|
|
25
|
+
area_registry as ar,
|
|
26
|
+
device_registry as dr,
|
|
27
|
+
entity_registry as er,
|
|
28
|
+
floor_registry as fr,
|
|
29
|
+
label_registry as lr,
|
|
30
|
+
)
|
|
31
|
+
from homeassistant.setup import async_setup_component
|
|
32
|
+
|
|
33
|
+
from ..common import MockConfigEntry, mock_device_registry
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def target_entities(
|
|
37
|
+
hass: HomeAssistant, domain: str
|
|
38
|
+
) -> tuple[list[str], list[str]]:
|
|
39
|
+
"""Create multiple entities associated with different targets.
|
|
40
|
+
|
|
41
|
+
Returns a dict with the following keys:
|
|
42
|
+
- included: List of entity_ids meant to be targeted.
|
|
43
|
+
- excluded: List of entity_ids not meant to be targeted.
|
|
44
|
+
"""
|
|
45
|
+
await async_setup_component(hass, domain, {})
|
|
46
|
+
|
|
47
|
+
config_entry = MockConfigEntry(domain="test")
|
|
48
|
+
config_entry.add_to_hass(hass)
|
|
49
|
+
|
|
50
|
+
floor_reg = fr.async_get(hass)
|
|
51
|
+
floor = floor_reg.async_get_floor_by_name("Test Floor") or floor_reg.async_create(
|
|
52
|
+
"Test Floor"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
area_reg = ar.async_get(hass)
|
|
56
|
+
area = area_reg.async_get_area_by_name("Test Area") or area_reg.async_create(
|
|
57
|
+
"Test Area", floor_id=floor.floor_id
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
label_reg = lr.async_get(hass)
|
|
61
|
+
label = label_reg.async_get_label_by_name("Test Label") or label_reg.async_create(
|
|
62
|
+
"Test Label"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
|
|
66
|
+
mock_device_registry(hass, {device.id: device})
|
|
67
|
+
|
|
68
|
+
entity_reg = er.async_get(hass)
|
|
69
|
+
# Entities associated with area
|
|
70
|
+
entity_area = entity_reg.async_get_or_create(
|
|
71
|
+
domain=domain,
|
|
72
|
+
platform="test",
|
|
73
|
+
unique_id=f"{domain}_area",
|
|
74
|
+
suggested_object_id=f"area_{domain}",
|
|
75
|
+
)
|
|
76
|
+
entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id)
|
|
77
|
+
entity_area_excluded = entity_reg.async_get_or_create(
|
|
78
|
+
domain=domain,
|
|
79
|
+
platform="test",
|
|
80
|
+
unique_id=f"{domain}_area_excluded",
|
|
81
|
+
suggested_object_id=f"area_{domain}_excluded",
|
|
82
|
+
)
|
|
83
|
+
entity_reg.async_update_entity(entity_area_excluded.entity_id, area_id=area.id)
|
|
84
|
+
|
|
85
|
+
# Entities associated with device
|
|
86
|
+
entity_reg.async_get_or_create(
|
|
87
|
+
domain=domain,
|
|
88
|
+
platform="test",
|
|
89
|
+
unique_id=f"{domain}_device",
|
|
90
|
+
suggested_object_id=f"device_{domain}",
|
|
91
|
+
device_id=device.id,
|
|
92
|
+
)
|
|
93
|
+
entity_reg.async_get_or_create(
|
|
94
|
+
domain=domain,
|
|
95
|
+
platform="test",
|
|
96
|
+
unique_id=f"{domain}_device_excluded",
|
|
97
|
+
suggested_object_id=f"device_{domain}_excluded",
|
|
98
|
+
device_id=device.id,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Entities associated with label
|
|
102
|
+
entity_label = entity_reg.async_get_or_create(
|
|
103
|
+
domain=domain,
|
|
104
|
+
platform="test",
|
|
105
|
+
unique_id=f"{domain}_label",
|
|
106
|
+
suggested_object_id=f"label_{domain}",
|
|
107
|
+
)
|
|
108
|
+
entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id})
|
|
109
|
+
entity_label_excluded = entity_reg.async_get_or_create(
|
|
110
|
+
domain=domain,
|
|
111
|
+
platform="test",
|
|
112
|
+
unique_id=f"{domain}_label_excluded",
|
|
113
|
+
suggested_object_id=f"label_{domain}_excluded",
|
|
114
|
+
)
|
|
115
|
+
entity_reg.async_update_entity(
|
|
116
|
+
entity_label_excluded.entity_id, labels={label.label_id}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Return all available entities
|
|
120
|
+
return {
|
|
121
|
+
"included": [
|
|
122
|
+
f"{domain}.standalone_{domain}",
|
|
123
|
+
f"{domain}.label_{domain}",
|
|
124
|
+
f"{domain}.area_{domain}",
|
|
125
|
+
f"{domain}.device_{domain}",
|
|
126
|
+
],
|
|
127
|
+
"excluded": [
|
|
128
|
+
f"{domain}.standalone_{domain}_excluded",
|
|
129
|
+
f"{domain}.label_{domain}_excluded",
|
|
130
|
+
f"{domain}.area_{domain}_excluded",
|
|
131
|
+
f"{domain}.device_{domain}_excluded",
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
|
|
137
|
+
"""Parametrize target entities for different target types.
|
|
138
|
+
|
|
139
|
+
Meant to be used with target_entities.
|
|
140
|
+
"""
|
|
141
|
+
return [
|
|
142
|
+
(
|
|
143
|
+
{CONF_ENTITY_ID: f"{domain}.standalone_{domain}"},
|
|
144
|
+
f"{domain}.standalone_{domain}",
|
|
145
|
+
1,
|
|
146
|
+
),
|
|
147
|
+
({ATTR_LABEL_ID: "test_label"}, f"{domain}.label_{domain}", 2),
|
|
148
|
+
({ATTR_AREA_ID: "test_area"}, f"{domain}.area_{domain}", 2),
|
|
149
|
+
({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.area_{domain}", 2),
|
|
150
|
+
({ATTR_LABEL_ID: "test_label"}, f"{domain}.device_{domain}", 2),
|
|
151
|
+
({ATTR_AREA_ID: "test_area"}, f"{domain}.device_{domain}", 2),
|
|
152
|
+
({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.device_{domain}", 2),
|
|
153
|
+
({ATTR_DEVICE_ID: "test_device"}, f"{domain}.device_{domain}", 1),
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _StateDescription(TypedDict):
|
|
158
|
+
"""Test state and expected service call count."""
|
|
159
|
+
|
|
160
|
+
state: str | None
|
|
161
|
+
attributes: dict
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class StateDescription(TypedDict):
|
|
165
|
+
"""Test state and expected service call count."""
|
|
166
|
+
|
|
167
|
+
included: _StateDescription
|
|
168
|
+
excluded: _StateDescription
|
|
169
|
+
count: int
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parametrize_trigger_states(
|
|
173
|
+
*,
|
|
174
|
+
trigger: str,
|
|
175
|
+
target_states: list[str | None | tuple[str | None, dict]],
|
|
176
|
+
other_states: list[str | None | tuple[str | None, dict]],
|
|
177
|
+
additional_attributes: dict | None = None,
|
|
178
|
+
trigger_from_none: bool = True,
|
|
179
|
+
) -> list[tuple[str, list[StateDescription]]]:
|
|
180
|
+
"""Parametrize states and expected service call counts.
|
|
181
|
+
|
|
182
|
+
The target_states and other_states iterables are either iterables of
|
|
183
|
+
states or iterables of (state, attributes) tuples.
|
|
184
|
+
|
|
185
|
+
Set `trigger_from_none` to False if the trigger is not expected to fire
|
|
186
|
+
when the initial state is None.
|
|
187
|
+
|
|
188
|
+
Returns a list of tuples with (trigger, list of states),
|
|
189
|
+
where states is a list of StateDescription dicts.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
additional_attributes = additional_attributes or {}
|
|
193
|
+
|
|
194
|
+
def state_with_attributes(
|
|
195
|
+
state: str | None | tuple[str | None, dict], count: int
|
|
196
|
+
) -> dict:
|
|
197
|
+
"""Return (state, attributes) dict."""
|
|
198
|
+
if isinstance(state, str) or state is None:
|
|
199
|
+
return {
|
|
200
|
+
"included": {
|
|
201
|
+
"state": state,
|
|
202
|
+
"attributes": additional_attributes,
|
|
203
|
+
},
|
|
204
|
+
"excluded": {
|
|
205
|
+
"state": state,
|
|
206
|
+
"attributes": {},
|
|
207
|
+
},
|
|
208
|
+
"count": count,
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
"included": {
|
|
212
|
+
"state": state[0],
|
|
213
|
+
"attributes": state[1] | additional_attributes,
|
|
214
|
+
},
|
|
215
|
+
"excluded": {
|
|
216
|
+
"state": state[0],
|
|
217
|
+
"attributes": state[1],
|
|
218
|
+
},
|
|
219
|
+
"count": count,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return [
|
|
223
|
+
# Initial state None
|
|
224
|
+
(
|
|
225
|
+
trigger,
|
|
226
|
+
list(
|
|
227
|
+
itertools.chain.from_iterable(
|
|
228
|
+
(
|
|
229
|
+
state_with_attributes(None, 0),
|
|
230
|
+
state_with_attributes(target_state, 0),
|
|
231
|
+
state_with_attributes(other_state, 0),
|
|
232
|
+
state_with_attributes(
|
|
233
|
+
target_state, 1 if trigger_from_none else 0
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
for target_state in target_states
|
|
237
|
+
for other_state in other_states
|
|
238
|
+
)
|
|
239
|
+
),
|
|
240
|
+
),
|
|
241
|
+
# Initial state different from target state
|
|
242
|
+
(
|
|
243
|
+
trigger,
|
|
244
|
+
# other_state,
|
|
245
|
+
list(
|
|
246
|
+
itertools.chain.from_iterable(
|
|
247
|
+
(
|
|
248
|
+
state_with_attributes(other_state, 0),
|
|
249
|
+
state_with_attributes(target_state, 1),
|
|
250
|
+
state_with_attributes(other_state, 0),
|
|
251
|
+
state_with_attributes(target_state, 1),
|
|
252
|
+
)
|
|
253
|
+
for target_state in target_states
|
|
254
|
+
for other_state in other_states
|
|
255
|
+
)
|
|
256
|
+
),
|
|
257
|
+
),
|
|
258
|
+
# Initial state same as target state
|
|
259
|
+
(
|
|
260
|
+
trigger,
|
|
261
|
+
list(
|
|
262
|
+
itertools.chain.from_iterable(
|
|
263
|
+
(
|
|
264
|
+
state_with_attributes(target_state, 0),
|
|
265
|
+
state_with_attributes(target_state, 0),
|
|
266
|
+
state_with_attributes(other_state, 0),
|
|
267
|
+
state_with_attributes(target_state, 1),
|
|
268
|
+
)
|
|
269
|
+
for target_state in target_states
|
|
270
|
+
for other_state in other_states
|
|
271
|
+
)
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
# Initial state unavailable / unknown
|
|
275
|
+
(
|
|
276
|
+
trigger,
|
|
277
|
+
list(
|
|
278
|
+
itertools.chain.from_iterable(
|
|
279
|
+
(
|
|
280
|
+
state_with_attributes(STATE_UNAVAILABLE, 0),
|
|
281
|
+
state_with_attributes(target_state, 0),
|
|
282
|
+
state_with_attributes(other_state, 0),
|
|
283
|
+
state_with_attributes(target_state, 1),
|
|
284
|
+
)
|
|
285
|
+
for target_state in target_states
|
|
286
|
+
for other_state in other_states
|
|
287
|
+
)
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
(
|
|
291
|
+
trigger,
|
|
292
|
+
list(
|
|
293
|
+
itertools.chain.from_iterable(
|
|
294
|
+
(
|
|
295
|
+
state_with_attributes(STATE_UNKNOWN, 0),
|
|
296
|
+
state_with_attributes(target_state, 0),
|
|
297
|
+
state_with_attributes(other_state, 0),
|
|
298
|
+
state_with_attributes(target_state, 1),
|
|
299
|
+
)
|
|
300
|
+
for target_state in target_states
|
|
301
|
+
for other_state in other_states
|
|
302
|
+
)
|
|
303
|
+
),
|
|
304
|
+
),
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def arm_trigger(
|
|
309
|
+
hass: HomeAssistant,
|
|
310
|
+
trigger: str,
|
|
311
|
+
trigger_options: dict | None,
|
|
312
|
+
trigger_target: dict,
|
|
313
|
+
) -> None:
|
|
314
|
+
"""Arm the specified trigger, call service test.automation when it triggers."""
|
|
315
|
+
|
|
316
|
+
# Local include to avoid importing the automation component unnecessarily
|
|
317
|
+
from homeassistant.components import automation # noqa: PLC0415
|
|
318
|
+
|
|
319
|
+
options = {CONF_OPTIONS: {**trigger_options}} if trigger_options is not None else {}
|
|
320
|
+
|
|
321
|
+
await async_setup_component(
|
|
322
|
+
hass,
|
|
323
|
+
automation.DOMAIN,
|
|
324
|
+
{
|
|
325
|
+
automation.DOMAIN: {
|
|
326
|
+
"trigger": {
|
|
327
|
+
CONF_PLATFORM: trigger,
|
|
328
|
+
CONF_TARGET: {**trigger_target},
|
|
329
|
+
}
|
|
330
|
+
| options,
|
|
331
|
+
"action": {
|
|
332
|
+
"service": "test.automation",
|
|
333
|
+
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
|
|
334
|
+
},
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def set_or_remove_state(
|
|
341
|
+
hass: HomeAssistant,
|
|
342
|
+
entity_id: str,
|
|
343
|
+
state: StateDescription,
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Set or remove the state of an entity."""
|
|
346
|
+
if state["state"] is None:
|
|
347
|
+
hass.states.async_remove(entity_id)
|
|
348
|
+
else:
|
|
349
|
+
hass.states.async_set(
|
|
350
|
+
entity_id, state["state"], state["attributes"], force_update=True
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def other_states(state: StrEnum) -> list[str]:
|
|
355
|
+
"""Return a sorted list with all states except the specified one."""
|
|
356
|
+
return sorted({s.value for s in state.__class__} - {state.value})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the Diagnostics integration.
|
|
3
|
+
|
|
4
|
+
This file is originally from homeassistant/core and modified by pytest-homeassistant-custom-component.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from homeassistant.config_entries import ConfigEntry
|
|
11
|
+
from homeassistant.core import HomeAssistant
|
|
12
|
+
from homeassistant.helpers.device_registry import DeviceEntry
|
|
13
|
+
from homeassistant.setup import async_setup_component
|
|
14
|
+
from homeassistant.util.json import JsonObjectType
|
|
15
|
+
|
|
16
|
+
from pytest_homeassistant_custom_component.typing import ClientSessionGenerator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _get_diagnostics_for_config_entry(
|
|
20
|
+
hass: HomeAssistant,
|
|
21
|
+
hass_client: ClientSessionGenerator,
|
|
22
|
+
config_entry: ConfigEntry,
|
|
23
|
+
) -> JsonObjectType:
|
|
24
|
+
"""Return the diagnostics config entry for the specified domain."""
|
|
25
|
+
assert await async_setup_component(hass, "diagnostics", {})
|
|
26
|
+
await hass.async_block_till_done()
|
|
27
|
+
|
|
28
|
+
client = await hass_client()
|
|
29
|
+
response = await client.get(
|
|
30
|
+
f"/api/diagnostics/config_entry/{config_entry.entry_id}"
|
|
31
|
+
)
|
|
32
|
+
assert response.status == HTTPStatus.OK
|
|
33
|
+
return cast(JsonObjectType, await response.json())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def get_diagnostics_for_config_entry(
|
|
37
|
+
hass: HomeAssistant,
|
|
38
|
+
hass_client: ClientSessionGenerator,
|
|
39
|
+
config_entry: ConfigEntry,
|
|
40
|
+
) -> JsonObjectType:
|
|
41
|
+
"""Return the diagnostics config entry for the specified domain."""
|
|
42
|
+
data = await _get_diagnostics_for_config_entry(hass, hass_client, config_entry)
|
|
43
|
+
return cast(JsonObjectType, data["data"])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _get_diagnostics_for_device(
|
|
47
|
+
hass: HomeAssistant,
|
|
48
|
+
hass_client: ClientSessionGenerator,
|
|
49
|
+
config_entry: ConfigEntry,
|
|
50
|
+
device: DeviceEntry,
|
|
51
|
+
) -> JsonObjectType:
|
|
52
|
+
"""Return the diagnostics for the specified device."""
|
|
53
|
+
assert await async_setup_component(hass, "diagnostics", {})
|
|
54
|
+
|
|
55
|
+
client = await hass_client()
|
|
56
|
+
response = await client.get(
|
|
57
|
+
f"/api/diagnostics/config_entry/{config_entry.entry_id}/device/{device.id}"
|
|
58
|
+
)
|
|
59
|
+
assert response.status == HTTPStatus.OK
|
|
60
|
+
return cast(JsonObjectType, await response.json())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def get_diagnostics_for_device(
|
|
64
|
+
hass: HomeAssistant,
|
|
65
|
+
hass_client: ClientSessionGenerator,
|
|
66
|
+
config_entry: ConfigEntry,
|
|
67
|
+
device: DeviceEntry,
|
|
68
|
+
) -> JsonObjectType:
|
|
69
|
+
"""Return the diagnostics for the specified device."""
|
|
70
|
+
data = await _get_diagnostics_for_device(hass, hass_client, config_entry, device)
|
|
71
|
+
return cast(JsonObjectType, data["data"])
|
|
@@ -15,11 +15,13 @@ from functools import partial
|
|
|
15
15
|
import importlib
|
|
16
16
|
import sys
|
|
17
17
|
import time
|
|
18
|
+
from types import ModuleType
|
|
18
19
|
from typing import Any, Literal, cast
|
|
19
20
|
from unittest.mock import MagicMock, patch, sentinel
|
|
20
21
|
|
|
21
22
|
from freezegun import freeze_time
|
|
22
|
-
|
|
23
|
+
import pytest
|
|
24
|
+
from sqlalchemy import create_engine, event as sqlalchemy_event
|
|
23
25
|
from sqlalchemy.orm.session import Session
|
|
24
26
|
|
|
25
27
|
from homeassistant import core as ha
|
|
@@ -32,16 +34,25 @@ from homeassistant.components.recorder import (
|
|
|
32
34
|
statistics,
|
|
33
35
|
)
|
|
34
36
|
from homeassistant.components.recorder.db_schema import (
|
|
37
|
+
EventData,
|
|
35
38
|
Events,
|
|
36
39
|
EventTypes,
|
|
37
40
|
RecorderRuns,
|
|
41
|
+
StateAttributes,
|
|
38
42
|
States,
|
|
39
43
|
StatesMeta,
|
|
40
44
|
)
|
|
45
|
+
from homeassistant.components.recorder.models import (
|
|
46
|
+
bytes_to_ulid_or_none,
|
|
47
|
+
bytes_to_uuid_hex_or_none,
|
|
48
|
+
)
|
|
41
49
|
from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask
|
|
42
|
-
from homeassistant.
|
|
50
|
+
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
|
|
51
|
+
from homeassistant.const import DEGREE, UnitOfTemperature
|
|
43
52
|
from homeassistant.core import Event, HomeAssistant, State
|
|
44
|
-
|
|
53
|
+
from homeassistant.helpers import recorder as recorder_helper
|
|
54
|
+
from homeassistant.util import dt as dt_util
|
|
55
|
+
from homeassistant.util.json import json_loads, json_loads_object
|
|
45
56
|
|
|
46
57
|
from . import db_schema_0
|
|
47
58
|
|
|
@@ -83,6 +94,11 @@ async def async_block_recorder(hass: HomeAssistant, seconds: float) -> None:
|
|
|
83
94
|
await event.wait()
|
|
84
95
|
|
|
85
96
|
|
|
97
|
+
async def async_wait_recorder(hass: HomeAssistant) -> bool:
|
|
98
|
+
"""Wait for recorder to initialize and return connection status."""
|
|
99
|
+
return await hass.data[recorder_helper.DATA_RECORDER].db_connected
|
|
100
|
+
|
|
101
|
+
|
|
86
102
|
def get_start_time(start: datetime) -> datetime:
|
|
87
103
|
"""Calculate a valid start time for statistics."""
|
|
88
104
|
start_minutes = start.minute - start.minute % 5
|
|
@@ -288,6 +304,7 @@ def record_states(
|
|
|
288
304
|
sns2 = "sensor.test2"
|
|
289
305
|
sns3 = "sensor.test3"
|
|
290
306
|
sns4 = "sensor.test4"
|
|
307
|
+
sns5 = "sensor.wind_direction"
|
|
291
308
|
sns1_attr = {
|
|
292
309
|
"device_class": "temperature",
|
|
293
310
|
"state_class": "measurement",
|
|
@@ -300,6 +317,11 @@ def record_states(
|
|
|
300
317
|
}
|
|
301
318
|
sns3_attr = {"device_class": "temperature"}
|
|
302
319
|
sns4_attr = {}
|
|
320
|
+
sns5_attr = {
|
|
321
|
+
"device_class": SensorDeviceClass.WIND_DIRECTION,
|
|
322
|
+
"state_class": SensorStateClass.MEASUREMENT_ANGLE,
|
|
323
|
+
"unit_of_measurement": DEGREE,
|
|
324
|
+
}
|
|
303
325
|
|
|
304
326
|
def set_state(entity_id, state, **kwargs):
|
|
305
327
|
"""Set the state."""
|
|
@@ -313,7 +335,7 @@ def record_states(
|
|
|
313
335
|
three = two + timedelta(seconds=30 * 5)
|
|
314
336
|
four = three + timedelta(seconds=14 * 5)
|
|
315
337
|
|
|
316
|
-
states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []}
|
|
338
|
+
states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: [], sns5: []}
|
|
317
339
|
with freeze_time(one) as freezer:
|
|
318
340
|
states[mp].append(
|
|
319
341
|
set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)})
|
|
@@ -322,6 +344,7 @@ def record_states(
|
|
|
322
344
|
states[sns2].append(set_state(sns2, "10", attributes=sns2_attr))
|
|
323
345
|
states[sns3].append(set_state(sns3, "10", attributes=sns3_attr))
|
|
324
346
|
states[sns4].append(set_state(sns4, "10", attributes=sns4_attr))
|
|
347
|
+
states[sns5].append(set_state(sns5, "10", attributes=sns5_attr))
|
|
325
348
|
|
|
326
349
|
freezer.move_to(one + timedelta(microseconds=1))
|
|
327
350
|
states[mp].append(
|
|
@@ -333,12 +356,14 @@ def record_states(
|
|
|
333
356
|
states[sns2].append(set_state(sns2, "15", attributes=sns2_attr))
|
|
334
357
|
states[sns3].append(set_state(sns3, "15", attributes=sns3_attr))
|
|
335
358
|
states[sns4].append(set_state(sns4, "15", attributes=sns4_attr))
|
|
359
|
+
states[sns5].append(set_state(sns5, "350", attributes=sns5_attr))
|
|
336
360
|
|
|
337
361
|
freezer.move_to(three)
|
|
338
362
|
states[sns1].append(set_state(sns1, "20", attributes=sns1_attr))
|
|
339
363
|
states[sns2].append(set_state(sns2, "20", attributes=sns2_attr))
|
|
340
364
|
states[sns3].append(set_state(sns3, "20", attributes=sns3_attr))
|
|
341
365
|
states[sns4].append(set_state(sns4, "20", attributes=sns4_attr))
|
|
366
|
+
states[sns5].append(set_state(sns5, "5", attributes=sns5_attr))
|
|
342
367
|
|
|
343
368
|
return zero, four, states
|
|
344
369
|
|
|
@@ -412,7 +437,15 @@ def create_engine_test_for_schema_version_postfix(
|
|
|
412
437
|
schema_module = get_schema_module_path(schema_version_postfix)
|
|
413
438
|
importlib.import_module(schema_module)
|
|
414
439
|
old_db_schema = sys.modules[schema_module]
|
|
440
|
+
instance: Recorder | None = None
|
|
441
|
+
if "hass" in kwargs:
|
|
442
|
+
hass: HomeAssistant = kwargs.pop("hass")
|
|
443
|
+
instance = recorder.get_instance(hass)
|
|
415
444
|
engine = create_engine(*args, **kwargs)
|
|
445
|
+
if instance is not None:
|
|
446
|
+
instance = recorder.get_instance(hass)
|
|
447
|
+
instance.engine = engine
|
|
448
|
+
sqlalchemy_event.listen(engine, "connect", instance._setup_recorder_connection)
|
|
416
449
|
old_db_schema.Base.metadata.create_all(engine)
|
|
417
450
|
with Session(engine) as session:
|
|
418
451
|
session.add(
|
|
@@ -432,16 +465,15 @@ def get_schema_module_path(schema_version_postfix: str) -> str:
|
|
|
432
465
|
return f"...components.recorder.db_schema_{schema_version_postfix}"
|
|
433
466
|
|
|
434
467
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
"""Run migration task."""
|
|
468
|
+
def get_patched_live_version(old_db_schema: ModuleType) -> int:
|
|
469
|
+
"""Return the patched live migration version."""
|
|
470
|
+
return min(
|
|
471
|
+
migration.LIVE_MIGRATION_MIN_SCHEMA_VERSION, old_db_schema.SCHEMA_VERSION
|
|
472
|
+
)
|
|
441
473
|
|
|
442
474
|
|
|
443
475
|
@contextmanager
|
|
444
|
-
def old_db_schema(schema_version_postfix: str) -> Iterator[None]:
|
|
476
|
+
def old_db_schema(hass: HomeAssistant, schema_version_postfix: str) -> Iterator[None]:
|
|
445
477
|
"""Fixture to initialize the db with the old schema."""
|
|
446
478
|
schema_module = get_schema_module_path(schema_version_postfix)
|
|
447
479
|
importlib.import_module(schema_module)
|
|
@@ -449,20 +481,24 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]:
|
|
|
449
481
|
|
|
450
482
|
with (
|
|
451
483
|
patch.object(recorder, "db_schema", old_db_schema),
|
|
484
|
+
patch.object(migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION),
|
|
452
485
|
patch.object(
|
|
453
|
-
|
|
486
|
+
migration,
|
|
487
|
+
"LIVE_MIGRATION_MIN_SCHEMA_VERSION",
|
|
488
|
+
get_patched_live_version(old_db_schema),
|
|
454
489
|
),
|
|
490
|
+
patch.object(migration, "non_live_data_migration_needed", return_value=False),
|
|
455
491
|
patch.object(core, "StatesMeta", old_db_schema.StatesMeta),
|
|
456
492
|
patch.object(core, "EventTypes", old_db_schema.EventTypes),
|
|
457
493
|
patch.object(core, "EventData", old_db_schema.EventData),
|
|
458
494
|
patch.object(core, "States", old_db_schema.States),
|
|
459
495
|
patch.object(core, "Events", old_db_schema.Events),
|
|
460
496
|
patch.object(core, "StateAttributes", old_db_schema.StateAttributes),
|
|
461
|
-
patch.object(migration.EntityIDMigration, "task", MockMigrationTask),
|
|
462
497
|
patch(
|
|
463
498
|
CREATE_ENGINE_TARGET,
|
|
464
499
|
new=partial(
|
|
465
500
|
create_engine_test_for_schema_version_postfix,
|
|
501
|
+
hass=hass,
|
|
466
502
|
schema_version_postfix=schema_version_postfix,
|
|
467
503
|
),
|
|
468
504
|
),
|
|
@@ -481,3 +517,97 @@ async def async_attach_db_engine(hass: HomeAssistant) -> None:
|
|
|
481
517
|
)
|
|
482
518
|
|
|
483
519
|
await instance.async_add_executor_job(_mock_setup_recorder_connection)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
EVENT_ORIGIN_ORDER = [ha.EventOrigin.local, ha.EventOrigin.remote]
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def db_event_to_native(event: Events, validate_entity_id: bool = True) -> Event | None:
|
|
526
|
+
"""Convert to a native HA Event."""
|
|
527
|
+
context = ha.Context(
|
|
528
|
+
id=bytes_to_ulid_or_none(event.context_id_bin),
|
|
529
|
+
user_id=bytes_to_uuid_hex_or_none(event.context_user_id_bin),
|
|
530
|
+
parent_id=bytes_to_ulid_or_none(event.context_parent_id_bin),
|
|
531
|
+
)
|
|
532
|
+
return Event(
|
|
533
|
+
event.event_type or "",
|
|
534
|
+
json_loads_object(event.event_data) if event.event_data else {},
|
|
535
|
+
ha.EventOrigin(event.origin)
|
|
536
|
+
if event.origin
|
|
537
|
+
else EVENT_ORIGIN_ORDER[event.origin_idx or 0],
|
|
538
|
+
event.time_fired_ts or 0,
|
|
539
|
+
context=context,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def db_event_data_to_native(event_data: EventData) -> dict[str, Any]:
|
|
544
|
+
"""Convert to an event data dictionary."""
|
|
545
|
+
shared_data = event_data.shared_data
|
|
546
|
+
if shared_data is None:
|
|
547
|
+
return {}
|
|
548
|
+
return cast(dict[str, Any], json_loads(shared_data))
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def db_state_to_native(state: States, validate_entity_id: bool = True) -> State | None:
|
|
552
|
+
"""Convert to an HA state object."""
|
|
553
|
+
context = ha.Context(
|
|
554
|
+
id=bytes_to_ulid_or_none(state.context_id_bin),
|
|
555
|
+
user_id=bytes_to_uuid_hex_or_none(state.context_user_id_bin),
|
|
556
|
+
parent_id=bytes_to_ulid_or_none(state.context_parent_id_bin),
|
|
557
|
+
)
|
|
558
|
+
attrs = json_loads_object(state.attributes) if state.attributes else {}
|
|
559
|
+
last_updated = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
|
|
560
|
+
if state.last_changed_ts is None or state.last_changed_ts == state.last_updated_ts:
|
|
561
|
+
last_changed = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
|
|
562
|
+
else:
|
|
563
|
+
last_changed = dt_util.utc_from_timestamp(state.last_changed_ts or 0)
|
|
564
|
+
if (
|
|
565
|
+
state.last_reported_ts is None
|
|
566
|
+
or state.last_reported_ts == state.last_updated_ts
|
|
567
|
+
):
|
|
568
|
+
last_reported = dt_util.utc_from_timestamp(state.last_updated_ts or 0)
|
|
569
|
+
else:
|
|
570
|
+
last_reported = dt_util.utc_from_timestamp(state.last_reported_ts or 0)
|
|
571
|
+
return State(
|
|
572
|
+
state.entity_id or "",
|
|
573
|
+
state.state, # type: ignore[arg-type]
|
|
574
|
+
# Join the state_attributes table on attributes_id to get the attributes
|
|
575
|
+
# for newer states
|
|
576
|
+
attrs,
|
|
577
|
+
last_changed=last_changed,
|
|
578
|
+
last_reported=last_reported,
|
|
579
|
+
last_updated=last_updated,
|
|
580
|
+
context=context,
|
|
581
|
+
validate_entity_id=validate_entity_id,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def db_state_attributes_to_native(state_attrs: StateAttributes) -> dict[str, Any]:
|
|
586
|
+
"""Convert to a state attributes dictionary."""
|
|
587
|
+
shared_attrs = state_attrs.shared_attrs
|
|
588
|
+
if shared_attrs is None:
|
|
589
|
+
return {}
|
|
590
|
+
return cast(dict[str, Any], json_loads(shared_attrs))
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
async def async_drop_index(
|
|
594
|
+
recorder: Recorder, table: str, index: str, caplog: pytest.LogCaptureFixture
|
|
595
|
+
) -> None:
|
|
596
|
+
"""Drop an index from the database.
|
|
597
|
+
|
|
598
|
+
migration._drop_index does not return or raise, so we verify the result
|
|
599
|
+
by checking the log for success or failure messages.
|
|
600
|
+
"""
|
|
601
|
+
|
|
602
|
+
finish_msg = f"Finished dropping index `{index}` from table `{table}`"
|
|
603
|
+
fail_msg = f"Failed to drop index `{index}` from table `{table}`"
|
|
604
|
+
|
|
605
|
+
count_finish = caplog.text.count(finish_msg)
|
|
606
|
+
count_fail = caplog.text.count(fail_msg)
|
|
607
|
+
|
|
608
|
+
await recorder.async_add_executor_job(
|
|
609
|
+
migration._drop_index, recorder.get_session, table, index
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
assert caplog.text.count(finish_msg) == count_finish + 1
|
|
613
|
+
assert caplog.text.count(fail_msg) == count_fail
|
|
@@ -27,7 +27,7 @@ from sqlalchemy.orm.session import Session
|
|
|
27
27
|
|
|
28
28
|
from homeassistant.core import Event, EventOrigin, State, split_entity_id
|
|
29
29
|
from homeassistant.helpers.json import JSONEncoder
|
|
30
|
-
|
|
30
|
+
from homeassistant.util import dt as dt_util
|
|
31
31
|
|
|
32
32
|
# SQLAlchemy Schema
|
|
33
33
|
Base = declarative_base()
|