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

Draft
wants to merge 31 commits into
base: dev
Choose a base branch
from
Draft

Conversation

Thomas55555
Copy link
Contributor

@Thomas55555 Thomas55555 commented Oct 29, 2024

Breaking change

Proposed change

Add Portainer integration.
In the first step it is possible to watch the state of the containers in your portainer instance.
In a second step we can add buttons to start, stop or restart the containers.
This integration uses the aiotainer library: https://github.com/Thomas55555/aiotainer

Type of change

  • Dependency upgrade
  • Bugfix (non-breaking change which fixes an issue)
  • New integration (thank you!)
  • New feature (which adds functionality to an existing integration)
  • Deprecation (breaking change to happen in the future)
  • Breaking change (fix/feature causing existing functionality to break)
  • Code quality improvements to existing code or addition of tests

Additional information

Checklist

  • The code change is tested and works locally.
  • Local tests pass. Your PR cannot be merged unless tests pass
  • There is no commented out code in this PR.
  • I have followed the development checklist
  • I have followed the perfect PR recommendations
  • The code has been formatted using Ruff (ruff format homeassistant tests)
  • Tests have been added to verify that the new code works.

If user exposed functionality or configuration variables are added/changed:

If the code communicates with devices, web services, or third-party tools:

  • The manifest file has all fields filled out correctly.
    Updated and included derived files by running: python3 -m script.hassfest.
  • New or updated dependencies have been added to requirements_all.txt.
    Updated by running python3 -m script.gen_requirements_all.
  • For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.

To help with the load of incoming pull requests:

@kbickar
Copy link
Contributor

kbickar commented Nov 4, 2024

Tested this out a bit and have some notes:

  • The Host field asks for a hostname or ip during setup but it actually requires a URL. Suggest adding "https://" if it doesn't start with that
  • Given the above requires a URL, it would be better to use that with the port rather than separate the port field. I have mine setup with a proxy so I access it with https://home/portainer but that's not possible as the port is put on the end making it https://home/portainer:80 instead of https://home/portainer:80
  • The parsing of the entities return value requires all fields to exist when they might not. Mine did not have EnableGPUManagement in the json response (maybe because there are no GPUs?) and so the parsing of the NodeData failed. Looking at the code, this and a few others have the omitempty attribute meaning they may not be present in the response: https://github.com/portainer/portainer/blob/develop/api/portainer.go

@Thomas55555
Copy link
Contributor Author

Tested this out a bit and have some notes:

  • The Host field asks for a hostname or ip during setup but it actually requires a URL. Suggest adding "https://" if it doesn't start with that

We now add http:// if port 9000 is given and we add https:// if port is 9443. If no port is given we raise an Exception.

  • Given the above requires a URL, it would be better to use that with the port rather than separate the port field. I have mine setup with a proxy so I access it with https://home/portainer but that's not possible as the port is put on the end making it https://home/portainer:80 instead of https://home/portainer:80

I also thought about it, but maybe for some people it's to difficult.
Another question. You mentioned port 80. The default port for http-connections is 9000. Have you tried this.
For me it's not possible to combine https and port 9000

https://home/portainer:80 instead of https://home/portainer:80

What do you mean exactly? https://home:80/portainer
Than you're right and it would be a problem.

  • The parsing of the entities return value requires all fields to exist when they might not. Mine did not have EnableGPUManagement in the json response (maybe because there are no GPUs?) and so the parsing of the NodeData failed. Looking at the code, this and a few others have the omitempty attribute meaning they may not be present in the response: https://github.com/portainer/portainer/blob/develop/api/portainer.go

Solved

@kbickar
Copy link
Contributor

kbickar commented Nov 13, 2024

I have it setup so navigating to https://home/portainer loads portainer (which is actually being hosted on 9443, but the redirect obscures that). Given the port is configurable, the integration shouldn't make assumptions about the port (I would add https:// if there's no protocol unless it's specifically 9000).

If all that's needed is a URL then just have the configuration be a URL. People that use portainer already know how to access it via the URL so that should be easily available and less complicated than splitting it into hostname (with a protocol) and port

@Thomas55555 Thomas55555 marked this pull request as ready for review November 21, 2024 22:19
@kbickar
Copy link
Contributor

kbickar commented Nov 24, 2024

