Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions homeassistant/components/evohome/entity.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Support for entities of the Evohome integration."""

from collections.abc import Mapping
from datetime import UTC, datetime
import logging
from typing import Any

Expand All @@ -14,6 +13,7 @@

from homeassistant.core import callback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util

from .coordinator import EvoDataUpdateCoordinator

Expand Down Expand Up @@ -161,7 +161,7 @@ async def get_schedule() -> None:
or self._schedule is None
or (
(until := self._setpoints.get("next_sp_from")) is not None
and until < datetime.now(UTC) # pylint: disable=home-assistant-enforce-utcnow
and until < dt_util.utcnow()
)
): # must use self._setpoints, not self.setpoints
await get_schedule()
Expand Down
6 changes: 3 additions & 3 deletions homeassistant/components/evohome/storage.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Support for (EMEA/EU-based) Honeywell TCC systems."""

from datetime import UTC, datetime, timedelta
from datetime import datetime, timedelta
from typing import Any, NotRequired, TypedDict

from evohomeasync.auth import (
Expand All @@ -12,6 +12,7 @@

from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util

from .const import STORAGE_KEY, STORAGE_VER

Expand Down Expand Up @@ -91,8 +92,7 @@ def _import_session_id(self, session: _SessionIdEntryT) -> None: # type: ignore

session_id_expires = session.get(SZ_SESSION_ID_EXPIRES)
if session_id_expires is None:
# pylint: disable-next=home-assistant-enforce-utcnow
self._session_id_expires = datetime.now(tz=UTC) + timedelta(minutes=15)
self._session_id_expires = dt_util.utcnow() + timedelta(minutes=15)
else:
self._session_id_expires = datetime.fromisoformat(session_id_expires)

Expand Down
42 changes: 42 additions & 0 deletions homeassistant/components/imou/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Support for Imou devices."""

from pyimouapi.device import ImouDeviceManager
from pyimouapi.ha_device import ImouHaDeviceManager
from pyimouapi.openapi import ImouOpenApiClient

from homeassistant.core import HomeAssistant, callback

from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, PLATFORMS
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator


async def async_setup_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool:
"""Set up Imou integration from a config entry."""
imou_client = ImouOpenApiClient(
entry.data[CONF_APP_ID],
entry.data[CONF_APP_SECRET],
API_URLS[entry.data[CONF_API_URL]],
)
device_manager = ImouDeviceManager(imou_client)
imou_device_manager = ImouHaDeviceManager(device_manager)
imou_coordinator = ImouDataUpdateCoordinator(hass, imou_device_manager, entry)
await imou_coordinator.async_config_entry_first_refresh()
entry.runtime_data = imou_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

# DataUpdateCoordinator schedules periodic refreshes only when it has
# listeners. With zero entities (e.g. an empty account at setup), register a
# no-op listener so polling continues and later devices are discovered via
# new_device_callbacks.
@callback
def _async_keep_polling() -> None:
"""Keep periodic polling when no entities are registered yet."""

entry.async_on_unload(imou_coordinator.async_add_listener(_async_keep_polling))

return True


async def async_unload_entry(hass: HomeAssistant, entry: ImouConfigEntry) -> bool:
"""Handle removal of an entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
109 changes: 109 additions & 0 deletions homeassistant/components/imou/button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Support for Imou button controls."""

from pyimouapi.exceptions import ImouException
from pyimouapi.ha_device import ImouHaDevice

from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from .const import PTZ_MOVE_DURATION_MS, imou_device_identifier
from .coordinator import ImouConfigEntry, ImouDataUpdateCoordinator
from .entity import ImouEntity

PARALLEL_UPDATES = 1
# Button types
PARAM_RESTART_DEVICE = "restart_device"
PARAM_MUTE = "mute"
PARAM_PTZ_UP = "ptz_up"
PARAM_PTZ_DOWN = "ptz_down"
PARAM_PTZ_LEFT = "ptz_left"
PARAM_PTZ_RIGHT = "ptz_right"

BUTTON_TYPES = (
PARAM_RESTART_DEVICE,
PARAM_MUTE,
PARAM_PTZ_UP,
PARAM_PTZ_DOWN,
PARAM_PTZ_LEFT,
PARAM_PTZ_RIGHT,
)

PTZ_BUTTON_TYPES = (
PARAM_PTZ_UP,
PARAM_PTZ_DOWN,
PARAM_PTZ_LEFT,
PARAM_PTZ_RIGHT,
)

BUTTON_DEVICE_CLASS: dict[str, ButtonDeviceClass] = {
PARAM_RESTART_DEVICE: ButtonDeviceClass.RESTART,
}


def _iter_buttons(
coordinator: ImouDataUpdateCoordinator,
) -> list[tuple[str, ImouHaDevice]]:
"""Return (button_type, device) pairs for supported buttons."""
return [
(button_type, device)
for device in coordinator.devices
for button_type in device.buttons
if button_type in BUTTON_TYPES
]


async def async_setup_entry(
hass: HomeAssistant,
entry: ImouConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Imou button entities."""
coordinator = entry.runtime_data

def _add_buttons(new_devices: list[ImouHaDevice]) -> None:
device_keys = {imou_device_identifier(device) for device in new_devices}
async_add_entities(
ImouButton(coordinator, button_type, device)
for button_type, device in _iter_buttons(coordinator)
if imou_device_identifier(device) in device_keys
)

coordinator.new_device_callbacks.append(_add_buttons)

@callback
def _remove_new_device_callback() -> None:
if _add_buttons in coordinator.new_device_callbacks:
coordinator.new_device_callbacks.remove(_add_buttons)

entry.async_on_unload(_remove_new_device_callback)
_add_buttons(coordinator.devices)


class ImouButton(ImouEntity, ButtonEntity):
"""Imou button entity."""

def __init__(
self,
coordinator: ImouDataUpdateCoordinator,
entity_type: str,
device: ImouHaDevice,
) -> None:
"""Initialize the Imou button entity."""
super().__init__(coordinator, entity_type, device)
if device_class := BUTTON_DEVICE_CLASS.get(entity_type):
self._attr_device_class = device_class
self._attr_translation_key = None

async def async_press(self) -> None:
"""Handle button press."""
duration = PTZ_MOVE_DURATION_MS if self._entity_type in PTZ_BUTTON_TYPES else 0
try:
await self.coordinator.device_manager.async_press_button(
self.device,
self._entity_type,
duration,
)
except ImouException as e:
raise HomeAssistantError(str(e)) from e
80 changes: 80 additions & 0 deletions homeassistant/components/imou/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Config flow for Imou."""

import logging
from typing import Any

from pyimouapi.exceptions import (
ConnectFailedException,
ImouException,
InvalidAppIdOrSecretException,
RequestFailedException,
)
from pyimouapi.openapi import ImouOpenApiClient
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)

from .const import API_URLS, CONF_API_URL, CONF_APP_ID, CONF_APP_SECRET, DOMAIN

_LOGGER = logging.getLogger(__name__)


class ImouConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Imou integration."""

VERSION = 1
MINOR_VERSION = 1

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step of the config flow."""
errors: dict[str, str] = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_APP_ID])
self._abort_if_unique_id_configured()
api_client = ImouOpenApiClient(
user_input[CONF_APP_ID],
user_input[CONF_APP_SECRET],
API_URLS[user_input[CONF_API_URL]],
)
try:
await api_client.async_get_token()
except InvalidAppIdOrSecretException:
errors["base"] = "invalid_auth"
except ConnectFailedException, RequestFailedException:
errors["base"] = "cannot_connect"
except ImouException as exception:
_LOGGER.debug("Imou error during config flow: %s", exception)
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="Imou",
data={
CONF_APP_ID: user_input[CONF_APP_ID],
CONF_APP_SECRET: user_input[CONF_APP_SECRET],
CONF_API_URL: user_input[CONF_API_URL],
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_APP_ID): str,
vol.Required(CONF_APP_SECRET): str,
vol.Required(CONF_API_URL, default="sg"): SelectSelector(
SelectSelectorConfig(
options=list(API_URLS),
translation_key="api_url",
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
errors=errors,
)
39 changes: 39 additions & 0 deletions homeassistant/components/imou/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Constants."""

from pyimouapi.ha_device import ImouHaDevice

from homeassistant.const import Platform

DOMAIN = "imou"


def imou_device_identifier(device: ImouHaDevice) -> str:
"""Return a device registry identifier (device_id + channel when present)."""
if device.channel_id is not None:
return f"{device.device_id}_{device.channel_id}"
return device.device_id


# API URL region mapping
API_URLS: dict[str, str] = {
"sg": "openapi-sg.easy4ip.com",
"eu": "openapi-or.easy4ip.com",
"na": "openapi-fk.easy4ip.com",
"cn": "openapi.lechange.cn",
}

CONF_API_URL = "api_url"
CONF_APP_ID = "app_id"
CONF_APP_SECRET = "app_secret"

PARAM_STATUS = "status"
PARAM_STATE = "state"


# How long each PTZ button press moves the camera, in milliseconds (Imou cloud API).
PTZ_MOVE_DURATION_MS = 500

# Upper bound for a full coordinator refresh (device list + status for all devices).
UPDATE_TIMEOUT = 300

PLATFORMS = [Platform.BUTTON]
Loading
Loading