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鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

User config flow and custom panel for Dynalite integration #77181

Merged
merged 13 commits into from
May 10, 2023
150 changes: 5 additions & 145 deletions homeassistant/components/dynalite/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
"""Support for the Dynalite networks."""
from __future__ import annotations

from typing import Any

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
Expand All @@ -17,158 +14,19 @@
# Loading the config flow file will register the flow
from .bridge import DynaliteBridge
from .const import (
ACTIVE_INIT,
ACTIVE_OFF,
ACTIVE_ON,
ATTR_AREA,
ATTR_CHANNEL,
ATTR_HOST,
CONF_ACTIVE,
CONF_AREA,
CONF_AUTO_DISCOVER,
CONF_BRIDGES,
CONF_CHANNEL,
CONF_CHANNEL_COVER,
CONF_CLOSE_PRESET,
CONF_DEVICE_CLASS,
CONF_DURATION,
CONF_FADE,
CONF_LEVEL,
CONF_NO_DEFAULT,
CONF_OPEN_PRESET,
CONF_POLL_TIMER,
CONF_PRESET,
CONF_ROOM_OFF,
CONF_ROOM_ON,
CONF_STOP_PRESET,
CONF_TEMPLATE,
CONF_TILT_TIME,
DEFAULT_CHANNEL_TYPE,
DEFAULT_NAME,
DEFAULT_PORT,
DEFAULT_TEMPLATES,
DOMAIN,
LOGGER,
PLATFORMS,
SERVICE_REQUEST_AREA_PRESET,
SERVICE_REQUEST_CHANNEL_LEVEL,
)
from .convert_config import convert_config


def num_string(value: int | str) -> str:
"""Test if value is a string of digits, aka an integer."""
new_value = str(value)
if new_value.isdigit():
return new_value
raise vol.Invalid("Not a string with numbers")


CHANNEL_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_FADE): vol.Coerce(float),
vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any(
"light", "switch"
),
}
)

CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA})

PRESET_DATA_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_FADE): vol.Coerce(float),
vol.Optional(CONF_LEVEL): vol.Coerce(float),
}
)

PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)})

TEMPLATE_ROOM_SCHEMA = vol.Schema(
{vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string}
)

TEMPLATE_TIMECOVER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_CHANNEL_COVER): num_string,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_OPEN_PRESET): num_string,
vol.Optional(CONF_CLOSE_PRESET): num_string,
vol.Optional(CONF_STOP_PRESET): num_string,
vol.Optional(CONF_DURATION): vol.Coerce(float),
vol.Optional(CONF_TILT_TIME): vol.Coerce(float),
}
)

TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA)

TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA})


def validate_area(config: dict[str, Any]) -> dict[str, Any]:
"""Validate that template parameters are only used if area is using the relevant template."""
conf_set = set()
for configs in DEFAULT_TEMPLATES.values():
for conf in configs:
conf_set.add(conf)
if config.get(CONF_TEMPLATE):
for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]:
conf_set.remove(conf)
for conf in conf_set:
if config.get(conf):
raise vol.Invalid(
f"{conf} should not be part of area {config[CONF_NAME]} config"
)
return config


AREA_DATA_SCHEMA = vol.Schema(
vol.All(
{
vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES),
vol.Optional(CONF_FADE): vol.Coerce(float),
vol.Optional(CONF_NO_DEFAULT): cv.boolean,
vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA,
vol.Optional(CONF_PRESET): PRESET_SCHEMA,
# the next ones can be part of the templates
vol.Optional(CONF_ROOM_ON): num_string,
vol.Optional(CONF_ROOM_OFF): num_string,
vol.Optional(CONF_CHANNEL_COVER): num_string,
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_OPEN_PRESET): num_string,
vol.Optional(CONF_CLOSE_PRESET): num_string,
vol.Optional(CONF_STOP_PRESET): num_string,
vol.Optional(CONF_DURATION): vol.Coerce(float),
vol.Optional(CONF_TILT_TIME): vol.Coerce(float),
},
validate_area,
)
)

AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)})

PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)})


BRIDGE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool),
vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float),
vol.Optional(CONF_AREA): AREA_SCHEMA,
vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA,
vol.Optional(CONF_ACTIVE, default=False): vol.Any(
ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean
),
vol.Optional(CONF_PRESET): PRESET_SCHEMA,
vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA,
}
)
from .panel import async_register_dynalite_frontend
from .schema import BRIDGE_SCHEMA

