-
-
Notifications
You must be signed in to change notification settings - Fork 37.6k
Add Starlink Integration #77091
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Starlink Integration #77091
Changes from all commits
38a1829
45355ec
df2bd9d
6c95fff
6cb0519
0005729
6db0093
4b821c3
ddad778
dcb7da4
b748522
3b51ab3
81d905e
8152e02
3bafb5a
452560e
4b8d153
3e8b863
4f9c47d
73a0357
ce70dba
1a4fc58
791f43b
679faca
97c845d
c542fd9
48d1828
f7db542
3f9bc2b
4a9749f
1ff7e40
0252e14
7ac4254
5f481a7
11582a7
eb34110
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| """Constants for the Starlink integration.""" | ||
|
|
||
| DOMAIN = "starlink" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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): | ||
|
boswelja marked this conversation as resolved.
|
||
| """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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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() | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use our dt utils as far as possible. Lines 108 to 109 in ccd8bc1
|
||||||
| - timedelta(seconds=data["uptime"]), | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this calculation stable so that the timestamp state doesn't update spammily?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I haven't noticed any issues, but I also haven't run this for longer than maybe a minute at a time. I can leave it polling for a while and see if you suspect there might be an issue? |
||||||
| ), | ||||||
| ) | ||||||
|
|
||||||
|
|
||||||
| 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 | ||||||
| ) | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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%]" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| { | ||
|
boswelja marked this conversation as resolved.
boswelja marked this conversation as resolved.
|
||
| "config": { | ||
| "abort": { | ||
| "already_configured": "Device is already configured" | ||
| }, | ||
| "error": { | ||
| "cannot_connect": "Failed to connect" | ||
| }, | ||
| "step": { | ||
| "user": { | ||
| "data": { | ||
| "ip_address": "IP Address" | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -401,6 +401,7 @@ | |
| "squeezebox", | ||
| "srp_energy", | ||
| "starline", | ||
| "starlink", | ||
| "steam_online", | ||
| "steamist", | ||
| "stookalert", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.