During startup it gives a new error:

    await api.get_status()
  File "/home/vscode/.local/ha-venv/lib/python3.12/site-packages/aiotainer/client.py", line 87, in get_status
    self.data = portainer_list_to_dictionary(portainer_list)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/vscode/.local/ha-venv/lib/python3.12/site-packages/aiotainer/utils.py", line 13, in portainer_list_to_dictionary
    NodeData.from_dict(portainer).id: NodeData.from_dict(portainer)
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<string>", line 74, in __mashumaro_from_dict__
mashumaro.exceptions.MissingField: Field "is_edge_device" of type bool is missing in NodeData instance```

@kbickar
Copy link
Contributor

kbickar commented Nov 30, 2024

Setup works and shows the states of all the containers as expected.

I tried stopping a container and it still shows as running in HA even a few minutes later while it shows as exited in portainer

@Thomas55555
Copy link
Contributor Author

How long did you wait?
The container snapshots are by default updated every 5minutes and the scan interval of the integration is also 5min. So in the worst case, you have to wait 10 minutes.
You can also reduce the scan interval:
https://docs.portainer.io/2.15/admin/settings#snapshot-interval

@kbickar
Copy link
Contributor

kbickar commented Dec 8, 2024

Ok, I lowered that and it seems to be updating, guess I just didn't wait long enough. In that case it seems all good

@Thomas55555
Copy link
Contributor Author

Ok, I lowered that and it seems to be updating, guess I just didn't wait long enough. In that case it seems all good

After the integration is added, I want to read out the settings in portainer and adjust the update interval in Home Assistant according to the setting in portainer.

Comment on lines +44 to +45
await self.async_set_unique_id(user_input[CONF_URL])
self._abort_if_unique_id_configured()
Copy link
Member

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

"""The constants for the Portainer integration."""

DOMAIN = "portainer"
NAME = "Portainer"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name is unused i believe

type PortainerConfigEntry = ConfigEntry[PortainerDataUpdateCoordinator]


class ContainerBaseEntity(CoordinatorEntity[PortainerDataUpdateCoordinator]):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a Portainer?


_LOGGER = logging.getLogger(__name__)

type PortainerConfigEntry = ConfigEntry[PortainerDataUpdateCoordinator]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already declared in init, delete one of those 2

CONTAINER_SENSOR_TYPES: tuple[ContainerSensorEntityDescription, ...] = (
ContainerSensorEntityDescription(
key="container_state",
translation_key="container_state",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
translation_key="container_state",
translation_key="container_state",
name=None,

If the name is None, the name of the device will be the name of the entity

class ContainerSensorEntityDescription(SensorEntityDescription):
"""Describes Portainer sensor entity."""

exists_fn: Callable[[NodeData], bool] = lambda _: True
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unused?

Comment on lines +54 to +64
entity_reg = er.async_get(hass)
for entity_entry in er.async_entries_for_config_entry(
entity_reg, entry.entry_id
):
for container_name in removed_containers:
for description in CONTAINER_SENSOR_TYPES:
if entity_entry.unique_id.startswith(
f"{node_id}-{container_name}-{description.key}"
):
_LOGGER.info("Deleting: %s", entity_entry.entity_id)
entity_reg.async_remove(entity_entry.entity_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since all containers are their own device, we can just remove the device (or to be more specific, remove the config entry from the device)

self.entity_description = description
# We use container name as unique id, because the container_id changes
# with an update of the container
self._attr_unique_id = f"{node_id}-{self.container_id}-{description.key}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self._attr_unique_id = f"{node_id}-{self.container_id}-{description.key}"
self._attr_unique_id = f"{node_id}-{container_id}-{description.key}"

Is that comment correct?

Comment on lines +129 to +130
# We use container name as unique id, because the container_id changes
# with an update of the container
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be more useful to have that thing above container as sensor then? It's been a while since I used portainer, was it service? Would it be more useful to show that? Because containers come and go all the time. So when you start 2 containers, what happens with the unique id? Because the name is the same right?

@home-assistant home-assistant bot marked this pull request as draft December 18, 2024 12:18
@home-assistant
Copy link

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

Copy link

There hasn't been any activity on this pull request recently. This pull request has been automatically marked as stale because of that and will be closed if no further activity occurs within 7 days.
If you are the author of this PR, please leave a comment if you want to keep it open. Also, please rebase your PR onto the latest dev branch to ensure that it's up to date with the latest changes.
Thank you for your contribution!

@github-actions github-actions bot removed the stale label Feb 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants