mirror of
https://github.com/home-assistant/core.git
synced 2026-04-02 08:26:41 +01:00
Add local/cloud option to Intellifire (#162739)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import asyncio
|
||||
|
||||
from intellifire4py import UnifiedFireplace
|
||||
from intellifire4py.cloud_interface import IntelliFireCloudInterface
|
||||
from intellifire4py.const import IntelliFireApiMode
|
||||
from intellifire4py.model import IntelliFireCommonFireplaceData
|
||||
|
||||
from homeassistant.const import (
|
||||
@@ -20,6 +21,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
API_MODE_LOCAL,
|
||||
CONF_AUTH_COOKIE,
|
||||
CONF_CONTROL_MODE,
|
||||
CONF_READ_MODE,
|
||||
@@ -55,8 +57,10 @@ def _construct_common_data(
|
||||
serial=entry.data[CONF_SERIAL],
|
||||
api_key=entry.data[CONF_API_KEY],
|
||||
ip_address=entry.data[CONF_IP_ADDRESS],
|
||||
read_mode=entry.options[CONF_READ_MODE],
|
||||
control_mode=entry.options[CONF_CONTROL_MODE],
|
||||
read_mode=IntelliFireApiMode(entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)),
|
||||
control_mode=IntelliFireApiMode(
|
||||
entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -97,12 +101,34 @@ async def async_migrate_entry(
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data=new,
|
||||
options={CONF_READ_MODE: "local", CONF_CONTROL_MODE: "local"},
|
||||
options={
|
||||
CONF_READ_MODE: API_MODE_LOCAL,
|
||||
CONF_CONTROL_MODE: API_MODE_LOCAL,
|
||||
},
|
||||
unique_id=new[CONF_SERIAL],
|
||||
version=1,
|
||||
minor_version=2,
|
||||
minor_version=3,
|
||||
)
|
||||
LOGGER.debug("Pseudo Migration %s successful", config_entry.version)
|
||||
LOGGER.debug("Migration to 1.3 successful")
|
||||
|
||||
if config_entry.minor_version < 3:
|
||||
# Migrate old option keys (cloud_read, cloud_control) to new keys
|
||||
old_options = config_entry.options
|
||||
new_options = {
|
||||
CONF_READ_MODE: old_options.get(
|
||||
"cloud_read", old_options.get(CONF_READ_MODE, API_MODE_LOCAL)
|
||||
),
|
||||
CONF_CONTROL_MODE: old_options.get(
|
||||
"cloud_control", old_options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
|
||||
),
|
||||
}
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
options=new_options,
|
||||
version=1,
|
||||
minor_version=3,
|
||||
)
|
||||
LOGGER.debug("Migration to 1.3 successful (options keys renamed)")
|
||||
|
||||
return True
|
||||
|
||||
@@ -139,9 +165,43 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: IntellifireConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
coordinator: IntellifireDataUpdateCoordinator = entry.runtime_data
|
||||
|
||||
new_read_mode = IntelliFireApiMode(
|
||||
entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)
|
||||
)
|
||||
new_control_mode = IntelliFireApiMode(
|
||||
entry.options.get(CONF_CONTROL_MODE, API_MODE_LOCAL)
|
||||
)
|
||||
|
||||
fireplace = coordinator.fireplace
|
||||
current_read_mode = fireplace.read_mode
|
||||
current_control_mode = fireplace.control_mode
|
||||
|
||||
# Only update modes that actually changed
|
||||
if new_read_mode != current_read_mode:
|
||||
LOGGER.debug("Updating read mode: %s -> %s", current_read_mode, new_read_mode)
|
||||
await fireplace.set_read_mode(new_read_mode)
|
||||
|
||||
if new_control_mode != current_control_mode:
|
||||
LOGGER.debug(
|
||||
"Updating control mode: %s -> %s", current_control_mode, new_control_mode
|
||||
)
|
||||
await fireplace.set_control_mode(new_control_mode)
|
||||
|
||||
# Refresh data with new mode settings
|
||||
await coordinator.async_request_refresh()
|
||||
|
||||
|
||||
async def _async_wait_for_initialization(
|
||||
fireplace: UnifiedFireplace, timeout=STARTUP_TIMEOUT
|
||||
):
|
||||
|
||||
@@ -13,7 +13,12 @@ from intellifire4py.local_api import IntelliFireAPILocal
|
||||
from intellifire4py.model import IntelliFireCommonFireplaceData
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
@@ -21,9 +26,12 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import selector
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import (
|
||||
API_MODE_CLOUD,
|
||||
API_MODE_LOCAL,
|
||||
CONF_AUTH_COOKIE,
|
||||
CONF_CONTROL_MODE,
|
||||
@@ -34,6 +42,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import IntellifireConfigEntry
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||
|
||||
@@ -70,7 +79,7 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for IntelliFire."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
MINOR_VERSION = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Config Flow Handler."""
|
||||
@@ -260,3 +269,85 @@ class IntelliFireConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="not_intellifire_device")
|
||||
|
||||
return await self.async_step_cloud_api()
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry: IntellifireConfigEntry) -> OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return IntelliFireOptionsFlowHandler()
|
||||
|
||||
|
||||
class IntelliFireOptionsFlowHandler(OptionsFlow):
|
||||
"""Options flow for IntelliFire component."""
|
||||
|
||||
config_entry: IntellifireConfigEntry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Validate connectivity for requested modes if runtime data is available
|
||||
coordinator = self.config_entry.runtime_data
|
||||
if coordinator is not None:
|
||||
fireplace = coordinator.fireplace
|
||||
|
||||
# Refresh connectivity status before validating
|
||||
await fireplace.async_validate_connectivity()
|
||||
|
||||
if (
|
||||
user_input[CONF_READ_MODE] == API_MODE_LOCAL
|
||||
and not fireplace.local_connectivity
|
||||
):
|
||||
errors[CONF_READ_MODE] = "local_unavailable"
|
||||
if (
|
||||
user_input[CONF_READ_MODE] == API_MODE_CLOUD
|
||||
and not fireplace.cloud_connectivity
|
||||
):
|
||||
errors[CONF_READ_MODE] = "cloud_unavailable"
|
||||
if (
|
||||
user_input[CONF_CONTROL_MODE] == API_MODE_LOCAL
|
||||
and not fireplace.local_connectivity
|
||||
):
|
||||
errors[CONF_CONTROL_MODE] = "local_unavailable"
|
||||
if (
|
||||
user_input[CONF_CONTROL_MODE] == API_MODE_CLOUD
|
||||
and not fireplace.cloud_connectivity
|
||||
):
|
||||
errors[CONF_CONTROL_MODE] = "cloud_unavailable"
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
existing_read = self.config_entry.options.get(CONF_READ_MODE, API_MODE_LOCAL)
|
||||
existing_control = self.config_entry.options.get(
|
||||
CONF_CONTROL_MODE, API_MODE_LOCAL
|
||||
)
|
||||
|
||||
cloud_local_options = selector.SelectSelectorConfig(
|
||||
options=[API_MODE_LOCAL, API_MODE_CLOUD],
|
||||
translation_key="api_mode",
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_READ_MODE,
|
||||
default=user_input.get(CONF_READ_MODE, existing_read)
|
||||
if user_input
|
||||
else existing_read,
|
||||
): selector.SelectSelector(cloud_local_options),
|
||||
vol.Required(
|
||||
CONF_CONTROL_MODE,
|
||||
default=user_input.get(CONF_CONTROL_MODE, existing_control)
|
||||
if user_input
|
||||
else existing_control,
|
||||
): selector.SelectSelector(cloud_local_options),
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -13,8 +13,8 @@ CONF_WEB_CLIENT_ID = "web_client_id" # part of the cloud cookie
|
||||
CONF_AUTH_COOKIE = "auth_cookie" # part of the cloud cookie
|
||||
|
||||
CONF_SERIAL = "serial"
|
||||
CONF_READ_MODE = "cloud_read"
|
||||
CONF_CONTROL_MODE = "cloud_control"
|
||||
CONF_READ_MODE = "read_mode"
|
||||
CONF_CONTROL_MODE = "control_mode"
|
||||
|
||||
|
||||
API_MODE_LOCAL = "local"
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import API_MODE_CLOUD, API_MODE_LOCAL
|
||||
from .coordinator import IntellifireConfigEntry, IntellifireDataUpdateCoordinator
|
||||
from .entity import IntellifireEntity
|
||||
|
||||
@@ -66,6 +67,22 @@ def _uptime_to_timestamp(
|
||||
|
||||
|
||||
INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = (
|
||||
IntellifireSensorEntityDescription(
|
||||
key="read_mode",
|
||||
translation_key="read_mode",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[API_MODE_LOCAL, API_MODE_CLOUD],
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda coordinator: coordinator.fireplace.read_mode.value,
|
||||
),
|
||||
IntellifireSensorEntityDescription(
|
||||
key="control_mode",
|
||||
translation_key="control_mode",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[API_MODE_LOCAL, API_MODE_CLOUD],
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value_fn=lambda coordinator: coordinator.fireplace.control_mode.value,
|
||||
),
|
||||
IntellifireSensorEntityDescription(
|
||||
key="flame_height",
|
||||
translation_key="flame_height",
|
||||
|
||||
@@ -100,6 +100,13 @@
|
||||
"connection_quality": {
|
||||
"name": "Connection quality"
|
||||
},
|
||||
"control_mode": {
|
||||
"name": "Control mode",
|
||||
"state": {
|
||||
"cloud": "Cloud",
|
||||
"local": "Local"
|
||||
}
|
||||
},
|
||||
"downtime": {
|
||||
"name": "Downtime"
|
||||
},
|
||||
@@ -115,6 +122,13 @@
|
||||
"ipv4_address": {
|
||||
"name": "IP address"
|
||||
},
|
||||
"read_mode": {
|
||||
"name": "Read mode",
|
||||
"state": {
|
||||
"cloud": "Cloud",
|
||||
"local": "Local"
|
||||
}
|
||||
},
|
||||
"target_temp": {
|
||||
"name": "Target temperature"
|
||||
},
|
||||
@@ -133,5 +147,29 @@
|
||||
"name": "Pilot light"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"error": {
|
||||
"cloud_unavailable": "Cloud connectivity is not available",
|
||||
"local_unavailable": "Local connectivity is not available"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"control_mode": "Send commands to",
|
||||
"read_mode": "Read data from"
|
||||
},
|
||||
"description": "Some users find that their fireplace hardware prioritizes `Cloud` communication and may experience timeouts with `Local` control. If you encounter connectivity issues, try switching to `Cloud` for the affected endpoint.",
|
||||
"title": "Endpoint selection"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"api_mode": {
|
||||
"options": {
|
||||
"cloud": "Cloud",
|
||||
"local": "Local"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ from intellifire4py.model import (
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.intellifire.const import (
|
||||
API_MODE_CLOUD,
|
||||
API_MODE_LOCAL,
|
||||
CONF_AUTH_COOKIE,
|
||||
CONF_CONTROL_MODE,
|
||||
@@ -56,6 +55,28 @@ def mock_fireplace_finder_none() -> Generator[MagicMock]:
|
||||
@pytest.fixture
|
||||
def mock_config_entry_current() -> MockConfigEntry:
|
||||
"""Return a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
minor_version=3,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "192.168.2.108",
|
||||
CONF_USERNAME: "grumpypanda@china.cn",
|
||||
CONF_PASSWORD: "you-stole-my-pandas",
|
||||
CONF_SERIAL: "3FB284769E4736F30C8973A7ED358123",
|
||||
CONF_WEB_CLIENT_ID: "FA2B1C3045601234D0AE17D72F8E975",
|
||||
CONF_API_KEY: "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2",
|
||||
CONF_AUTH_COOKIE: "B984F21A6378560019F8A1CDE41B6782",
|
||||
CONF_USER_ID: "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456",
|
||||
},
|
||||
options={CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_LOCAL},
|
||||
unique_id="3FB284769E4736F30C8973A7ED358123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry_v1_2_old_options() -> MockConfigEntry:
|
||||
"""Config entry at v1.2 with old option keys (cloud_read, cloud_control)."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
@@ -70,7 +91,8 @@ def mock_config_entry_current() -> MockConfigEntry:
|
||||
CONF_AUTH_COOKIE: "B984F21A6378560019F8A1CDE41B6782",
|
||||
CONF_USER_ID: "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456",
|
||||
},
|
||||
options={CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_CLOUD},
|
||||
# Old option keys as they exist in upstream/dev
|
||||
options={"cloud_read": "cloud", "cloud_control": "local"},
|
||||
unique_id="3FB284769E4736F30C8973A7ED358123",
|
||||
)
|
||||
|
||||
|
||||
@@ -49,6 +49,66 @@
|
||||
'state': '988451',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.intellifire_control_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'local',
|
||||
'cloud',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.intellifire_control_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Control mode',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Control mode',
|
||||
'platform': 'intellifire',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'control_mode',
|
||||
'unique_id': 'control_mode_mock_serial',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.intellifire_control_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by unpublished Intellifire API',
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'IntelliFire Control mode',
|
||||
'options': list([
|
||||
'local',
|
||||
'cloud',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.intellifire_control_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'local',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.intellifire_downtime-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -306,6 +366,66 @@
|
||||
'state': '192.168.2.108',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.intellifire_read_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'local',
|
||||
'cloud',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.intellifire_read_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Read mode',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Read mode',
|
||||
'platform': 'intellifire',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'read_mode',
|
||||
'unique_id': 'read_mode_mock_serial',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.intellifire_read_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by unpublished Intellifire API',
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'IntelliFire Read mode',
|
||||
'options': list([
|
||||
'local',
|
||||
'cloud',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.intellifire_read_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'local',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_sensor_entities[sensor.intellifire_target_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -5,7 +5,14 @@ from unittest.mock import AsyncMock
|
||||
from intellifire4py.exceptions import LoginError
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.intellifire.const import CONF_SERIAL, DOMAIN
|
||||
from homeassistant.components.intellifire.const import (
|
||||
API_MODE_CLOUD,
|
||||
API_MODE_LOCAL,
|
||||
CONF_CONTROL_MODE,
|
||||
CONF_READ_MODE,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
@@ -227,3 +234,167 @@ async def test_reauth_flow(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_options_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test options flow for changing read/control modes."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Enable both connectivity for this test
|
||||
mock_fp.local_connectivity = True
|
||||
mock_fp.cloud_connectivity = True
|
||||
|
||||
# Start options flow
|
||||
result = await hass.config_entries.options.async_init(
|
||||
mock_config_entry_current.entry_id
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
# Submit new options - both should succeed with connectivity enabled
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_READ_MODE: API_MODE_CLOUD, CONF_CONTROL_MODE: API_MODE_LOCAL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_READ_MODE: API_MODE_CLOUD,
|
||||
CONF_CONTROL_MODE: API_MODE_LOCAL,
|
||||
}
|
||||
|
||||
|
||||
async def test_options_flow_local_read_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test options flow shows error when local connectivity unavailable for read mode."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Disable local connectivity
|
||||
mock_fp.local_connectivity = False
|
||||
mock_fp.cloud_connectivity = True
|
||||
|
||||
# Start options flow
|
||||
result = await hass.config_entries.options.async_init(
|
||||
mock_config_entry_current.entry_id
|
||||
)
|
||||
|
||||
# Try to select local read mode - should fail
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_CLOUD},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_READ_MODE: "local_unavailable"}
|
||||
# Verify connectivity was checked
|
||||
mock_fp.async_validate_connectivity.assert_called_once()
|
||||
|
||||
|
||||
async def test_options_flow_local_control_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test options flow shows error when local connectivity unavailable for control mode."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Disable local connectivity
|
||||
mock_fp.local_connectivity = False
|
||||
mock_fp.cloud_connectivity = True
|
||||
|
||||
# Start options flow
|
||||
result = await hass.config_entries.options.async_init(
|
||||
mock_config_entry_current.entry_id
|
||||
)
|
||||
|
||||
# Try to select local control mode - should fail
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_READ_MODE: API_MODE_CLOUD, CONF_CONTROL_MODE: API_MODE_LOCAL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_CONTROL_MODE: "local_unavailable"}
|
||||
|
||||
|
||||
async def test_options_flow_cloud_read_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test options flow shows error when cloud connectivity unavailable for read mode."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Disable cloud connectivity
|
||||
mock_fp.local_connectivity = True
|
||||
mock_fp.cloud_connectivity = False
|
||||
|
||||
# Start options flow
|
||||
result = await hass.config_entries.options.async_init(
|
||||
mock_config_entry_current.entry_id
|
||||
)
|
||||
|
||||
# Try to select cloud read mode - should fail
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_READ_MODE: API_MODE_CLOUD, CONF_CONTROL_MODE: API_MODE_LOCAL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_READ_MODE: "cloud_unavailable"}
|
||||
# Verify connectivity was checked
|
||||
mock_fp.async_validate_connectivity.assert_called_once()
|
||||
|
||||
|
||||
async def test_options_flow_cloud_control_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test options flow shows error when cloud connectivity unavailable for control mode."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Disable cloud connectivity
|
||||
mock_fp.local_connectivity = True
|
||||
mock_fp.cloud_connectivity = False
|
||||
|
||||
# Start options flow
|
||||
result = await hass.config_entries.options.async_init(
|
||||
mock_config_entry_current.entry_id
|
||||
)
|
||||
|
||||
# Try to select cloud control mode - should fail
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_CLOUD},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_CONTROL_MODE: "cloud_unavailable"}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from intellifire4py.const import IntelliFireApiMode
|
||||
|
||||
from homeassistant.components.intellifire import CONF_USER_ID
|
||||
from homeassistant.components.intellifire.const import (
|
||||
API_MODE_CLOUD,
|
||||
@@ -26,13 +28,16 @@ from homeassistant.core import HomeAssistant
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_minor_migration(
|
||||
async def test_migration_v1_1_to_v1_3(
|
||||
hass: HomeAssistant, mock_config_entry_old, mock_apis_single_fp
|
||||
) -> None:
|
||||
"""With the new library we are going to end up rewriting the config entries."""
|
||||
"""Test migration from v1.1 to v1.3."""
|
||||
mock_config_entry_old.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_old.entry_id)
|
||||
|
||||
# Verify the migration updated to v1.3
|
||||
assert mock_config_entry_old.minor_version == 3
|
||||
|
||||
assert mock_config_entry_old.data == {
|
||||
"ip_address": "192.168.2.108",
|
||||
"host": "192.168.2.108",
|
||||
@@ -45,9 +50,15 @@ async def test_minor_migration(
|
||||
"password": "you-stole-my-pandas",
|
||||
}
|
||||
|
||||
# Verify options were set with new keys
|
||||
assert mock_config_entry_old.options == {
|
||||
CONF_READ_MODE: API_MODE_LOCAL,
|
||||
CONF_CONTROL_MODE: API_MODE_LOCAL,
|
||||
}
|
||||
|
||||
async def test_minor_migration_error(hass: HomeAssistant, mock_apis_single_fp) -> None:
|
||||
"""Test the case where we completely fail to initialize."""
|
||||
|
||||
async def test_migration_v1_1_error(hass: HomeAssistant, mock_apis_single_fp) -> None:
|
||||
"""Test migration failure when cloud lookup fails."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
@@ -67,6 +78,62 @@ async def test_minor_migration_error(hass: HomeAssistant, mock_apis_single_fp) -
|
||||
assert mock_config_entry.state is ConfigEntryState.MIGRATION_ERROR
|
||||
|
||||
|
||||
async def test_migration_v1_2_to_v1_3(
|
||||
hass: HomeAssistant, mock_config_entry_v1_2_old_options, mock_apis_single_fp
|
||||
) -> None:
|
||||
"""Test migration from v1.2 with old option keys to v1.3 with new keys."""
|
||||
mock_config_entry_v1_2_old_options.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_v1_2_old_options.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify the migration updated the minor version
|
||||
assert mock_config_entry_v1_2_old_options.minor_version == 3
|
||||
|
||||
# Verify the old option keys were migrated to new keys
|
||||
# Old: {"cloud_read": "cloud", "cloud_control": "local"}
|
||||
# New: {"read_mode": "cloud", "control_mode": "local"}
|
||||
assert mock_config_entry_v1_2_old_options.options == {
|
||||
CONF_READ_MODE: "cloud",
|
||||
CONF_CONTROL_MODE: "local",
|
||||
}
|
||||
|
||||
|
||||
async def test_migration_v1_2_to_v1_3_defaults(
|
||||
hass: HomeAssistant, mock_apis_single_fp
|
||||
) -> None:
|
||||
"""Test migration from v1.2 with no options defaults to local."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
minor_version=2,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "192.168.2.108",
|
||||
CONF_USERNAME: "grumpypanda@china.cn",
|
||||
CONF_PASSWORD: "you-stole-my-pandas",
|
||||
CONF_SERIAL: "3FB284769E4736F30C8973A7ED358123",
|
||||
CONF_WEB_CLIENT_ID: "FA2B1C3045601234D0AE17D72F8E975",
|
||||
CONF_API_KEY: "B5C4DA27AAEF31D1FB21AFF9BFA6BCD2",
|
||||
CONF_AUTH_COOKIE: "B984F21A6378560019F8A1CDE41B6782",
|
||||
CONF_USER_ID: "52C3F9E8B9D3AC99F8E4D12345678901FE9A2BC7D85F7654E28BF98BCD123456",
|
||||
},
|
||||
options={},
|
||||
unique_id="3FB284769E4736F30C8973A7ED358123",
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify the migration updated the minor version
|
||||
assert mock_config_entry.minor_version == 3
|
||||
|
||||
# Verify defaults were applied
|
||||
assert mock_config_entry.options == {
|
||||
CONF_READ_MODE: API_MODE_LOCAL,
|
||||
CONF_CONTROL_MODE: API_MODE_LOCAL,
|
||||
}
|
||||
|
||||
|
||||
async def test_init_with_no_username(hass: HomeAssistant, mock_apis_single_fp) -> None:
|
||||
"""Test the case where we completely fail to initialize."""
|
||||
mock_config_entry = MockConfigEntry(
|
||||
@@ -109,3 +176,169 @@ async def test_connectivity_bad(
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert len(hass.states.async_all()) == 0
|
||||
|
||||
|
||||
async def test_update_options_change_read_mode_only(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test that changing only read mode triggers set_read_mode but not set_control_mode."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Get the coordinator and mock async_request_refresh
|
||||
coordinator = mock_config_entry_current.runtime_data
|
||||
coordinator.async_request_refresh = AsyncMock()
|
||||
|
||||
# Reset mock call counts
|
||||
mock_fp.set_read_mode.reset_mock()
|
||||
mock_fp.set_control_mode.reset_mock()
|
||||
|
||||
# Change only read mode (local -> cloud), keep control mode same
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry_current,
|
||||
options={CONF_READ_MODE: API_MODE_CLOUD, CONF_CONTROL_MODE: API_MODE_LOCAL},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Only set_read_mode should be called
|
||||
mock_fp.set_read_mode.assert_called_once()
|
||||
mock_fp.set_control_mode.assert_not_called()
|
||||
# async_request_refresh should always be called
|
||||
coordinator.async_request_refresh.assert_called_once()
|
||||
|
||||
|
||||
async def test_update_options_change_control_mode_only(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test that changing only control mode triggers set_control_mode but not set_read_mode."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Get the coordinator and mock async_request_refresh
|
||||
coordinator = mock_config_entry_current.runtime_data
|
||||
coordinator.async_request_refresh = AsyncMock()
|
||||
|
||||
# Reset mock call counts
|
||||
mock_fp.set_read_mode.reset_mock()
|
||||
mock_fp.set_control_mode.reset_mock()
|
||||
|
||||
# Change only control mode (local -> cloud), keep read mode same
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry_current,
|
||||
options={CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_CLOUD},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Only set_control_mode should be called
|
||||
mock_fp.set_read_mode.assert_not_called()
|
||||
mock_fp.set_control_mode.assert_called_once()
|
||||
# async_request_refresh should always be called
|
||||
coordinator.async_request_refresh.assert_called_once()
|
||||
|
||||
|
||||
async def test_update_options_change_both_modes(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test that changing both modes triggers both set methods."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Get the coordinator and mock async_request_refresh
|
||||
coordinator = mock_config_entry_current.runtime_data
|
||||
coordinator.async_request_refresh = AsyncMock()
|
||||
|
||||
# Reset mock call counts
|
||||
mock_fp.set_read_mode.reset_mock()
|
||||
mock_fp.set_control_mode.reset_mock()
|
||||
|
||||
# Change both modes
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry_current,
|
||||
options={CONF_READ_MODE: API_MODE_CLOUD, CONF_CONTROL_MODE: API_MODE_CLOUD},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Both should be called
|
||||
mock_fp.set_read_mode.assert_called_once()
|
||||
mock_fp.set_control_mode.assert_called_once()
|
||||
# async_request_refresh should always be called
|
||||
coordinator.async_request_refresh.assert_called_once()
|
||||
|
||||
|
||||
async def test_update_options_no_change(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry_current: MockConfigEntry,
|
||||
mock_apis_single_fp,
|
||||
) -> None:
|
||||
"""Test that no mode change triggers neither set method but refresh is still called."""
|
||||
_mock_local, _mock_cloud, mock_fp = mock_apis_single_fp
|
||||
|
||||
mock_config_entry_current.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry_current.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Get the coordinator and mock async_request_refresh
|
||||
coordinator = mock_config_entry_current.runtime_data
|
||||
coordinator.async_request_refresh = AsyncMock()
|
||||
|
||||
# Reset mock call counts
|
||||
mock_fp.set_read_mode.reset_mock()
|
||||
mock_fp.set_control_mode.reset_mock()
|
||||
|
||||
# First change options to CLOUD/CLOUD to trigger listener
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry_current,
|
||||
options={CONF_READ_MODE: API_MODE_CLOUD, CONF_CONTROL_MODE: API_MODE_CLOUD},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Simulate that the fireplace updated its modes after the first change
|
||||
# This makes the next update a true "no change" scenario
|
||||
mock_fp.read_mode = IntelliFireApiMode.CLOUD
|
||||
mock_fp.control_mode = IntelliFireApiMode.CLOUD
|
||||
|
||||
# Reset mocks after the first change
|
||||
mock_fp.set_read_mode.reset_mock()
|
||||
mock_fp.set_control_mode.reset_mock()
|
||||
coordinator.async_request_refresh.reset_mock()
|
||||
|
||||
# Now update options to LOCAL/LOCAL - listener fires but fireplace modes
|
||||
# were set to CLOUD/CLOUD, so this IS a mode change
|
||||
# Instead, we update to the same CLOUD/CLOUD that the fireplace now has
|
||||
# But wait - HA won't fire listener if options didn't change!
|
||||
|
||||
# To properly test "no mode change triggers neither setter":
|
||||
# Change options to something different from current options (so listener fires)
|
||||
# but the fireplace already has the target modes
|
||||
# Set fireplace to LOCAL/LOCAL (matching what we'll update to)
|
||||
mock_fp.read_mode = IntelliFireApiMode.LOCAL
|
||||
mock_fp.control_mode = IntelliFireApiMode.LOCAL
|
||||
|
||||
# Update options back to LOCAL/LOCAL - listener fires because options changed
|
||||
# from CLOUD/CLOUD, but fireplace already has LOCAL/LOCAL modes
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry_current,
|
||||
options={CONF_READ_MODE: API_MODE_LOCAL, CONF_CONTROL_MODE: API_MODE_LOCAL},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Neither set method should be called since new options match fireplace state
|
||||
mock_fp.set_read_mode.assert_not_called()
|
||||
mock_fp.set_control_mode.assert_not_called()
|
||||
# But async_request_refresh should still be called
|
||||
coordinator.async_request_refresh.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user