Skip to content

Commit cbf1b39

Browse files
erwindounaCopilottr4nt0rgjohansson-ST
authored
Portainer add sensor platform (home-assistant#153059)
Co-authored-by: Copilot <[email protected]> Co-authored-by: Manu <[email protected]> Co-authored-by: G Johansson <[email protected]>
1 parent 142daf5 commit cbf1b39

File tree

6 files changed

+368
-1
lines changed

6 files changed

+368
-1
lines changed

homeassistant/components/portainer/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818

1919
from .coordinator import PortainerCoordinator
2020

21-
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SWITCH]
21+
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
22+
2223

2324
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
2425

homeassistant/components/portainer/icons.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
{
22
"entity": {
3+
"sensor": {
4+
"image": {
5+
"default": "mdi:docker"
6+
}
7+
},
38
"switch": {
49
"container": {
510
"default": "mdi:arrow-down-box",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Sensor platform for Portainer integration."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable
6+
from dataclasses import dataclass
7+
8+
from pyportainer.models.docker import DockerContainer
9+
10+
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
11+
from homeassistant.core import HomeAssistant
12+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
13+
14+
from .coordinator import PortainerConfigEntry, PortainerCoordinator
15+
from .entity import PortainerContainerEntity, PortainerCoordinatorData
16+
17+
18+
@dataclass(frozen=True, kw_only=True)
19+
class PortainerSensorEntityDescription(SensorEntityDescription):
20+
"""Class to hold Portainer sensor description."""
21+
22+
value_fn: Callable[[DockerContainer], str | None]
23+
24+
25+
CONTAINER_SENSORS: tuple[PortainerSensorEntityDescription, ...] = (
26+
PortainerSensorEntityDescription(
27+
key="image",
28+
translation_key="image",
29+
value_fn=lambda data: data.image,
30+
),
31+
)
32+
33+
34+
async def async_setup_entry(
35+
hass: HomeAssistant,
36+
entry: PortainerConfigEntry,
37+
async_add_entities: AddConfigEntryEntitiesCallback,
38+
) -> None:
39+
"""Set up Portainer sensors based on a config entry."""
40+
coordinator = entry.runtime_data
41+
42+
async_add_entities(
43+
PortainerContainerSensor(
44+
coordinator,
45+
entity_description,
46+
container,
47+
endpoint,
48+
)
49+
for endpoint in coordinator.data.values()
50+
for container in endpoint.containers.values()
51+
for entity_description in CONTAINER_SENSORS
52+
)
53+
54+
55+
class PortainerContainerSensor(PortainerContainerEntity, SensorEntity):
56+
"""Representation of a Portainer container sensor."""
57+
58+
entity_description: PortainerSensorEntityDescription
59+
60+
def __init__(
61+
self,
62+
coordinator: PortainerCoordinator,
63+
entity_description: PortainerSensorEntityDescription,
64+
device_info: DockerContainer,
65+
via_device: PortainerCoordinatorData,
66+
) -> None:
67+
"""Initialize the Portainer container sensor."""
68+
self.entity_description = entity_description
69+
super().__init__(device_info, coordinator, via_device)
70+
71+
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"
72+
73+
@property
74+
def available(self) -> bool:
75+
"""Return if the device is available."""
76+
return super().available and self.endpoint_id in self.coordinator.data
77+
78+
@property
79+
def native_value(self) -> str | None:
80+
"""Return the state of the sensor."""
81+
return self.entity_description.value_fn(
82+
self.coordinator.data[self.endpoint_id].containers[self.device_id]
83+
)

homeassistant/components/portainer/strings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
"name": "Status"
4747
}
4848
},
49+
"sensor": {
50+
"image": {
51+
"name": "Image"
52+
}
53+
},
4954
"switch": {
5055
"container": {
5156
"name": "Container"
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
# serializer version: 1
2+
# name: test_all_entities[sensor.focused_einstein_image-entry]
3+
EntityRegistryEntrySnapshot({
4+
'aliases': set({
5+
}),
6+
'area_id': None,
7+
'capabilities': None,
8+
'config_entry_id': <ANY>,
9+
'config_subentry_id': <ANY>,
10+
'device_class': None,
11+
'device_id': <ANY>,
12+
'disabled_by': None,
13+
'domain': 'sensor',
14+
'entity_category': None,
15+
'entity_id': 'sensor.focused_einstein_image',
16+
'has_entity_name': True,
17+
'hidden_by': None,
18+
'icon': None,
19+
'id': <ANY>,
20+
'labels': set({
21+
}),
22+
'name': None,
23+
'options': dict({
24+
}),
25+
'original_device_class': None,
26+
'original_icon': None,
27+
'original_name': 'Image',
28+
'platform': 'portainer',
29+
'previous_unique_id': None,
30+
'suggested_object_id': None,
31+
'supported_features': 0,
32+
'translation_key': 'image',
33+
'unique_id': 'portainer_test_entry_123_focused_einstein_image',
34+
'unit_of_measurement': None,
35+
})
36+
# ---
37+
# name: test_all_entities[sensor.focused_einstein_image-state]
38+
StateSnapshot({
39+
'attributes': ReadOnlyDict({
40+
'friendly_name': 'focused_einstein Image',
41+
}),
42+
'context': <ANY>,
43+
'entity_id': 'sensor.focused_einstein_image',
44+
'last_changed': <ANY>,
45+
'last_reported': <ANY>,
46+
'last_updated': <ANY>,
47+
'state': 'docker.io/library/redis:7',
48+
})
49+
# ---
50+
# name: test_all_entities[sensor.funny_chatelet_image-entry]
51+
EntityRegistryEntrySnapshot({
52+
'aliases': set({
53+
}),
54+
'area_id': None,
55+
'capabilities': None,
56+
'config_entry_id': <ANY>,
57+
'config_subentry_id': <ANY>,
58+
'device_class': None,
59+
'device_id': <ANY>,
60+
'disabled_by': None,
61+
'domain': 'sensor',
62+
'entity_category': None,
63+
'entity_id': 'sensor.funny_chatelet_image',
64+
'has_entity_name': True,
65+
'hidden_by': None,
66+
'icon': None,
67+
'id': <ANY>,
68+
'labels': set({
69+
}),
70+
'name': None,
71+
'options': dict({
72+
}),
73+
'original_device_class': None,
74+
'original_icon': None,
75+
'original_name': 'Image',
76+
'platform': 'portainer',
77+
'previous_unique_id': None,
78+
'suggested_object_id': None,
79+
'supported_features': 0,
80+
'translation_key': 'image',
81+
'unique_id': 'portainer_test_entry_123_funny_chatelet_image',
82+
'unit_of_measurement': None,
83+
})
84+
# ---
85+
# name: test_all_entities[sensor.funny_chatelet_image-state]
86+
StateSnapshot({
87+
'attributes': ReadOnlyDict({
88+
'friendly_name': 'funny_chatelet Image',
89+
}),
90+
'context': <ANY>,
91+
'entity_id': 'sensor.funny_chatelet_image',
92+
'last_changed': <ANY>,
93+
'last_reported': <ANY>,
94+
'last_updated': <ANY>,
95+
'state': 'docker.io/library/ubuntu:latest',
96+
})
97+
# ---
98+
# name: test_all_entities[sensor.practical_morse_image-entry]
99+
EntityRegistryEntrySnapshot({
100+
'aliases': set({
101+
}),
102+
'area_id': None,
103+
'capabilities': None,
104+
'config_entry_id': <ANY>,
105+
'config_subentry_id': <ANY>,
106+
'device_class': None,
107+
'device_id': <ANY>,
108+
'disabled_by': None,
109+
'domain': 'sensor',
110+
'entity_category': None,
111+
'entity_id': 'sensor.practical_morse_image',
112+
'has_entity_name': True,
113+
'hidden_by': None,
114+
'icon': None,
115+
'id': <ANY>,
116+
'labels': set({
117+
}),
118+
'name': None,
119+
'options': dict({
120+
}),
121+
'original_device_class': None,
122+
'original_icon': None,
123+
'original_name': 'Image',
124+
'platform': 'portainer',
125+
'previous_unique_id': None,
126+
'suggested_object_id': None,
127+
'supported_features': 0,
128+
'translation_key': 'image',
129+
'unique_id': 'portainer_test_entry_123_practical_morse_image',
130+
'unit_of_measurement': None,
131+
})
132+
# ---
133+
# name: test_all_entities[sensor.practical_morse_image-state]
134+
StateSnapshot({
135+
'attributes': ReadOnlyDict({
136+
'friendly_name': 'practical_morse Image',
137+
}),
138+
'context': <ANY>,
139+
'entity_id': 'sensor.practical_morse_image',
140+
'last_changed': <ANY>,
141+
'last_reported': <ANY>,
142+
'last_updated': <ANY>,
143+
'state': 'docker.io/library/python:3.13-slim',
144+
})
145+
# ---
146+
# name: test_all_entities[sensor.serene_banach_image-entry]
147+
EntityRegistryEntrySnapshot({
148+
'aliases': set({
149+
}),
150+
'area_id': None,
151+
'capabilities': None,
152+
'config_entry_id': <ANY>,
153+
'config_subentry_id': <ANY>,
154+
'device_class': None,
155+
'device_id': <ANY>,
156+
'disabled_by': None,
157+
'domain': 'sensor',
158+
'entity_category': None,
159+
'entity_id': 'sensor.serene_banach_image',
160+
'has_entity_name': True,
161+
'hidden_by': None,
162+
'icon': None,
163+
'id': <ANY>,
164+
'labels': set({
165+
}),
166+
'name': None,
167+
'options': dict({
168+
}),
169+
'original_device_class': None,
170+
'original_icon': None,
171+
'original_name': 'Image',
172+
'platform': 'portainer',
173+
'previous_unique_id': None,
174+
'suggested_object_id': None,
175+
'supported_features': 0,
176+
'translation_key': 'image',
177+
'unique_id': 'portainer_test_entry_123_serene_banach_image',
178+
'unit_of_measurement': None,
179+
})
180+
# ---
181+
# name: test_all_entities[sensor.serene_banach_image-state]
182+
StateSnapshot({
183+
'attributes': ReadOnlyDict({
184+
'friendly_name': 'serene_banach Image',
185+
}),
186+
'context': <ANY>,
187+
'entity_id': 'sensor.serene_banach_image',
188+
'last_changed': <ANY>,
189+
'last_reported': <ANY>,
190+
'last_updated': <ANY>,
191+
'state': 'docker.io/library/nginx:latest',
192+
})
193+
# ---
194+
# name: test_all_entities[sensor.stoic_turing_image-entry]
195+
EntityRegistryEntrySnapshot({
196+
'aliases': set({
197+
}),
198+
'area_id': None,
199+
'capabilities': None,
200+
'config_entry_id': <ANY>,
201+
'config_subentry_id': <ANY>,
202+
'device_class': None,
203+
'device_id': <ANY>,
204+
'disabled_by': None,
205+
'domain': 'sensor',
206+
'entity_category': None,
207+
'entity_id': 'sensor.stoic_turing_image',
208+
'has_entity_name': True,
209+
'hidden_by': None,
210+
'icon': None,
211+
'id': <ANY>,
212+
'labels': set({
213+
}),
214+
'name': None,
215+
'options': dict({
216+
}),
217+
'original_device_class': None,
218+
'original_icon': None,
219+
'original_name': 'Image',
220+
'platform': 'portainer',
221+
'previous_unique_id': None,
222+
'suggested_object_id': None,
223+
'supported_features': 0,
224+
'translation_key': 'image',
225+
'unique_id': 'portainer_test_entry_123_stoic_turing_image',
226+
'unit_of_measurement': None,
227+
})
228+
# ---
229+
# name: test_all_entities[sensor.stoic_turing_image-state]
230+
StateSnapshot({
231+
'attributes': ReadOnlyDict({
232+
'friendly_name': 'stoic_turing Image',
233+
}),
234+
'context': <ANY>,
235+
'entity_id': 'sensor.stoic_turing_image',
236+
'last_changed': <ANY>,
237+
'last_reported': <ANY>,
238+
'last_updated': <ANY>,
239+
'state': 'docker.io/library/postgres:15',
240+
})
241+
# ---
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Tests for the Portainer sensor platform."""
2+
3+
from unittest.mock import patch
4+
5+
import pytest
6+
from syrupy.assertion import SnapshotAssertion
7+
8+
from homeassistant.const import Platform
9+
from homeassistant.core import HomeAssistant
10+
from homeassistant.helpers import entity_registry as er
11+
12+
from . import setup_integration
13+
14+
from tests.common import MockConfigEntry, snapshot_platform
15+
16+
17+
@pytest.mark.usefixtures("mock_portainer_client")
18+
async def test_all_entities(
19+
hass: HomeAssistant,
20+
snapshot: SnapshotAssertion,
21+
mock_config_entry: MockConfigEntry,
22+
entity_registry: er.EntityRegistry,
23+
) -> None:
24+
"""Test all entities."""
25+
with patch(
26+
"homeassistant.components.portainer._PLATFORMS",
27+
[Platform.SENSOR],
28+
):
29+
await setup_integration(hass, mock_config_entry)
30+
await snapshot_platform(
31+
hass, entity_registry, snapshot, mock_config_entry.entry_id
32+
)

0 commit comments

Comments
 (0)