Files
SmartIR/custom_components/smartir/config_flow.py
T
kobim 992098fbf1 feat: Add config flow for SmartIR integration with multi-step UI wizard
- Implemented a new config flow in `config_flow.py` for SmartIR, allowing users to select platforms, controllers, and devices through a guided interface.
- Added support for new device codes in `codes_index.json` for Apton and Andersson manufacturers.
- Enhanced fan, light, and media player components to support setup from config entries.
- Introduced `strings.json` and `translations/en.json` for improved localization of the configuration steps.
- Created a tool `build_index.py` to rebuild the `codes_index.json` from individual device JSON files.
- Updated `smartir_capture.py` to handle single-code modes and excluded modes for better device configuration.
- Added `iot_class` and `config_flow` attributes in `manifest.json` to support the new configuration flow.
2026-07-01 20:11:48 +02:00

663 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Config flow for SmartIR — 6-step UI wizard with device list selection."""
from __future__ import annotations
import json
import logging
import os
import aiofiles
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
BooleanSelector,
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from . import COMPONENT_ABS_DIR, Helper
from .const import (
ALL_CONTROLLERS_SENTINEL,
CONF_CONTROLLER_DATA,
CONF_DELAY,
CONF_DEVICE_CLASS,
CONF_DEVICE_CODE,
CONF_DEVICE_NAME,
CONF_HUMIDITY_SENSOR,
CONF_PLATFORM,
CONF_POWER_SENSOR,
CONF_POWER_SENSOR_RESTORE_STATE,
CONF_SOURCE_NAMES,
CONF_TEMPERATURE_SENSOR,
CONTROLLER_HINTS,
DEFAULT_DELAY,
DEFAULT_DEVICE_NAMES,
ENTITY_BASED_CONTROLLERS,
INDEX_FILENAME,
MANUAL_ENTRY_SENTINEL,
PLATFORM_SUBDIR,
PLATFORMS,
)
DOMAIN = "smartir"
_LOGGER = logging.getLogger(__name__)
# ── Module-level helpers ──────────────────────────────────────────────────────
async def _load_device_json(platform: str, device_code: int) -> dict | None:
"""Return device JSON dict, downloading from GitHub if not cached locally."""
subdir = PLATFORM_SUBDIR[platform]
codes_dir = os.path.join(COMPONENT_ABS_DIR, "codes", subdir)
os.makedirs(codes_dir, exist_ok=True)
path = os.path.join(codes_dir, f"{device_code}.json")
if not os.path.exists(path):
source = (
"https://raw.githubusercontent.com/"
f"smartHomeHub/SmartIR/master/codes/{subdir}/{device_code}.json"
)
try:
await Helper.downloader(source, path)
except Exception:
return None
try:
async with aiofiles.open(path, mode="r") as f:
return json.loads(await f.read())
except Exception:
return None
def _controller_schema(
controller_type: str,
current_data: str = "",
current_delay: str = DEFAULT_DELAY,
) -> vol.Schema:
"""Build controller step schema; entity picker for Broadlink/Xiaomi, text for rest."""
if controller_type in ENTITY_BASED_CONTROLLERS:
data_field: object = EntitySelector(EntitySelectorConfig(domain="remote"))
else:
data_field = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
fields: dict = {}
if current_data:
fields[
vol.Required(
CONF_CONTROLLER_DATA,
description={"suggested_value": current_data},
)
] = data_field
else:
fields[vol.Required(CONF_CONTROLLER_DATA)] = data_field
fields[
vol.Optional(
CONF_DELAY,
description={"suggested_value": current_delay},
)
] = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
return vol.Schema(fields)
def _options_schema(platform: str, defaults: dict | None = None) -> vol.Schema:
"""Build optional-sensors schema for the given platform."""
d = defaults or {}
def _opt(key: str) -> vol.Optional:
val = d.get(key)
if val is not None and val != "":
return vol.Optional(key, description={"suggested_value": val})
return vol.Optional(key)
if platform == "climate":
return vol.Schema(
{
_opt(CONF_TEMPERATURE_SENSOR): EntitySelector(
EntitySelectorConfig(domain="sensor")
),
_opt(CONF_HUMIDITY_SENSOR): EntitySelector(
EntitySelectorConfig(domain="sensor")
),
_opt(CONF_POWER_SENSOR): EntitySelector(EntitySelectorConfig()),
vol.Optional(
CONF_POWER_SENSOR_RESTORE_STATE,
default=bool(d.get(CONF_POWER_SENSOR_RESTORE_STATE, False)),
): BooleanSelector(),
}
)
if platform == "media_player":
src_existing = d.get(CONF_SOURCE_NAMES, {})
src_str = (
json.dumps(src_existing)
if isinstance(src_existing, dict) and src_existing
else ""
)
fields: dict = {
_opt(CONF_POWER_SENSOR): EntitySelector(EntitySelectorConfig()),
vol.Optional(
CONF_DEVICE_CLASS,
description={"suggested_value": d.get(CONF_DEVICE_CLASS, "tv")},
): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)),
}
src_key = (
vol.Optional(
CONF_SOURCE_NAMES,
description={"suggested_value": src_str},
)
if src_str
else vol.Optional(CONF_SOURCE_NAMES)
)
fields[src_key] = TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True)
)
return vol.Schema(fields)
# fan and light
return vol.Schema(
{_opt(CONF_POWER_SENSOR): EntitySelector(EntitySelectorConfig())}
)
def _parse_options(platform: str, user_input: dict) -> dict:
"""Clean and coerce options form input before storing."""
out: dict = {}
def _maybe(key: str) -> None:
val = user_input.get(key, "")
if isinstance(val, str):
val = val.strip()
if val:
out[key] = val
if platform == "climate":
_maybe(CONF_TEMPERATURE_SENSOR)
_maybe(CONF_HUMIDITY_SENSOR)
_maybe(CONF_POWER_SENSOR)
out[CONF_POWER_SENSOR_RESTORE_STATE] = bool(
user_input.get(CONF_POWER_SENSOR_RESTORE_STATE, False)
)
elif platform == "media_player":
_maybe(CONF_POWER_SENSOR)
out[CONF_DEVICE_CLASS] = (
(user_input.get(CONF_DEVICE_CLASS) or "tv").strip() or "tv"
)
src_raw = user_input.get(CONF_SOURCE_NAMES, "")
if isinstance(src_raw, str) and src_raw.strip():
try:
parsed = json.loads(src_raw.strip())
if isinstance(parsed, dict):
out[CONF_SOURCE_NAMES] = parsed
except json.JSONDecodeError:
pass
elif isinstance(src_raw, dict) and src_raw:
out[CONF_SOURCE_NAMES] = src_raw
else: # fan, light
_maybe(CONF_POWER_SENSOR)
return out
def _device_placeholders(device_data: dict, platform: str) -> dict:
"""Build description_placeholders dict for the options step."""
p: dict = {
"manufacturer": device_data.get("manufacturer", "—"),
"models": ", ".join(device_data.get("supportedModels", [])) or "—",
"controller_type": device_data.get("supportedController", "—"),
"device_code": str(device_data.get("device_code", "—")),
}
if platform == "climate":
p["temp_range"] = (
f"{device_data.get('minTemperature', '—')} "
f"{device_data.get('maxTemperature', '—')} °C"
)
p["operation_modes"] = ", ".join(device_data.get("operationModes", []))
p["fan_modes"] = ", ".join(device_data.get("fanModes", []))
swing = device_data.get("swingModes", [])
p["swing_modes"] = ", ".join(swing) if swing else "none"
return p
# ── Config Flow ───────────────────────────────────────────────────────────────
class SmartIRConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle multi-step UI config flow for SmartIR."""
VERSION = 1
def __init__(self) -> None:
self._platform: str = ""
self._controller_filter: str = ALL_CONTROLLERS_SENTINEL
self._manufacturer: str = ""
self._device_code: int = 0
self._device_data: dict = {}
self._device_name: str = ""
self._controller_data: str = ""
self._delay: str = DEFAULT_DELAY
self._index_cache: list[dict] | None = None
# ── Index helpers ─────────────────────────────────────────────────────────
async def _get_index(self) -> list[dict]:
"""Load and cache index entries for the current platform."""
if self._index_cache is not None:
return self._index_cache
path = os.path.join(COMPONENT_ABS_DIR, INDEX_FILENAME)
try:
async with aiofiles.open(path, mode="r") as f:
data = json.loads(await f.read())
self._index_cache = data.get(self._platform, [])
except Exception:
_LOGGER.warning("Could not load %s", INDEX_FILENAME)
self._index_cache = []
return self._index_cache
def _filtered(self, entries: list[dict]) -> list[dict]:
if self._controller_filter == ALL_CONTROLLERS_SENTINEL:
return entries
return [e for e in entries if e.get("controller") == self._controller_filter]
# ── Step 1: platform ──────────────────────────────────────────────────────
async def async_step_user(self, user_input: dict | None = None) -> FlowResult:
if user_input is not None:
self._platform = user_input["platform"]
return await self.async_step_controller_filter()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required("platform"): SelectSelector(
SelectSelectorConfig(
options=[
{"label": v, "value": k}
for k, v in PLATFORMS.items()
],
mode=SelectSelectorMode.LIST,
)
)
}
),
)
# ── Step 2: controller filter ─────────────────────────────────────────────
async def async_step_controller_filter(
self, user_input: dict | None = None
) -> FlowResult:
entries = await self._get_index()
if user_input is not None:
self._controller_filter = user_input.get(
"controller_type", ALL_CONTROLLERS_SENTINEL
)
return await self.async_step_manufacturer()
available = sorted({e["controller"] for e in entries})
options = [{"label": "Any (show all)", "value": ALL_CONTROLLERS_SENTINEL}]
options += [{"label": c, "value": c} for c in available]
return self.async_show_form(
step_id="controller_filter",
data_schema=vol.Schema(
{
vol.Required(
"controller_type", default=ALL_CONTROLLERS_SENTINEL
): SelectSelector(
SelectSelectorConfig(
options=options, mode=SelectSelectorMode.LIST
)
)
}
),
)
# ── Step 3: manufacturer ──────────────────────────────────────────────────
async def async_step_manufacturer(
self, user_input: dict | None = None
) -> FlowResult:
entries = self._filtered(await self._get_index())
manufacturers = sorted({e["manufacturer"] for e in entries})
if user_input is not None:
sel = user_input.get("manufacturer", "")
if sel == MANUAL_ENTRY_SENTINEL:
return await self.async_step_device_manual()
self._manufacturer = sel
return await self.async_step_device_select()
options = [
{"label": "— Enter code manually —", "value": MANUAL_ENTRY_SENTINEL}
]
options += [{"label": m, "value": m} for m in manufacturers]
return self.async_show_form(
step_id="manufacturer",
data_schema=vol.Schema(
{
vol.Required("manufacturer"): SelectSelector(
SelectSelectorConfig(
options=options, mode=SelectSelectorMode.DROPDOWN
)
)
}
),
)
# ── Step 4a: device from index list ───────────────────────────────────────
async def async_step_device_select(
self, user_input: dict | None = None
) -> FlowResult:
entries = [
e
for e in self._filtered(await self._get_index())
if e.get("manufacturer") == self._manufacturer
]
errors: dict[str, str] = {}
if user_input is not None:
sel = user_input.get("device_code", "")
if sel == MANUAL_ENTRY_SENTINEL:
return await self.async_step_device_manual()
try:
device_code = int(sel)
except (ValueError, TypeError):
errors["device_code"] = "invalid_device_code"
else:
device_data = await _load_device_json(self._platform, device_code)
if device_data is None:
errors["device_code"] = "invalid_device_code"
else:
self._device_code = device_code
self._device_data = device_data
models = device_data.get("supportedModels", [])
self._device_name = (
f"{self._manufacturer} {models[0]}"
if models
else DEFAULT_DEVICE_NAMES[self._platform]
)
return await self.async_step_device_name()
options = [
{"label": "— Enter code manually —", "value": MANUAL_ENTRY_SENTINEL}
]
for e in sorted(entries, key=lambda x: x["code"]):
models_str = ", ".join(e.get("models", []))
options.append(
{"label": f"{e['code']}{models_str}", "value": str(e["code"])}
)
return self.async_show_form(
step_id="device_select",
data_schema=vol.Schema(
{
vol.Required("device_code"): SelectSelector(
SelectSelectorConfig(
options=options, mode=SelectSelectorMode.DROPDOWN
)
)
}
),
description_placeholders={"manufacturer": self._manufacturer},
errors=errors,
)
# ── Step 4b: manual code entry ────────────────────────────────────────────
async def async_step_device_manual(
self, user_input: dict | None = None
) -> FlowResult:
errors: dict[str, str] = {}
if user_input is not None:
try:
device_code = int(user_input["device_code"])
if device_code <= 0:
raise ValueError("non-positive")
except (ValueError, KeyError, TypeError):
errors["device_code"] = "invalid_device_code"
else:
device_data = await _load_device_json(self._platform, device_code)
if device_data is None:
errors["device_code"] = "invalid_device_code"
else:
self._device_code = device_code
self._device_data = device_data
manufacturer = device_data.get("manufacturer", "")
models = device_data.get("supportedModels", [])
self._manufacturer = manufacturer
self._device_name = (
f"{manufacturer} {models[0]}"
if models
else DEFAULT_DEVICE_NAMES[self._platform]
)
return await self.async_step_device_name()
return self.async_show_form(
step_id="device_manual",
data_schema=vol.Schema(
{
vol.Required("device_code"): TextSelector(
TextSelectorConfig(type=TextSelectorType.NUMBER)
)
}
),
errors=errors,
)
# ── Step 4c: confirm / edit name ──────────────────────────────────────────
async def async_step_device_name(
self, user_input: dict | None = None
) -> FlowResult:
if user_input is not None:
name = (user_input.get("name") or "").strip() or self._device_name
self._device_name = name
return await self.async_step_controller()
return self.async_show_form(
step_id="device_name",
data_schema=vol.Schema(
{
vol.Required(
"name",
description={"suggested_value": self._device_name},
): TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
}
),
description_placeholders={
"manufacturer": self._device_data.get("manufacturer", ""),
"models": ", ".join(self._device_data.get("supportedModels", [])),
"controller_type": self._device_data.get("supportedController", ""),
"device_code": str(self._device_code),
},
)
# ── Step 5: controller data ───────────────────────────────────────────────
async def async_step_controller(
self, user_input: dict | None = None
) -> FlowResult:
errors: dict[str, str] = {}
controller_type = self._device_data.get("supportedController", "")
hint = CONTROLLER_HINTS.get(controller_type, "Enter the controller connection string.")
if user_input is not None:
controller_data = user_input.get(CONF_CONTROLLER_DATA, "")
if isinstance(controller_data, str):
controller_data = controller_data.strip()
if not controller_data:
errors[CONF_CONTROLLER_DATA] = "controller_data_required"
else:
self._controller_data = controller_data
delay_raw = user_input.get(CONF_DELAY, DEFAULT_DELAY)
try:
float(delay_raw)
self._delay = str(delay_raw)
except (ValueError, TypeError):
self._delay = DEFAULT_DELAY
uid = (
f"smartir_{self._platform}_{self._device_code}"
f"_{self._controller_data}"
)
await self.async_set_unique_id(uid)
self._abort_if_unique_id_configured()
return await self.async_step_options_platform()
return self.async_show_form(
step_id="controller",
data_schema=_controller_schema(
controller_type, self._controller_data, self._delay
),
description_placeholders={
"controller_type": controller_type,
"controller_hint": hint,
},
errors=errors,
)
# ── Step 6: optional sensors / platform settings ──────────────────────────
async def async_step_options_platform(
self, user_input: dict | None = None
) -> FlowResult:
if user_input is not None:
return self._create_entry(user_input)
return self.async_show_form(
step_id="options_platform",
data_schema=_options_schema(self._platform),
description_placeholders=_device_placeholders(
self._device_data, self._platform
),
)
# ── Entry creation ────────────────────────────────────────────────────────
def _create_entry(self, options_input: dict) -> FlowResult:
try:
delay_float = float(self._delay)
except (ValueError, TypeError):
delay_float = 0.5
# Immutable identification stored in data
data: dict = {
CONF_PLATFORM: self._platform,
CONF_DEVICE_CODE: self._device_code,
CONF_DEVICE_NAME: self._device_name,
}
# Mutable settings stored in options (editable via options flow)
options: dict = {
CONF_CONTROLLER_DATA: self._controller_data,
CONF_DELAY: delay_float,
}
options.update(_parse_options(self._platform, options_input))
mfr = self._device_data.get("manufacturer", "SmartIR")
title = f"{mfr}{self._device_name}"
return self.async_create_entry(title=title, data=data, options=options)
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> SmartIROptionsFlow:
return SmartIROptionsFlow(config_entry)
# ── Options Flow ──────────────────────────────────────────────────────────────
class SmartIROptionsFlow(OptionsFlow):
"""Edit controller and sensor settings for an existing SmartIR entry."""
def __init__(self, config_entry: ConfigEntry) -> None:
self._entry = config_entry
self._platform: str = config_entry.data.get(CONF_PLATFORM, "")
self._device_code: int = config_entry.data.get(CONF_DEVICE_CODE, 0)
self._device_data: dict = {}
# Pre-fill from current options
self._controller_data: str = config_entry.options.get(CONF_CONTROLLER_DATA, "")
self._delay: str = str(config_entry.options.get(CONF_DELAY, DEFAULT_DELAY))
async def async_step_init(self, user_input: dict | None = None) -> FlowResult:
if not self._device_data:
self._device_data = (
await _load_device_json(self._platform, self._device_code) or {}
)
return await self.async_step_controller()
async def async_step_controller(
self, user_input: dict | None = None
) -> FlowResult:
errors: dict[str, str] = {}
controller_type = self._device_data.get("supportedController", "")
hint = CONTROLLER_HINTS.get(controller_type, "Enter the controller connection string.")
if user_input is not None:
controller_data = user_input.get(CONF_CONTROLLER_DATA, "")
if isinstance(controller_data, str):
controller_data = controller_data.strip()
if not controller_data:
errors[CONF_CONTROLLER_DATA] = "controller_data_required"
else:
self._controller_data = controller_data
delay_raw = user_input.get(CONF_DELAY, DEFAULT_DELAY)
try:
float(delay_raw)
self._delay = str(delay_raw)
except (ValueError, TypeError):
self._delay = DEFAULT_DELAY
return await self.async_step_options_platform()
return self.async_show_form(
step_id="controller",
data_schema=_controller_schema(
controller_type, self._controller_data, self._delay
),
description_placeholders={
"controller_type": controller_type,
"controller_hint": hint,
},
errors=errors,
)
async def async_step_options_platform(
self, user_input: dict | None = None
) -> FlowResult:
if user_input is not None:
try:
delay_float = float(self._delay)
except (ValueError, TypeError):
delay_float = 0.5
updated: dict = {
CONF_CONTROLLER_DATA: self._controller_data,
CONF_DELAY: delay_float,
}
updated.update(_parse_options(self._platform, user_input))
return self.async_create_entry(title="", data=updated)
return self.async_show_form(
step_id="options_platform",
data_schema=_options_schema(self._platform, defaults=self._entry.options),
description_placeholders=_device_placeholders(
self._device_data, self._platform
),
)