-
-
Notifications
You must be signed in to change notification settings - Fork 33.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Portainer integration #129438
base: dev
Are you sure you want to change the base?
Add Portainer integration #129438
Changes from all commits
75ca092
c9b0cd2
b8daeac
04292ba
2ad4ea4
e0b21a0
1d24713
87522d5
cd6867e
e8bf2a7
220b3ac
9abc418
220057a
6fc954a
c76baf3
e1877a8
ebc783f
7fc628c
b904c51
a01a67c
0515c3f
7d4b7f8
e086e5d
f90bef3
d6828e4
ab8ed01
7cad1c8
1b7a312
cb9e689
2d35cbe
6c6a6c0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
"""The Portainer integration.""" | ||
|
||
import logging | ||
|
||
from aiotainer.auth import Auth | ||
from aiotainer.client import PortainerClient | ||
|
||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_URL, CONF_VERIFY_SSL, Platform | ||
from homeassistant.core import HomeAssistant | ||
from homeassistant.helpers import aiohttp_client | ||
|
||
from .coordinator import PortainerDataUpdateCoordinator | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
PLATFORMS: list[Platform] = [ | ||
Platform.SENSOR, | ||
] | ||
|
||
|
||
type PortainerConfigEntry = ConfigEntry[PortainerDataUpdateCoordinator] | ||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: | ||
"""Set up this integration using UI.""" | ||
client_session = aiohttp_client.async_get_clientsession( | ||
hass, entry.data[CONF_VERIFY_SSL] | ||
) | ||
auth = Auth(client_session, entry.data[CONF_URL], entry.data[CONF_ACCESS_TOKEN]) | ||
portainer_api = PortainerClient(auth) | ||
coordinator = PortainerDataUpdateCoordinator(hass, portainer_api) | ||
await coordinator.async_config_entry_first_refresh() | ||
entry.runtime_data = coordinator | ||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
return True | ||
|
||
|
||
async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: | ||
"""Handle unload of an entry.""" | ||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,56 @@ | ||
"""The config_flow for Portainer API integration.""" | ||
|
||
from typing import Any | ||
|
||
from aiohttp.client_exceptions import ClientConnectionError | ||
from aiotainer.auth import Auth | ||
from aiotainer.client import PortainerClient | ||
import voluptuous as vol | ||
|
||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_URL, CONF_VERIFY_SSL | ||
from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
import homeassistant.helpers.config_validation as cv | ||
|
||
from .const import DOMAIN | ||
|
||
DATA_SCHEMA = vol.Schema( | ||
{ | ||
vol.Required(CONF_URL): cv.string, | ||
vol.Required(CONF_ACCESS_TOKEN): cv.string, | ||
vol.Required(CONF_VERIFY_SSL, default=True): bool, | ||
} | ||
) | ||
|
||
|
||
class PortainerFlow(ConfigFlow, domain=DOMAIN): | ||
"""Config flow for Portainer.""" | ||
|
||
VERSION = 1 | ||
|
||
async def async_step_user( | ||
self, user_input: dict[Any, Any] | None = None | ||
) -> ConfigFlowResult: | ||
"""Handle a flow initialized by the user.""" | ||
errors = {} | ||
if user_input is not None: | ||
websession = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) | ||
auth = Auth(websession, user_input[CONF_URL], user_input[CONF_ACCESS_TOKEN]) | ||
api = PortainerClient(auth) | ||
try: | ||
await api.get_status() | ||
except (TimeoutError, ClientConnectionError): | ||
errors["base"] = "cannot_connect" | ||
else: | ||
await self.async_set_unique_id(user_input[CONF_URL]) | ||
self._abort_if_unique_id_configured() | ||
return self.async_create_entry( | ||
title=user_input[CONF_URL], | ||
data=user_input, | ||
) | ||
|
||
return self.async_show_form( | ||
step_id="user", | ||
data_schema=DATA_SCHEMA, | ||
errors=errors, | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
"""The constants for the Portainer integration.""" | ||
|
||
DOMAIN = "portainer" | ||
NAME = "Portainer" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. name is unused i believe |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
"""Data UpdateCoordinator for the Portainer integration.""" | ||
|
||
from datetime import timedelta | ||
import logging | ||
|
||
from aiotainer.client import PortainerClient | ||
from aiotainer.exceptions import ApiException | ||
from aiotainer.model import NodeData | ||
|
||
from homeassistant.core import HomeAssistant | ||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
||
from .const import DOMAIN | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
SCAN_INTERVAL = timedelta(seconds=30) | ||
|
||
|
||
class PortainerDataUpdateCoordinator(DataUpdateCoordinator[dict[int, NodeData]]): | ||
"""Class to manage fetching data.""" | ||
|
||
def __init__(self, hass: HomeAssistant, api: PortainerClient) -> None: | ||
"""Initialize data updater.""" | ||
super().__init__( | ||
hass, | ||
_LOGGER, | ||
name=DOMAIN, | ||
update_interval=SCAN_INTERVAL, | ||
) | ||
self.api = api | ||
|
||
async def _async_update_data(self) -> dict[int, NodeData]: | ||
"""Subscribe for websocket and poll data from the API.""" | ||
try: | ||
return await self.api.get_status() | ||
except ApiException as err: | ||
raise UpdateFailed(err) from err |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,59 @@ | ||||||
"""Platform for Portainer base entity.""" | ||||||
|
||||||
import logging | ||||||
from typing import TYPE_CHECKING | ||||||
|
||||||
from aiotainer.model import Container, NodeData, Snapshot | ||||||
|
||||||
from homeassistant.config_entries import ConfigEntry | ||||||
from homeassistant.helpers.device_registry import DeviceInfo | ||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity | ||||||
|
||||||
from . import PortainerDataUpdateCoordinator | ||||||
from .const import DOMAIN | ||||||
|
||||||
_LOGGER = logging.getLogger(__name__) | ||||||
|
||||||
type PortainerConfigEntry = ConfigEntry[PortainerDataUpdateCoordinator] | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is already declared in init, delete one of those 2 |
||||||
|
||||||
|
||||||
class ContainerBaseEntity(CoordinatorEntity[PortainerDataUpdateCoordinator]): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a Portainer? |
||||||
"""Defining the Portainer base Entity.""" | ||||||
|
||||||
_attr_has_entity_name = True | ||||||
|
||||||
def __init__( | ||||||
self, | ||||||
coordinator: PortainerDataUpdateCoordinator, | ||||||
node_id: int, | ||||||
container_id: str, | ||||||
) -> None: | ||||||
"""Initialize PortainerEntity.""" | ||||||
super().__init__(coordinator) | ||||||
self.node_id = node_id | ||||||
self.container_id = container_id | ||||||
if TYPE_CHECKING: | ||||||
assert coordinator.config_entry is not None | ||||||
Comment on lines
+35
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead, overwrite the type in the coordinator |
||||||
entry: PortainerConfigEntry = coordinator.config_entry | ||||||
self._attr_device_info = DeviceInfo( | ||||||
identifiers={(DOMAIN, str(node_id))}, | ||||||
name=self.node_attributes.name, | ||||||
configuration_url=entry.data["url"], | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
) | ||||||
|
||||||
@property | ||||||
def node_attributes(self) -> NodeData: | ||||||
"""Get the node attributes of the current node.""" | ||||||
return self.coordinator.data[self.node_id] | ||||||
|
||||||
@property | ||||||
def snapshot_attributes(self) -> Snapshot: | ||||||
"""Get latest snapshot attributes.""" | ||||||
return self.node_attributes.snapshots[-1] | ||||||
|
||||||
@property | ||||||
def container_attributes(self) -> Container: | ||||||
"""Get the container attributes of the current container.""" | ||||||
return self.snapshot_attributes.docker_snapshot_raw.containers[ | ||||||
self.container_id | ||||||
] | ||||||
Comment on lines
+44
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's also add an available property that checks that the node_id is known and the container is known as well |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
{ | ||
"entity": { | ||
"sensor": { | ||
"container_state": { | ||
"default": "mdi:state-machine" | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
{ | ||
"domain": "portainer", | ||
"name": "Portainer", | ||
"codeowners": ["@Thomas55555"], | ||
"config_flow": true, | ||
"documentation": "https://www.home-assistant.io/integrations/portainer", | ||
"integration_type": "service", | ||
"iot_class": "local_polling", | ||
"loggers": ["aiotainer"], | ||
"quality_scale": "bronze", | ||
"requirements": ["aiotainer==0.0.1b6"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
rules: | ||
# Bronze | ||
action-setup: | ||
status: exempt | ||
comment: no actions/services are implemented | ||
appropriate-polling: done | ||
brands: done | ||
common-modules: done | ||
config-flow-test-coverage: done | ||
config-flow: done | ||
dependency-transparency: done | ||
docs-actions: | ||
status: exempt | ||
comment: no actions/services are implemented | ||
docs-high-level-description: done | ||
docs-installation-instructions: done | ||
docs-removal-instructions: done | ||
entity-event-setup: | ||
status: exempt | ||
comment: no subscription required, only polling | ||
entity-unique-id: done | ||
has-entity-name: done | ||
runtime-data: done | ||
test-before-configure: done | ||
test-before-setup: done | ||
unique-config-entry: done | ||
|
||
# Silver | ||
action-exceptions: | ||
status: exempt | ||
comment: no actions/services are implemented | ||
config-entry-unloading: done | ||
docs-configuration-parameters: | ||
status: exempt | ||
comment: no configuration options | ||
docs-installation-parameters: todo | ||
entity-unavailable: done | ||
integration-owner: done | ||
log-when-unavailable: done | ||
parallel-updates: | ||
status: todo | ||
comment: not set at the moment, we use a coordinator | ||
reauthentication-flow: todo | ||
test-coverage: todo | ||
|
||
# Gold | ||
devices: done | ||
diagnostics: todo | ||
discovery-update-info: | ||
status: exempt | ||
comment: no discovery possible | ||
discovery: | ||
status: exempt | ||
comment: no discovery possible | ||
docs-data-update: todo | ||
docs-examples: todo | ||
docs-known-limitations: done | ||
docs-supported-devices: done | ||
docs-supported-functions: done | ||
docs-troubleshooting: todo | ||
docs-use-cases: todo | ||
dynamic-devices: todo | ||
entity-category: done | ||
entity-device-class: done | ||
entity-disabled-by-default: | ||
status: exempt | ||
comment: no lesspopular entities in this integration | ||
entity-translations: | ||
status: exempt | ||
comment: Entities just have the name of the container which is not translatable | ||
exception-translations: todo | ||
icon-translations: done | ||
reconfiguration-flow: todo | ||
repair-issues: | ||
status: exempt | ||
comment: no known use cases for repair issues or flows, yet | ||
stale-devices: todo | ||
|
||
# Platinum | ||
async-dependency: done | ||
inject-websession: done | ||
strict-typing: todo |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
An URL is not a good unique id, can we find something unique in the API like an (unique) user id? Otherwise we need to use _abort_entries_match