"""Config flow fglair: меню, ручной ввод, импорт, облако (discover замокан).""" from __future__ import annotations import json from unittest.mock import AsyncMock, patch from helpers import DSN, KEY_ID, LANIP_KEY from homeassistant import config_entries from homeassistant.config_entries import SOURCE_RECONFIGURE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from pytest_homeassistant_custom_component.common import MockConfigEntry import pyfglair from custom_components.fglair.const import DOMAIN from custom_components.fglair.trial import TrialResult from pyfglair import Prop, Template, Value, ValueKind from pyfglair.provision import Device MANUAL_INPUT = { "host": "192.168.1.50", "device_port": 80, "dsn": DSN, "lanip_key": LANIP_KEY, "lanip_key_id": KEY_ID, "name": "Bedroom", "model": "AP-WC1E", } LIMITS_INPUT = { "temp_min": 16.0, "temp_max": 30.0, "temp_step": 0.5, "temp_num": 1, "temp_den": 1, "temp_offset": 0, } TRIAL_OK = (TrialResult(ok=True), Template.A, False) TRIAL_NETWORK_FAIL = ( TrialResult(ok=False, reason="unreachable"), Template.A, False, ) def _trial_ok(properties=None): return ( TrialResult(ok=True, properties=properties or {}), Template.A, False, ) async def _menu(hass: HomeAssistant): return await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) async def _select(hass: HomeAssistant, flow_id: str, option: str): return await hass.config_entries.flow.async_configure( flow_id, {"next_step_id": option} ) async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict): return await hass.config_entries.flow.async_configure(flow_id, user_input) async def _pages( hass: HomeAssistant, flow_id: str, *, template: str = "A", caps: dict | None = None, limits: dict | None = None, ): """Проходит страницы template → capabilities → limits.""" result = await _configure(hass, flow_id, {"template": template}) assert result["step_id"] == "capabilities", result result = await _configure(hass, flow_id, caps or {}) assert result["step_id"] == "limits", result return await _configure(hass, flow_id, limits or LIMITS_INPUT) def _suggested(schema, field: str): for key in schema.schema: if getattr(key, "schema", None) == field: description = key.description if isinstance(description, dict): return description.get("suggested_value") return None async def test_user_menu(hass: HomeAssistant): result = await _menu(hass) assert result["type"] == "menu" assert set(result["menu_options"]) == {"cloud", "manual", "import_json"} async def test_manual_success(hass: HomeAssistant): with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ) as trial: result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") assert result["type"] == "form" result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["step_id"] == "template" result = await _pages(hass, result["flow_id"]) assert result["type"] == "create_entry" assert result["title"] == "Bedroom" assert result["result"].unique_id == DSN data = result["data"] assert data["lanip_key_id"] == KEY_ID assert data["template"] == "A" assert data["temp_step"] == 5 assert data["overrides"]["adjust_temperature"] == { "num": 1, "den": 1, "offset": 0, "min": 160, "max": 300, } assert isinstance(data["features"], dict) assert data["features"]["mode_heat"] is True trial.assert_called_once() assert trial.call_args.args[0]["host"] == "192.168.1.50" async def test_template_change_and_conversion(hass: HomeAssistant): with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _pages( hass, result["flow_id"], template="B", limits={ **LIMITS_INPUT, "temp_min": 17.0, "temp_max": 28.0, "temp_step": 1.0, "temp_num": 2, "temp_den": 4, "temp_offset": 5, }, ) assert result["type"] == "create_entry" assert result["data"]["template"] == "B" assert result["data"]["temp_step"] == 10 assert result["data"]["overrides"]["adjust_temperature"] == { "num": 2, "den": 4, "offset": 5, "min": 170, "max": 280, } async def test_limits_invalid_range(hass: HomeAssistant): with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure( hass, result["flow_id"], {"template": "B"} ) result = await _configure(hass, result["flow_id"], {}) assert result["step_id"] == "limits" result = await _configure( hass, result["flow_id"], {**LIMITS_INPUT, "temp_min": 30.0, "temp_max": 16.0}, ) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_range"} result = await _configure(hass, result["flow_id"], LIMITS_INPUT) assert result["type"] == "create_entry" async def test_limits_unreachable_range(hass: HomeAssistant): """Конверсия выводит таблицу за min/max — пустой достижимый диапазон.""" with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") result = await _configure(hass, result["flow_id"], MANUAL_INPUT) result = await _configure( hass, result["flow_id"], {"template": "A"} ) result = await _configure(hass, result["flow_id"], {}) result = await _configure( hass, result["flow_id"], { **LIMITS_INPUT, "temp_min": 30.0, "temp_max": 40.0, "temp_num": 1, "temp_den": 2, }, ) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_range"} async def test_template_device_values(hass: HomeAssistant): props = { Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200), Prop.OPERATION_MODE: Value(ValueKind.INT, 3), Prop.FAN_SPEED: Value(ValueKind.INT, 4), Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011), Prop.AF_VERTICAL_NUM_DIR: Value(ValueKind.INT, 5), } with patch( "custom_components.fglair.config_flow._run_trial", return_value=( TrialResult(ok=True, properties=props), Template.A, False, ), ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["step_id"] == "template" values = result["description_placeholders"]["device_values"] assert "20.0" in values assert "0x0000001B" in values assert "cool" in values assert "направлений заслонок: верт. 5" in values # capabilities: дефолты из маски, swing выключен result = await _configure(hass, result["flow_id"], {"template": "A"}) assert result["step_id"] == "capabilities" assert _suggested(result["data_schema"], "swing_vertical") is False assert _suggested(result["data_schema"], "mode_cool") is True assert _suggested(result["data_schema"], "af_vertical_direction") is True async def test_manual_connect_error(hass: HomeAssistant): with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_NETWORK_FAIL, ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["type"] == "form" assert result["errors"] == {"base": "cannot_connect"} async def test_manual_key_mismatch_error(hass: HomeAssistant): with patch( "custom_components.fglair.config_flow._run_trial", return_value=( TrialResult(ok=False, reason="key_mismatch"), Template.A, False, ), ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["type"] == "form" assert result["errors"] == {"base": "key_mismatch"} async def test_manual_already_configured(hass: HomeAssistant): existing = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DSN) existing.add_to_hass(hass) with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["type"] == "abort" assert result["reason"] == "already_configured" async def test_manual_error_keeps_input(hass: HomeAssistant): with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_NETWORK_FAIL, ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "manual") result = await _configure(hass, result["flow_id"], MANUAL_INPUT) assert result["errors"] == {"base": "cannot_connect"} assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"] assert _suggested(result["data_schema"], "dsn") == DSN assert _suggested(result["data_schema"], "lanip_key") is None async def test_import_json_success(hass: HomeAssistant): payload = json.dumps( { "ip_address": "192.168.1.60", "dsn": DSN, "lanip_key": LANIP_KEY, "lanip_key_id": KEY_ID, "model": "AP-WB2E", "name": "Kitchen", } ) with patch( "custom_components.fglair.config_flow._run_trial", return_value=(TrialResult(ok=True), Template.B, False), ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "import_json") assert result["type"] == "form" result = await _configure( hass, result["flow_id"], {"config_json": payload} ) assert result["step_id"] == "template" assert _suggested(result["data_schema"], "template") == "B" result = await _pages(hass, result["flow_id"], template="B") assert result["type"] == "create_entry" assert result["data"]["host"] == "192.168.1.60" assert result["data"]["template"] == "B" async def test_import_json_invalid(hass: HomeAssistant): result = await _menu(hass) result = await _select(hass, result["flow_id"], "import_json") result = await _configure( hass, result["flow_id"], {"config_json": "not json"} ) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_import"} async def test_cloud_flow_success(hass: HomeAssistant): device = Device( name="Bedroom", model="AP-WC1E", dsn=DSN, lan_ip="192.168.1.50", lanip_key=LANIP_KEY, lanip_key_id=KEY_ID, region="eu", ) with patch( "pyfglair.discover", AsyncMock(return_value=[device]) ), patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "cloud") assert result["type"] == "form" result = await _configure( hass, result["flow_id"], {"email": "user@example.com", "password": "secret", "region": "eu"}, ) assert result["step_id"] == "cloud_device" result = await _configure(hass, result["flow_id"], {"dsn": DSN}) assert result["step_id"] == "template" result = await _pages(hass, result["flow_id"]) assert result["type"] == "create_entry" assert result["data"]["host"] == "192.168.1.50" assert result["data"]["lanip_key_id"] == KEY_ID async def test_cloud_no_devices(hass: HomeAssistant): with patch( "pyfglair.discover", AsyncMock( side_effect=pyfglair.ProvisionError( "У аккаунта нет устройств", "no_devices" ) ), ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "cloud") result = await _configure( hass, result["flow_id"], {"email": "user@example.com", "password": "secret", "region": "eu"}, ) assert result["type"] == "form" assert result["errors"] == {"base": "no_devices"} async def test_cloud_auth_error(hass: HomeAssistant): with patch( "pyfglair.discover", AsyncMock( side_effect=pyfglair.ProvisionError("401 unauthorized", "auth") ), ): result = await _menu(hass) result = await _select(hass, result["flow_id"], "cloud") result = await _configure( hass, result["flow_id"], {"email": "user@example.com", "password": "bad", "region": "eu"}, ) assert result["type"] == "form" assert result["errors"] == {"base": "invalid_auth"} async def test_yaml_import(hass: HomeAssistant): payload = { "ip_address": "192.168.1.70", "dsn": DSN, "lanip_key": LANIP_KEY, "lanip_key_id": KEY_ID, "model": "AP-WF1E", } with patch( "custom_components.fglair.config_flow._run_trial", return_value=(TrialResult(ok=True), Template.F, False), ): assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload}) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].data["template"] == "F" assert entries[0].data["host"] == "192.168.1.70" assert isinstance(entries[0].data["features"], dict) async def test_yaml_import_invalid(hass: HomeAssistant): assert await async_setup_component(hass, DOMAIN, {DOMAIN: "not-a-dict"}) await hass.async_block_till_done() assert hass.config_entries.async_entries(DOMAIN) == [] assert not hass.config_entries.flow.async_progress() async def test_import_json_null_device_port(hass: HomeAssistant): payload = json.dumps( { "ip_address": "192.168.1.71", "dsn": DSN, "lanip_key": LANIP_KEY, "lanip_key_id": str(KEY_ID), "device_port": None, } ) with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ) as trial: result = await _menu(hass) result = await _select(hass, result["flow_id"], "import_json") result = await _configure( hass, result["flow_id"], {"config_json": payload} ) assert result["step_id"] == "template" result = await _pages(hass, result["flow_id"]) assert result["type"] == "create_entry" assert result["data"]["device_port"] == 80 assert trial.call_args.args[0]["device_port"] == 80 RECONFIGURE_ENTRY_DATA = { "host": "127.0.0.1", "device_port": 9, "dsn": DSN, "lanip_key": LANIP_KEY, "lanip_key_id": KEY_ID, "name": "Old name", "model": "AP-WC1E", "template": "A", } async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled): from test_entities import _setup_entry proc = mock_ac(["--set", "operation_mode=6"]) entry = await _setup_entry(hass, proc.port) with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id}, ) assert result["type"] == "menu" result = await _select(hass, result["flow_id"], "manual") assert result["type"] == "form" assert _suggested(result["data_schema"], "host") == "127.0.0.1" result = await _configure( hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"} ) assert result["step_id"] == "template" result = await _pages(hass, result["flow_id"]) assert result["type"] == "abort" assert result["reason"] == "reconfigure_successful" await hass.async_block_till_done() assert entry.data["name"] == "Renamed" assert entry.state.name == "LOADED" assert await hass.config_entries.async_unload(entry.entry_id) async def test_reconfigure_wrong_device(hass: HomeAssistant): entry = MockConfigEntry( domain=DOMAIN, data=RECONFIGURE_ENTRY_DATA, unique_id=DSN ) entry.add_to_hass(hass) with patch( "custom_components.fglair.config_flow._run_trial", return_value=TRIAL_OK, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id}, ) result = await _select(hass, result["flow_id"], "manual") result = await _configure( hass, result["flow_id"], {**MANUAL_INPUT, "dsn": "AC000W00OTHER001"}, ) assert result["type"] == "abort" assert result["reason"] == "unique_id_mismatch" async def test_options_flow_shows_and_updates_key( hass: HomeAssistant, mock_ac, socket_enabled ): from test_entities import _setup_entry proc = mock_ac(["--set", "operation_mode=6"]) entry = await _setup_entry(hass, proc.port) result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == "form" assert result["step_id"] == "init" assert _suggested(result["data_schema"], "lanip_key") == LANIP_KEY assert result["description_placeholders"]["dsn"] == DSN result = await hass.config_entries.options.async_configure( result["flow_id"], {"lanip_key": LANIP_KEY} ) assert result["type"] == "create_entry" await hass.async_block_till_done() assert entry.data["lanip_key"] == LANIP_KEY assert await hass.config_entries.async_unload(entry.entry_id) async def test_options_flow_new_key_reloads( hass: HomeAssistant, mock_ac, socket_enabled ): from test_entities import _setup_entry proc = mock_ac(["--set", "operation_mode=6"]) entry = await _setup_entry(hass, proc.port) result = await hass.config_entries.options.async_init(entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], {"lanip_key": "bmV3LWtleQ=="} ) assert result["type"] == "create_entry" await hass.async_block_till_done() assert entry.data["lanip_key"] == "bmV3LWtleQ==" assert entry.state.name in ("LOADED", "SETUP_RETRY") if entry.state.name == "LOADED": assert await hass.config_entries.async_unload(entry.entry_id) async def test_run_trial_probes_unknown_model(hass: HomeAssistant): """Без oem_model шаблон определяется пробингом A/B/F.""" from custom_components.fglair import config_flow as cf with patch.object( cf, "probe_template", return_value=(Template.B, {}) ) as probe, patch.object( cf, "trial_connect", return_value=TrialResult(ok=True) ): result = await hass.async_add_executor_job( cf._run_trial, { "host": "127.0.0.1", "dsn": DSN, "lanip_key": LANIP_KEY, "lanip_key_id": KEY_ID, "model": "", "template": "A", }, ) trial_result, template, probed = result assert trial_result.ok assert template is Template.B assert probed is True probe.assert_called_once()