CONFIG_SCHEMA = vol.Schema(
ziv1234 marked this conversation as resolved.
Show resolved Hide resolved
{
Expand Down Expand Up @@ -277,6 +135,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

await async_register_dynalite_frontend(hass)

return True


Expand Down
57 changes: 49 additions & 8 deletions homeassistant/components/dynalite/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@

from typing import Any

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.const import CONF_HOST
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue

from .bridge import DynaliteBridge
from .const import DOMAIN, LOGGER
from .const import DEFAULT_PORT, DOMAIN, LOGGER
from .convert_config import convert_config


Expand All @@ -23,8 +27,20 @@ def __init__(self) -> None:

async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult:
"""Import a new bridge as a config entry."""
LOGGER.debug("Starting async_step_import - %s", import_info)
LOGGER.debug("Starting async_step_import (deprecated) - %s", import_info)
# Raise an issue that this is deprecated and has been imported
async_create_issue(
self.hass,
DOMAIN,
"deprecated_yaml",
is_fixable=False,
is_persistent=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
)

host = import_info[CONF_HOST]
# Check if host already exists
for entry in self._async_current_entries():
if entry.data[CONF_HOST] == host:
self.hass.config_entries.async_update_entry(
Expand All @@ -33,9 +49,34 @@ async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult:
return self.async_abort(reason="already_configured")

# New entry
bridge = DynaliteBridge(self.hass, convert_config(import_info))
return await self._try_create(import_info)

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Step when user initializes a integration."""
if user_input is not None:
return await self._try_create(user_input)

schema = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
}
)
return self.async_show_form(step_id="user", data_schema=schema)

async def _try_create(self, info: dict[str, Any]) -> FlowResult:
"""Try to connect and if successful, create entry."""
host = info[CONF_HOST]
configured_hosts = [
entry.data[CONF_HOST] for entry in self._async_current_entries()
]
if host in configured_hosts:
return self.async_abort(reason="already_configured")
ziv1234 marked this conversation as resolved.
Show resolved Hide resolved
bridge = DynaliteBridge(self.hass, convert_config(info))
if not await bridge.async_setup():
LOGGER.error("Unable to setup bridge - import info=%s", import_info)
return self.async_abort(reason="no_connection")
LOGGER.debug("Creating entry for the bridge - %s", import_info)
return self.async_create_entry(title=host, data=import_info)
LOGGER.error("Unable to setup bridge - import info=%s", info)
return self.async_abort(reason="cannot_connect")
LOGGER.debug("Creating entry for the bridge - %s", info)
return self.async_create_entry(title=info[CONF_HOST], data=info)
4 changes: 3 additions & 1 deletion homeassistant/components/dynalite/manifest.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
{
"domain": "dynalite",
"name": "Philips Dynalite",
"after_dependencies": ["panel_custom"],
"codeowners": ["@ziv1234"],
"config_flow": true,
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/dynalite",
"iot_class": "local_push",
"loggers": ["dynalite_devices_lib"],
"requirements": ["dynalite_devices==0.1.47"]
"requirements": ["dynalite_devices==0.1.47", "dynalite_panel==0.0.2"]
}
117 changes: 117 additions & 0 deletions homeassistant/components/dynalite/panel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Dynalite API interface for the frontend."""

from dynalite_panel import get_build_id, locate_dir
import voluptuous as vol

from homeassistant.components import panel_custom, websocket_api
from homeassistant.components.cover import DEVICE_CLASSES
from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant, callback

from .const import (
CONF_ACTIVE,
CONF_AREA,
CONF_AUTO_DISCOVER,
CONF_PRESET,
CONF_TEMPLATE,
DEFAULT_NAME,
DEFAULT_PORT,
DOMAIN,
LOGGER,
)
from .schema import BRIDGE_SCHEMA

URL_BASE = "/dynalite_static"

RELEVANT_CONFS = [
CONF_NAME,
CONF_HOST,
CONF_PORT,
CONF_AUTO_DISCOVER,
CONF_AREA,
CONF_DEFAULT,
CONF_ACTIVE,
CONF_PRESET,
CONF_TEMPLATE,
]


@websocket_api.websocket_command(
{
vol.Required("type"): "dynalite/get-config",
}
)
@websocket_api.require_admin
@callback
def get_dynalite_config(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Retrieve the Dynalite config for the frontend."""
entries = hass.config_entries.async_entries(DOMAIN)
relevant_config = {
entry.entry_id: {
conf: entry.data[conf] for conf in RELEVANT_CONFS if conf in entry.data
}
for entry in entries
}
dynalite_defaults = {
"DEFAULT_NAME": DEFAULT_NAME,
"DEVICE_CLASSES": DEVICE_CLASSES,
"DEFAULT_PORT": DEFAULT_PORT,
}
connection.send_result(
msg["id"], {"config": relevant_config, "default": dynalite_defaults}
)


@websocket_api.websocket_command(
{
vol.Required("type"): "dynalite/save-config",
vol.Required("entry_id"): str,
vol.Required("config"): BRIDGE_SCHEMA,
}
)
@websocket_api.require_admin
@callback
def save_dynalite_config(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Retrieve the Dynalite config for the frontend."""
entry_id = msg["entry_id"]
entry = hass.config_entries.async_get_entry(entry_id)
if not entry:
LOGGER.error(
"Dynalite - received updated config for invalid entry - %s", entry_id
)
connection.send_result(msg["id"], {"error": True})
return
message_conf = msg["config"]
message_data = {
conf: message_conf[conf] for conf in RELEVANT_CONFS if conf in message_conf
elupus marked this conversation as resolved.
Show resolved Hide resolved
}
LOGGER.info("Updating Dynalite config entry")
hass.config_entries.async_update_entry(entry, data=message_data)
connection.send_result(msg["id"], {})


async def async_register_dynalite_frontend(hass: HomeAssistant):
"""Register the Dynalite frontend configuration panel."""
websocket_api.async_register_command(hass, get_dynalite_config)
websocket_api.async_register_command(hass, save_dynalite_config)
if DOMAIN not in hass.data.get("frontend_panels", {}):
path = locate_dir()
build_id = get_build_id()
hass.http.register_static_path(
URL_BASE, path, cache_headers=(build_id != "dev")
)

await panel_custom.async_register_panel(
hass=hass,
frontend_url_path=DOMAIN,
webcomponent_name="dynalite-panel",
sidebar_title=DOMAIN.capitalize(),
sidebar_icon="mdi:power",
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
embed_iframe=True,
require_admin=True,
)