From 76e32841aed49394f9be8d900df67ccb18d84eca Mon Sep 17 00:00:00 2001 From: Yaroslav Berezhinskiy Date: Sat, 3 Oct 2026 23:06:34 +0300 Subject: [PATCH] Add Starlink positioning exclusive switch (#184021) --- .../components/starlink/coordinator.py | 11 ++ homeassistant/components/starlink/icons.json | 8 ++ .../components/starlink/strings.json | 3 + homeassistant/components/starlink/switch.py | 13 ++ .../fixtures/status_data_success.json | 5 +- .../starlink/snapshots/test_diagnostics.ambr | 3 + tests/components/starlink/test_switch.py | 131 ++++++++++++++++++ 7 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tests/components/starlink/test_switch.py diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py index 8f0fc823764c..9959c7de6667 100644 --- a/homeassistant/components/starlink/coordinator.py +++ b/homeassistant/components/starlink/coordinator.py @@ -20,6 +20,7 @@ from starlink_grpc import ( history_stats, location_data, reboot, + set_gps_config, set_sleep_config, set_stow_state, status_data, @@ -110,6 +111,16 @@ class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]): except GrpcError as exc: raise HomeAssistantError from exc + async def async_set_gps_enabled(self, enable: bool) -> None: + """Set whether Starlink system uses GPS for position data.""" + async with asyncio.timeout(4): + try: + await self.hass.async_add_executor_job( + set_gps_config, enable, self.channel_context + ) + except GrpcError as exc: + raise HomeAssistantError from exc + async def async_set_sleep_schedule_enabled(self, sleep_schedule: bool) -> None: """Set whether Starlink system uses the configured sleep schedule.""" async with asyncio.timeout(4): diff --git a/homeassistant/components/starlink/icons.json b/homeassistant/components/starlink/icons.json index ecb1f946204c..c83ddb4e1ff4 100644 --- a/homeassistant/components/starlink/icons.json +++ b/homeassistant/components/starlink/icons.json @@ -25,6 +25,14 @@ "upload": { "default": "mdi:upload" } + }, + "switch": { + "starlink_positioning_exclusive": { + "default": "mdi:crosshairs-gps", + "state": { + "on": "mdi:crosshairs-off" + } + } } } } diff --git a/homeassistant/components/starlink/strings.json b/homeassistant/components/starlink/strings.json index 2c417d231404..362484077689 100644 --- a/homeassistant/components/starlink/strings.json +++ b/homeassistant/components/starlink/strings.json @@ -82,6 +82,9 @@ "sleep_schedule": { "name": "Sleep schedule" }, + "starlink_positioning_exclusive": { + "name": "Use Starlink positioning exclusively" + }, "stowed": { "name": "Stowed" } diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py index becc68885210..ccefd732f0ab 100644 --- a/homeassistant/components/starlink/switch.py +++ b/homeassistant/components/starlink/switch.py @@ -9,6 +9,7 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -80,4 +81,16 @@ SWITCHES = [ False ), ), + StarlinkSwitchEntityDescription( + key="starlink_positioning_exclusive", + translation_key="starlink_positioning_exclusive", + entity_category=EntityCategory.CONFIG, + value_fn=lambda data: ( + None + if (gps_enabled := data.status.get("gps_enabled")) is None + else not gps_enabled + ), + turn_on_fn=lambda coordinator: coordinator.async_set_gps_enabled(False), + turn_off_fn=lambda coordinator: coordinator.async_set_gps_enabled(True), + ), ] diff --git a/tests/components/starlink/fixtures/status_data_success.json b/tests/components/starlink/fixtures/status_data_success.json index e8cdc27e625e..0d6411ec42f4 100644 --- a/tests/components/starlink/fixtures/status_data_success.json +++ b/tests/components/starlink/fixtures/status_data_success.json @@ -19,7 +19,10 @@ "obstruction_interval": null, "direction_azimuth": -179.00344848632812, "direction_elevation": 68.67173767089844, - "is_snr_above_noise_floor": true + "is_snr_above_noise_floor": true, + "gps_ready": true, + "gps_enabled": true, + "gps_sats": 12 }, { "wedges_fraction_obstructed[]": [ diff --git a/tests/components/starlink/snapshots/test_diagnostics.ambr b/tests/components/starlink/snapshots/test_diagnostics.ambr index c54e0b2df6df..b270a0c6a323 100644 --- a/tests/components/starlink/snapshots/test_diagnostics.ambr +++ b/tests/components/starlink/snapshots/test_diagnostics.ambr @@ -71,6 +71,9 @@ 'direction_elevation': 68.67173767089844, 'downlink_throughput_bps': 10108.2724609375, 'fraction_obstructed': 0.0, + 'gps_enabled': True, + 'gps_ready': True, + 'gps_sats': 12, 'hardware_version': 'rev3_proto2', 'id': '**REDACTED**', 'is_snr_above_noise_floor': True, diff --git a/tests/components/starlink/test_switch.py b/tests/components/starlink/test_switch.py new file mode 100644 index 000000000000..4499bae3b93c --- /dev/null +++ b/tests/components/starlink/test_switch.py @@ -0,0 +1,131 @@ +"""Tests Starlink switches.""" + +from copy import deepcopy +from unittest.mock import patch + +import pytest +from starlink_grpc import GrpcError + +from homeassistant.components.starlink.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_IP_ADDRESS, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.json import JsonArrayType + +from .patchers import ( + HISTORY_STATS_SUCCESS_PATCHER, + LOCATION_DATA_SUCCESS_PATCHER, + SLEEP_DATA_SUCCESS_PATCHER, + STATUS_DATA_FIXTURE, + STATUS_DATA_TARGET, +) + +from tests.common import MockConfigEntry + +ENTITY_ID = "switch.starlink_use_starlink_positioning_exclusively" +SET_GPS_CONFIG_TARGET = "homeassistant.components.starlink.coordinator.set_gps_config" + + +async def setup_integration( + hass: HomeAssistant, status_data: JsonArrayType +) -> MockConfigEntry: + """Set up the Starlink integration with the given status data.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_IP_ADDRESS: "1.2.3.4:0000"}) + + with ( + LOCATION_DATA_SUCCESS_PATCHER, + SLEEP_DATA_SUCCESS_PATCHER, + HISTORY_STATS_SUCCESS_PATCHER, + patch(STATUS_DATA_TARGET, return_value=status_data), + ): + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +@pytest.mark.parametrize( + ("gps_enabled", "expected_state"), + [ + (True, STATE_OFF), + (False, STATE_ON), + (None, STATE_UNKNOWN), + ], +) +async def test_starlink_positioning_exclusive_state( + hass: HomeAssistant, gps_enabled: bool | None, expected_state: str +) -> None: + """Test the switch is on when the dish does not use GPS.""" + status_data = deepcopy(STATUS_DATA_FIXTURE) + status_data[0]["gps_enabled"] = gps_enabled + + await setup_integration(hass, status_data) + + assert hass.states.get(ENTITY_ID).state == expected_state + + +async def test_starlink_positioning_exclusive_not_reported( + hass: HomeAssistant, +) -> None: + """Test the switch is unknown when the dish omits the GPS state.""" + status_data = deepcopy(STATUS_DATA_FIXTURE) + del status_data[0]["gps_enabled"] + + await setup_integration(hass, status_data) + + assert hass.states.get(ENTITY_ID).state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("service", "gps_enabled"), + [ + (SERVICE_TURN_ON, False), + (SERVICE_TURN_OFF, True), + ], +) +async def test_starlink_positioning_exclusive_set( + hass: HomeAssistant, service: str, gps_enabled: bool +) -> None: + """Test turning the switch on disables GPS and turning it off enables it.""" + entry = await setup_integration(hass, deepcopy(STATUS_DATA_FIXTURE)) + + with patch(SET_GPS_CONFIG_TARGET) as mock_set_gps_config: + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + mock_set_gps_config.assert_called_once_with( + gps_enabled, entry.runtime_data.channel_context + ) + + +async def test_starlink_positioning_exclusive_error(hass: HomeAssistant) -> None: + """Test a communication error is raised as HomeAssistantError.""" + await setup_integration(hass, deepcopy(STATUS_DATA_FIXTURE)) + + with ( + patch(SET_GPS_CONFIG_TARGET, side_effect=GrpcError("error")), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + )