1
mirror of https://github.com/home-assistant/core synced 2024-09-03 08:14:07 +02:00
ha-core/homeassistant/components/rfxtrx/config_flow.py

578 lines
20 KiB
Python

"""Config flow for RFXCOM RFXtrx integration."""
from __future__ import annotations
import copy
import itertools
import os
from typing import TypedDict, cast
import RFXtrx as rfxtrxmod
import serial
import serial.tools.list_ports
import voluptuous as vol
from homeassistant import config_entries, exceptions
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_COMMAND_OFF,
CONF_COMMAND_ON,
CONF_DEVICE,
CONF_DEVICE_ID,
CONF_DEVICES,
CONF_HOST,
CONF_PORT,
CONF_TYPE,
)
from homeassistant.core import callback
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from . import (
DOMAIN,
DeviceTuple,
get_device_id,
get_device_tuple_from_identifiers,
get_rfx_object,
)
from .binary_sensor import supported as binary_supported
from .const import (
CONF_AUTOMATIC_ADD,
CONF_DATA_BITS,
CONF_OFF_DELAY,
CONF_PROTOCOLS,
CONF_REPLACE_DEVICE,
CONF_VENETIAN_BLIND_MODE,
CONST_VENETIAN_BLIND_MODE_DEFAULT,
CONST_VENETIAN_BLIND_MODE_EU,
CONST_VENETIAN_BLIND_MODE_US,
DEVICE_PACKET_TYPE_LIGHTING4,
)
CONF_EVENT_CODE = "event_code"
CONF_MANUAL_PATH = "Enter Manually"
RECV_MODES = sorted(itertools.chain(*rfxtrxmod.lowlevel.Status.RECMODES))
class DeviceData(TypedDict):
"""Dict data representing a device entry."""
event_code: str | None
device_id: DeviceTuple
def none_or_int(value, base):
"""Check if strin is one otherwise convert to int."""
if value is None:
return None
return int(value, base)
class OptionsFlow(config_entries.OptionsFlow):
"""Handle Rfxtrx options."""
_device_registry: dr.DeviceRegistry
_device_entries: list[dr.DeviceEntry]
def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize rfxtrx options flow."""
self._config_entry = config_entry
self._global_options = None
self._selected_device = None
self._selected_device_entry_id: str | None = None
self._selected_device_event_code: str | None = None
self._selected_device_object: rfxtrxmod.RFXtrxEvent | None = None
async def async_step_init(self, user_input=None):
"""Manage the options."""
return await self.async_step_prompt_options()
async def async_step_prompt_options(self, user_input=None):
"""Prompt for options."""
errors = {}
if user_input is not None:
self._global_options = {
CONF_AUTOMATIC_ADD: user_input[CONF_AUTOMATIC_ADD],
CONF_PROTOCOLS: user_input[CONF_PROTOCOLS] or None,
}
if CONF_DEVICE in user_input:
entry_id = user_input[CONF_DEVICE]
device_data = self._get_device_data(entry_id)
self._selected_device_entry_id = entry_id
event_code = device_data[CONF_EVENT_CODE]
self._selected_device_event_code = event_code
self._selected_device = self._config_entry.data[CONF_DEVICES][
event_code
]
self._selected_device_object = get_rfx_object(event_code)
return await self.async_step_set_device_options()
if CONF_EVENT_CODE in user_input:
self._selected_device_event_code = user_input[CONF_EVENT_CODE]
self._selected_device = {}
selected_device_object = get_rfx_object(
self._selected_device_event_code
)
if selected_device_object is None:
errors[CONF_EVENT_CODE] = "invalid_event_code"
elif not self._can_add_device(selected_device_object):
errors[CONF_EVENT_CODE] = "already_configured_device"
else:
self._selected_device_object = selected_device_object
return await self.async_step_set_device_options()
if not errors:
self.update_config_data(global_options=self._global_options)
return self.async_create_entry(title="", data={})
device_registry = dr.async_get(self.hass)
device_entries = dr.async_entries_for_config_entry(
device_registry, self._config_entry.entry_id
)
self._device_registry = device_registry
self._device_entries = device_entries
configure_devices = {
entry.id: entry.name_by_user if entry.name_by_user else entry.name
for entry in device_entries
if self._get_device_event_code(entry.id) is not None
}
options = {
vol.Optional(
CONF_AUTOMATIC_ADD,
default=self._config_entry.data[CONF_AUTOMATIC_ADD],
): bool,
vol.Optional(
CONF_PROTOCOLS,
default=self._config_entry.data.get(CONF_PROTOCOLS) or [],
): cv.multi_select(RECV_MODES),
vol.Optional(CONF_EVENT_CODE): str,
vol.Optional(CONF_DEVICE): vol.In(configure_devices),
}
return self.async_show_form(
step_id="prompt_options", data_schema=vol.Schema(options), errors=errors
)
async def async_step_set_device_options(self, user_input=None):
"""Manage device options."""
errors = {}
if user_input is not None:
assert self._selected_device_object
assert self._selected_device_event_code
device_id = get_device_id(
self._selected_device_object.device,
data_bits=user_input.get(CONF_DATA_BITS),
)
if CONF_REPLACE_DEVICE in user_input:
await self._async_replace_device(user_input[CONF_REPLACE_DEVICE])
devices = {self._selected_device_event_code: None}
self.update_config_data(
global_options=self._global_options, devices=devices
)
return self.async_create_entry(title="", data={})
try:
command_on = none_or_int(user_input.get(CONF_COMMAND_ON), 16)
except ValueError:
errors[CONF_COMMAND_ON] = "invalid_input_2262_on"
try:
command_off = none_or_int(user_input.get(CONF_COMMAND_OFF), 16)
except ValueError:
errors[CONF_COMMAND_OFF] = "invalid_input_2262_off"
try:
off_delay = none_or_int(user_input.get(CONF_OFF_DELAY), 10)
except ValueError:
errors[CONF_OFF_DELAY] = "invalid_input_off_delay"
if not errors:
devices = {}
device = {
CONF_DEVICE_ID: device_id,
}
devices[self._selected_device_event_code] = device
if off_delay:
device[CONF_OFF_DELAY] = off_delay
if user_input.get(CONF_DATA_BITS):
device[CONF_DATA_BITS] = user_input[CONF_DATA_BITS]
if command_on:
device[CONF_COMMAND_ON] = command_on
if command_off:
device[CONF_COMMAND_OFF] = command_off
if user_input.get(CONF_VENETIAN_BLIND_MODE):
device[CONF_VENETIAN_BLIND_MODE] = user_input[
CONF_VENETIAN_BLIND_MODE
]
self.update_config_data(
global_options=self._global_options, devices=devices
)
return self.async_create_entry(title="", data={})
device_data = self._selected_device
data_schema = {}
if binary_supported(self._selected_device_object):
if device_data.get(CONF_OFF_DELAY):
off_delay_schema = {
vol.Optional(
CONF_OFF_DELAY,
description={"suggested_value": device_data[CONF_OFF_DELAY]},
): str,
}
else:
off_delay_schema = {
vol.Optional(CONF_OFF_DELAY): str,
}
data_schema.update(off_delay_schema)
if (
self._selected_device_object.device.packettype
== DEVICE_PACKET_TYPE_LIGHTING4
):
data_schema.update(
{
vol.Optional(
CONF_DATA_BITS, default=device_data.get(CONF_DATA_BITS, 0)
): int,
vol.Optional(
CONF_COMMAND_ON,
default=hex(device_data.get(CONF_COMMAND_ON, 0)),
): str,
vol.Optional(
CONF_COMMAND_OFF,
default=hex(device_data.get(CONF_COMMAND_OFF, 0)),
): str,
}
)
if isinstance(self._selected_device_object.device, rfxtrxmod.RfyDevice):
data_schema.update(
{
vol.Optional(
CONF_VENETIAN_BLIND_MODE,
default=device_data.get(
CONF_VENETIAN_BLIND_MODE, CONST_VENETIAN_BLIND_MODE_DEFAULT
),
): vol.In(
[
CONST_VENETIAN_BLIND_MODE_DEFAULT,
CONST_VENETIAN_BLIND_MODE_US,
CONST_VENETIAN_BLIND_MODE_EU,
]
),
}
)
devices = {
entry.id: entry.name_by_user if entry.name_by_user else entry.name
for entry in self._device_entries
if self._can_replace_device(entry.id)
}
if devices:
data_schema.update(
{
vol.Optional(CONF_REPLACE_DEVICE): vol.In(devices),
}
)
return self.async_show_form(
step_id="set_device_options",
data_schema=vol.Schema(data_schema),
errors=errors,
)
async def _async_replace_device(self, replace_device):
"""Migrate properties of a device into another."""
device_registry = self._device_registry
old_device = self._selected_device_entry_id
old_entry = device_registry.async_get(old_device)
device_registry.async_update_device(
replace_device,
area_id=old_entry.area_id,
name_by_user=old_entry.name_by_user,
)
old_device_data = self._get_device_data(old_device)
new_device_data = self._get_device_data(replace_device)
old_device_id = "_".join(x for x in old_device_data[CONF_DEVICE_ID])
new_device_id = "_".join(x for x in new_device_data[CONF_DEVICE_ID])
entity_registry = er.async_get(self.hass)
entity_entries = er.async_entries_for_device(
entity_registry, old_device, include_disabled_entities=True
)
entity_migration_map = {}
for entry in entity_entries:
unique_id = entry.unique_id
new_unique_id = unique_id.replace(old_device_id, new_device_id)
new_entity_id = entity_registry.async_get_entity_id(
entry.domain, entry.platform, new_unique_id
)
if new_entity_id is not None:
entity_migration_map[new_entity_id] = entry
for entry in entity_migration_map.values():
entity_registry.async_remove(entry.entity_id)
for entity_id, entry in entity_migration_map.items():
entity_registry.async_update_entity(
entity_id,
new_entity_id=entry.entity_id,
name=entry.name,
icon=entry.icon,
)
device_registry.async_remove_device(old_device)
def _can_add_device(self, new_rfx_obj):
"""Check if device does not already exist."""
new_device_id = get_device_id(new_rfx_obj.device)
for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items():
rfx_obj = get_rfx_object(packet_id)
device_id = get_device_id(rfx_obj.device, entity_info.get(CONF_DATA_BITS))
if new_device_id == device_id:
return False
return True
def _can_replace_device(self, entry_id):
"""Check if device can be replaced with selected device."""
device_data = self._get_device_data(entry_id)
if (event_code := device_data[CONF_EVENT_CODE]) is not None:
rfx_obj = get_rfx_object(event_code)
if (
rfx_obj.device.packettype
== self._selected_device_object.device.packettype
and rfx_obj.device.subtype
== self._selected_device_object.device.subtype
and self._selected_device_event_code != event_code
):
return True
return False
def _get_device_event_code(self, entry_id):
data = self._get_device_data(entry_id)
return data[CONF_EVENT_CODE]
def _get_device_data(self, entry_id) -> DeviceData:
"""Get event code based on device identifier."""
event_code: str | None = None
entry = self._device_registry.async_get(entry_id)
assert entry
device_id = get_device_tuple_from_identifiers(entry.identifiers)
assert device_id
for packet_id, entity_info in self._config_entry.data[CONF_DEVICES].items():
if tuple(entity_info.get(CONF_DEVICE_ID)) == device_id:
event_code = cast(str, packet_id)
break
return DeviceData(event_code=event_code, device_id=device_id)
@callback
def update_config_data(self, global_options=None, devices=None):
"""Update data in ConfigEntry."""
entry_data = self._config_entry.data.copy()
entry_data[CONF_DEVICES] = copy.deepcopy(self._config_entry.data[CONF_DEVICES])
if global_options:
entry_data.update(global_options)
if devices:
for event_code, options in devices.items():
if options is None:
entry_data[CONF_DEVICES].pop(event_code)
else:
entry_data[CONF_DEVICES][event_code] = options
self.hass.config_entries.async_update_entry(self._config_entry, data=entry_data)
self.hass.async_create_task(
self.hass.config_entries.async_reload(self._config_entry.entry_id)
)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for RFXCOM RFXtrx."""
VERSION = 1
async def async_step_user(self, user_input=None):
"""Step when user initializes a integration."""
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured()
errors = {}
if user_input is not None:
if user_input[CONF_TYPE] == "Serial":
return await self.async_step_setup_serial()
return await self.async_step_setup_network()
list_of_types = ["Serial", "Network"]
schema = vol.Schema({vol.Required(CONF_TYPE): vol.In(list_of_types)})
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
async def async_step_setup_network(self, user_input=None):
"""Step when setting up network configuration."""
errors = {}
if user_input is not None:
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
try:
data = await self.async_validate_rfx(host=host, port=port)
except CannotConnect:
errors["base"] = "cannot_connect"
if not errors:
return self.async_create_entry(title="RFXTRX", data=data)
schema = vol.Schema(
{vol.Required(CONF_HOST): str, vol.Required(CONF_PORT): int}
)
return self.async_show_form(
step_id="setup_network",
data_schema=schema,
errors=errors,
)
async def async_step_setup_serial(self, user_input=None):
"""Step when setting up serial configuration."""
errors = {}
if user_input is not None:
user_selection = user_input[CONF_DEVICE]
if user_selection == CONF_MANUAL_PATH:
return await self.async_step_setup_serial_manual_path()
dev_path = await self.hass.async_add_executor_job(
get_serial_by_id, user_selection
)
try:
data = await self.async_validate_rfx(device=dev_path)
except CannotConnect:
errors["base"] = "cannot_connect"
if not errors:
return self.async_create_entry(title="RFXTRX", data=data)
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
list_of_ports = {}
for port in ports:
list_of_ports[
port.device
] = f"{port}, s/n: {port.serial_number or 'n/a'}" + (
f" - {port.manufacturer}" if port.manufacturer else ""
)
list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(list_of_ports)})
return self.async_show_form(
step_id="setup_serial",
data_schema=schema,
errors=errors,
)
async def async_step_setup_serial_manual_path(self, user_input=None):
"""Select path manually."""
errors = {}
if user_input is not None:
device = user_input[CONF_DEVICE]
try:
data = await self.async_validate_rfx(device=device)
except CannotConnect:
errors["base"] = "cannot_connect"
if not errors:
return self.async_create_entry(title="RFXTRX", data=data)
schema = vol.Schema({vol.Required(CONF_DEVICE): str})
return self.async_show_form(
step_id="setup_serial_manual_path",
data_schema=schema,
errors=errors,
)
async def async_validate_rfx(self, host=None, port=None, device=None):
"""Create data for rfxtrx entry."""
success = await self.hass.async_add_executor_job(
_test_transport, host, port, device
)
if not success:
raise CannotConnect
data = {
CONF_HOST: host,
CONF_PORT: port,
CONF_DEVICE: device,
CONF_AUTOMATIC_ADD: False,
CONF_DEVICES: {},
}
return data
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Get the options flow for this handler."""
return OptionsFlow(config_entry)
def _test_transport(host, port, device):
"""Construct a rfx object based on config."""
if port is not None:
try:
conn = rfxtrxmod.PyNetworkTransport((host, port))
except OSError:
return False
conn.close()
else:
try:
conn = rfxtrxmod.PySerialTransport(device)
except serial.serialutil.SerialException:
return False
if conn.serial is None:
return False
conn.close()
return True
def get_serial_by_id(dev_path: str) -> str:
"""Return a /dev/serial/by-id match for given device if available."""
by_id = "/dev/serial/by-id"
if not os.path.isdir(by_id):
return dev_path
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
if os.path.realpath(path) == dev_path:
return path
return dev_path
class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""