Skip to content

Commit ae6c26e

Browse files
committed
Reliability and feature pass: direct commands, discovery, lift detection
Builds on the earlier read-timeout fix with several improvements informed by other Stagg EKG community projects. Reliability - Tighten retry budget in _send_command to 5s × 2 attempts (~10.5s worst case) so the executor doesn't fall behind the 5s poll interval. - Add KettleTransientError parent class with KettleResponseError sibling; malformed kettle responses now soft-fail the same way timeouts do. - Cap stale-state retention at 6 consecutive transient failures so an actually-offline kettle eventually shows unavailable in HA. - Raise instead of silently using mode="Unknown" when the kettle returns a truncated state payload. Features - Direct temperature setting via `setsetting settempr <F>` as the new default, replacing up to 150 dial-step commands with one HTTP call. Falls back to dial rotation automatically if the firmware ignores the command, and exposes a user-selectable method in the options flow. - Direct heat/stop via `ss S_Heat` / `ss S_Off` instead of button-2 toggling — cleaner state transitions, no toggle-direction ambiguity. - LAN auto-discovery in the config flow: scans the HA host's /24 for kettles and presents found IPs as a dropdown, with manual entry as a fallback. - New "Lifted" binary sensor with a 90s cooldown, edge-triggered on docked → undocked transitions. Cosmetic - Dynamic mdi:kettle ↔ mdi:kettle-steam icon on the Mode sensor while heating. - Move the standalone stagg_ekg_api.py and its examples doc into examples/ so the integration root is just the HA component.
1 parent f09a74c commit ae6c26e

12 files changed

Lines changed: 428 additions & 56 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ name: Coffee Kettle
211211
212212
## Standalone Python API
213213
214-
The repository includes a standalone Python API (`stagg_ekg_api.py`) for use outside Home Assistant.
214+
The repository includes a standalone Python API in [`examples/stagg_ekg_api.py`](examples/stagg_ekg_api.py) for use outside Home Assistant.
215215

216216
### Quick Start
217217

