From c0ef45c7c87854306048a2ae040db21c9aa4db7d Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Sun, 16 Aug 2026 05:25:36 +0000 Subject: [PATCH] Harden the cloud-courses toggle against three review findings - _on_cloud_courses_changed cleared the canonical-view cache but never pushed state: select.py's current_option reads coordinator.data, which only moves on async_set_updated_data, so a change here (the new toggle, or apply_cloud_courses naming a program -- which has called this same method since before the toggle existed) sat stale in the UI until an unrelated poll or observe happened to run next. Now calls _push_cache_snapshot() too. - _refresh_cloud_course_issue now runs from __init__.py's options-update listener on every entry save, not just a cloud-course-specific one. Saving an unrelated option (CONF_BYPASS_REMOTE_CONTROL, say) before this device's first poll, or while it's rehydrated offline, read /course/vs/0 as empty -- indistinguishable from "nothing pending" -- and would delete a Repair a real poll had every reason to raise. Now a no-op on an empty rep, leaving whatever issue state already exists untouched until a real poll can judge it. - The "cloud_courses" menu's off-state note was a raw English string built in config_flow.py and substituted via description_placeholders into all 7 locales' descriptions -- unlike the SmartThings screen names quoted elsewhere (deliberately English everywhere; that's a third-party app's own label, not ours), this one named LocalThings' own "Offer downloaded cycles"/"Device settings" labels, which are translated per locale and should have matched. Replaced with a permanent, state-independent sentence translated in the catalog itself, in all 7 locales, instead of conditional Python-built text. Also restores a word an earlier edit dropped from async_step_cloud_courses's docstring ("can complete confidently"). Tests: two new regression tests, each confirmed to fail against the pre-fix code before being fixed -- one drives coordinator.data through a toggle via a fixture already sitting on a one-time cloud override, so current_option actually depends on the cloud store instead of falling back to the raw course code; the other simulates a second coordinator against the same entry with an empty resource cache (a not-yet-polled restart) and confirms an existing Repair survives an unrelated option save. Full suite (1585 tests), ruff, and `ty check custom_components tests` all pass. --- custom_components/localthings/config_flow.py | 27 ++++++----- custom_components/localthings/coordinator.py | 25 ++++++++++- .../localthings/translations/cs.json | 2 +- .../localthings/translations/de.json | 2 +- .../localthings/translations/en.json | 2 +- .../localthings/translations/es.json | 2 +- .../localthings/translations/it.json | 2 +- .../localthings/translations/ko.json | 2 +- .../localthings/translations/nl.json | 2 +- tests/test_cloud_courses_flow.py | 45 +++++++++++++++++++ 10 files changed, 89 insertions(+), 22 deletions(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index f4efb74..04a0122 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -1042,27 +1042,26 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): """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 confidently: it asks about a + 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. + + Setup still works with cloud_courses_enabled off (see + CONF_CLOUD_COURSES_ENABLED's own comment for why) -- someone who + wants to name one cycle without turning the feature fully on for + everything else still can (issue #364). The catalog's own + description covers that state directly rather than through a + description_placeholder built here: a placeholder is a literal + substitution HA never runs back through translation, so an + English sentence assembled in Python would show untranslated text + in every other locale -- unlike the SmartThings screen names + quoted elsewhere in this catalog, which stay English everywhere + because that's a third-party app's own label, not one of ours. """ - coord = self._coordinator() - # Setup still works with the option off (see CONF_CLOUD_COURSES_ - # ENABLED's comment for why) -- flagged here rather than blocked, so - # someone who wants to name one cycle without turning the feature - # fully on for everything else still can (issue #364). - status = ( - "" - if coord is None or coord.cloud_courses_enabled - else '\n\n⚠️ "Offer downloaded cycles" is currently off in Device ' - "settings, so anything named here won't appear as a selectable cycle until " - "it's turned back on. Naming still works and nothing already saved is lost." - ) return self.async_show_menu( step_id="cloud_courses", menu_options=["cloud_guided", "cloud_manual"], - description_placeholders={"status": status}, ) @callback diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index 496991b..dc80977 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -588,15 +588,26 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Refresh everything that depends on the cloud-course store or the cloud_courses_enabled option: the canonical-view cache (a newly learned/named program, or the option flipping, changes what - entity_resources() hands out) and the Repair. + entity_resources() hands out), the live entities, and the Repair. Also the __init__.py options-update listener's one hook (issue #364): toggling cloud_courses_enabled changes _cloud_view()'s answer without touching last_resources, so a canonical view cached from before the toggle would otherwise keep answering with it. + + _push_cache_snapshot is what actually gets a change here in front + of a user, not just correct on the next read: select.py's + current_option comes from coordinator.data, which only moves on + async_set_updated_data, and nothing prompts Home Assistant to + re-read a live `options` property (cycle's callable options included) + without the state-changed signal that call sends. Without it, both + this and a name applied through apply_cloud_courses -- which has + called this same method since before this option existed -- would + sit stale until whatever poll or observe happened to run next. """ self._canonical_cache.clear() self._refresh_cloud_course_issue() + self._push_cache_snapshot() @property def cloud_courses(self) -> CloudCourses: @@ -685,12 +696,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): _observe_cloud_courses' recording, so turning the option back on re-evaluates against everything already learned instead of only what arrives afterward. + + Also a no-op with an empty cloud_course_rep (issue #364): this now + runs from __init__.py's options-update listener on every entry + save, including an unrelated one (CONF_BYPASS_REMOTE_CONTROL, say) + made before this device's first poll or while it's rehydrated + offline. /course/vs/0 unpolled reads as no advertised slots, which + would otherwise delete a Repair a real poll had every reason to + raise, on evidence that only means "haven't asked the device yet." + Leaves whatever issue state already exists untouched rather than + guess either way; the next real poll re-evaluates for real. """ issue_id = f"cloud_courses_{self._entry.entry_id}" if not self.cloud_courses_enabled: ir.async_delete_issue(self.hass, DOMAIN, issue_id) return rep = self.cloud_course_rep() + if not rep: + return record = self._cloud.snapshot() courses = cycle_options(self.canonical_resources(MAIN)) pending = cloudcourse.undiscovered(rep, record, courses) diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 6840faa..9b7c50d 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -1565,7 +1565,7 @@ }, "cloud_courses": { "title": "Stažené cykly", - "description": "Průvodce nastavením vás provede: v aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, a ten, na kterém zde záleží. Vyberte tam cyklus, vraťte se sem a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě.{status}", + "description": "Průvodce nastavením vás provede: v aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, a ten, na kterém zde záleží. Vyberte tam cyklus, vraťte se sem a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě. Pojmenování funguje v obou případech, ale pojmenovaný cyklus se nabízí jako vybratelný cyklus jen tehdy, když je v nastavení zařízení zapnuto „Nabízet stažené cykly“.", "menu_options": { "cloud_guided": "Průvodce nastavením", "cloud_manual": "Upravit názvy" diff --git a/custom_components/localthings/translations/de.json b/custom_components/localthings/translations/de.json index 0ea3ecb..503c90e 100644 --- a/custom_components/localthings/translations/de.json +++ b/custom_components/localthings/translations/de.json @@ -1565,7 +1565,7 @@ }, "cloud_courses": { "title": "Download-Programme", - "description": "Die geführte Einrichtung begleitet Sie dabei: Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, und die, auf die es hier ankommt. Wählen Sie dort ein Programm aus, kommen Sie dann zurück und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren.{status}", + "description": "Die geführte Einrichtung begleitet Sie dabei: Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, und die, auf die es hier ankommt. Wählen Sie dort ein Programm aus, kommen Sie dann zurück und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren. Benennen funktioniert so oder so, aber ein benanntes Programm wird nur dann als auswählbares Programm angeboten, wenn \"Heruntergeladene Programme anbieten\" in den Geräteeinstellungen aktiviert ist.", "menu_options": { "cloud_guided": "Geführte Einrichtung", "cloud_manual": "Namen bearbeiten" diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index edf6251..f8c92b6 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1565,7 +1565,7 @@ }, "cloud_courses": { "title": "Download cycles", - "description": "Guided setup walks you through it: in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Pick a cycle there, then come back 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.{status}", + "description": "Guided setup walks you through it: in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Pick a cycle there, then come back 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. Naming works either way, but a named cycle is only offered as a selectable cycle while \"Offer downloaded cycles\" is on in Device settings.", "menu_options": { "cloud_guided": "Guided setup", "cloud_manual": "Edit names" diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index f85ba88..faf11a3 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -103,7 +103,7 @@ }, "cloud_courses": { "title": "Ciclos descargados", - "description": "La configuración guiada te acompaña: en la app SmartThings (no en el dispositivo), abre este dispositivo y toca \"Descarga de Programas\" -- una fila aparte de \"Ciclo\", más abajo en la pantalla, y la que importa aquí. Elige un ciclo allí, vuelve aquí y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo.{status}", + "description": "La configuración guiada te acompaña: en la app SmartThings (no en el dispositivo), abre este dispositivo y toca \"Descarga de Programas\" -- una fila aparte de \"Ciclo\", más abajo en la pantalla, y la que importa aquí. Elige un ciclo allí, vuelve aquí y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo. Nombrar funciona en cualquier caso, pero un ciclo con nombre solo se ofrece como ciclo seleccionable mientras \"Ofrecer ciclos descargados\" esté activado en los ajustes del dispositivo.", "menu_options": { "cloud_guided": "Configuración guiada", "cloud_manual": "Editar nombres" diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 4019a40..fd1fdb1 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -1565,7 +1565,7 @@ }, "cloud_courses": { "title": "Cicli scaricati", - "description": "La configurazione guidata accompagna l'utente: nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, ed è quella che conta qui. Scegliere un ciclo lì, poi tornare qui e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa.{status}", + "description": "La configurazione guidata accompagna l'utente: nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, ed è quella che conta qui. Scegliere un ciclo lì, poi tornare qui e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa. Assegnare un nome funziona comunque, ma un ciclo con nome viene offerto come ciclo selezionabile solo quando \"Offri cicli scaricati\" è attivo nelle impostazioni del dispositivo.", "menu_options": { "cloud_guided": "Configurazione guidata", "cloud_manual": "Modifica nomi" diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 8556768..5c6aa88 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -1565,7 +1565,7 @@ }, "cloud_courses": { "title": "다운로드 코스", - "description": "안내 설정은 다음과 같이 진행됩니다. 기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 여기서 중요한 것은 이 항목입니다. 그곳에서 코스를 선택한 다음 돌아와서, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요.{status}", + "description": "안내 설정은 다음과 같이 진행됩니다. 기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 여기서 중요한 것은 이 항목입니다. 그곳에서 코스를 선택한 다음 돌아와서, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요. 이름 지정은 어느 경우든 가능하지만, 이름을 지정한 코스는 기기 설정에서 \"다운로드 코스 제공\"이 켜져 있을 때만 선택 가능한 코스로 제공됩니다.", "menu_options": { "cloud_guided": "안내 설정", "cloud_manual": "이름 편집" diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b373fb6..1cd4843 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1565,7 +1565,7 @@ }, "cloud_courses": { "title": "Gedownloade programma's", - "description": "Begeleide installatie loodst je stap voor stap: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Kies daar een programma, kom terug en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren.{status}", + "description": "Begeleide installatie loodst je stap voor stap: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Kies daar een programma, kom terug en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren. Namen geven werkt sowieso, maar een benoemd programma wordt alleen als selecteerbaar programma aangeboden zolang \"Gedownloade programma's aanbieden\" aan staat in de apparaatinstellingen.", "menu_options": { "cloud_guided": "Begeleide installatie", "cloud_manual": "Namen bewerken" diff --git a/tests/test_cloud_courses_flow.py b/tests/test_cloud_courses_flow.py index 8d20fad..5ded5b7 100644 --- a/tests/test_cloud_courses_flow.py +++ b/tests/test_cloud_courses_flow.py @@ -332,6 +332,51 @@ async def test_disabling_does_not_stop_passive_observation(hass: HomeAssistant): assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS +async def test_disabling_pushes_the_new_current_option_immediately(hass: HomeAssistant): + """select.py's current_option reads coordinator.data, not + canonical_resources() -- clearing only the canonical-view cache left + it stale until whatever poll happened to run next. The fixture is + sitting on a one-time Jeans ('6B') override, so naming it is what + makes the live selection actually depend on the cloud store rather + than falling back to the raw course code.""" + entry = _entry(hass) + coordinator = await _coordinator(hass, entry) + coordinator.apply_cloud_courses({"55": "Sports", "6B": "Jeans"}, "87") + await _flush(hass) + assert coordinator.data["cycle"] == "cloud:6B" + + hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: False}) + coordinator._on_cloud_courses_changed() + + assert coordinator.data["cycle"] == "87" + + +async def test_an_unrelated_option_save_before_first_poll_does_not_clear_a_real_repair( + hass: HomeAssistant, +): + """_on_cloud_courses_changed now runs from __init__.py's options-update + listener on *every* entry save, not just a cloud-course-specific one -- + including one made before this device's first poll (a fresh restart, + still rehydrating). /course/vs/0 unpolled reads as an empty rep, which + must not be treated as evidence that nothing is pending: that would + delete a Repair a real poll had every reason to raise.""" + entry = _entry(hass) + coordinator = await _coordinator(hass, entry) + coordinator._refresh_cloud_course_issue() + await _flush(hass) + registry = ir.async_get(hass) + issue_id = f"cloud_courses_{entry.entry_id}" + assert registry.async_get_issue(DOMAIN, issue_id) is not None + + # A second coordinator against the same entry, standing in for a + # restart that hasn't polled yet -- its own resource cache is empty. + fresh = LocalThingsCoordinator(hass, entry) + fresh._on_cloud_courses_changed() + await _flush(hass) + + assert registry.async_get_issue(DOMAIN, issue_id) is not None + + # --------------------------------------------------------------------------- # Options flow # ---------------------------------------------------------------------------