Skip to content
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

Open
wants to merge 27 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,8 @@ build.json @home-assistant/supervisor
/tests/components/point/ @fredrike
/homeassistant/components/poolsense/ @haemishkyd
/tests/components/poolsense/ @haemishkyd
/homeassistant/components/portainer/ @Thomas55555
/tests/components/portainer/ @Thomas55555
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson
/homeassistant/components/private_ble_device/ @Jc2k
Expand Down
41 changes: 41 additions & 0 deletions homeassistant/components/portainer/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""The Portainer integration."""

import logging

from aiotainer.client import PortainerClient

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client

from . import api
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]
)
api_auth = api.AsyncConfigEntryAuth(client_session, entry.data)
portainer_api = PortainerClient(api_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)
29 changes: 29 additions & 0 deletions homeassistant/components/portainer/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""API for Portainer bound to Home Assistant OAuth."""

import logging
from types import MappingProxyType
from typing import Any

from aiohttp import ClientSession
from aiotainer.auth import AbstractAuth

from homeassistant.const import CONF_ACCESS_TOKEN, CONF_URL

_LOGGER = logging.getLogger(__name__)


class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Portainer authentication tied to an OAuth2 based config entry."""

def __init__(
self,
websession: ClientSession,
data: dict[str, Any] | MappingProxyType[str, Any],
) -> None:
"""Initialize Portainer auth."""
self.data = data
super().__init__(websession, data[CONF_URL])

async def async_get_access_token(self) -> str:
"""Return a valid access token."""
return self.data[CONF_ACCESS_TOKEN]
55 changes: 55 additions & 0 deletions homeassistant/components/portainer/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""The config_flow for Portainer API integration."""

from typing import Any

from aiohttp.client_exceptions import ClientConnectionError
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 .api import AsyncConfigEntryAuth
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])
api = PortainerClient(AsyncConfigEntryAuth(websession, user_input))
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,
)
4 changes: 4 additions & 0 deletions homeassistant/components/portainer/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""The constants for the Portainer integration."""

DOMAIN = "portainer"
NAME = "Portainer"
38 changes: 38 additions & 0 deletions homeassistant/components/portainer/coordinator.py
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
59 changes: 59 additions & 0 deletions homeassistant/components/portainer/entity.py
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]


class ContainerBaseEntity(CoordinatorEntity[PortainerDataUpdateCoordinator]):
"""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
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"],
)

@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
]
9 changes: 9 additions & 0 deletions homeassistant/components/portainer/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"container_state": {
"default": "mdi:state-machine"
}
}
}
}
11 changes: 11 additions & 0 deletions homeassistant/components/portainer/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"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"],
"requirements": ["aiotainer==0.0.1b5"]
}
84 changes: 84 additions & 0 deletions homeassistant/components/portainer/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
rules:
# Bronze
action-setup:
status: exempt
comment: no actions/services are implemented
appropriate-polling: done
brands:
status: todo
comment: waiting for PR to get merged
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: done
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
Loading
Loading