mirror of
https://github.com/home-assistant/core.git
synced 2025-08-01 09:38:21 +00:00
Add action to retrieve list of programs on miele appliance (#149307)
This commit is contained in:
parent
7f9be420d2
commit
cf05f1046d
@ -105,6 +105,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
|
"get_programs": {
|
||||||
|
"service": "mdi:stack-overflow"
|
||||||
|
},
|
||||||
"set_program": {
|
"set_program": {
|
||||||
"service": "mdi:arrow-right-circle-outline"
|
"service": "mdi:arrow-right-circle-outline"
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,12 @@ import aiohttp
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.const import ATTR_DEVICE_ID
|
from homeassistant.const import ATTR_DEVICE_ID
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall
|
from homeassistant.core import (
|
||||||
|
HomeAssistant,
|
||||||
|
ServiceCall,
|
||||||
|
ServiceResponse,
|
||||||
|
SupportsResponse,
|
||||||
|
)
|
||||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||||
from homeassistant.helpers.service import async_extract_config_entry_ids
|
from homeassistant.helpers.service import async_extract_config_entry_ids
|
||||||
@ -27,6 +32,13 @@ SERVICE_SET_PROGRAM_SCHEMA = vol.Schema(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SERVICE_GET_PROGRAMS = "get_programs"
|
||||||
|
SERVICE_GET_PROGRAMS_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(ATTR_DEVICE_ID): str,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -47,17 +59,12 @@ async def _extract_config_entry(service_call: ServiceCall) -> MieleConfigEntry:
|
|||||||
return target_entries[0]
|
return target_entries[0]
|
||||||
|
|
||||||
|
|
||||||
async def set_program(call: ServiceCall) -> None:
|
async def _get_serial_number(call: ServiceCall) -> str:
|
||||||
"""Set a program on a Miele appliance."""
|
"""Extract the serial number from the device identifier."""
|
||||||
|
|
||||||
_LOGGER.debug("Set program call: %s", call)
|
|
||||||
config_entry = await _extract_config_entry(call)
|
|
||||||
device_reg = dr.async_get(call.hass)
|
device_reg = dr.async_get(call.hass)
|
||||||
api = config_entry.runtime_data.api
|
|
||||||
device = call.data[ATTR_DEVICE_ID]
|
device = call.data[ATTR_DEVICE_ID]
|
||||||
device_entry = device_reg.async_get(device)
|
device_entry = device_reg.async_get(device)
|
||||||
|
|
||||||
data = {"programId": call.data[ATTR_PROGRAM_ID]}
|
|
||||||
serial_number = next(
|
serial_number = next(
|
||||||
(
|
(
|
||||||
identifier[1]
|
identifier[1]
|
||||||
@ -71,6 +78,18 @@ async def set_program(call: ServiceCall) -> None:
|
|||||||
translation_domain=DOMAIN,
|
translation_domain=DOMAIN,
|
||||||
translation_key="invalid_target",
|
translation_key="invalid_target",
|
||||||
)
|
)
|
||||||
|
return serial_number
|
||||||
|
|
||||||
|
|
||||||
|
async def set_program(call: ServiceCall) -> None:
|
||||||
|
"""Set a program on a Miele appliance."""
|
||||||
|
|
||||||
|
_LOGGER.debug("Set program call: %s", call)
|
||||||
|
config_entry = await _extract_config_entry(call)
|
||||||
|
api = config_entry.runtime_data.api
|
||||||
|
|
||||||
|
serial_number = await _get_serial_number(call)
|
||||||
|
data = {"programId": call.data[ATTR_PROGRAM_ID]}
|
||||||
try:
|
try:
|
||||||
await api.set_program(serial_number, data)
|
await api.set_program(serial_number, data)
|
||||||
except aiohttp.ClientResponseError as ex:
|
except aiohttp.ClientResponseError as ex:
|
||||||
@ -84,9 +103,82 @@ async def set_program(call: ServiceCall) -> None:
|
|||||||
) from ex
|
) from ex
|
||||||
|
|
||||||
|
|
||||||
|
async def get_programs(call: ServiceCall) -> ServiceResponse:
|
||||||
|
"""Get available programs from appliance."""
|
||||||
|
|
||||||
|
config_entry = await _extract_config_entry(call)
|
||||||
|
api = config_entry.runtime_data.api
|
||||||
|
serial_number = await _get_serial_number(call)
|
||||||
|
|
||||||
|
try:
|
||||||
|
programs = await api.get_programs(serial_number)
|
||||||
|
except aiohttp.ClientResponseError as ex:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="get_programs_error",
|
||||||
|
translation_placeholders={
|
||||||
|
"status": str(ex.status),
|
||||||
|
"message": ex.message,
|
||||||
|
},
|
||||||
|
) from ex
|
||||||
|
|
||||||
|
return {
|
||||||
|
"programs": [
|
||||||
|
{
|
||||||
|
"program_id": item["programId"],
|
||||||
|
"program": item["program"],
|
||||||
|
"parameters": (
|
||||||
|
{
|
||||||
|
"temperature": (
|
||||||
|
{
|
||||||
|
"min": item["parameters"]["temperature"]["min"],
|
||||||
|
"max": item["parameters"]["temperature"]["max"],
|
||||||
|
"step": item["parameters"]["temperature"]["step"],
|
||||||
|
"mandatory": item["parameters"]["temperature"][
|
||||||
|
"mandatory"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if "temperature" in item["parameters"]
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
"duration": (
|
||||||
|
{
|
||||||
|
"min": {
|
||||||
|
"hours": item["parameters"]["duration"]["min"][0],
|
||||||
|
"minutes": item["parameters"]["duration"]["min"][1],
|
||||||
|
},
|
||||||
|
"max": {
|
||||||
|
"hours": item["parameters"]["duration"]["max"][0],
|
||||||
|
"minutes": item["parameters"]["duration"]["max"][1],
|
||||||
|
},
|
||||||
|
"mandatory": item["parameters"]["duration"][
|
||||||
|
"mandatory"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if "duration" in item["parameters"]
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if item["parameters"]
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for item in programs
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_services(hass: HomeAssistant) -> None:
|
async def async_setup_services(hass: HomeAssistant) -> None:
|
||||||
"""Set up services."""
|
"""Set up services."""
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA
|
DOMAIN, SERVICE_SET_PROGRAM, set_program, SERVICE_SET_PROGRAM_SCHEMA
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hass.services.async_register(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_GET_PROGRAMS,
|
||||||
|
get_programs,
|
||||||
|
SERVICE_GET_PROGRAMS_SCHEMA,
|
||||||
|
supports_response=SupportsResponse.ONLY,
|
||||||
|
)
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
# Services descriptions for Miele integration
|
# Services descriptions for Miele integration
|
||||||
|
|
||||||
|
get_programs:
|
||||||
|
fields:
|
||||||
|
device_id:
|
||||||
|
selector:
|
||||||
|
device:
|
||||||
|
integration: miele
|
||||||
|
required: true
|
||||||
|
|
||||||
set_program:
|
set_program:
|
||||||
fields:
|
fields:
|
||||||
device_id:
|
device_id:
|
||||||
|
@ -1062,6 +1062,9 @@
|
|||||||
"invalid_target": {
|
"invalid_target": {
|
||||||
"message": "Invalid device targeted."
|
"message": "Invalid device targeted."
|
||||||
},
|
},
|
||||||
|
"get_programs_error": {
|
||||||
|
"message": "'Get programs' action failed {status} / {message}."
|
||||||
|
},
|
||||||
"set_program_error": {
|
"set_program_error": {
|
||||||
"message": "'Set program' action failed {status} / {message}."
|
"message": "'Set program' action failed {status} / {message}."
|
||||||
},
|
},
|
||||||
@ -1070,12 +1073,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
|
"get_programs": {
|
||||||
|
"name": "Get programs",
|
||||||
|
"description": "Returns a list of available programs.",
|
||||||
|
"fields": {
|
||||||
|
"device_id": {
|
||||||
|
"description": "[%key:component::miele::services::set_program::fields::device_id::description%]",
|
||||||
|
"name": "[%key:component::miele::services::set_program::fields::device_id::name%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"set_program": {
|
"set_program": {
|
||||||
"name": "Set program",
|
"name": "Set program",
|
||||||
"description": "Sets and starts a program on the appliance.",
|
"description": "Sets and starts a program on the appliance.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"device_id": {
|
"device_id": {
|
||||||
"description": "The device to set the program on.",
|
"description": "The target device for this action.",
|
||||||
"name": "Device"
|
"name": "Device"
|
||||||
},
|
},
|
||||||
"program_id": {
|
"program_id": {
|
||||||
|
@ -20,8 +20,8 @@ from .const import CLIENT_ID, CLIENT_SECRET
|
|||||||
|
|
||||||
from tests.common import (
|
from tests.common import (
|
||||||
MockConfigEntry,
|
MockConfigEntry,
|
||||||
async_load_fixture,
|
|
||||||
async_load_json_object_fixture,
|
async_load_json_object_fixture,
|
||||||
|
load_json_value_fixture,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -99,13 +99,13 @@ async def action_fixture(hass: HomeAssistant, load_action_file: str) -> MieleAct
|
|||||||
@pytest.fixture(scope="package")
|
@pytest.fixture(scope="package")
|
||||||
def load_programs_file() -> str:
|
def load_programs_file() -> str:
|
||||||
"""Fixture for loading programs file."""
|
"""Fixture for loading programs file."""
|
||||||
return "programs_washing_machine.json"
|
return "programs.json"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]:
|
async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]:
|
||||||
"""Fixture for available programs."""
|
"""Fixture for available programs."""
|
||||||
return await async_load_fixture(hass, load_programs_file, DOMAIN)
|
return load_json_value_fixture(load_programs_file, DOMAIN)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
34
tests/components/miele/fixtures/programs.json
Normal file
34
tests/components/miele/fixtures/programs.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"programId": 1,
|
||||||
|
"program": "Cottons",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"programId": 146,
|
||||||
|
"program": "QuickPowerWash",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"programId": 123,
|
||||||
|
"program": "Dark garments / Denim",
|
||||||
|
"parameters": {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"programId": 13,
|
||||||
|
"program": "Fan plus",
|
||||||
|
"parameters": {
|
||||||
|
"temperature": {
|
||||||
|
"min": 30,
|
||||||
|
"max": 250,
|
||||||
|
"step": 5,
|
||||||
|
"mandatory": false
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"min": [0, 1],
|
||||||
|
"max": [12, 0],
|
||||||
|
"mandatory": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
@ -1,117 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"programId": 146,
|
|
||||||
"program": "QuickPowerWash",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 123,
|
|
||||||
"program": "Dark garments / Denim",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 190,
|
|
||||||
"program": "ECO 40-60 ",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 27,
|
|
||||||
"program": "Proofing",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 23,
|
|
||||||
"program": "Shirts",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 9,
|
|
||||||
"program": "Silks ",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 8,
|
|
||||||
"program": "Woollens ",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 4,
|
|
||||||
"program": "Delicates",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 3,
|
|
||||||
"program": "Minimum iron",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 1,
|
|
||||||
"program": "Cottons",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 69,
|
|
||||||
"program": "Cottons hygiene",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 37,
|
|
||||||
"program": "Outerwear",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 122,
|
|
||||||
"program": "Express 20",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 29,
|
|
||||||
"program": "Sportswear",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 31,
|
|
||||||
"program": "Automatic plus",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 39,
|
|
||||||
"program": "Pillows",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 22,
|
|
||||||
"program": "Curtains",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 129,
|
|
||||||
"program": "Down filled items",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 53,
|
|
||||||
"program": "First wash",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 95,
|
|
||||||
"program": "Down duvets",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 52,
|
|
||||||
"program": "Separate rinse / Starch",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 21,
|
|
||||||
"program": "Drain / Spin",
|
|
||||||
"parameters": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"programId": 91,
|
|
||||||
"program": "Clean machine",
|
|
||||||
"parameters": {}
|
|
||||||
}
|
|
||||||
]
|
|
48
tests/components/miele/snapshots/test_services.ambr
Normal file
48
tests/components/miele/snapshots/test_services.ambr
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# serializer version: 1
|
||||||
|
# name: test_services_with_response
|
||||||
|
dict({
|
||||||
|
'programs': list([
|
||||||
|
dict({
|
||||||
|
'parameters': dict({
|
||||||
|
}),
|
||||||
|
'program': 'Cottons',
|
||||||
|
'program_id': 1,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'parameters': dict({
|
||||||
|
}),
|
||||||
|
'program': 'QuickPowerWash',
|
||||||
|
'program_id': 146,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'parameters': dict({
|
||||||
|
}),
|
||||||
|
'program': 'Dark garments / Denim',
|
||||||
|
'program_id': 123,
|
||||||
|
}),
|
||||||
|
dict({
|
||||||
|
'parameters': dict({
|
||||||
|
'duration': dict({
|
||||||
|
'mandatory': True,
|
||||||
|
'max': dict({
|
||||||
|
'hours': 12,
|
||||||
|
'minutes': 0,
|
||||||
|
}),
|
||||||
|
'min': dict({
|
||||||
|
'hours': 0,
|
||||||
|
'minutes': 1,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
'temperature': dict({
|
||||||
|
'mandatory': False,
|
||||||
|
'max': 250,
|
||||||
|
'min': 30,
|
||||||
|
'step': 5,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
'program': 'Fan plus',
|
||||||
|
'program_id': 13,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
# ---
|
@ -4,10 +4,15 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
from aiohttp import ClientResponseError
|
from aiohttp import ClientResponseError
|
||||||
import pytest
|
import pytest
|
||||||
|
from syrupy.assertion import SnapshotAssertion
|
||||||
from voluptuous import MultipleInvalid
|
from voluptuous import MultipleInvalid
|
||||||
|
|
||||||
from homeassistant.components.miele.const import DOMAIN
|
from homeassistant.components.miele.const import DOMAIN
|
||||||
from homeassistant.components.miele.services import ATTR_PROGRAM_ID, SERVICE_SET_PROGRAM
|
from homeassistant.components.miele.services import (
|
||||||
|
ATTR_PROGRAM_ID,
|
||||||
|
SERVICE_GET_PROGRAMS,
|
||||||
|
SERVICE_SET_PROGRAM,
|
||||||
|
)
|
||||||
from homeassistant.const import ATTR_DEVICE_ID
|
from homeassistant.const import ATTR_DEVICE_ID
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||||
@ -44,6 +49,28 @@ async def test_services(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_services_with_response(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
device_registry: DeviceRegistry,
|
||||||
|
mock_miele_client: MagicMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
snapshot: SnapshotAssertion,
|
||||||
|
) -> None:
|
||||||
|
"""Tests that the custom services that returns a response are correct."""
|
||||||
|
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)})
|
||||||
|
assert snapshot == await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_GET_PROGRAMS,
|
||||||
|
{
|
||||||
|
ATTR_DEVICE_ID: device.id,
|
||||||
|
},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_service_api_errors(
|
async def test_service_api_errors(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
device_registry: DeviceRegistry,
|
device_registry: DeviceRegistry,
|
||||||
@ -60,7 +87,7 @@ async def test_service_api_errors(
|
|||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_SET_PROGRAM,
|
SERVICE_SET_PROGRAM,
|
||||||
{"device_id": device.id, ATTR_PROGRAM_ID: 1},
|
{ATTR_DEVICE_ID: device.id, ATTR_PROGRAM_ID: 1},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
mock_miele_client.set_program.assert_called_once_with(
|
mock_miele_client.set_program.assert_called_once_with(
|
||||||
@ -68,6 +95,29 @@ async def test_service_api_errors(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_service_api_errors(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
device_registry: DeviceRegistry,
|
||||||
|
mock_miele_client: MagicMock,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
) -> None:
|
||||||
|
"""Test service api errors."""
|
||||||
|
await setup_integration(hass, mock_config_entry)
|
||||||
|
device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_APPLIANCE)})
|
||||||
|
|
||||||
|
# Test http error
|
||||||
|
mock_miele_client.get_programs.side_effect = ClientResponseError("TestInfo", "test")
|
||||||
|
with pytest.raises(HomeAssistantError, match="'Get programs' action failed"):
|
||||||
|
await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_GET_PROGRAMS,
|
||||||
|
{ATTR_DEVICE_ID: device.id},
|
||||||
|
blocking=True,
|
||||||
|
return_response=True,
|
||||||
|
)
|
||||||
|
mock_miele_client.get_programs.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
async def test_service_validation_errors(
|
async def test_service_validation_errors(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
device_registry: DeviceRegistry,
|
device_registry: DeviceRegistry,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user