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.
PR #251 and PR #275 predate this repo's ruff-format adoption on those
files; running the formatter (single->double quotes, line wrapping,
trailing-comma cleanup) keeps the merged code consistent with the rest
of the codebase. No behavior change.
Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated
ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET,
PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree
with `ruff format`, and fixes the pre-existing lint and type-check debt
those tools surfaced so both run clean.
Production-code type fixes include: HA's ConfigFlowResult vs. the
generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its
platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via
cast() instead of an unchecked annotation, converting HA device_class
strings to their proper enum types, a resolve_registry callback typed
as `object` instead of `DeviceRegistry | None`, and a couple of other
narrow correctness fixes (CA key type validation, an index-out-of-bounds
false positive from an empty-tuple fallback, a bool/dict argument swap).
Test-file fixes are mechanical: narrowing SamsungEntityDescription to
the correct subclass via isinstance()/cast() before accessing
subclass-only fields, and asserting Optional write_fn/unit_fn fields
are set before calling them.
PR #68 restated its own translation data in Python: a 60-line
TRANSLATED_SELECT_STATES table of frozensets duplicating every
entity.select.*.state key, a second _TRANSLATED_COURSE_TABLES table
naming which course tables have translations, and a strings.json that
was a 835-line byte-for-byte copy of translations/en.json save 43
[%key:...%] references. Each needed hand-syncing, and one was already
drifting.
Home Assistant loads exactly one file per language for a custom
integration -- translations/<lang>.json. It never reads strings.json and
never resolves [%key:...%]; both belong to Core's build tooling, which
custom integrations don't run through (hassfest skips a missing
strings.json and validates translations/en.json instead). So en.json is
the source, and the new catalog.py reads the keys and states back out of
it for the two decisions Python genuinely has to make:
- select._display() normalizes a raw Samsung option to a lowercase
state key only when the catalog knows it, else leaves the vendor's
casing alone. Derived sets are identical to the removed literals.
- laundry.cycle_select() keys off a device-reported course table only
when that table has an entry, else falls back to the name-only
'cycle' key. Translating Table_00 is now a translations-only change.
Also fixes six names that had already drifted between the Python
descriptors and the catalog, restoring HA's sentence case for two
generic ones (Auto release dry, Bubble soak) and taking the catalog's
wording for the rest, and adds a test so the vestigial descriptor names
can't silently disagree with the UI again.
Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
Course codes on the shared /course/vs/0 contract aren't guaranteed
consistent across board generations: washer/combo devices report course
table Table_02, dryer devices Table_03 (x.com.samsung.da.st.courseTable,
previously fully ignored), and every code in washer_cycle/dryer_cycle was
confirmed exclusively against those. FlexWash's older DA_WM_A51 board
reports Table_00 instead -- applying the same translations there risked
showing a wrong name for any code that happens to numerically collide
between tables, not just an untranslated one.
SelectDesc.translation_key can now be a callable (resources -> key or
None), mirroring the existing pattern for `options`. laundry.cycle_select
gains optional table_href/validated_table params: when given, the renamed
washer_cycle_table_02/dryer_cycle_table_03 keys only apply when the
device's own course table matches exactly -- a different table, or no
table id at all, gets no translation_key (raw code display) rather than
a guess. dishwasher's call site is unchanged (static key, unconditional):
no equivalent table-id resource exists in any dump seen, and no evidence
its course codes vary by table the way washer/dryer's do.
entity.py and select.py resolve a callable translation_key once (via
coordinator.last_resources) and reuse that resolved value everywhere
_display() needs it, rather than re-checking the raw descriptor field.
_normalize() lowercased every select's options/state for display, but
that's only needed for entities with a translation_key (whose
strings.json lookup requires lowercase keys, per hassfest). Untranslated
selects (Cycle, Smart Dry, Sound mode, LED brightness) had no lookup to
protect and were just getting mangled -- "AI Wash" became "ai wash",
"ExtraHigh" became "extrahigh", etc.
Replace with _display(), which only lowercases for translation_key
entities and otherwise passes the device's own casing through, with two
cosmetic fixups: a fully lowercase wire value (e.g. "voice") is
title-cased, and a PascalCase value (e.g. "ExtraHigh") gets a space at
the case boundary. Already human-friendly values pass through
untouched. Avoids hand-authoring strings.json translations for
open-ended, per-model option lists (e.g. dishwasher cycle names) that
would silently regress on any value we didn't enumerate.