Two fixes for issue #9 (WW90T634DHE washer):
- washer dosing selects: the four detergent/softener dosing selects read their
current value from `<Prefix>LevelCtrl_<code>` (un-padded, e.g. "3") but their
options from `Supported<Prefix>LevelCtrl_<hexpairs>` (zero-padded, e.g. "03").
HA's SelectEntity renders a select "unknown" whenever current_option is not in
options, so all four sat "unknown" (idle and running) even though every other
select worked -- which is why it was only those four. Normalize the current
value to the supported code with the same integer value so it matches an
option (and its translation); convert back to the device's native un-padded
format on write.
- diagnostics: pkg_version("smartthings-local") reads package metadata off disk
(listdir + open + read_text), tripping HA's event-loop blocking-call detector.
Offload it to the executor. Audited the rest of the package: config_flow's
socket/crypto and every coordinator DTLS call are already offloaded via
async_add_executor_job -- this was the only blocking call left on the loop.
Also documents air-conditioner support in the README (device table, capability
module list, platform list), missed when that support landed.
Updates the washer dosing tests to the corrected value/write format and adds a
current-option-is-a-valid-option regression; adds a diagnostics test asserting
the version lookup runs off the event loop.
Add support for Samsung room air conditioners (ARTIK051_PRAC-class), the
first device whose core controls map onto a single Home Assistant `climate`
entity rather than a scatter of switches/selects/numbers.
- New `climate` platform + `ClimateDesc`: one composite entity that reads
power, HVAC mode, current/target temperature, fan (wind) strength, swing
(wind direction) and the convenient-mode preset across several OCF
resources and writes back to each. On/off folds into HVACMode.OFF /
TURN_ON/OFF; convenient mode folds into preset_mode. The entity binds one
primary resource (/mode/vs/0) and reads its siblings from the coordinator
snapshot, reusing the cross-resource read pattern from number/select.
- New `airconditioner` capability module + by_type registry, routed via the
`_PRAC_` modelNum token. Reuses common ALARMS/ENERGY_METER,
fridge.FIRMWARE_UPDATE and dishwasher.DIAGNOSIS; air purify and auto clean
as config switches; air dust filter status/usage as diagnostics (usage
normalized to a percentage of rated capacity).
- Climate-consumed and all-zero/ambiguous resources (temperature/wind,
/sensors, /humidity) are declared as AC-scoped coverage so every href in
the dump binds or is covered -- no coverage-gap repair.
- Fan/swing modes map onto HA standard constants (auto-localized); the
custom fan `turbo`, presets `quiet/smart/speed`, and the filter-status
enum get translations in strings.json + translations/en.json.
- Scrubbed fixture, golden, and tests: registry routing, zero unbound
hrefs, the climate write contract, and filter-% normalization.
The suite was never run in CI (only hassfest/HACS), and requirements-dev
listed only pytest + smartthings-local while conftest.py needs the full
Home Assistant test harness.
- requirements-dev.txt: add pytest-homeassistant-custom-component (pulls
in home-assistant + pytest + pytest-socket) and the integration's
runtime deps (cbor2, pyOpenSSL, cryptography) needed to import it.
- .github/workflows/validate.yml: add a Pytest job on Python 3.14
(current Home Assistant's floor) that installs requirements-dev and
runs the suite.
- test_config_flow.py: the UDP liveness-sweep test needs real loopback
sockets, which the HA harness blocks by default; take the pytest-socket
socket_enabled fixture so it runs instead of erroring.
Full suite: 249 passed.
Review follow-up. An explicit exists_fn bypasses entity._is_included's
empty-{} stub carve-out, so the sentinel-aware energy meter would drop
power_watts/energy_kwh when /device/0 returns a not-yet-fetched stub for
/energy/consumption/vs/0. Restore the carve-out (`not rep or ...`), and
hide power_watts when instantaneousPower is absent from a populated rep
(not just when it's the -500 sentinel) so a partial rep can't spawn a
phantom power sensor.
First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound
hrefs and an "incomplete capability coverage" repair. Handle them and,
while here, make the washer/dryer/dishwasher families consistent instead
of each carrying a bespoke variant of the same controls.
Dryer coverage:
- /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor
fallback pairs (prefer the standard OCF resource, fall back to -vs).
- /buzzersound/vs/0: new Buzzer sound select.
- /course/vs/0: cycle select shared with washer/dishwasher; ignore the
/st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0).
Consistency / de-duplication:
- Move generic OCF controls (power/kids-lock/remote-control fallback
pairs, energy meter) into common.py; every registry uses them.
- Move shared laundry controls (buzzer, job-beginning-status, and the
/course/vs/0 cycle-select machinery) into laundry.py; washer and
dishwasher stop hand-rolling their own copies.
- Energy meter is now sentinel-aware everywhere: the dead '-500'
instantaneousPower reading no longer shows a misleading 0 W (fixes it
on dryers and dishwashers, matching the earlier washer fix).
- Job-beginning-status reads x.com.samsung.da.currentStatus, the field
every dump actually carries; the dryer sensor was previously blank.
Adds a scrubbed dryer fixture, golden, and capability tests, plus an
.claude/skills/adding-device-support skill capturing the dump-reading,
OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0.
The config flow only probed 49154/49155, so appliances whose local
CoAP/DTLS API binds elsewhere in the ephemeral range (e.g. a dishwasher
answering on 49153) could never be added.
Add a fast UDP liveness sweep across 49152-49160 that uses the ICMP
port-unreachable / ECONNREFUSED asymmetry to find the live port(s)
before attempting the expensive DTLS handshake. Closed ports fall out
immediately; a live-but-silent port is kept as a candidate. The real
handshake then runs only against discovered ports, preferring the
historically known 49154/49155 when several look live.
Also drop the probe's /device/0 GET deadline from a bare 15s literal to
a named PROBE_GET_TIMEOUT_S constant (10s) — the slowest observed full
dump is ~8s, and 10s matches the per-resource read timeout used
elsewhere.
Bumps version to 0.5.0.
Assumes the LevelCtrl code scheme is None/Low/Medium/High (00-03) on both
dispensers -- code 00 has no on-screen equivalent in the app's 3-choice
Faible/Moyen/Élevé picker, assumed to be what "Activation" off collapses
to -- and Level2Ctrl is Soft/Medium/Hard for detergent water hardness,
1x/2x/3x for softener concentration. detergent_quantity and
softener_quantity share one translation_key (same vocabulary), same
pattern as fridge.py's shared 'brightness_level' key.
Not cross-device verified: only one dump + screenshot set (issue #9) to go
on, and the softener concentration reading doesn't cleanly match its
screenshot (assumed to be a setting changed between dump and screenshots,
not a different code scheme -- see the comment in washer.py).
progress_percentage lacked the active-state gate already applied to
progress/cycle_active/finish_time, so it kept showing a stale device value
(e.g. 1%) while idle -- now zeroed the same way. Shared by dryer/dishwasher/
oven via operational.py's OPERATIONAL_STATE.
Also exposes detergent/softener auto-dispense quantity, water hardness/
concentration, and low-reservoir alarms from /course/vs/0's options array,
using the same decode/RMW helpers already used for course selection and
drum-clean tracking. Gated by exists_fn so washer models without these
fields (e.g. the existing test fixture) are unaffected.
Water consumption is unaffected -- common.WATER_METER is already wired
into the washer registry; this reporter's device just doesn't expose
/water/consumption/vs/0.
Washer (#6): instantaneousPower is a dead sentinel ('-500') on every
TP1-class washer dump collected so far, and cumulativePower is absent
outright on at least one model. WASHER_ENERGY_METER now hides both
sensors instead of showing a misleading "0 W"/perpetual "unavailable".
Fridge (#7): temperature sensors/setpoints hardcoded '°F', ignoring the
unit each device actually reports per-reading -- fixed via a new
unit_fn hook read live from the resource. Also corrects
DEFROST_BLOCK_STATUS's polarity (DEFROST_BLOCK_ON means actively
defrosting, not "blocked", confirmed against live dumps) and adds
REFRIGERATION_FALLBACK for /refrigeration/0, closing the last unbound
href surfaced by issue #7's diagnostic dump.
Oven: applies the same live-unit-reading fix defensively to
OVEN_SETPOINT, which shares the same aggregate resource shape.
Was left at 0.1.0 across the last two releases. Also de-hardcode the
diagnostics test's expected version so this doesn't happen again --
it now reads manifest.json directly instead of a copy-pasted literal.
Adds cycles-until-due and last-cleaned sensors decoded from the same
options[] array the course selector already reads (DrumCleanProposal_N -
WashingTimes_N for cycles remaining, DrumCleanLog_<iso> for last-cleaned),
verified byte-for-byte against a live app screenshot ("Potreba cistenia po
37 cykloch" / "Naposledy cistene pred 10 dnami").
Course selection is now a writable select entity sourced from each
device's own x.com.samsung.da.editCourseList (via a new options-as-callable
form on SelectDesc, reading the coordinator's full resource snapshot
instead of just the entity's own href), rather than a hardcoded course
table baked into Python. A MostUsed_ field on the same resource was
considered as a fallback but rejected after byte-level analysis showed it
doesn't reliably encode a course list. When a device never populates
editCourseList, the selector isn't created at all (exists_fn now takes
(rep, resources) to check a sibling href, matching the existing
match_fn(rep, resources) pattern).
Display names moved out of Python into strings.json/translations under
entity.select.{washer,dishwasher}_cycle.state.*, matching this
integration's existing translation_key convention (fridge.py's ice_type,
flex_zone_mode, etc.) instead of hardcoding English names in code.
discover() flagged /cycleinterface/vs/0, /drlc/0, and /operational/state/0
as unbound on real washer dumps, which would surface a spurious device
coverage gap for washer owners. All three are noise: cycleinterface is
empty on every dump seen, drlc/0 is an OCF-native duplicate of the
already-ignored /drlc/vs/0, and operational/state/0 is a read-only
OCF-native duplicate of /operational/state/vs/0 (already modeled by the
richer, write-capable operational.OPERATIONAL_STATE).
_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.
x.com.samsung.da.delayStartTime is HH:MM:SS until the cycle starts
(e.g. "01:00" means 1 hour from pressing start), not a time of day. It
was wired up as a time entity with a suspicious hour % 24 wrap -- a
sign it was never really a clock time. Replace with a number entity
(0-24h, 1h steps) that reads/writes the field as elapsed hours.
Exposes whether a device is currently in observe (push) or poll mode,
for troubleshooting the observe-mode feature without digging through
logs. Disabled by default since it's not everyday-use information.
Replace the breadth/silence downgrade heuristic with a simpler rule: a
still-live OBSERVE session is never torn down on a sweep/cache mismatch
(the 30s sweep already corrects the cache regardless of mode) -- only a
proven reconnect invalidates subscriptions. A mismatch instead triggers
extra hot/warm subpolls this cycle as a bounded fallback.
Also stop treating a summary-poll block-level ACK timeout as session
death: distinguish TimeoutError (transfer was progressing, session
likely alive) from ConnectionError (session actually closed), backed by
a consecutive-timeout counter so a genuinely dead channel still
recovers. This was causing a flaky/slow device (e.g. a dishwasher) to
flap observe<->poll every ~45s on nothing but a slow blockwise GET.
Also remove the dead is_active/active_when scaffolding (never wired
into the coordinator) and add DEBUG logging for each OBSERVE notify
received, including its href.
A poll-failure-triggered reconnect while in observe mode left OBSERVE
silently dead: the new session had zero subscriptions, mode stayed
'observe' so the hot/warm sub-poll fallback stayed disabled, and the
ObserveRefreshTask kept retrying against the closed session instead of
the live one. Downgrading to poll mode on reconnect hands recovery to
the existing poll-mode retry path, which re-subscribes on the new
session.
Also snapshot ObserveManager._notified before intersecting it against
the subscribed set, closing a rare set-mutated-during-iteration race
between the DTLS reader thread (on_notification) and the executor
thread computing the grace-period success fraction.
Maps the fridge/dishwasher hrefs flagged by last session's coverage-gap
Repairs issue: defrost delay (switch) + block status (diagnostic binary
sensor), master ice-maker enable, self-check trigger/status, dishwasher
diagnosis trigger/status, and last-operation-source. Adds fallback
capabilities for /doors, /temperatures, and /icemaker/status that only
bind when the richer per-instance hrefs they duplicate are absent, so
simpler devices without those hrefs still get the data. Everything else
in the original gap list moves to capabilities.ignored (Bixby audio
feedback, inert DR/energy-planner resources, redundant metadata).
Fixes discover() reporting a known href as an unregistered gap whenever
its match_fn declined to bind for that device (e.g. a filter capability
on hardware without that filter) — it's now only a gap when no capability
is registered for the href at all.
Device naming now uses x.com.samsung.da.modelNum (not the OCF /oic/p,d
metadata, which produced inconsistent names like "[dishwasher] Samsung")
to build "Samsung <Type> (<model>)".
Completes the device-capability-diagnostics spec:
- diagnostics.py: the standard HA diagnostics hook, returning device type,
one_ui_version, unbound hrefs, and the redacted raw resource tree, plus
integration and smartthings-local version numbers. This is what the
Repairs issue (added in the previous commit) points users at, and what
they attach to a device-support issue.
- config_flow.py: _probe_and_validate now also reports oneUiVersion and
whether the device type is recognized. Recognized types are unaffected;
an unrecognized type shows a new confirm_unknown_type step explaining
that only common capabilities will be available before creating the
entry, so expectations are set at setup time rather than only after the
fact via Repairs.
- .github/ISSUE_TEMPLATE/device-support.yml: structured template for
filing a capability gap, linked from both the Repairs issue and the
config-flow confirmation step.
- README: short section pointing at this whole mechanism.
Companion to the git-filter-repo pass that just stripped every prior
version of these two files (which carried a real Samsung account email,
Bixby access token/device-ID hash, and real WiFi/BLE MAC addresses) out of
history entirely. This commit reintroduces only the already-redacted
content, so the real values are no longer reachable from any commit.
Implements the first half of the device-capability-diagnostics spec:
- registry/capabilities/ignored.py: known-noise hrefs (Bixby/voice
provisioning, WiFi/BLE info, OTA/region housekeeping, and a couple of
redundant hrefs) declared as no-entity Capability objects, so
discover()'s existing unknown-resource reporting treats them as covered
instead of flagging every device as having gaps. Folded into each
by_type registry and into the global fallback CAPABILITIES set.
- discovery.py: log() callback now passes the raw href instead of a
formatted message, so callers can collect a clean unbound-hrefs list.
- coordinator.py: wires that callback into self._unbound_hrefs, tracks
device_type_name/one_ui_version, and raises (or clears) a Repairs issue
when a device's type is unrecognized or it has genuinely unmodeled
hrefs left over.
- registry/redact.py: recursive, substring-keyed redaction for anything
that looks like account/identity data, ahead of the diagnostics.py
platform that will consume it.
Verified against the real dishwasher/refrigerator fixtures: known-noise
hrefs no longer show up as gaps, while genuine gaps (e.g. /bespoke/vs/0
on the fridge) still do.
The DTLS/CoAP transport code that made "ocf" an accurate name moved out to
the smartthings-local package. What's left here (capability.py, entities.py,
discovery.py, adapter.py, identity.py, capabilities/, by_type/, plus the
/device/0 batch parser) is entirely the device capability registry, so name
the package for what it does.
Flattened the redundant ocf/registry/ nesting into a single top-level
registry/ package and updated every import across the platform modules and
test suite accordingly. Verified: full test suite (80/80) passes, and the
Docker dev container reconnects to both live appliances and rediscovers
their entities cleanly after the rename.
Dishwasher:
- Revert remote_control to BinarySensorDesc ("Smart Control", read-only)
- Add cycle SelectDesc on /course/vs/0 via options-array RMW (AI Wash,
Pre blast, Self clean, Normal, Express 60, Heavy, Pots and pans,
Delicate, Plastic, Baby Care)
- Add Storm Wash+ and Auto release dry SwitchDescs on /course/vs/0
- Add Sanitize SwitchDesc and Smart Dry SelectDesc on /dishwasher/vs/0
- Add Sound volume NumberDesc (0–15 step 5) on /settings/sound/volume/vs/0
- Add Door LED night brightness, night start/end time to DOOR_LED capability
- Add SOUND_VOLUME capability to laundry module
Stale-state fixes (firmware leaves values set after cycle ends):
- finish_time: suppress when machine state is not active
- progress: return 'Idle' when machine state is not active
- water filter: gate WATER_FILTER capability on filterStatus != 'notused'
Translations:
- Add ICESTATUS_RUN → "Making ice" to strings.json and en.json
Tests:
- Update dishwasher and refrigerator golden fixtures to reflect current entity set
- Remove dead test_project_extrapolates_remaining_time (on_observation removed)
Samsung appliances silently drop CoAP requests when hit faster than their
firmware ceiling (~8–14 req/s measured; dishwasher unknown). Multi-block
Block2 fetches on LAN hit ~100 req/s instantaneously, causing the observed
burst timeouts on block 4 and block 10 of /device/0.
Adds rate_limit_rps (default 5 req/s / 200 ms) to DtlsCoapSession with a
pace() method that enforces the inter-block delay via _stop.wait() so session
teardown interrupts the sleep cleanly. Coordinator calls pace() between hrefs
in _poll_hrefs_blocking. Also adds device IP to all GET log lines for easier
per-device filtering.
- Add button platform (button.py + PLATFORMS) so oven/washer Start/Pause/Stop
controls actually appear in HA
- Load DTLS cert chain from memory via _load_pem_chain(); eliminate all temp
file writes for key material in config_flow and coordinator
- Fix PEM boundary: ensure leaf and CA blocks are separated by a newline in
the stored fullchain regardless of whether the user pasted a trailing newline
- Dryer mode select: add static options tuple and value_fn to decode Course_XX
hex strings back to human names; was permanently empty before
- Remove callable branch from LocalThingsSelect (options_field covers dynamic
case; callable contract was broken — called with no args vs Callable[[dict]])
- Guard native_min/max_value overrides with hasattr + super() fallback to
prevent AttributeError when native_min=None and range_field returns nothing
- Add async_close() public method to coordinator; __init__ now calls it instead
of reaching in to _close_session directly
- Update tests: rename CONF_CERT_PEM/KEY_PEM → CA/LEAF variants, add
leaf_cert_pem/leaf_key_pem to mock_probe return, fix mock PEM constants
(no trailing newlines, matching what .strip() stores)
Makes the integration fully self-contained for HACS distribution.
Users installing via HACS get everything in one directory; no external
samsung_appliance package needed.
All component imports updated to relative (.ocf.*); all test imports
updated to custom_components.localthings.ocf.*. ocf_root_ca.pem moves
with the package and resolves correctly via Path(__file__).parent.
Rename tests/fixtures/dumps/{IP}.json to tests/fixtures/{name}_device.json.
Scrub PII: serialNum, otnDUID, modelNum, diagEndPoint replaced with
TEST-* placeholders. conftest: _load_device(name) as primary API;
_load_resources(ip) kept as legacy shim for existing test call sites.
Remove key_fn from Capability — pattern cap keys are now always derived
automatically: strip digits from href segments, join with '_', append
'_' + desc.key. /door/cooler/0 + 'open' -> door_cooler_open.
DOOR_GENERIC entity key: 'open' (not 'door_open'). Remove _door_key helper.
Fridge registry: DOOR_FRIDGE/DOOR_FREEZER replaced by DOOR_GENERIC pattern cap.
Golden baseline: door_fridge_open -> door_cooler_open.
Move device dumps to tests/fixtures/dumps/ (tracked) so tests run in CI
without local-tools/ dependencies.
- for_device(): suffix fallback for descriptive names
("french_door_refrigerator" → refrigerator registry)
- Capability.href_prefix: replaces rt_filter for pattern caps;
DOOR_GENERIC now uses href_prefix='/door/' (batch reps have no rt)
- discover(): apply href_prefix gate before rt_filter in pattern loop
- Remove dead bound_hrefs variable from discover()
- Remove vestigial default_observe_port from RuntimeDescriptor