1
0
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:
Jeef
2026-03-10 14:11:57 -06:00
committed by GitHub
parent 60dc88fa15
commit 4ae6099d84
9 changed files with 768 additions and 16 deletions

View File

@@ -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
):

View File

@@ -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,
)

View File

@@ -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"

View File

@@ -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",

View File

@@ -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"
}
}
}
}

View File

@@ -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",
)

View File

@@ -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({

View File

@@ -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"}

View File

@@ -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()