laundry: guided setup for download cycles
Naming downloaded programs from a list of hex slot ids was the weak part of this feature: it asked about programs in the abstract, long after the user had touched the appliance, and the per-slot fields rendered as raw keys because Home Assistant can't translate dynamic ones. Guided setup asks in the moment instead. It waits on a progress step while the user selects a program on the appliance, then asks for that one's name -- so the field is a single static key, and "which one is this?" is answered by the user having just turned the dial to it. The prompt also shows the appliance's own reported remaining time, which differs per program and is device-reported rather than decoded. Two things it has to get right: - It waits for a *transition*, not a state. After naming a program the appliance is still sitting on it, so a loop keyed on "a known slot is loaded" would re-offer the same one forever. Each round baselines on whatever is loaded when it starts. - Re-selecting an already-named program is not an error -- it is how someone checks their work -- so it gets the existing name pre-filled and the counter deliberately does not move, rather than a rejection. Names persist as they are entered rather than batching to the end of the flow, which makes closing the dialog a clean "save and exit" with nothing pending to lose, and makes the flow resumable: reopening picks up from the store. async_remove cancels an in-flight round, so walking away actually stops the probing instead of holding the session lock every few seconds until the timeout. /course/vs/0 is cold-tier, so passively a selection can take a whole poll interval to appear. async_probe_cloud_courses live-reads it through the normal apply path, keeping learning and persistence in one place. The bulk form stays, under its own step, as the way to rename things later -- which guided setup is bad at. New strings ship in English in every catalog and need translating.
This commit is contained in:
@@ -158,6 +158,22 @@ def supports_cloud_courses(rep, courses) -> bool:
|
||||
return bool(cloud_slots(rep, courses))
|
||||
|
||||
|
||||
def loaded_slot(rep) -> str | None:
|
||||
"""The slot whose payload the appliance currently holds -- the one-time
|
||||
override when one is set, else the saved default.
|
||||
|
||||
Deliberately not gated on the course being Download: the guided setup
|
||||
flow watches this before the Download course has been confirmed, and
|
||||
during that walk a change here *is* the signal that the user selected a
|
||||
different program. A stale token can't produce a false positive because
|
||||
the flow waits for a change from its own baseline, not for a value.
|
||||
"""
|
||||
options = rep.get("x.com.samsung.da.options")
|
||||
return slot_of(option_value(options, ONESHOT_PREFIX)) or slot_of(
|
||||
option_value(options, DEFAULT_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]:
|
||||
"""Restore the persisted record, dropping anything not the shape this
|
||||
module writes -- it round-trips through the config entry as plain JSON
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import errno
|
||||
@@ -77,6 +78,14 @@ _HYSTERESIS_MINUTES = NumberSelector(
|
||||
)
|
||||
)
|
||||
|
||||
# Guided download-cycle setup: how long a round waits for the user to
|
||||
# select a program, and how often it live-reads /course/vs/0 while doing
|
||||
# so. The read takes the session lock, so the interval is a few seconds
|
||||
# rather than sub-second -- fast enough to feel immediate to someone
|
||||
# standing at the appliance, slow enough not to starve polling.
|
||||
_CLOUD_WAIT_TIMEOUT_S = 180.0
|
||||
_CLOUD_PROBE_INTERVAL_S = 3.0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SAMSUNG_CLOUD_HOST = "connect-v2.samsungiotcloud.com"
|
||||
@@ -830,6 +839,12 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
def __init__(self) -> None:
|
||||
self._debug_href: str = ""
|
||||
self._debug_result: tuple[int, dict] | None = None
|
||||
# Guided download-cycle setup. `_cloud_task` is created once per
|
||||
# round and reused across re-entries (Home Assistant re-enters a
|
||||
# progress step while its spinner is up).
|
||||
self._cloud_task: asyncio.Task[str | None] | None = None
|
||||
self._cloud_slot: str | None = None
|
||||
self._cloud_baseline: str | None = None
|
||||
|
||||
def _coordinator(self):
|
||||
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||
@@ -912,6 +927,163 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
|
||||
async def async_step_cloud_courses(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Entry point for download-cycle setup (issue #342).
|
||||
|
||||
Guided setup is offered first because it is the only version of this
|
||||
that a first-time user can complete confidently: it asks about a
|
||||
program in the moment they select it, rather than about a list of hex
|
||||
ids some time later. The bulk form stays for renaming afterwards,
|
||||
which guided setup is bad at.
|
||||
"""
|
||||
return self.async_show_menu(
|
||||
step_id="cloud_courses",
|
||||
menu_options=["cloud_guided", "cloud_manual"],
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_remove(self) -> None:
|
||||
"""Stop probing when the flow goes away.
|
||||
|
||||
Closing the dialog is the documented way to leave guided setup, so it
|
||||
has to actually stop: an abandoned round would otherwise go on
|
||||
live-reading /course/vs/0 every few seconds until its timeout, taking
|
||||
the session lock each time, for a user who has walked away.
|
||||
"""
|
||||
if self._cloud_task is not None and not self._cloud_task.done():
|
||||
self._cloud_task.cancel()
|
||||
self._cloud_task = None
|
||||
|
||||
async def async_step_cloud_guided(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Start (or restart) a guided discovery round."""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
self._cloud_task = None
|
||||
self._cloud_slot = None
|
||||
# Baseline: whatever is loaded right now. The round completes when
|
||||
# the appliance moves off it, so the program the user has *already*
|
||||
# selected can't immediately re-trigger and loop the flow.
|
||||
self._cloud_baseline = cloudcourse.loaded_slot(coord.cloud_course_rep())
|
||||
return await self.async_step_cloud_wait()
|
||||
|
||||
async def async_step_cloud_wait(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Wait for the user to select a different downloaded program.
|
||||
|
||||
The task is created once and reused across re-entries -- Home
|
||||
Assistant polls this step while the spinner is up, and building a
|
||||
fresh task each time would restart the wait forever.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
if self._cloud_task is None:
|
||||
self._cloud_task = self.hass.async_create_task(
|
||||
self._await_cloud_selection(coord), eager_start=False
|
||||
)
|
||||
if not self._cloud_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="cloud_wait",
|
||||
progress_action="cloud_wait",
|
||||
progress_task=self._cloud_task,
|
||||
description_placeholders=self._cloud_progress_placeholders(coord),
|
||||
)
|
||||
|
||||
self._cloud_slot = self._cloud_task.result()
|
||||
self._cloud_task = None
|
||||
if self._cloud_slot is None:
|
||||
return self.async_show_progress_done(next_step_id="cloud_timeout")
|
||||
return self.async_show_progress_done(next_step_id="cloud_name")
|
||||
|
||||
async def _await_cloud_selection(self, coord) -> str | None:
|
||||
"""Poll until the loaded program changes; None on timeout."""
|
||||
deadline = time.monotonic() + _CLOUD_WAIT_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
slot = await coord.async_probe_cloud_courses()
|
||||
if slot is not None and slot != self._cloud_baseline:
|
||||
return slot
|
||||
await asyncio.sleep(_CLOUD_PROBE_INTERVAL_S)
|
||||
return None
|
||||
|
||||
def _cloud_progress_placeholders(self, coord) -> dict[str, str]:
|
||||
rep = coord.cloud_course_rep()
|
||||
courses = cycle_options(coord.canonical_resources(MAIN))
|
||||
record = coord.cloud_courses.snapshot()
|
||||
slots = cloudcourse.cloud_slots(rep, courses)
|
||||
named = sum(1 for s in slots if (record["slots"].get(s) or {}).get("name"))
|
||||
return {"named": str(named), "total": str(len(slots))}
|
||||
|
||||
async def async_step_cloud_name(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Name the program the user just selected.
|
||||
|
||||
Persists immediately rather than batching to the end of the flow, so
|
||||
closing the dialog at any point is a clean "save and exit" -- there is
|
||||
no pending work to lose, and reopening resumes from the store.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None or self._cloud_slot is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
slot = self._cloud_slot
|
||||
existing = (coord.cloud_courses.snapshot()["slots"].get(slot) or {}).get("name", "")
|
||||
|
||||
if user_input is not None:
|
||||
name = str(user_input.get("name", "")).strip()
|
||||
errors = self._apply_cloud_course_names(coord, [slot], {f"name_{slot}": name})
|
||||
if errors:
|
||||
return self._cloud_name_form(coord, slot, existing, errors=errors)
|
||||
# Straight back to waiting: the appliance is still sitting on this
|
||||
# program, and the next round baselines on it, so there is nothing
|
||||
# to click through.
|
||||
self._cloud_baseline = slot
|
||||
return await self.async_step_cloud_wait()
|
||||
|
||||
return self._cloud_name_form(coord, slot, existing)
|
||||
|
||||
def _cloud_name_form(
|
||||
self, coord, slot: str, existing: str, errors: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""One text field, with the copy switched on whether this program is
|
||||
already set up. Re-selecting one is not an error -- it is how someone
|
||||
checks their work -- so it gets an edit form rather than a rejection,
|
||||
and the counter deliberately does not move."""
|
||||
placeholders = self._cloud_progress_placeholders(coord)
|
||||
placeholders["slot"] = slot
|
||||
placeholders["remaining"] = (
|
||||
coord.resource("/operational/state/vs/0").get("x.com.samsung.da.remainingTime") or "--"
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="cloud_name",
|
||||
data_schema=vol.Schema({vol.Optional("name", default=existing): _TEXT}),
|
||||
errors=errors or {},
|
||||
description_placeholders=placeholders,
|
||||
last_step=False,
|
||||
)
|
||||
|
||||
async def async_step_cloud_timeout(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Nothing was selected in time. Offer another round rather than
|
||||
dropping the user out of the flow -- and an explicit finish, for
|
||||
anyone who doesn't think to close the dialog."""
|
||||
return self.async_show_menu(
|
||||
step_id="cloud_timeout",
|
||||
menu_options=["cloud_guided", "cloud_finish"],
|
||||
)
|
||||
|
||||
async def async_step_cloud_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
|
||||
async def async_step_cloud_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Name the cloud "Download" programs this appliance has (issue #342).
|
||||
|
||||
@@ -1036,7 +1208,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
|
||||
pending = [s for s in advertised if s not in slots]
|
||||
return self.async_show_form(
|
||||
step_id="cloud_courses",
|
||||
step_id="cloud_manual",
|
||||
data_schema=vol.Schema(fields),
|
||||
errors=errors or {},
|
||||
description_placeholders={
|
||||
|
||||
@@ -513,6 +513,29 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""/course/vs/0's live rep -- what advertises the slot list."""
|
||||
return self.resource(cloudcourse.COURSE_HREF)
|
||||
|
||||
async def async_probe_cloud_courses(self) -> str | None:
|
||||
"""Live-read /course/vs/0, apply it, and report which slot is loaded.
|
||||
|
||||
/course/vs/0 is cold-tier, so passively a program selection can take
|
||||
a whole poll interval to show up -- far too slow for the guided setup
|
||||
flow, which is a person standing at the appliance waiting for Home
|
||||
Assistant to notice. The read goes through the normal apply path, so
|
||||
learning and persistence still happen in exactly one place.
|
||||
|
||||
Returns None when the read fails; the caller is a retry loop and a
|
||||
single missed read is not worth surfacing.
|
||||
"""
|
||||
try:
|
||||
code, rep = await self.async_raw_read(cloudcourse.COURSE_HREF)
|
||||
except Exception:
|
||||
# One missed probe; the caller is a retry loop.
|
||||
self._log.debug("cloud-course probe failed", exc_info=True)
|
||||
return None
|
||||
if not _coap_accepted(code) or not rep:
|
||||
return None
|
||||
self._observe.apply(cloudcourse.COURSE_HREF, rep, source="poll")
|
||||
return cloudcourse.loaded_slot(rep)
|
||||
|
||||
def apply_cloud_courses(self, names: dict[str, str], download_course: str | None) -> None:
|
||||
"""The one mutation path for the cloud-program store (issue #342).
|
||||
|
||||
|
||||
@@ -1446,12 +1446,38 @@
|
||||
"finish": "Dokončit"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"cloud_manual": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
|
||||
"data": {
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -1462,6 +1488,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -1446,12 +1446,38 @@
|
||||
"title": "Gemerkte Modi vergessen",
|
||||
"description": "Aktuell gemerkt: {codes}\n\nDies sind Modi, in denen sich dieses Gerät selbst gemeldet hat, ohne sie als unterstützt anzugeben; sie werden aufbewahrt, damit sie auswählbar bleiben. Das Vergessen ist die Lösung, wenn sich einer davon als falsch herausgestellt hat -- alles, was das Gerät tatsächlich erneut meldet, wird einfach erneut gemerkt, es sei denn, Sie schalten auch „Vom Gerät gemeldete, aber nicht angegebene Modi merken“ in den Geräteeinstellungen aus."
|
||||
},
|
||||
"cloud_courses": {
|
||||
"cloud_manual": {
|
||||
"title": "Download-Programme",
|
||||
"description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Wählen Sie am Gerät das Download-Programm aus, gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.",
|
||||
"data": {
|
||||
"download_course": "Programmcode des Download-Programms"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -1462,6 +1488,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -1446,12 +1446,38 @@
|
||||
"finish": "Finish"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"cloud_manual": {
|
||||
"title": "Download cycles",
|
||||
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
|
||||
"data": {
|
||||
"download_course": "Download cycle course code"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -1462,6 +1488,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -93,12 +93,38 @@
|
||||
"finish": "Finalizar"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"cloud_manual": {
|
||||
"title": "Ciclos descargados",
|
||||
"description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en el dispositivo, selecciona Descarga de Programas y ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Código de programa de Descarga de Programas"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -109,6 +135,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -1446,12 +1446,38 @@
|
||||
"finish": "Fine"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"cloud_manual": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -1462,6 +1488,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -1446,12 +1446,38 @@
|
||||
"finish": "완료"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"cloud_manual": {
|
||||
"title": "다운로드 코스",
|
||||
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
|
||||
"data": {
|
||||
"download_course": "다운로드 코스 코드"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -1462,6 +1488,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -1446,12 +1446,38 @@
|
||||
"finish": "Voltooien"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"cloud_manual": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
|
||||
"data": {
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\n{named} of {total} named — you can close this dialog whenever you like, names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\n{named} of {total} named — everything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -1462,6 +1488,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\n{named} of {total} named — you can close this dialog at any time, names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -10,10 +10,12 @@ flow that supplies the names.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
@@ -26,6 +28,18 @@ from custom_components.localthings.registry.subdevices import MAIN
|
||||
from tests.conftest import _load_device
|
||||
from tests.test_subdevice_discovery import ENTRY_DATA
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_guided_waits(monkeypatch):
|
||||
"""Guided setup polls the appliance on a human timescale. Left at its
|
||||
real values a single lingering round would hold the suite for its whole
|
||||
timeout, since async_block_till_done waits on the task."""
|
||||
from custom_components.localthings import config_flow as cf
|
||||
|
||||
monkeypatch.setattr(cf, "_CLOUD_PROBE_INTERVAL_S", 0.01)
|
||||
monkeypatch.setattr(cf, "_CLOUD_WAIT_TIMEOUT_S", 0.5)
|
||||
|
||||
|
||||
FIXTURE = "washer_ww5000c_cloud"
|
||||
COURSE = cloudcourse.COURSE_HREF
|
||||
|
||||
@@ -251,8 +265,8 @@ async def test_naming_through_the_flow_persists(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
await handler.async_step_cloud_courses()
|
||||
await handler.async_step_cloud_courses(
|
||||
await handler.async_step_cloud_manual()
|
||||
await handler.async_step_cloud_manual(
|
||||
{"name_55": "Sports", "name_6B": "Jeans", "download_course": "87"}
|
||||
)
|
||||
await _flush(hass)
|
||||
@@ -265,7 +279,7 @@ async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant)
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
result = await handler.async_step_cloud_courses(
|
||||
result = await handler.async_step_cloud_manual(
|
||||
{"name_55": "Sports", "name_6B": "sports", "download_course": "87"}
|
||||
)
|
||||
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
|
||||
@@ -275,11 +289,11 @@ async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant)
|
||||
async def test_clearing_a_name_removes_the_program_from_the_select(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "87"})
|
||||
await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": "87"})
|
||||
await _flush(hass)
|
||||
assert "cloud:55" in _cycle_options(coordinator)
|
||||
|
||||
await handler.async_step_cloud_courses({"name_55": "", "download_course": "87"})
|
||||
await handler.async_step_cloud_manual({"name_55": "", "download_course": "87"})
|
||||
await _flush(hass)
|
||||
assert "cloud:55" not in _cycle_options(coordinator)
|
||||
|
||||
@@ -288,7 +302,7 @@ async def test_without_a_confirmed_download_course_nothing_is_offered(hass: Home
|
||||
"""Names alone aren't enough -- there'd be no course code to write."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": ""})
|
||||
await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": ""})
|
||||
await _flush(hass)
|
||||
|
||||
assert coordinator.cloud_courses.named() == {"55": "Sports"}
|
||||
@@ -298,9 +312,9 @@ async def test_without_a_confirmed_download_course_nothing_is_offered(hass: Home
|
||||
async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_cloud_courses()
|
||||
result = await handler.async_step_cloud_manual()
|
||||
|
||||
assert result["step_id"] == "cloud_courses"
|
||||
assert result["step_id"] == "cloud_manual"
|
||||
assert result["description_placeholders"]["total"] == "9"
|
||||
# Two learned so far, seven still to walk through on the appliance.
|
||||
assert result["description_placeholders"]["found"] == "2"
|
||||
@@ -380,7 +394,7 @@ async def test_the_flow_rejects_a_download_course_the_device_does_not_offer(
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
result = await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "FF"})
|
||||
result = await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": "FF"})
|
||||
assert result["errors"] == {"base": "cloud_course_unknown_course"}
|
||||
assert coordinator.cloud_courses.snapshot()["download_course"] is None
|
||||
assert coordinator.cloud_courses.named() == {}
|
||||
@@ -397,5 +411,181 @@ async def test_the_flow_rejects_a_name_shadowing_a_personal_course(hass: HomeAss
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_cloud_courses({"name_55": "MyCo", "download_course": "87"})
|
||||
result = await handler.async_step_cloud_manual({"name_55": "MyCo", "download_course": "87"})
|
||||
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guided setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stub_probe(coordinator, sequence):
|
||||
"""Drive async_probe_cloud_courses from a scripted list of loaded slots,
|
||||
standing in for the user turning the dial. The last entry repeats."""
|
||||
seq = list(sequence)
|
||||
|
||||
async def probe() -> str | None:
|
||||
return seq.pop(0) if len(seq) > 1 else seq[0]
|
||||
|
||||
# setattr, not assignment: the stub stands in for a bound method.
|
||||
setattr(coordinator, "async_probe_cloud_courses", probe) # noqa: B010
|
||||
|
||||
|
||||
async def _run_wait(handler):
|
||||
"""Re-enter the progress step the way Home Assistant does until its task
|
||||
settles, then follow the completion through."""
|
||||
result = await handler.async_step_cloud_wait()
|
||||
for _ in range(200):
|
||||
if result["type"] != "progress":
|
||||
return result
|
||||
if handler._cloud_task is not None:
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(asyncio.shield(handler._cloud_task), 5)
|
||||
result = await handler.async_step_cloud_wait()
|
||||
raise AssertionError("progress step never settled")
|
||||
|
||||
|
||||
async def test_guided_waits_for_a_change_not_a_state(hass: HomeAssistant):
|
||||
"""The trap this design exists to avoid. After naming a program the
|
||||
appliance is still sitting on it, so a loop that fires on "a known slot is
|
||||
loaded" would re-offer the same one forever. Each round baselines on what
|
||||
is loaded when it starts and completes only on a change."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
# The fixture is already loaded with 6B, so that is the baseline.
|
||||
await handler.async_step_cloud_guided()
|
||||
assert handler._cloud_baseline == "6B"
|
||||
|
||||
# The appliance stays on 6B: no round completes.
|
||||
_stub_probe(coordinator, ["6B"])
|
||||
handler._cloud_task = None
|
||||
task = hass.async_create_task(handler._await_cloud_selection(coordinator))
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def test_guided_names_a_newly_selected_program(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
|
||||
_stub_probe(coordinator, ["55"])
|
||||
result = await _run_wait(handler)
|
||||
assert result["step_id"] == "cloud_name"
|
||||
|
||||
form = await handler.async_step_cloud_name()
|
||||
assert form["description_placeholders"]["slot"] == "55"
|
||||
# Persisted immediately -- closing the dialog now would lose nothing.
|
||||
await handler.async_step_cloud_name({"name": "Sports"})
|
||||
await _flush(hass)
|
||||
assert coordinator.cloud_courses.named() == {"55": "Sports"}
|
||||
|
||||
|
||||
async def test_guided_rebaselines_so_the_named_program_cannot_refire(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
await handler.async_step_cloud_name({"name": "Sports"})
|
||||
await _flush(hass)
|
||||
# The next round starts from the program just named, so the appliance
|
||||
# sitting on it is not a new selection.
|
||||
assert handler._cloud_baseline == "55"
|
||||
|
||||
|
||||
async def test_reselecting_a_named_program_offers_an_edit_not_an_error(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Re-picking one is how someone checks their work. It gets the existing
|
||||
name pre-filled, and the counter deliberately does not move."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
|
||||
form = await handler.async_step_cloud_name()
|
||||
assert form["errors"] == {}
|
||||
assert form["data_schema"]({})["name"] == "Sports"
|
||||
before = form["description_placeholders"]["named"]
|
||||
await handler.async_step_cloud_name({"name": "Sports"})
|
||||
await _flush(hass)
|
||||
after = handler._cloud_progress_placeholders(coordinator)["named"]
|
||||
assert before == after == "1"
|
||||
|
||||
|
||||
async def test_guided_times_out_into_a_retry_or_finish_menu(hass: HomeAssistant, monkeypatch):
|
||||
from custom_components.localthings import config_flow as cf
|
||||
|
||||
monkeypatch.setattr(cf, "_CLOUD_WAIT_TIMEOUT_S", 0.0)
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["6B"])
|
||||
|
||||
result = await _run_wait(handler)
|
||||
assert result["step_id"] == "cloud_timeout"
|
||||
menu = await handler.async_step_cloud_timeout()
|
||||
assert set(menu["menu_options"]) == {"cloud_guided", "cloud_finish"}
|
||||
|
||||
|
||||
async def test_a_failed_probe_does_not_end_the_round(hass: HomeAssistant):
|
||||
"""A dropped read is one missed poll, not a reason to bail on someone
|
||||
standing at the appliance."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, [None, None, "55"])
|
||||
|
||||
result = await _run_wait(handler)
|
||||
assert result["step_id"] == "cloud_name"
|
||||
assert handler._cloud_slot == "55"
|
||||
|
||||
|
||||
async def test_the_entry_step_is_a_menu_offering_both_paths(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_cloud_courses()
|
||||
assert set(result["menu_options"]) == {"cloud_guided", "cloud_manual"}
|
||||
|
||||
|
||||
async def test_closing_the_dialog_stops_probing_the_appliance(hass: HomeAssistant):
|
||||
"""Closing the dialog is the documented way to leave guided setup, so it
|
||||
has to actually stop -- an abandoned round would otherwise keep taking the
|
||||
session lock every few seconds for a user who has walked away."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
|
||||
probes = 0
|
||||
|
||||
async def probe() -> str | None:
|
||||
nonlocal probes
|
||||
probes += 1
|
||||
return "6B" # never changes, so the round would run to timeout
|
||||
|
||||
# setattr, not assignment: the stub stands in for a bound method.
|
||||
setattr(coordinator, "async_probe_cloud_courses", probe) # noqa: B010
|
||||
result = await handler.async_step_cloud_wait()
|
||||
assert result["type"] == "progress"
|
||||
task = handler._cloud_task
|
||||
assert task is not None and not task.done()
|
||||
|
||||
handler.async_remove()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
assert task.cancelled()
|
||||
settled = probes
|
||||
await asyncio.sleep(0.05)
|
||||
assert probes == settled
|
||||
|
||||
Reference in New Issue
Block a user