Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

- name: Initialize CodeQL
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/init@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
with:
languages: python

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/analyze@64d10c13136e1c5bce3e5fbde8d4906eeaafc885 # v3.30.6
with:
category: "/language:python"
5 changes: 0 additions & 5 deletions homeassistant/brands/ibm.json

This file was deleted.

2 changes: 1 addition & 1 deletion homeassistant/components/enphase_envoy/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.3.0"],
"requirements": ["pyenphase==2.4.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
Expand Down
26 changes: 24 additions & 2 deletions homeassistant/components/idasen_desk/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@

from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

_LOGGER = logging.getLogger(__name__)

type IdasenDeskConfigEntry = ConfigEntry[IdasenDeskCoordinator]

UPDATE_DEBOUNCE_TIME = 0.2


class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]):
"""Class to manage updates for the Idasen Desk."""
Expand All @@ -33,9 +36,22 @@ def __init__(
hass, _LOGGER, config_entry=config_entry, name=config_entry.title
)
self.address = address
self.desk = Desk(self._async_handle_update)

self._expected_connected = False
self._height: int | None = None

self.desk = Desk(self.async_set_updated_data)
@callback
def async_update_data() -> None:
self.async_set_updated_data(self._height)

self._debouncer = Debouncer(
hass=self.hass,
logger=_LOGGER,
cooldown=UPDATE_DEBOUNCE_TIME,
immediate=True,
function=async_update_data,
)

async def async_connect(self) -> bool:
"""Connect to desk."""
Expand All @@ -60,3 +76,9 @@ async def async_connect_if_expected(self) -> None:
"""Ensure that the desk is connected if that is the expected state."""
if self._expected_connected:
await self.async_connect()

@callback
def _async_handle_update(self, height: int | None) -> None:
"""Handle an update from the desk."""
self._height = height
self._debouncer.async_schedule_call()
2 changes: 1 addition & 1 deletion homeassistant/components/portainer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from .coordinator import PortainerCoordinator

_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]
_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SWITCH]

type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]

Expand Down
10 changes: 1 addition & 9 deletions homeassistant/components/portainer/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,7 @@ def __init__(
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)

# Container ID's are ephemeral, so use the container name for the unique ID
# The first one, should always be unique, it's fine if users have aliases
# According to Docker's API docs, the first name is unique
device_identifier = (
self._device_info.names[0].replace("/", " ").strip()
if self._device_info.names
else None
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}"
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"

@property
def available(self) -> bool:
Expand Down
16 changes: 8 additions & 8 deletions homeassistant/components/portainer/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,25 +57,25 @@ def __init__(
self.device_id = self._device_info.id
self.endpoint_id = via_device.endpoint.id

device_name = (
self._device_info.names[0].replace("/", " ").strip()
if self._device_info.names
else None
)
# Container ID's are ephemeral, so use the container name for the unique ID
# The first one, should always be unique, it's fine if users have aliases
# According to Docker's API docs, the first name is unique
assert self._device_info.names, "Container names list unexpectedly empty"
self.device_name = self._device_info.names[0].replace("/", " ").strip()

self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}")
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_name}")
},
manufacturer=DEFAULT_NAME,
configuration_url=URL(
f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/containers/{self.device_id}"
),
model="Container",
name=device_name,
name=self.device_name,
via_device=(
DOMAIN,
f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}",
),
translation_key=None if device_name else "unknown_container",
translation_key=None if self.device_name else "unknown_container",
)
12 changes: 12 additions & 0 deletions homeassistant/components/portainer/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"container": {
"default": "mdi:arrow-down-box",
"state": {
"on": "mdi:arrow-up-box"
}
}
}
}
}
5 changes: 5 additions & 0 deletions homeassistant/components/portainer/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
"status": {
"name": "Status"
}
},
"switch": {
"container": {
"name": "Container"
}
}
},
"exceptions": {
Expand Down
141 changes: 141 additions & 0 deletions homeassistant/components/portainer/switch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""Switch platform for Portainer containers."""

from __future__ import annotations

from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any

from pyportainer import Portainer
from pyportainer.exceptions import (
PortainerAuthenticationError,
PortainerConnectionError,
PortainerTimeoutError,
)
from pyportainer.models.docker import DockerContainer

from homeassistant.components.switch import (
SwitchDeviceClass,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from . import PortainerConfigEntry
from .const import DOMAIN
from .coordinator import PortainerCoordinator
from .entity import PortainerContainerEntity, PortainerCoordinatorData


@dataclass(frozen=True, kw_only=True)
class PortainerSwitchEntityDescription(SwitchEntityDescription):
"""Class to hold Portainer switch description."""

is_on_fn: Callable[[DockerContainer], bool | None]
turn_on_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]


async def perform_action(
action: str, portainer: Portainer, endpoint_id: int, container_id: str
) -> None:
"""Stop a container."""
try:
if action == "start":
await portainer.start_container(endpoint_id, container_id)
elif action == "stop":
await portainer.stop_container(endpoint_id, container_id)
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": repr(err)},
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err


SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = (
PortainerSwitchEntityDescription(
key="container",
translation_key="container",
device_class=SwitchDeviceClass.SWITCH,
is_on_fn=lambda data: data.state == "running",
turn_on_fn=perform_action,
turn_off_fn=perform_action,
),
)


async def async_setup_entry(
hass: HomeAssistant,
entry: PortainerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Portainer switch sensors."""

coordinator = entry.runtime_data

async_add_entities(
PortainerContainerSwitch(
coordinator=coordinator,
entity_description=entity_description,
device_info=container,
via_device=endpoint,
)
for endpoint in coordinator.data.values()
for container in endpoint.containers.values()
for entity_description in SWITCHES
)


class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
"""Representation of a Portainer container switch."""

entity_description: PortainerSwitchEntityDescription

def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerSwitchEntityDescription,
device_info: DockerContainer,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer container switch."""
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)

self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}"

@property
def is_on(self) -> bool | None:
"""Return the state of the device."""
return self.entity_description.is_on_fn(
self.coordinator.data[self.endpoint_id].containers[self.device_id]
)

async def async_turn_on(self, **kwargs: Any) -> None:
"""Start (turn on) the container."""
await self.entity_description.turn_on_fn(
"start", self.coordinator.portainer, self.endpoint_id, self.device_id
)
await self.coordinator.async_request_refresh()

async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop (turn off) the container."""
await self.entity_description.turn_off_fn(
"stop", self.coordinator.portainer, self.endpoint_id, self.device_id
)
await self.coordinator.async_request_refresh()
6 changes: 3 additions & 3 deletions homeassistant/components/roborock/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ async def async_step_code(
reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()}
)
self._abort_if_unique_id_configured(error="already_configured_account")
return self._create_entry(self._client, self._username, user_data)
return await self._create_entry(self._client, self._username, user_data)

return self.async_show_form(
step_id="code",
Expand Down Expand Up @@ -176,7 +176,7 @@ async def async_step_reauth_confirm(
return await self.async_step_code()
return self.async_show_form(step_id="reauth_confirm", errors=errors)

def _create_entry(
async def _create_entry(
self, client: RoborockApiClient, username: str, user_data: UserData
) -> ConfigFlowResult:
"""Finished config flow and create entry."""
Expand All @@ -185,7 +185,7 @@ def _create_entry(
data={
CONF_USERNAME: username,
CONF_USER_DATA: user_data.as_dict(),
CONF_BASE_URL: client.base_url,
CONF_BASE_URL: await client.base_url,
},
)

Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/roborock/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==2.49.1",
"python-roborock==2.50.2",
"vacuum-map-parser-roborock==0.1.4"
]
}
Loading
Loading