Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 55 additions & 43 deletions homeassistant/components/lamarzocco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession

from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
from .const import CONF_INSTALLATION_KEY, CONF_OFFLINE_MODE, CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import (
LaMarzoccoBluetoothUpdateCoordinator,
LaMarzoccoConfigEntry,
Expand Down Expand Up @@ -118,45 +118,51 @@ async def disconnect_bluetooth(_: Event) -> None:
_LOGGER.info(
"Bluetooth device not found during lamarzocco setup, continuing with cloud only"
)
try:
settings = await cloud_client.get_thing_settings(serial)
except AuthFail as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from ex
except (RequestNotSuccessful, TimeoutError) as ex:
_LOGGER.debug(ex, exc_info=True)
if not bluetooth_client:
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="api_error"
) from ex
_LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True)
else:
gateway_version = version.parse(
settings.firmwares[FirmwareType.GATEWAY].build_version
)

if gateway_version < version.parse("v5.0.9"):
# incompatible gateway firmware, create an issue
ir.async_create_issue(
hass,
DOMAIN,
"unsupported_gateway_firmware",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="unsupported_gateway_firmware",
translation_placeholders={"gateway_version": str(gateway_version)},
)
# Update BLE Token if exists
if settings.ble_auth_token:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_TOKEN: settings.ble_auth_token,
},
async def _get_thing_settings() -> None:
"""Get thing settings from cloud to verify details and get BLE token."""
try:
settings = await cloud_client.get_thing_settings(serial)
except AuthFail as ex:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from ex
except (RequestNotSuccessful, TimeoutError) as ex:
_LOGGER.debug(ex, exc_info=True)
if not bluetooth_client:
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="api_error"
) from ex
_LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True)
else:
gateway_version = version.parse(
settings.firmwares[FirmwareType.GATEWAY].build_version
)

if gateway_version < version.parse("v5.0.9"):
# incompatible gateway firmware, create an issue
ir.async_create_issue(
hass,
DOMAIN,
"unsupported_gateway_firmware",
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="unsupported_gateway_firmware",
translation_placeholders={"gateway_version": str(gateway_version)},
)
# Update BLE Token if exists
if settings.ble_auth_token:
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_TOKEN: settings.ble_auth_token,
},
)

if not (local_mode := entry.options.get(CONF_OFFLINE_MODE, False)):
await _get_thing_settings()

device = LaMarzoccoMachine(
serial_number=entry.unique_id,
cloud_client=cloud_client,
Expand All @@ -170,12 +176,18 @@ async def disconnect_bluetooth(_: Event) -> None:
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
)

await asyncio.gather(
coordinators.config_coordinator.async_config_entry_first_refresh(),
coordinators.settings_coordinator.async_config_entry_first_refresh(),
coordinators.schedule_coordinator.async_config_entry_first_refresh(),
coordinators.statistics_coordinator.async_config_entry_first_refresh(),
)
if not local_mode:
await asyncio.gather(
coordinators.config_coordinator.async_config_entry_first_refresh(),
coordinators.settings_coordinator.async_config_entry_first_refresh(),
coordinators.schedule_coordinator.async_config_entry_first_refresh(),
coordinators.statistics_coordinator.async_config_entry_first_refresh(),
)

if local_mode and not bluetooth_client:
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="bluetooth_required_offline"
)

# bt coordinator only if bluetooth client is available
# and after the initial refresh of the config coordinator
Expand Down
17 changes: 14 additions & 3 deletions homeassistant/components/lamarzocco/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo

from . import create_client_session
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
from .const import CONF_INSTALLATION_KEY, CONF_OFFLINE_MODE, CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import LaMarzoccoConfigEntry

CONF_MACHINE = "machine"
Expand Down Expand Up @@ -379,19 +379,30 @@ async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options for the custom component."""
if user_input:
return self.async_create_entry(title="", data=user_input)
errors: dict[str, str] = {}

if user_input:
if user_input.get(CONF_OFFLINE_MODE) and not user_input.get(
CONF_USE_BLUETOOTH
):
errors[CONF_USE_BLUETOOTH] = "bluetooth_required_offline"
else:
return self.async_create_entry(title="", data=user_input)
options_schema = vol.Schema(
{
vol.Optional(
CONF_USE_BLUETOOTH,
default=self.config_entry.options.get(CONF_USE_BLUETOOTH, True),
): cv.boolean,
vol.Optional(
CONF_OFFLINE_MODE,
default=self.config_entry.options.get(CONF_OFFLINE_MODE, False),
): cv.boolean,
}
)

return self.async_show_form(
step_id="init",
data_schema=options_schema,
errors=errors,
)
1 change: 1 addition & 0 deletions homeassistant/components/lamarzocco/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@

CONF_USE_BLUETOOTH: Final = "use_bluetooth"
CONF_INSTALLATION_KEY: Final = "installation_key"
CONF_OFFLINE_MODE: Final = "offline_mode"
14 changes: 11 additions & 3 deletions homeassistant/components/lamarzocco/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN
from .const import CONF_OFFLINE_MODE, DOMAIN

SCAN_INTERVAL = timedelta(seconds=60)
SETTINGS_UPDATE_INTERVAL = timedelta(hours=8)
Expand All @@ -49,7 +49,8 @@ class LaMarzoccoRuntimeData:
class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
"""Base class for La Marzocco coordinators."""

_default_update_interval = SCAN_INTERVAL
_default_update_interval: timedelta | None = SCAN_INTERVAL
_ignore_offline_mode = False
config_entry: LaMarzoccoConfigEntry
update_success = False

Expand All @@ -60,12 +61,17 @@ def __init__(
device: LaMarzoccoMachine,
) -> None:
"""Initialize coordinator."""
update_interval = self._default_update_interval
if not self._ignore_offline_mode and entry.options.get(
CONF_OFFLINE_MODE, False
):
update_interval = None
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=self._default_update_interval,
update_interval=update_interval,
)
self.device = device
self._websocket_task: Task | None = None
Expand Down Expand Up @@ -214,6 +220,8 @@ async def _internal_async_update_data(self) -> None:
class LaMarzoccoBluetoothUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco Bluetooth API centrally."""

