From f5e582f4856d4b4477e960e313913f706edbee72 Mon Sep 17 00:00:00 2001 From: Hamish Date: Sun, 27 Sep 2026 04:29:21 +0930 Subject: [PATCH] indi_allsky: Add remaining exposure data sensors and night binary sensor (#182966) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../components/indi_allsky/__init__.py | 2 +- .../components/indi_allsky/binary_sensor.py | 73 +++++++++++++ .../components/indi_allsky/sensor.py | 30 +++++- .../components/indi_allsky/strings.json | 11 ++ .../snapshots/test_binary_sensor.ambr | 51 +++++++++ .../indi_allsky/snapshots/test_sensor.ambr | 101 ++++++++++++++++++ .../indi_allsky/test_binary_sensor.py | 62 +++++++++++ tests/components/indi_allsky/test_sensor.py | 26 ++++- 8 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/indi_allsky/binary_sensor.py create mode 100644 tests/components/indi_allsky/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/indi_allsky/test_binary_sensor.py diff --git a/homeassistant/components/indi_allsky/__init__.py b/homeassistant/components/indi_allsky/__init__.py index 832240ccce7f..22ac34f9a1fa 100644 --- a/homeassistant/components/indi_allsky/__init__.py +++ b/homeassistant/components/indi_allsky/__init__.py @@ -5,7 +5,7 @@ from homeassistant.core import HomeAssistant from .coordinator import IndiAllSkyConfigEntry, IndiAllSkyDataUpdateCoordinator -_PLATFORMS: list[Platform] = [Platform.CAMERA, Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: IndiAllSkyConfigEntry) -> bool: diff --git a/homeassistant/components/indi_allsky/binary_sensor.py b/homeassistant/components/indi_allsky/binary_sensor.py new file mode 100644 index 000000000000..c2cf6a3922e2 --- /dev/null +++ b/homeassistant/components/indi_allsky/binary_sensor.py @@ -0,0 +1,73 @@ +"""Support for INDI Allsky binary sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ( + IndiAllSkyConfigEntry, + IndiAllSkyData, + IndiAllSkyDataUpdateCoordinator, +) +from .entity import IndiAllSkyEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class IndiAllSkyBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing INDI Allsky binary sensor entities.""" + + is_on_fn: Callable[[IndiAllSkyData], bool | None] + + +BINARY_SENSOR_DESCRIPTIONS: tuple[IndiAllSkyBinarySensorEntityDescription, ...] = ( + IndiAllSkyBinarySensorEntityDescription( + key="night", + translation_key="night", + is_on_fn=lambda data: data.exposure.night if data.exposure else None, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: IndiAllSkyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up INDI Allsky binary sensors based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + IndiAllSkyBinarySensor(coordinator, entry, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class IndiAllSkyBinarySensor(IndiAllSkyEntity, BinarySensorEntity): + """Representation of an INDI Allsky binary sensor.""" + + entity_description: IndiAllSkyBinarySensorEntityDescription + + def __init__( + self, + coordinator: IndiAllSkyDataUpdateCoordinator, + entry: IndiAllSkyConfigEntry, + description: IndiAllSkyBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, entry) + self.entity_description = description + self._attr_unique_id = f"{entry.entry_id}_{description.key}" + + @property + @override + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/indi_allsky/sensor.py b/homeassistant/components/indi_allsky/sensor.py index 11098097c6ff..b507b6df26c3 100644 --- a/homeassistant/components/indi_allsky/sensor.py +++ b/homeassistant/components/indi_allsky/sensor.py @@ -2,6 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import override from homeassistant.components.sensor import ( @@ -14,6 +15,7 @@ from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from .coordinator import ( IndiAllSkyConfigEntry, @@ -25,11 +27,20 @@ from .entity import IndiAllSkyEntity PARALLEL_UPDATES = 0 +def _parse_timestamp(data: IndiAllSkyData) -> datetime | None: + if data.exposure and data.exposure.create_date: + if dt := dt_util.parse_datetime(data.exposure.create_date): + if dt.tzinfo is None: + return dt.replace(tzinfo=dt_util.UTC) + return dt_util.as_utc(dt) + return None + + @dataclass(frozen=True, kw_only=True) class IndiAllSkySensorEntityDescription(SensorEntityDescription): """Class describing INDI Allsky sensor entities.""" - value_fn: Callable[[IndiAllSkyData], StateType] + value_fn: Callable[[IndiAllSkyData], StateType | datetime] SENSOR_DESCRIPTIONS: tuple[IndiAllSkySensorEntityDescription, ...] = ( @@ -40,6 +51,21 @@ SENSOR_DESCRIPTIONS: tuple[IndiAllSkySensorEntityDescription, ...] = ( entity_registry_enabled_default=False, value_fn=lambda data: data.exposure.binmode if data.exposure else None, ), + IndiAllSkySensorEntityDescription( + key="camera_id", + translation_key="camera_id", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.exposure.camera_id if data.exposure else None, + ), + IndiAllSkySensorEntityDescription( + key="create_date", + translation_key="create_date", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=_parse_timestamp, + ), IndiAllSkySensorEntityDescription( key="exposure", translation_key="exposure", @@ -116,6 +142,6 @@ class IndiAllSkySensor(IndiAllSkyEntity, SensorEntity): @property @override - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/indi_allsky/strings.json b/homeassistant/components/indi_allsky/strings.json index 4405368225d3..e9a46432df86 100644 --- a/homeassistant/components/indi_allsky/strings.json +++ b/homeassistant/components/indi_allsky/strings.json @@ -27,13 +27,24 @@ } }, "entity": { + "binary_sensor": { + "night": { + "name": "Night" + } + }, "sensor": { "binmode": { "name": "Binning mode" }, + "camera_id": { + "name": "Camera ID" + }, "camera_sensor_temp": { "name": "Camera sensor temperature" }, + "create_date": { + "name": "Exposure creation time" + }, "exposure": { "name": "Exposure time" }, diff --git a/tests/components/indi_allsky/snapshots/test_binary_sensor.ambr b/tests/components/indi_allsky/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..bfaa2dbb5967 --- /dev/null +++ b/tests/components/indi_allsky/snapshots/test_binary_sensor.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_binary_sensor_setup_and_states[binary_sensor.indi_allsky_night-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.indi_allsky_night', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Night', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Night', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'night', + 'unique_id': '1234567890abcdef1234567890abcdef_night', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_setup_and_states[binary_sensor.indi_allsky_night-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Night', + }), + 'context': , + 'entity_id': 'binary_sensor.indi_allsky_night', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/indi_allsky/snapshots/test_sensor.ambr b/tests/components/indi_allsky/snapshots/test_sensor.ambr index 47365b1c0f68..95cbdf7e4b75 100644 --- a/tests/components/indi_allsky/snapshots/test_sensor.ambr +++ b/tests/components/indi_allsky/snapshots/test_sensor.ambr @@ -49,6 +49,56 @@ 'state': 'unknown', }) # --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_camera_id-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.indi_allsky_camera_id', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Camera ID', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Camera ID', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'camera_id', + 'unique_id': '1234567890abcdef1234567890abcdef_camera_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_camera_id-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'INDI Allsky Camera ID', + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_camera_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_setup_and_states[sensor.indi_allsky_camera_sensor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -107,6 +157,57 @@ 'state': 'unknown', }) # --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_exposure_creation_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.indi_allsky_exposure_creation_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Exposure creation time', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Exposure creation time', + 'platform': 'indi_allsky', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'create_date', + 'unique_id': '1234567890abcdef1234567890abcdef_create_date', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup_and_states[sensor.indi_allsky_exposure_creation_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'INDI Allsky Exposure creation time', + }), + 'context': , + 'entity_id': 'sensor.indi_allsky_exposure_creation_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_setup_and_states[sensor.indi_allsky_exposure_time-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/indi_allsky/test_binary_sensor.py b/tests/components/indi_allsky/test_binary_sensor.py new file mode 100644 index 000000000000..6d4cd6d476b4 --- /dev/null +++ b/tests/components/indi_allsky/test_binary_sensor.py @@ -0,0 +1,62 @@ +"""Tests for the INDI Allsky binary sensor platform.""" + +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +from aioindiallsky import ExposureData +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("mock_indi_allsky_client") +async def test_binary_sensor_setup_and_states( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test standard successful setup and binary sensor snapshots using snapshot_platform.""" + with patch( + "homeassistant.components.indi_allsky._PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +async def test_binary_sensor_updates( + hass: HomeAssistant, + mock_indi_allsky_client: AsyncMock, + mock_config_entry: MockConfigEntry, + mock_exposure_data: ExposureData, +) -> None: + """Test binary sensor state values update on exposure_complete event.""" + with patch( + "homeassistant.components.indi_allsky._PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + for callback in mock_indi_allsky_client.callbacks.get("exposure_complete", []): + callback(mock_exposure_data) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.indi_allsky_night") + assert state is not None + assert state.state == "off" + + for callback in mock_indi_allsky_client.callbacks.get("exposure_complete", []): + callback(replace(mock_exposure_data, night=True)) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.indi_allsky_night") + assert state is not None + assert state.state == "on" diff --git a/tests/components/indi_allsky/test_sensor.py b/tests/components/indi_allsky/test_sensor.py index 9eda4d3c893b..b2bffaf685a6 100644 --- a/tests/components/indi_allsky/test_sensor.py +++ b/tests/components/indi_allsky/test_sensor.py @@ -45,6 +45,8 @@ async def test_disabled_sensors( for entity_id in ( "sensor.indi_allsky_binning_mode", + "sensor.indi_allsky_camera_id", + "sensor.indi_allsky_exposure_creation_time", "sensor.indi_allsky_filename", "sensor.indi_allsky_gain", ): @@ -79,6 +81,20 @@ async def test_sensor_updates( suggested_object_id="indi_allsky_binning_mode", disabled_by=None, ) + entity_registry.async_get_or_create( + domain="sensor", + platform="indi_allsky", + unique_id=f"{mock_config_entry.entry_id}_camera_id", + suggested_object_id="indi_allsky_camera_id", + disabled_by=None, + ) + entity_registry.async_get_or_create( + domain="sensor", + platform="indi_allsky", + unique_id=f"{mock_config_entry.entry_id}_create_date", + suggested_object_id="indi_allsky_exposure_creation_time", + disabled_by=None, + ) entity_registry.async_get_or_create( domain="sensor", platform="indi_allsky", @@ -129,8 +145,16 @@ async def test_sensor_updates( assert state is not None assert state.state == "0.0" + state = hass.states.get("sensor.indi_allsky_camera_id") + assert state is not None + assert state.state == "1" + + state = hass.states.get("sensor.indi_allsky_exposure_creation_time") + assert state is not None + assert state.state == "2026-08-13T22:53:41+00:00" + for callback in mock_indi_allsky_client.callbacks.get("exposure_complete", []): - callback(replace(mock_exposure_data, temp=12.5)) + callback(replace(mock_exposure_data, temp=12.5, adu=512.0, hfr=2.4)) await hass.async_block_till_done() state = hass.states.get("sensor.indi_allsky_camera_sensor_temperature")