laundry: fix six issues from review of the cloud-cycle branch
Also drops appliance-specific wording from the new user-facing strings. The setup step said "your washer" and told people to "turn the dial", which is wrong for the DW5000C dishwasher that advertises the same tokens. The two that could have caused a wrong wash cycle: - The Download-course candidate was counted on every poll that saw a loaded one-time payload, not on the polls where one was actually loaded. Since a stale token is never evicted, it keeps being reported through however long the appliance then sits on some ordinary course -- so "most frequent" ranked by dwell time. Reproduced: one poll on Course_87 then 200 on Course_1B suggests 1B, and accepting the suggestion makes picking a download program start a Cotton wash. Now only a change of the payload counts, which is the moment the device is known to accept a program. - The Download-course dropdown had custom_value=True, contradicting its own comment, so a typed-in code went into the Course_ token of a real write unchecked. Off now, plus a server-side check against the device's own course list where the value is stored. Two that quietly broke things beyond this feature: - cycle_select now always supplies a display_fn (to label cloud programs), which defeated select._display's "no state table and no fallback -> return raw" exit. Every dryer, dishwasher and air dresser on an unrecognized course table would have had its options and state reshaped from '0E' to '0 E', breaking automations and recorder history. The exit now keys off whether anything actually named the value, not whether a fallback existed. - The synthetic cloud field reached diagnostics, which reads canonical_resources -- publishing user-typed program names in a dump people paste into issues, directly against the comment claiming it never could. Dropped at the redaction boundary, with a matching strip for the debug read service, which wants device state unredacted but shouldn't present our bookkeeping as something the appliance said. And two smaller ones: - The repair fired on any device advertising slots, so the DW5000C -- four advertised, none ever loaded -- got a permanent warning nothing the owner did in Home Assistant could clear. It now waits until a payload has been seen, which is the only evidence that household uses downloaded programs. - The name-collision check read only the translation catalog, missing the device's own personal-course labels, which the select renders identically.
This commit is contained in:
1 parent
9d28b088cb
commit
b921bdbb28
17 files changed
+279
-50
No files matched your search
@@ -10,6 +10,7 @@ flow that supplies the names.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import cbor2
|
||||
@@ -21,6 +22,7 @@ from custom_components.localthings import cloudcourse
|
||||
from custom_components.localthings.const import CONF_CLOUD_COURSES, DOMAIN
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
from tests.conftest import _load_device
|
||||
from tests.test_subdevice_discovery import ENTRY_DATA
|
||||
|
||||
@@ -320,3 +322,90 @@ async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistan
|
||||
# Two learned so far, seven still to walk through on the appliance.
|
||||
assert result["description_placeholders"]["found"] == "2"
|
||||
assert coordinator.cloud_courses.download_candidates() == ["87"]
|
||||
|
||||
|
||||
async def test_no_repair_until_a_program_has_actually_been_seen(hass: HomeAssistant):
|
||||
"""The DW5000C advertises four downloaded programs and has never loaded
|
||||
one, so nothing about them is learnable and no name field can be offered.
|
||||
Warning its owner about a feature they may never use -- with no action
|
||||
that could clear it -- is worse than staying quiet until they run one."""
|
||||
entry = _entry(hass)
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
resources = _load_device("dishwasher_dw5000c_cloud")
|
||||
coordinator._run_discovery(resources)
|
||||
for href, rep in resources.items():
|
||||
coordinator._observe.apply(href, rep, source="poll")
|
||||
coordinator._refresh_cloud_course_issue()
|
||||
await _flush(hass)
|
||||
|
||||
assert cloudcourse.advertised_slots(coordinator.cloud_course_rep()) == ["8E", "8D", "8F", "02"]
|
||||
assert coordinator.cloud_courses.snapshot()["slots"] == {}
|
||||
registry = ir.async_get(hass)
|
||||
assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None
|
||||
|
||||
|
||||
async def test_the_synthetic_field_never_reaches_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
enable_custom_integrations,
|
||||
):
|
||||
"""canonical_resources carries it so entity descriptors can read it, and
|
||||
diagnostics reads canonical_resources -- so the drop has to happen at the
|
||||
redaction boundary. A dump is meant to be what the appliance said, and
|
||||
cloud program names are text the user typed."""
|
||||
from custom_components.localthings.diagnostics import (
|
||||
async_get_config_entry_diagnostics,
|
||||
)
|
||||
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator.set_cloud_download_course("87")
|
||||
coordinator.set_cloud_course_name("55", "Marc's weekend towels")
|
||||
await _flush(hass)
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
|
||||
dump = json.dumps(await async_get_config_entry_diagnostics(hass, entry))
|
||||
assert cloudcourse.FIELD not in dump
|
||||
assert "Marc's weekend towels" not in dump
|
||||
# The device's own tokens are still there -- only our field is dropped.
|
||||
assert "CloudExtraCourse_0A5C286B2D0C55301A" in dump
|
||||
|
||||
|
||||
async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant):
|
||||
from custom_components.localthings.registry.redact import strip_synthetic
|
||||
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.set_cloud_download_course("87")
|
||||
coordinator.set_cloud_course_name("55", "Sports")
|
||||
await _flush(hass)
|
||||
|
||||
stripped = strip_synthetic(coordinator.canonical_resources(MAIN))
|
||||
assert cloudcourse.FIELD not in stripped[COURSE]
|
||||
assert "x.com.samsung.da.options" in stripped[COURSE]
|
||||
|
||||
|
||||
async def test_the_flow_rejects_a_download_course_the_device_does_not_offer(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Whatever lands here becomes the Course_ token of a real write."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
result = await handler.async_step_cloud_courses({"name_55": "Sports", "download_course": "FF"})
|
||||
assert result["errors"] == {"base": "cloud_course_unknown_course"}
|
||||
assert coordinator.cloud_courses.download_course() is None
|
||||
assert coordinator.cloud_courses.named() == {}
|
||||
|
||||
|
||||
async def test_the_flow_rejects_a_name_shadowing_a_personal_course(hass: HomeAssistant):
|
||||
"""Personal course names come from the device, not the catalog, and the
|
||||
select renders them the same way -- so they collide the same way."""
|
||||
coordinator = await _coordinator(hass)
|
||||
# 'MyCo' as a personal course label on a code the device offers.
|
||||
rep = dict(coordinator.resource("/wm/personalcourse/vs/0") or {})
|
||||
rep["x.com.samsung.da.courses"] = ["1C_01044D79436F"]
|
||||
coordinator._observe.apply("/wm/personalcourse/vs/0", rep, source="poll")
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_cloud_courses({"name_55": "MyCo", "download_course": "87"})
|
||||
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
|
||||
Reference in new issue
Block a user