diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index ba78ee32409..5925a52a374 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -18,7 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession from .coordinator import PortainerCoordinator -_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH] +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] + type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index 316851d2c67..26a6eddc78f 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -1,5 +1,10 @@ { "entity": { + "sensor": { + "image": { + "default": "mdi:docker" + } + }, "switch": { "container": { "default": "mdi:arrow-down-box", diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py new file mode 100644 index 00000000000..bc84d26d02a --- /dev/null +++ b/homeassistant/components/portainer/sensor.py @@ -0,0 +1,83 @@ +"""Sensor platform for Portainer integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pyportainer.models.docker import DockerContainer + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PortainerConfigEntry, PortainerCoordinator +from .entity import PortainerContainerEntity, PortainerCoordinatorData + + +@dataclass(frozen=True, kw_only=True) +class PortainerSensorEntityDescription(SensorEntityDescription): + """Class to hold Portainer sensor description.""" + + value_fn: Callable[[DockerContainer], str | None] + + +CONTAINER_SENSORS: tuple[PortainerSensorEntityDescription, ...] = ( + PortainerSensorEntityDescription( + key="image", + translation_key="image", + value_fn=lambda data: data.image, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PortainerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Portainer sensors based on a config entry.""" + coordinator = entry.runtime_data + + async_add_entities( + PortainerContainerSensor( + coordinator, + entity_description, + container, + endpoint, + ) + for endpoint in coordinator.data.values() + for container in endpoint.containers.values() + for entity_description in CONTAINER_SENSORS + ) + + +class PortainerContainerSensor(PortainerContainerEntity, SensorEntity): + """Representation of a Portainer container sensor.""" + + entity_description: PortainerSensorEntityDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerSensorEntityDescription, + device_info: DockerContainer, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer container sensor.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator, via_device) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.endpoint_id in self.coordinator.data + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn( + self.coordinator.data[self.endpoint_id].containers[self.device_id] + ) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index e48f8505277..38aa5c87df7 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -46,6 +46,11 @@ "name": "Status" } }, + "sensor": { + "image": { + "name": "Image" + } + }, "switch": { "container": { "name": "Container" diff --git a/tests/components/portainer/snapshots/test_sensor.ambr b/tests/components/portainer/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..87b4be479a9 --- /dev/null +++ b/tests/components/portainer/snapshots/test_sensor.ambr @@ -0,0 +1,241 @@ +# serializer version: 1 +# name: test_all_entities[sensor.focused_einstein_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.focused_einstein_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_focused_einstein_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.focused_einstein_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'focused_einstein Image', + }), + 'context': , + 'entity_id': 'sensor.focused_einstein_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/redis:7', + }) +# --- +# name: test_all_entities[sensor.funny_chatelet_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.funny_chatelet_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.funny_chatelet_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'funny_chatelet Image', + }), + 'context': , + 'entity_id': 'sensor.funny_chatelet_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/ubuntu:latest', + }) +# --- +# name: test_all_entities[sensor.practical_morse_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.practical_morse_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_practical_morse_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.practical_morse_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'practical_morse Image', + }), + 'context': , + 'entity_id': 'sensor.practical_morse_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/python:3.13-slim', + }) +# --- +# name: test_all_entities[sensor.serene_banach_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.serene_banach_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_serene_banach_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'serene_banach Image', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/nginx:latest', + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stoic_turing_image', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Image', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'image', + 'unique_id': 'portainer_test_entry_123_stoic_turing_image', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_image-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'stoic_turing Image', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_image', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'docker.io/library/postgres:15', + }) +# --- diff --git a/tests/components/portainer/test_sensor.py b/tests/components/portainer/test_sensor.py new file mode 100644 index 00000000000..2c597a16983 --- /dev/null +++ b/tests/components/portainer/test_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the Portainer sensor platform.""" + +from unittest.mock import patch + +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_portainer_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + )