From 3ad4caa3d73975c71a5c8639f6b84ee959f6d1b9 Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Sun, 8 Jan 2023 09:13:37 +1300 Subject: [PATCH] Add Starlink Integration (#77091) Co-authored-by: J. Nick Koston --- .coveragerc | 3 + CODEOWNERS | 2 + homeassistant/components/starlink/__init__.py | 35 ++++++++ .../components/starlink/config_flow.py | 52 +++++++++++ homeassistant/components/starlink/const.py | 3 + .../components/starlink/coordinator.py | 38 ++++++++ homeassistant/components/starlink/entity.py | 64 ++++++++++++++ .../components/starlink/manifest.json | 9 ++ homeassistant/components/starlink/sensor.py | 79 +++++++++++++++++ .../components/starlink/strings.json | 17 ++++ .../components/starlink/translations/en.json | 17 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 ++ homeassistant/package_constraints.txt | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/starlink/__init__.py | 1 + tests/components/starlink/patchers.py | 24 +++++ tests/components/starlink/test_config_flow.py | 88 +++++++++++++++++++ tests/components/starlink/test_init.py | 46 ++++++++++ 21 files changed, 493 insertions(+) create mode 100644 homeassistant/components/starlink/__init__.py create mode 100644 homeassistant/components/starlink/config_flow.py create mode 100644 homeassistant/components/starlink/const.py create mode 100644 homeassistant/components/starlink/coordinator.py create mode 100644 homeassistant/components/starlink/entity.py create mode 100644 homeassistant/components/starlink/manifest.json create mode 100644 homeassistant/components/starlink/sensor.py create mode 100644 homeassistant/components/starlink/strings.json create mode 100644 homeassistant/components/starlink/translations/en.json mode change 100755 => 100644 script/gen_requirements_all.py create mode 100644 tests/components/starlink/__init__.py create mode 100644 tests/components/starlink/patchers.py create mode 100644 tests/components/starlink/test_config_flow.py create mode 100644 tests/components/starlink/test_init.py diff --git a/.coveragerc b/.coveragerc index 60920bc831b..95748dbecbe 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1216,6 +1216,9 @@ omit = homeassistant/components/squeezebox/__init__.py homeassistant/components/squeezebox/browse_media.py homeassistant/components/squeezebox/media_player.py + homeassistant/components/starlink/coordinator.py + homeassistant/components/starlink/entity.py + homeassistant/components/starlink/sensor.py homeassistant/components/starline/__init__.py homeassistant/components/starline/account.py homeassistant/components/starline/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 0934fc47d91..dc8503e87e4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1116,6 +1116,8 @@ build.json @home-assistant/supervisor /tests/components/srp_energy/ @briglx /homeassistant/components/starline/ @anonym-tsk /tests/components/starline/ @anonym-tsk +/homeassistant/components/starlink/ @boswelja +/tests/components/starlink/ @boswelja /homeassistant/components/statistics/ @fabaff @ThomDietrich /tests/components/statistics/ @fabaff @ThomDietrich /homeassistant/components/steam_online/ @tkdrob diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py new file mode 100644 index 00000000000..944df5714f5 --- /dev/null +++ b/homeassistant/components/starlink/__init__.py @@ -0,0 +1,35 @@ +"""The Starlink integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import StarlinkUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Starlink from a config entry.""" + coordinator = StarlinkUpdateCoordinator( + hass=hass, + url=entry.data[CONF_IP_ADDRESS], + name=entry.title, + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/starlink/config_flow.py b/homeassistant/components/starlink/config_flow.py new file mode 100644 index 00000000000..4154ef09adf --- /dev/null +++ b/homeassistant/components/starlink/config_flow.py @@ -0,0 +1,52 @@ +"""Config flow for Starlink.""" +from __future__ import annotations + +from typing import Any + +from starlink_grpc import ChannelContext, GrpcError, get_id +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +CONFIG_SCHEMA = vol.Schema( + {vol.Required(CONF_IP_ADDRESS, default="192.168.100.1:9200"): str} +) + + +class StarlinkConfigFlow(ConfigFlow, domain=DOMAIN): + """The configuration flow for a Starlink system.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Ask the user for a server address and a name for the system.""" + errors = {} + if user_input: + # Input validation. If everything looks good, create the entry + if uid := await self.get_device_id(url=user_input[CONF_IP_ADDRESS]): + # Make sure we're not configuring the same device + await self.async_set_unique_id(uid) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="Starlink", + data=user_input, + ) + errors[CONF_IP_ADDRESS] = "cannot_connect" + return self.async_show_form( + step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + ) + + async def get_device_id(self, url: str) -> str | None: + """Get the device UID, or None if no device exists at the given URL.""" + context = ChannelContext(target=url) + try: + response = await self.hass.async_add_executor_job(get_id, context) + except GrpcError: + response = None + context.close() + return response diff --git a/homeassistant/components/starlink/const.py b/homeassistant/components/starlink/const.py new file mode 100644 index 00000000000..e2f88c5e442 --- /dev/null +++ b/homeassistant/components/starlink/const.py @@ -0,0 +1,3 @@ +"""Constants for the Starlink integration.""" + +DOMAIN = "starlink" diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py new file mode 100644 index 00000000000..4cec8613e42 --- /dev/null +++ b/homeassistant/components/starlink/coordinator.py @@ -0,0 +1,38 @@ +"""Contains the shared Coordinator for Starlink systems.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import async_timeout +from starlink_grpc import ChannelContext, GrpcError, StatusDict, status_data + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class StarlinkUpdateCoordinator(DataUpdateCoordinator[StatusDict]): + """Coordinates updates between all Starlink sensors defined in this file.""" + + def __init__(self, hass: HomeAssistant, name: str, url: str) -> None: + """Initialize an UpdateCoordinator for a group of sensors.""" + self.channel_context = ChannelContext(target=url) + + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=timedelta(seconds=5), + ) + + async def _async_update_data(self) -> StatusDict: + async with async_timeout.timeout(4): + try: + status = await self.hass.async_add_executor_job( + status_data, self.channel_context + ) + return status[0] + except GrpcError as exc: + raise UpdateFailed from exc diff --git a/homeassistant/components/starlink/entity.py b/homeassistant/components/starlink/entity.py new file mode 100644 index 00000000000..ba9c65368ac --- /dev/null +++ b/homeassistant/components/starlink/entity.py @@ -0,0 +1,64 @@ +"""Contains base entity classes for Starlink entities.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from starlink_grpc import StatusDict + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import StarlinkUpdateCoordinator + + +@dataclass +class StarlinkSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[StatusDict], datetime | StateType] + + +@dataclass +class StarlinkSensorEntityDescription( + SensorEntityDescription, StarlinkSensorEntityDescriptionMixin +): + """Describes a Starlink sensor entity.""" + + +class StarlinkSensorEntity(CoordinatorEntity[StarlinkUpdateCoordinator], SensorEntity): + """A SensorEntity that is registered under the Starlink device, and handles creating unique IDs.""" + + entity_description: StarlinkSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: StarlinkUpdateCoordinator, + description: StarlinkSensorEntityDescription, + ) -> None: + """Initialize the sensor and set the update coordinator.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{self.coordinator.data['id']}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, self.coordinator.data["id"]), + }, + sw_version=self.coordinator.data["software_version"], + hw_version=self.coordinator.data["hardware_version"], + name="Starlink", + configuration_url=f"http://{self.coordinator.channel_context.target.split(':')[0]}", + manufacturer="SpaceX", + model="Starlink", + ) + + @property + def native_value(self) -> StateType | datetime: + """Calculate the sensor value from the entity description.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json new file mode 100644 index 00000000000..5a27ff19cea --- /dev/null +++ b/homeassistant/components/starlink/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "starlink", + "name": "Starlink", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/starlink", + "requirements": ["starlink-grpc-core==1.1.1"], + "codeowners": ["@boswelja"], + "iot_class": "local_polling" +} diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py new file mode 100644 index 00000000000..54347821a63 --- /dev/null +++ b/homeassistant/components/starlink/sensor.py @@ -0,0 +1,79 @@ +"""Contains sensors exposed by the Starlink integration.""" +from __future__ import annotations + +from datetime import datetime, timedelta + +from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE, UnitOfDataRate, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .entity import StarlinkSensorEntity, StarlinkSensorEntityDescription + +SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( + StarlinkSensorEntityDescription( + key="ping", + name="Ping", + icon="mdi:speedometer", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + value_fn=lambda data: round(data["pop_ping_latency_ms"]), + ), + StarlinkSensorEntityDescription( + key="azimuth", + name="Azimuth", + icon="mdi:compass", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DEGREE, + value_fn=lambda data: round(data["direction_azimuth"]), + ), + StarlinkSensorEntityDescription( + key="elevation", + name="Elevation", + icon="mdi:compass", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DEGREE, + value_fn=lambda data: round(data["direction_elevation"]), + ), + StarlinkSensorEntityDescription( + key="uplink_throughput", + name="Uplink throughput", + icon="mdi:upload", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, + value_fn=lambda data: round(data["uplink_throughput_bps"]), + ), + StarlinkSensorEntityDescription( + key="downlink_throughput", + name="Downlink throughput", + icon="mdi:download", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfDataRate.BITS_PER_SECOND, + value_fn=lambda data: round(data["downlink_throughput_bps"]), + ), + StarlinkSensorEntityDescription( + key="last_boot_time", + name="Last boot time", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: datetime.now().astimezone() + - timedelta(seconds=data["uptime"]), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up all sensors for this entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + StarlinkSensorEntity(coordinator, description) for description in SENSORS + ) diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json new file mode 100644 index 00000000000..dddbada730d --- /dev/null +++ b/homeassistant/components/starlink/strings.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + } + } + } + } +} diff --git a/homeassistant/components/starlink/translations/en.json b/homeassistant/components/starlink/translations/en.json new file mode 100644 index 00000000000..52d4a77460b --- /dev/null +++ b/homeassistant/components/starlink/translations/en.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "ip_address": "IP Address" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5a90f65580f..58100b9c2be 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -403,6 +403,7 @@ FLOWS = { "squeezebox", "srp_energy", "starline", + "starlink", "steam_online", "steamist", "stookalert", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 76eabb12bc3..4333cb51ff5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5167,6 +5167,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "starlink": { + "name": "Starlink", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "startca": { "name": "Start.ca", "integration_type": "hub", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e468d88edbd..005b32accc3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -62,6 +62,7 @@ httplib2>=0.19.0 # want to ensure we have wheels built. grpcio==1.51.1 grpcio-status==1.51.1 +grpcio-reflection==1.51.1 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, diff --git a/requirements_all.txt b/requirements_all.txt index da30561e91d..f19a630aa18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2378,6 +2378,9 @@ starline==0.1.5 # homeassistant.components.starlingbank starlingbank==3.2 +# homeassistant.components.starlink +starlink-grpc-core==1.1.1 + # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34734ef9dd0..52ac74d670c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1666,6 +1666,9 @@ srpenergy==1.3.6 # homeassistant.components.starline starline==0.1.5 +# homeassistant.components.starlink +starlink-grpc-core==1.1.1 + # homeassistant.components.statsd statsd==3.2.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py old mode 100755 new mode 100644 index 513960c2030..c4a2313e589 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -73,6 +73,7 @@ httplib2>=0.19.0 # want to ensure we have wheels built. grpcio==1.51.1 grpcio-status==1.51.1 +grpcio-reflection==1.51.1 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, diff --git a/tests/components/starlink/__init__.py b/tests/components/starlink/__init__.py new file mode 100644 index 00000000000..8c55e8f0d99 --- /dev/null +++ b/tests/components/starlink/__init__.py @@ -0,0 +1 @@ +"""Tests for the Starlink integration.""" diff --git a/tests/components/starlink/patchers.py b/tests/components/starlink/patchers.py new file mode 100644 index 00000000000..7fe9d17f7c0 --- /dev/null +++ b/tests/components/starlink/patchers.py @@ -0,0 +1,24 @@ +"""General Starlink patchers.""" +from unittest.mock import patch + +from starlink_grpc import StatusDict + +from homeassistant.components.starlink.coordinator import StarlinkUpdateCoordinator + +SETUP_ENTRY_PATCHER = patch( + "homeassistant.components.starlink.async_setup_entry", return_value=True +) + +COORDINATOR_SUCCESS_PATCHER = patch.object( + StarlinkUpdateCoordinator, + "_async_update_data", + return_value=StatusDict(id="1", software_version="1", hardware_version="1"), +) + +DEVICE_FOUND_PATCHER = patch( + "homeassistant.components.starlink.config_flow.get_id", return_value="some-valid-id" +) + +NO_DEVICE_PATCHER = patch( + "homeassistant.components.starlink.config_flow.get_id", return_value=None +) diff --git a/tests/components/starlink/test_config_flow.py b/tests/components/starlink/test_config_flow.py new file mode 100644 index 00000000000..3bb3f286638 --- /dev/null +++ b/tests/components/starlink/test_config_flow.py @@ -0,0 +1,88 @@ +"""Test the Starlink config flow.""" +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.starlink.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant + +from .patchers import DEVICE_FOUND_PATCHER, NO_DEVICE_PATCHER, SETUP_ENTRY_PATCHER + +from tests.common import MockConfigEntry + + +async def test_flow_user_fails_can_succeed(hass: HomeAssistant) -> None: + """Test user initialized flow can still succeed after failure when Starlink is available.""" + user_input = {CONF_IP_ADDRESS: "192.168.100.1:9200"} + + with NO_DEVICE_PATCHER, SETUP_ENTRY_PATCHER: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] + + with DEVICE_FOUND_PATCHER, SETUP_ENTRY_PATCHER: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == user_input + + +async def test_flow_user_success(hass: HomeAssistant) -> None: + """Test user initialized flow succeeds when Starlink is available.""" + user_input = {CONF_IP_ADDRESS: "192.168.100.1:9200"} + + with DEVICE_FOUND_PATCHER, SETUP_ENTRY_PATCHER: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == user_input + + +async def test_flow_user_duplicate_abort(hass: HomeAssistant) -> None: + """Test user initialized flow aborts when Starlink is already configured.""" + user_input = {CONF_IP_ADDRESS: "192.168.100.1:9200"} + + entry = MockConfigEntry( + domain=DOMAIN, + data=user_input, + unique_id="some-valid-id", + state=config_entries.ConfigEntryState.LOADED, + ) + entry.add_to_hass(hass) + + with DEVICE_FOUND_PATCHER, SETUP_ENTRY_PATCHER: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/starlink/test_init.py b/tests/components/starlink/test_init.py new file mode 100644 index 00000000000..72d3be52b4a --- /dev/null +++ b/tests/components/starlink/test_init.py @@ -0,0 +1,46 @@ +"""Tests Starlink integration init/unload.""" +from homeassistant.components.starlink.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant + +from .patchers import COORDINATOR_SUCCESS_PATCHER + +from tests.common import MockConfigEntry + + +async def test_successful_entry(hass: HomeAssistant) -> None: + """Test configuring Starlink.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, + ) + + with COORDINATOR_SUCCESS_PATCHER: + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + assert entry.entry_id in hass.data[DOMAIN] + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test removing Starlink.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: "1.2.3.4:0000"}, + ) + + with COORDINATOR_SUCCESS_PATCHER: + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert entry.entry_id not in hass.data[DOMAIN]