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>
This commit is contained in:
Hamish
2026-09-26 20:59:21 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 42b3a5c1f2
commit f5e582f485
8 changed files with 352 additions and 4 deletions
@@ -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:
@@ -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)
+28 -2
View File
@@ -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)
@@ -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"
},
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'INDI Allsky Night',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.indi_allsky_night',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.indi_allsky_camera_id',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'INDI Allsky Camera ID',
}),
'context': <ANY>,
'entity_id': 'sensor.indi_allsky_camera_id',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.indi_allsky_exposure_creation_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Exposure creation time',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'INDI Allsky Exposure creation time',
}),
'context': <ANY>,
'entity_id': 'sensor.indi_allsky_exposure_creation_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensor_setup_and_states[sensor.indi_allsky_exposure_time-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -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"
+25 -1
View File
@@ -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")