Skip to content

Commit 639a96f

Browse files
authored
La Marzocco add Bluetooth offline mode (home-assistant#157011)
1 parent b6786c5 commit 639a96f

File tree

16 files changed

+1045
-180
lines changed

16 files changed

+1045
-180
lines changed

homeassistant/components/lamarzocco/__init__.py

Lines changed: 51 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from .const import CONF_INSTALLATION_KEY, CONF_USE_BLUETOOTH, DOMAIN
3737
from .coordinator import (
38+
LaMarzoccoBluetoothUpdateCoordinator,
3839
LaMarzoccoConfigEntry,
3940
LaMarzoccoConfigUpdateCoordinator,
4041
LaMarzoccoRuntimeData,
@@ -72,38 +73,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
7273
client=create_client_session(hass),
7374
)
7475

75-
try:
76-
settings = await cloud_client.get_thing_settings(serial)
77-
except AuthFail as ex:
78-
raise ConfigEntryAuthFailed(
79-
translation_domain=DOMAIN, translation_key="authentication_failed"
80-
) from ex
81-
except (RequestNotSuccessful, TimeoutError) as ex:
82-
_LOGGER.debug(ex, exc_info=True)
83-
raise ConfigEntryNotReady(
84-
translation_domain=DOMAIN, translation_key="api_error"
85-
) from ex
86-
87-
gateway_version = version.parse(
88-
settings.firmwares[FirmwareType.GATEWAY].build_version
89-
)
90-
91-
if gateway_version < version.parse("v5.0.9"):
92-
# incompatible gateway firmware, create an issue
93-
ir.async_create_issue(
94-
hass,
95-
DOMAIN,
96-
"unsupported_gateway_firmware",
97-
is_fixable=False,
98-
severity=ir.IssueSeverity.ERROR,
99-
translation_key="unsupported_gateway_firmware",
100-
translation_placeholders={"gateway_version": str(gateway_version)},
101-
)
102-
10376
# initialize Bluetooth
10477
bluetooth_client: LaMarzoccoBluetoothClient | None = None
10578
if entry.options.get(CONF_USE_BLUETOOTH, True) and (
106-
token := (entry.data.get(CONF_TOKEN) or settings.ble_auth_token)
79+
token := entry.data.get(CONF_TOKEN)
10780
):
10881
if CONF_MAC not in entry.data:
10982
for discovery_info in async_discovered_service_info(hass):
@@ -145,6 +118,44 @@ async def disconnect_bluetooth(_: Event) -> None:
145118
_LOGGER.info(
146119
"Bluetooth device not found during lamarzocco setup, continuing with cloud only"
147120
)
121+
try:
122+
settings = await cloud_client.get_thing_settings(serial)
123+
except AuthFail as ex:
124+
raise ConfigEntryAuthFailed(
125+
translation_domain=DOMAIN, translation_key="authentication_failed"
126+
) from ex
127+
except (RequestNotSuccessful, TimeoutError) as ex:
128+
_LOGGER.debug(ex, exc_info=True)
129+
if not bluetooth_client:
130+
raise ConfigEntryNotReady(
131+
translation_domain=DOMAIN, translation_key="api_error"
132+
) from ex
133+
_LOGGER.debug("Cloud failed, continuing with Bluetooth only", exc_info=True)
134+
else:
135+
gateway_version = version.parse(
136+
settings.firmwares[FirmwareType.GATEWAY].build_version
137+
)
138+
139+
if gateway_version < version.parse("v5.0.9"):
140+
# incompatible gateway firmware, create an issue
141+
ir.async_create_issue(
142+
hass,
143+
DOMAIN,
144+
"unsupported_gateway_firmware",
145+
is_fixable=False,
146+
severity=ir.IssueSeverity.ERROR,
147+
translation_key="unsupported_gateway_firmware",
148+
translation_placeholders={"gateway_version": str(gateway_version)},
149+
)
150+
# Update BLE Token if exists
151+
if settings.ble_auth_token:
152+
hass.config_entries.async_update_entry(
153+
entry,
154+
data={
155+
**entry.data,
156+
CONF_TOKEN: settings.ble_auth_token,
157+
},
158+
)
148159

149160
device = LaMarzoccoMachine(
150161
serial_number=entry.unique_id,
@@ -153,7 +164,7 @@ async def disconnect_bluetooth(_: Event) -> None:
153164
)
154165

155166
coordinators = LaMarzoccoRuntimeData(
156-
LaMarzoccoConfigUpdateCoordinator(hass, entry, device, cloud_client),
167+
LaMarzoccoConfigUpdateCoordinator(hass, entry, device),
157168
LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
158169
LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
159170
LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
@@ -166,6 +177,16 @@ async def disconnect_bluetooth(_: Event) -> None:
166177
coordinators.statistics_coordinator.async_config_entry_first_refresh(),
167178
)
168179

180+
# bt coordinator only if bluetooth client is available
181+
# and after the initial refresh of the config coordinator
182+
# to fetch only if the others failed
183+
if bluetooth_client:
184+
bluetooth_coordinator = LaMarzoccoBluetoothUpdateCoordinator(
185+
hass, entry, device
186+
)
187+
await bluetooth_coordinator.async_config_entry_first_refresh()
188+
coordinators.bluetooth_coordinator = bluetooth_coordinator
189+
169190
entry.runtime_data = coordinators
170191

171192
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

homeassistant/components/lamarzocco/binary_sensor.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from pylamarzocco import LaMarzoccoMachine
88
from pylamarzocco.const import BackFlushStatus, MachineState, ModelName, WidgetType
9-
from pylamarzocco.models import BackFlush, MachineStatus
9+
from pylamarzocco.models import BackFlush, MachineStatus, NoWater
1010

1111
from homeassistant.components.binary_sensor import (
1212
BinarySensorDeviceClass,
@@ -39,8 +39,15 @@ class LaMarzoccoBinarySensorEntityDescription(
3939
key="water_tank",
4040
translation_key="water_tank",
4141
device_class=BinarySensorDeviceClass.PROBLEM,
42-
is_on_fn=lambda machine: WidgetType.CM_NO_WATER in machine.dashboard.config,
42+
is_on_fn=(
43+
lambda machine: cast(
44+
NoWater, machine.dashboard.config[WidgetType.CM_NO_WATER]
45+
).allarm
46+
if WidgetType.CM_NO_WATER in machine.dashboard.config
47+
else False
48+
),
4349
entity_category=EntityCategory.DIAGNOSTIC,
50+
bt_offline_mode=True,
4451
),
4552
LaMarzoccoBinarySensorEntityDescription(
4653
key="brew_active",
@@ -93,7 +100,9 @@ async def async_setup_entry(
93100
coordinator = entry.runtime_data.config_coordinator
94101

95102
async_add_entities(
96-
LaMarzoccoBinarySensorEntity(coordinator, description)
103+
LaMarzoccoBinarySensorEntity(
104+
coordinator, description, entry.runtime_data.bluetooth_coordinator
105+
)
97106
for description in ENTITIES
98107
if description.supported_fn(coordinator)
99108
)

homeassistant/components/lamarzocco/coordinator.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
import logging
1111
from typing import Any
1212

13-
from pylamarzocco import LaMarzoccoCloudClient, LaMarzoccoMachine
14-
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
13+
from pylamarzocco import LaMarzoccoMachine
14+
from pylamarzocco.exceptions import (
15+
AuthFail,
16+
BluetoothConnectionFailed,
17+
RequestNotSuccessful,
18+
)
1519

1620
from homeassistant.config_entries import ConfigEntry
1721
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
@@ -36,6 +40,7 @@ class LaMarzoccoRuntimeData:
3640
settings_coordinator: LaMarzoccoSettingsUpdateCoordinator
3741
schedule_coordinator: LaMarzoccoScheduleUpdateCoordinator
3842
statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator
43+
bluetooth_coordinator: LaMarzoccoBluetoothUpdateCoordinator | None = None
3944

4045

4146
type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
@@ -46,14 +51,13 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
4651

4752
_default_update_interval = SCAN_INTERVAL
4853
config_entry: LaMarzoccoConfigEntry
49-
_websocket_task: Task | None = None
54+
update_success = False
5055

5156
def __init__(
5257
self,
5358
hass: HomeAssistant,
5459
entry: LaMarzoccoConfigEntry,
5560
device: LaMarzoccoMachine,
56-
cloud_client: LaMarzoccoCloudClient | None = None,
5761
) -> None:
5862
"""Initialize coordinator."""
5963
super().__init__(
@@ -64,7 +68,7 @@ def __init__(
6468
update_interval=self._default_update_interval,
6569
)
6670
self.device = device
67-
self.cloud_client = cloud_client
71+
self._websocket_task: Task | None = None
6872

6973
@property
7074
def websocket_terminated(self) -> bool:
@@ -81,14 +85,28 @@ async def __handle_internal_update(
8185
await func()
8286
except AuthFail as ex:
8387
_LOGGER.debug("Authentication failed", exc_info=True)
88+
self.update_success = False
8489
raise ConfigEntryAuthFailed(
8590
translation_domain=DOMAIN, translation_key="authentication_failed"
8691
) from ex
8792
except RequestNotSuccessful as ex:
8893
_LOGGER.debug(ex, exc_info=True)
94+
self.update_success = False
95+
# if no bluetooth coordinator, this is a fatal error
96+
# otherwise, bluetooth may still work
97+
if not self.device.bluetooth_client_available:
98+
raise UpdateFailed(
99+
translation_domain=DOMAIN, translation_key="api_error"
100+
) from ex
101+
except BluetoothConnectionFailed as err:
102+
self.update_success = False
89103
raise UpdateFailed(
90-
translation_domain=DOMAIN, translation_key="api_error"
91-
) from ex
104+
translation_domain=DOMAIN,
105+
translation_key="bluetooth_connection_failed",
106+
) from err
107+
else:
108+
self.update_success = True
109+
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
92110

93111
async def _async_setup(self) -> None:
94112
"""Set up coordinator."""
@@ -109,19 +127,17 @@ async def _internal_async_update_data(self) -> None:
109127
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
110128
"""Class to handle fetching data from the La Marzocco API centrally."""
111129

112-
cloud_client: LaMarzoccoCloudClient
113-
114130
async def _internal_async_setup(self) -> None:
115131
"""Set up the coordinator."""
116-
await self.cloud_client.async_get_access_token()
132+
await self.device.ensure_token_valid()
117133
await self.device.get_dashboard()
118134
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
119135

120136
async def _internal_async_update_data(self) -> None:
121137
"""Fetch data from API endpoint."""
122138

123139
# ensure token stays valid; does nothing if token is still valid
124-
await self.cloud_client.async_get_access_token()
140+
await self.device.ensure_token_valid()
125141

126142
# Only skip websocket reconnection if it's currently connected and the task is still running
127143
if self.device.websocket.connected and not self.websocket_terminated:
@@ -193,3 +209,19 @@ async def _internal_async_update_data(self) -> None:
193209
"""Fetch data from API endpoint."""
194210
await self.device.get_coffee_and_flush_counter()
195211
_LOGGER.debug("Current statistics: %s", self.device.statistics.to_dict())
212+
213+
214+
class LaMarzoccoBluetoothUpdateCoordinator(LaMarzoccoUpdateCoordinator):
215+
"""Class to handle fetching data from the La Marzocco Bluetooth API centrally."""
216+
217+
async def _internal_async_setup(self) -> None:
218+
"""Initial setup for Bluetooth coordinator."""
219+
await self.device.get_model_info_from_bluetooth()
220+
221+
async def _internal_async_update_data(self) -> None:
222+
"""Fetch data from Bluetooth endpoint."""
223+
# if the websocket is connected and the machine is connected to the cloud
224+
# skip bluetooth update, because we get push updates
225+
if self.device.websocket.connected and self.device.dashboard.connected:
226+
return
227+
await self.device.get_dashboard_from_bluetooth()

homeassistant/components/lamarzocco/entity.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
from homeassistant.helpers.update_coordinator import CoordinatorEntity
1818

1919
from .const import DOMAIN
20-
from .coordinator import LaMarzoccoUpdateCoordinator
20+
from .coordinator import (
21+
LaMarzoccoBluetoothUpdateCoordinator,
22+
LaMarzoccoUpdateCoordinator,
23+
)
2124

2225

2326
@dataclass(frozen=True, kw_only=True)
@@ -26,6 +29,7 @@ class LaMarzoccoEntityDescription(EntityDescription):
2629

2730
available_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True
2831
supported_fn: Callable[[LaMarzoccoUpdateCoordinator], bool] = lambda _: True
32+
bt_offline_mode: bool = False
2933

3034

3135
class LaMarzoccoBaseEntity(
@@ -45,14 +49,19 @@ def __init__(
4549
super().__init__(coordinator)
4650
device = coordinator.device
4751
self._attr_unique_id = f"{device.serial_number}_{key}"
52+
sw_version = (
53+
device.settings.firmwares[FirmwareType.MACHINE].build_version
54+
if FirmwareType.MACHINE in device.settings.firmwares
55+
else None
56+
)
4857
self._attr_device_info = DeviceInfo(
4958
identifiers={(DOMAIN, device.serial_number)},
50-
name=device.dashboard.name,
59+
name=device.dashboard.name or self.coordinator.config_entry.title,
5160
manufacturer="La Marzocco",
5261
model=device.dashboard.model_name.value,
5362
model_id=device.dashboard.model_code.value,
5463
serial_number=device.serial_number,
55-
sw_version=device.settings.firmwares[FirmwareType.MACHINE].build_version,
64+
sw_version=sw_version,
5665
)
5766
connections: set[tuple[str, str]] = set()
5867
if coordinator.config_entry.data.get(CONF_ADDRESS):
@@ -77,8 +86,12 @@ def available(self) -> bool:
7786
if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config
7887
else MachineState.OFF
7988
)
80-
return super().available and not (
81-
self._unavailable_when_machine_off and machine_state is MachineState.OFF
89+
return (
90+
super().available
91+
and not (
92+
self._unavailable_when_machine_off and machine_state is MachineState.OFF
93+
)
94+
and self.coordinator.update_success
8295
)
8396

8497

@@ -90,6 +103,11 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
90103
@property
91104
def available(self) -> bool:
92105
"""Return True if entity is available."""
106+
if (
107+
self.entity_description.bt_offline_mode
108+
and self.bluetooth_coordinator is not None
109+
):
110+
return self.bluetooth_coordinator.last_update_success
93111
if super().available:
94112
return self.entity_description.available_fn(self.coordinator)
95113
return False
@@ -98,7 +116,17 @@ def __init__(
98116
self,
99117
coordinator: LaMarzoccoUpdateCoordinator,
100118
entity_description: LaMarzoccoEntityDescription,
119+
bluetooth_coordinator: LaMarzoccoBluetoothUpdateCoordinator | None = None,
101120
) -> None:
102121
"""Initialize the entity."""
103122
super().__init__(coordinator, entity_description.key)
104123
self.entity_description = entity_description
124+
self.bluetooth_coordinator = bluetooth_coordinator
125+
126+
async def async_added_to_hass(self) -> None:
127+
"""Handle when entity is added to hass."""
128+
await super().async_added_to_hass()
129+
if self.bluetooth_coordinator is not None:
130+
self.async_on_remove(
131+
self.bluetooth_coordinator.async_add_listener(self.async_write_ha_state)
132+
)

0 commit comments

Comments
 (0)