_ignore_offline_mode = True

async def _internal_async_setup(self) -> None:
"""Initial setup for Bluetooth coordinator."""
await self.device.get_model_info_from_bluetooth()
Expand Down
8 changes: 8 additions & 0 deletions homeassistant/components/lamarzocco/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@
"bluetooth_connection_failed": {
"message": "Error while connecting to machine via Bluetooth"
},
"bluetooth_required_offline": {
"message": "Bluetooth is required when offline mode is enabled, but no Bluetooth device was found"
Comment thread
zweckj marked this conversation as resolved.
},
"button_error": {
"message": "Error while executing button {key}"
},
Expand All @@ -223,12 +226,17 @@
}
},
"options": {
"error": {
"bluetooth_required_offline": "Bluetooth is required when offline mode is enabled."
},
"step": {
"init": {
"data": {
"offline_mode": "Offline Mode",
"use_bluetooth": "Use Bluetooth"
},
"data_description": {
"offline_mode": "Enable offline mode to operate without internet connectivity through Bluetooth. Only local features will be available. Requires Bluetooth to be enabled.",
"use_bluetooth": "Should the integration try to use Bluetooth to control the machine?"
}
}
Expand Down
67 changes: 66 additions & 1 deletion tests/components/lamarzocco/test_bluetooth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import pytest
from syrupy.assertion import SnapshotAssertion

from homeassistant.components.lamarzocco.const import DOMAIN
from homeassistant.components.lamarzocco.const import CONF_OFFLINE_MODE, DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
Expand Down Expand Up @@ -299,6 +299,71 @@ async def test_setup_through_bluetooth_only(
)


async def test_manual_offline_mode_no_bluetooth_device(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry_bluetooth: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test manual offline mode with no Bluetooth device found."""

mock_config_entry_bluetooth.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry_bluetooth, options={CONF_OFFLINE_MODE: True}
)
await hass.config_entries.async_setup(mock_config_entry_bluetooth.entry_id)
await hass.async_block_till_done()

assert mock_config_entry_bluetooth.state is ConfigEntryState.SETUP_RETRY


async def test_manual_offline_mode(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry_bluetooth: MockConfigEntry,
freezer: FrozenDateTimeFactory,
mock_ble_device_from_address: MagicMock,
) -> None:
"""Test that manual offline mode successfully sets up and updates entities via Bluetooth, and marks non-Bluetooth entities as unavailable."""

mock_config_entry_bluetooth.add_to_hass(hass)
hass.config_entries.async_update_entry(
mock_config_entry_bluetooth, options={CONF_OFFLINE_MODE: True}
)
await hass.config_entries.async_setup(mock_config_entry_bluetooth.entry_id)
await hass.async_block_till_done()

main_switch = f"switch.{mock_lamarzocco.serial_number}"
state = hass.states.get(main_switch)
assert state
assert state.state == STATE_ON

# Simulate Bluetooth update changing machine mode to standby
mock_lamarzocco.dashboard.config[
WidgetType.CM_MACHINE_STATUS
].mode = MachineMode.STANDBY

# Trigger Bluetooth coordinator update
freezer.tick(timedelta(seconds=61))
async_fire_time_changed(hass)
await hass.async_block_till_done()

# Verify entity state was updated
state = hass.states.get(main_switch)
assert state
assert state.state == STATE_OFF

# verify other entities are unavailable
sample_entities = (
f"binary_sensor.{mock_lamarzocco.serial_number}_backflush_active",
f"update.{mock_lamarzocco.serial_number}_gateway_firmware",
)
for entity_id in sample_entities:
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNAVAILABLE


@pytest.mark.parametrize(
("mock_ble_device", "has_client"),
[
Expand Down
44 changes: 44 additions & 0 deletions tests/components/lamarzocco/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from homeassistant.components.lamarzocco.config_flow import CONF_MACHINE
from homeassistant.components.lamarzocco.const import (
CONF_INSTALLATION_KEY,
CONF_OFFLINE_MODE,
CONF_USE_BLUETOOTH,
DOMAIN,
)
Expand Down Expand Up @@ -522,4 +523,47 @@ async def test_options_flow(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_USE_BLUETOOTH: False,
CONF_OFFLINE_MODE: False,
}


async def test_options_flow_bluetooth_required_for_offline_mode(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test options flow validates that Bluetooth is required when offline mode is enabled."""
await async_init_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED

result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)

assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"

result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_USE_BLUETOOTH: False,
CONF_OFFLINE_MODE: True,
},
)
await hass.async_block_till_done()

assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
assert result["errors"] == {CONF_USE_BLUETOOTH: "bluetooth_required_offline"}

# recover
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_USE_BLUETOOTH: True,
CONF_OFFLINE_MODE: True,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_USE_BLUETOOTH: True,
CONF_OFFLINE_MODE: True,
}
Loading