@@ -229,7 +229,7 @@ state = kettle.get_state()
229229
print(f"Current: {state.current_temp_c}°C / Target: {state.set_temp_c}°C")
230230
```
231231

232-
See [PYTHON_API_EXAMPLES.md](PYTHON_API_EXAMPLES.md) for complete usage examples.
232+
See [examples/README.md](examples/README.md) for complete usage examples.
233233

234234
## Technical Details
235235

custom_components/fellow/__init__.py

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,22 @@
88
from homeassistant.const import Platform
99
from homeassistant.core import HomeAssistant
1010
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
11+
from homeassistant.util import dt as dt_util
1112

1213
from .const import DOMAIN
13-
from .kettle import KettleTimeoutError, StaggEKGClient
14+
from .kettle import KettleTransientError, StaggEKGClient
15+
16+
# After this many consecutive transient failures, give up holding the
17+
# last known state and let HA mark the device unavailable. At a 5s poll
18+
# interval that's roughly half a minute of staleness — long enough to
19+
# absorb the "kettle just turned off" window, short enough that a truly
20+
# offline kettle doesn't show stale data forever.
21+
MAX_CONSECUTIVE_TRANSIENT_FAILURES = 6
22+
23+
# Lift-detection: once we observe the kettle leaving the base, treat it
24+
# as "lifted" for at least this long, even if a brief docked reading
25+
# appears (the kettle can flap as it's set back down).
26+
LIFT_COOLDOWN_SECONDS = 90
1427

1528
_LOGGER = logging.getLogger(__name__)
1629

@@ -19,12 +32,23 @@
1932

2033
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
2134
"""Set up Stagg EKG from a config entry."""
22-
from .const import CONF_TEMPERATURE_UNIT, UNIT_CELSIUS
35+
from .const import (
36+
CONF_TEMP_SET_METHOD,
37+
CONF_TEMPERATURE_UNIT,
38+
TEMP_METHOD_DIRECT,
39+
UNIT_CELSIUS,
40+
)
2341

2442
host = entry.data["host"]
43+
# Options take precedence over the original entry data so changes from
44+
# the options flow apply without re-adding the integration.
45+
temp_method = entry.options.get(
46+
CONF_TEMP_SET_METHOD,
47+
entry.data.get(CONF_TEMP_SET_METHOD, TEMP_METHOD_DIRECT),
48+
)
2549

2650
# Create API client
27-
client = StaggEKGClient(host=host)
51+
client = StaggEKGClient(host=host, temp_method=temp_method)
2852

2953
# Sync kettle units with configured preference
3054
configured_unit = entry.data.get(CONF_TEMPERATURE_UNIT, UNIT_CELSIUS)
@@ -49,9 +73,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
4973
# Forward setup to platforms
5074
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
5175

76+
# Reload when options change so settings like temp_set_method take
77+
# effect without an HA restart.
78+
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
79+
5280
return True
5381

5482

83+
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
84+
"""Reload the integration when the user changes options."""
85+
await hass.config_entries.async_reload(entry.entry_id)
86+
87+
5588
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
5689
"""Unload a config entry."""
5790
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
@@ -66,6 +99,9 @@ class StaggEKGDataUpdateCoordinator(DataUpdateCoordinator):
6699
def __init__(self, hass: HomeAssistant, client: StaggEKGClient) -> None:
67100
"""Initialize."""
68101
self.client = client
102+
self._consecutive_transient_failures = 0
103+
self._was_docked: bool | None = None
104+
self._lifted_at = None
69105

70106
super().__init__(
71107
hass,
@@ -74,21 +110,55 @@ def __init__(self, hass: HomeAssistant, client: StaggEKGClient) -> None:
74110
update_interval=timedelta(seconds=5),
75111
)
76112

113+
@property
114+
def recently_lifted(self) -> bool:
115+
"""Whether the kettle was lifted off the base in the recent past.
116+
117+
Edge-triggered on docked → undocked, with a cooldown window so a
118+
single flapping reading after re-docking doesn't immediately clear
119+
the signal.
120+
"""
121+
if self._lifted_at is None:
122+
return False
123+
return (
124+
dt_util.utcnow() - self._lifted_at
125+
).total_seconds() < LIFT_COOLDOWN_SECONDS
126+
77127
async def _async_update_data(self):
78128
"""Fetch data from API."""
79129
try:
80130
state = await self.hass.async_add_executor_job(self.client.get_state)
81-
82-
return {
83-
"state": state,
84-
}
85-
except KettleTimeoutError as err:
86-
# The kettle stops answering HTTP for a few seconds when it
87-
# transitions to Off. Keep the last known state instead of
88-
# surfacing an error every time that happens.
89-
if self.data is not None:
90-
_LOGGER.debug("Kettle unresponsive, keeping last state: %s", err)
131+
except KettleTransientError as err:
132+
# The kettle stops answering HTTP (or returns truncated data)
133+
# for a few seconds when it transitions to Off. Hold the last
134+
# known state for a while instead of surfacing an error every
135+
# time that happens — but give up after enough consecutive
136+
# failures so HA can mark the device unavailable if it really
137+
# is offline.
138+
self._consecutive_transient_failures += 1
139+
if (
140+
self.data is not None
141+
and self._consecutive_transient_failures
142+
<= MAX_CONSECUTIVE_TRANSIENT_FAILURES
143+
):
144+
_LOGGER.debug(
145+
"Kettle transient failure %d/%d, keeping last state: %s",
146+
self._consecutive_transient_failures,
147+
MAX_CONSECUTIVE_TRANSIENT_FAILURES,
148+
err,
149+
)
91150
return self.data
92151
raise UpdateFailed(f"Kettle unresponsive: {err}")
93152
except Exception as err:
94153
raise UpdateFailed(f"Error communicating with API: {err}")
154+
155+
self._consecutive_transient_failures = 0
156+
157+
# Track docked → undocked transition for lift detection. We only
158+
# care about the falling edge; the cooldown window in
159+
# `recently_lifted` handles re-dock flapping.
160+
if self._was_docked and not state.is_docked:
161+
self._lifted_at = dt_util.utcnow()
162+
self._was_docked = state.is_docked
163+
164+
return {"state": state}

custom_components/fellow/binary_sensor.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ async def async_setup_entry(
2525
entities = [
2626
StaggEKGPowerBinarySensor(coordinator, entry),
2727
StaggEKGWaterBinarySensor(coordinator, entry),
28+
StaggEKGLiftedBinarySensor(coordinator, entry),
2829
]
2930

3031
async_add_entities(entities)
@@ -86,6 +87,26 @@ def extra_state_attributes(self):
8687
return {}
8788

8889

90+
class StaggEKGLiftedBinarySensor(StaggEKGBinarySensorBase):
91+
"""Binary sensor that fires when the kettle is taken off the base."""
92+
93+
_attr_device_class = BinarySensorDeviceClass.MOVING
94+
95+
def __init__(
96+
self, coordinator: StaggEKGDataUpdateCoordinator, entry: ConfigEntry
97+
) -> None:
98+
"""Initialize the binary sensor."""
99+
super().__init__(coordinator, entry)
100+
self._attr_unique_id = f"{entry.entry_id}_lifted"
101+
self._attr_name = "Lifted"
102+
self._attr_icon = "mdi:kettle-pour-over"
103+
104+
@property
105+
def is_on(self) -> bool:
106+
"""Return true if the kettle was recently lifted from the base."""
107+
return self.coordinator.recently_lifted
108+
109+
89110
class StaggEKGWaterBinarySensor(StaggEKGBinarySensorBase):
90111
"""Binary sensor for water detection."""
91112

custom_components/fellow/config_flow.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,27 @@
1313
from homeassistant.exceptions import HomeAssistantError
1414
import homeassistant.helpers.config_validation as cv
1515

16-
from .const import DOMAIN, CONF_TEMPERATURE_UNIT, UNIT_CELSIUS, UNIT_FAHRENHEIT
16+
from .const import (
17+
CONF_TEMP_SET_METHOD,
18+
CONF_TEMPERATURE_UNIT,
19+
DOMAIN,
20+
TEMP_METHOD_DIAL,
21+
TEMP_METHOD_DIRECT,
22+
UNIT_CELSIUS,
23+
UNIT_FAHRENHEIT,
24+
)
25+
from .discovery import discover_kettles
1726
from .kettle import StaggEKGClient
1827

1928
_LOGGER = logging.getLogger(__name__)
2029

21-
STEP_USER_DATA_SCHEMA = vol.Schema(
30+
# Sentinel option in the discovery picker that means "skip the list and
31+
# enter an IP by hand."
32+
MANUAL_HOST_CHOICE = "manual"
33+
34+
STEP_MANUAL_DATA_SCHEMA = vol.Schema(
2235
{
23-
vol.Required(CONF_HOST, default="10.1.1.177"): str,
36+
vol.Required(CONF_HOST): str,
2437
vol.Required(CONF_TEMPERATURE_UNIT, default=UNIT_CELSIUS): vol.In(
2538
[UNIT_CELSIUS, UNIT_FAHRENHEIT]
2639
),
@@ -51,10 +64,64 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
5164

5265
VERSION = 1
5366

67+
def __init__(self) -> None:
68+
"""Initialize config flow state."""
69+
self._discovered: list[str] = []
70+
5471
async def async_step_user(
5572
self, user_input: dict[str, Any] | None = None
5673
) -> FlowResult:
57-
"""Handle the initial step."""
74+
"""Run discovery first, then route to the picker or manual entry."""
75+
self._discovered = await discover_kettles(self.hass)
76+
if self._discovered:
77+
return await self.async_step_pick()
78+
return await self.async_step_manual()
79+
80+
async def async_step_pick(
81+
self, user_input: dict[str, Any] | None = None
82+
) -> FlowResult:
83+
"""Let the user choose a discovered kettle or fall through to manual entry."""
84+
errors: dict[str, str] = {}
85+
if user_input is not None:
86+
choice = user_input[CONF_HOST]
87+
if choice == MANUAL_HOST_CHOICE:
88+
return await self.async_step_manual()
89+
data = {
90+
CONF_HOST: choice,
91+
CONF_TEMPERATURE_UNIT: user_input[CONF_TEMPERATURE_UNIT],
92+
}
93+
try:
94+
info = await validate_input(self.hass, data)
95+
except CannotConnect:
96+
errors["base"] = "cannot_connect"
97+
except Exception: # pylint: disable=broad-except
98+
_LOGGER.exception("Unexpected exception")
99+
errors["base"] = "unknown"
100+
else:
101+
return self.async_create_entry(title=info["title"], data=data)
102+
103+
host_options = {ip: ip for ip in self._discovered}
104+
host_options[MANUAL_HOST_CHOICE] = "Enter IP manually"
105+
106+
return self.async_show_form(
107+
step_id="pick",
108+
data_schema=vol.Schema(
109+
{
110+
vol.Required(CONF_HOST, default=self._discovered[0]): vol.In(
111+
host_options
112+
),
113+
vol.Required(
114+
CONF_TEMPERATURE_UNIT, default=UNIT_CELSIUS
115+
): vol.In([UNIT_CELSIUS, UNIT_FAHRENHEIT]),
116+
}
117+
),
118+
errors=errors,
119+
)
120+
121+
async def async_step_manual(
122+
self, user_input: dict[str, Any] | None = None
123+
) -> FlowResult:
124+
"""Manual host entry (used when discovery finds nothing or user opts out)."""
58125
errors: dict[str, str] = {}
59126
if user_input is not None:
60127
try:
@@ -68,7 +135,7 @@ async def async_step_user(
68135
return self.async_create_entry(title=info["title"], data=user_input)
69136

70137
return self.async_show_form(
71-
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
138+
step_id="manual", data_schema=STEP_MANUAL_DATA_SCHEMA, errors=errors
72139
)
73140

74141
@staticmethod
@@ -109,6 +176,9 @@ async def async_step_init(
109176
return self.async_create_entry(title="", data={})
110177

111178
current_unit = self.config_entry.data.get(CONF_TEMPERATURE_UNIT, UNIT_CELSIUS)
179+
current_method = self.config_entry.data.get(
180+
CONF_TEMP_SET_METHOD, TEMP_METHOD_DIRECT
181+
)
112182

113183
return self.async_show_form(
114184
step_id="init",
@@ -118,6 +188,10 @@ async def async_step_init(
118188
CONF_TEMPERATURE_UNIT,
119189
default=current_unit
120190
): vol.In([UNIT_CELSIUS, UNIT_FAHRENHEIT]),
191+
vol.Required(
192+
CONF_TEMP_SET_METHOD,
193+
default=current_method,
194+
): vol.In([TEMP_METHOD_DIRECT, TEMP_METHOD_DIAL]),
121195
}
122196
),
123197
)

custom_components/fellow/const.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44

55
# Configuration
66
CONF_TEMPERATURE_UNIT = "temperature_unit"
7+
CONF_TEMP_SET_METHOD = "temp_set_method"
78

89
# Temperature units (matches kettle's internal values)
910
UNIT_CELSIUS = "celsius"
1011
UNIT_FAHRENHEIT = "fahrenheit"
1112

13+
# Temperature-setting methods
14+
# - direct: single `setsetting settempr <F>` firmware command (fast, preferred)
15+
# - dial: emulate physical dial via left/right step commands (compatible fallback)
16+
TEMP_METHOD_DIRECT = "direct"
17+
TEMP_METHOD_DIAL = "dial"
18+
1219
# Temperature limits (verified from kettle hardware)
1320
MIN_TEMP_C = 40 # Minimum kettle can be set to
1421
MAX_TEMP_C = 100 # Maximum (boiling point)

0 commit comments

Comments
 (0)