From 1d662ee231b32eff237dcc8df86cc7454b9a9f24 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 29 Sep 2026 18:22:12 +0200 Subject: [PATCH] Add started and restart count sensors to Portainer containers (#183225) --- homeassistant/components/portainer/icons.json | 6 + homeassistant/components/portainer/sensor.py | 22 + .../components/portainer/strings.json | 6 + .../portainer/snapshots/test_sensor.ambr | 624 ++++++++++++++++++ tests/components/portainer/test_sensor.py | 24 +- 5 files changed, 681 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index 1a655891b570..16d2221b3c3e 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -38,6 +38,12 @@ "container_health_state": { "default": "mdi:heart-pulse" }, + "container_restart_count": { + "default": "mdi:restart" + }, + "container_started_at": { + "default": "mdi:clock-start" + }, "container_state": { "default": "mdi:state-machine" }, diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 3c8d15f10024..1e77830a44e3 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -138,6 +138,28 @@ CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = ( options=["healthy", "unhealthy", "starting"], entity_category=EntityCategory.DIAGNOSTIC, ), + # Docker reports 0001-01-01 as the start time of a container that never started. + PortainerContainerSensorEntityDescription( + key="container_started_at", + translation_key="container_started_at", + value_fn=lambda data: ( + started_at + if (state := data.container_inspect.state) + and state.started_at + and (started_at := dt_util.parse_datetime(state.started_at)) + and started_at.year > 1 + else None + ), + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + ), + PortainerContainerSensorEntityDescription( + key="container_restart_count", + translation_key="container_restart_count", + value_fn=lambda data: data.container_inspect.restart_count, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), PortainerContainerSensorEntityDescription( key="memory_limit", translation_key="memory_limit", diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index ac007499c9cf..c8e4c2c0e5cc 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -127,6 +127,12 @@ "unhealthy": "Unhealthy" } }, + "container_restart_count": { + "name": "Restart count" + }, + "container_started_at": { + "name": "Started" + }, "container_state": { "name": "State", "state": { diff --git a/tests/components/portainer/snapshots/test_sensor.ambr b/tests/components/portainer/snapshots/test_sensor.ambr index 357a4042e3e0..62cc6e32afcb 100644 --- a/tests/components/portainer/snapshots/test_sensor.ambr +++ b/tests/components/portainer/snapshots/test_sensor.ambr @@ -512,6 +512,110 @@ 'state': '9.7412109375', }) # --- +# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_restart_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_restart_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Restart count', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_restart_count', + 'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_container_restart_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_restart_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Restart count', + : , + }), + 'context': , + 'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_restart_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_started-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.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_started', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Started', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Started', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_started_at', + 'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_container_started_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_started-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Started', + }), + 'context': , + 'entity_id': 'sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_started', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-06T09:06:59+00:00', + }) +# --- # name: test_all_entities[sensor.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_state-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1103,6 +1207,110 @@ 'state': '9.7412109375', }) # --- +# name: test_all_entities[sensor.focused_einstein_restart_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.focused_einstein_restart_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Restart count', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_restart_count', + 'unique_id': 'portainer_test_entry_123_focused_einstein_container_restart_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.focused_einstein_restart_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'focused_einstein Restart count', + : , + }), + 'context': , + 'entity_id': 'sensor.focused_einstein_restart_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.focused_einstein_started-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.focused_einstein_started', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Started', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Started', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_started_at', + 'unique_id': 'portainer_test_entry_123_focused_einstein_container_started_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.focused_einstein_started-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'focused_einstein Started', + }), + 'context': , + 'entity_id': 'sensor.focused_einstein_started', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-06T09:06:59+00:00', + }) +# --- # name: test_all_entities[sensor.focused_einstein_state-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1521,6 +1729,110 @@ 'state': '9.7412109375', }) # --- +# name: test_all_entities[sensor.funny_chatelet_restart_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.funny_chatelet_restart_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Restart count', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_restart_count', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_container_restart_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.funny_chatelet_restart_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'funny_chatelet Restart count', + : , + }), + 'context': , + 'entity_id': 'sensor.funny_chatelet_restart_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.funny_chatelet_started-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.funny_chatelet_started', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Started', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Started', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_started_at', + 'unique_id': 'portainer_test_entry_123_funny_chatelet_container_started_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.funny_chatelet_started-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'funny_chatelet Started', + }), + 'context': , + 'entity_id': 'sensor.funny_chatelet_started', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-06T09:06:59+00:00', + }) +# --- # name: test_all_entities[sensor.funny_chatelet_state-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3034,6 +3346,110 @@ 'state': '9.7412109375', }) # --- +# name: test_all_entities[sensor.practical_morse_restart_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.practical_morse_restart_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Restart count', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_restart_count', + 'unique_id': 'portainer_test_entry_123_practical_morse_container_restart_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.practical_morse_restart_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'practical_morse Restart count', + : , + }), + 'context': , + 'entity_id': 'sensor.practical_morse_restart_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.practical_morse_started-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.practical_morse_started', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Started', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Started', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_started_at', + 'unique_id': 'portainer_test_entry_123_practical_morse_container_started_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.practical_morse_started-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'practical_morse Started', + }), + 'context': , + 'entity_id': 'sensor.practical_morse_started', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-06T09:06:59+00:00', + }) +# --- # name: test_all_entities[sensor.practical_morse_state-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -3553,6 +3969,110 @@ 'state': '9.7412109375', }) # --- +# name: test_all_entities[sensor.serene_banach_restart_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.serene_banach_restart_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Restart count', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_restart_count', + 'unique_id': 'portainer_test_entry_123_serene_banach_container_restart_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_restart_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'serene_banach Restart count', + : , + }), + 'context': , + 'entity_id': 'sensor.serene_banach_restart_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.serene_banach_started-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.serene_banach_started', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Started', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Started', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_started_at', + 'unique_id': 'portainer_test_entry_123_serene_banach_container_started_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.serene_banach_started-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'serene_banach Started', + }), + 'context': , + 'entity_id': 'sensor.serene_banach_started', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-06T09:06:59+00:00', + }) +# --- # name: test_all_entities[sensor.serene_banach_state-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -4072,6 +4592,110 @@ 'state': '9.7412109375', }) # --- +# name: test_all_entities[sensor.stoic_turing_restart_count-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.stoic_turing_restart_count', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Restart count', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Restart count', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_restart_count', + 'unique_id': 'portainer_test_entry_123_stoic_turing_container_restart_count', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_restart_count-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'stoic_turing Restart count', + : , + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_restart_count', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[sensor.stoic_turing_started-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.stoic_turing_started', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Started', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Started', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'container_started_at', + 'unique_id': 'portainer_test_entry_123_stoic_turing_container_started_at', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.stoic_turing_started-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'timestamp', + : 'stoic_turing Started', + }), + 'context': , + 'entity_id': 'sensor.stoic_turing_started', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2020-01-06T09:06:59+00:00', + }) +# --- # name: test_all_entities[sensor.stoic_turing_state-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/portainer/test_sensor.py b/tests/components/portainer/test_sensor.py index 8b36529f0917..4abe68c9ebf8 100644 --- a/tests/components/portainer/test_sensor.py +++ b/tests/components/portainer/test_sensor.py @@ -6,13 +6,14 @@ from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory from pyportainer.exceptions import PortainerTimeoutError from pyportainer.models.docker import EndpointStatus +from pyportainer.models.docker_inspect import DockerInspect from pyportainer.models.portainer import Endpoint import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.portainer.const import DOMAIN from homeassistant.components.portainer.coordinator import DEFAULT_DF_SCAN_INTERVAL -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util @@ -23,6 +24,7 @@ from tests.common import ( MockConfigEntry, async_fire_time_changed, async_load_json_array_fixture, + load_json_value_fixture, snapshot_platform, ) @@ -101,3 +103,23 @@ async def test_df_endpoint_timeout_only_marks_that_endpoint_unavailable( state := hass.states.get("sensor.my_environment_image_disk_usage_total_size") ) assert state.state != STATE_UNAVAILABLE + + +async def test_container_never_started( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the started sensor is unknown for a container that never started.""" + inspect = cast( + dict[str, Any], load_json_value_fixture("container_inspect.json", DOMAIN) + ) + inspect["State"]["StartedAt"] = "0001-01-01T00:00:00Z" + mock_portainer_client.inspect_container.return_value = DockerInspect.from_dict( + inspect + ) + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get("sensor.focused_einstein_started")) + assert state.state == STATE_UNKNOWN