Continues narrowing SamsungEntityDescription accesses to the correct
subclass and asserting Optional write_fn/match_fn/exists_fn fields are
set before calling them, per the pattern established in the previous
commit.
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.
common:
- Make KIDS_LOCK_GENERIC also a read-only BinarySensorDesc (device_class='lock'),
flipping value_fn to not bool(v) so /kidslock/0 value=False and
/kidslock/vs/0 kidsLock='Ready' render with the same polarity ('On'
means open/unlocked per HA's lock device class). The old SwitchDesc
form never honored device_class='lock' -- HA's switch platform only
accepts 'outlet'/'switch' -- so the surface was a plain switch whose
'On' meaning drifted across boards. Tests updated.
air_monitor:
- Add state_class='measurement' to dust/fine_dust/super_fine_dust so
the readings feed HA long-term statistics (co2 already had it).
- Import _AIR_QUALITY_SENSORS from air_purifier instead of duplicating
it byte-for-byte; update common.sensor_item_value's docstring to
mention the third caller.
by_type/__init__: drop trailing whitespace on the new 'ASM' line.
translations/en.json + nl.json: move the new 'dnd' switch entry to its
correct alphabetical position (after display_light, before fast_preheat).
SKILL.md: add an explicit read-side rule to §5's educated-guesses
section -- guessed unit/device_class/state_class on a SensorDesc
silently mislabels readings forever with no 4.xx to catch it (unlike
guessed writes, which the device rejects). The prior air_monitor
docstring cited this carve-out as if it existed; now it does.
tests/water_purifier (issue #196): change the ailite fixture's
favorite.defaultTemperature from '85' to '50' so the test actually
reproduces the reported bug -- '50' is in showList only, so a
descriptor reading from supportedList would fail the assertion that
the current default is in its options list.
KIDS_LOCK_VS_FALLBACK's write_fn wrote 'Enable', a value no dump in the
fixture corpus (washers, dryers, dishwashers, ovens, ranges, microwaves,
air purifiers, air dressers -- everything that lacks the OCF-standard
/kidslock/0) has ever reported back; every one reports 'Ready' or 'Run'.
It was never a confirmed write contract.
#181's reporter tested directly: writing the *correct* value ('Run')
still returns 4.05, and the SmartThings app itself has no control for
kids lock either -- the resource is genuinely read-only on that
hardware, not just wrong-valued. #183's reporter hit the identical
symptom (toggle does nothing) on a different device family reporting
the same Run/Ready vocabulary.
Convert the entity to a read-only BinarySensorDesc. binary_sensor's
'lock' device class is inverted from the old switch reading (On means
open/unlocked), so value_fn flips accordingly -- callers reading the
flattened 'child_lock' state key need to account for the new polarity.
KIDS_LOCK_GENERIC (the OCF-standard /kidslock/0 boolean) is untouched;
nothing suggests that one is broken.
/power/0 and /power/vs/0 had no poll_tier, so they only ever refreshed on
the once-per-30s summary poll -- everything else that drives real-time
switch/climate/fan state (e.g. remote-control enablement) is already on
the faster subscribe/subpoll 'warm' cadence for exactly this reason. Scoped
to just this one tier bump; the model-specific priority-flip claim in the
same issue thread isn't independently verified, so it isn't part of this
change.
Samsung pre-populates /alarms/vs/0 with one row per supported alarm type,
each carrying a '<Name>_OFF' placeholder code (no 'Deleted' state at all)
when that alarm isn't firing. common._active_alarm_codes only ever
filtered on 'Deleted' state, so every device using this shared capability
(including the AC family) showed these inert placeholders --
'ErrorCode_OFF', 'FilterAlarm_OFF' -- as if they were live alarms.
Confirmed the '_OFF' suffix convention holds across every alarm code seen
in this repo's fixtures so far (ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF,
CT_E_OFF, WaterTankFull_OFF, AC_V_0002_OFF, all placeholders; DoorA_Opened,
FilterAlarm, SNSF_Reached, all genuinely active with no suffix) -- issue
#166's own dump has both a FilterAlarm_OFF placeholder and, on a second
unit, a live FilterAlarm/state=Created alert, which is what motivated
generalizing range_hood.py's existing (but narrower, ErrorCode_OFF-only)
special case into the shared helper instead of duplicating it further.
The other three points in #166 (filter-usage percentage vs. filterStatus
disagreement, an "air purification" config toggle the reporter says has no
physical effect, a "beep on/off" control) don't have a confirmed code fix:
the percentage math already matches the device's own filterUsage/
filterCapacity fields (filterStatus is a separate device-computed field we
already relay verbatim, not something we derive), the air-purify resource
is correctly wired to what the board reports and its absence from the
official app's own options list suggests an inert shared-board-profile stub
rather than an integration bug, and a "Beep volume" NumberDesc keyed off
the same Volume_100 option both dumps report already exists (0 mutes it).
Opus review of #149 caught that swapping is_stub_rep(rep) in for the
default field-presence gate (not just the 9 hand-audited exists_fn call
sites) was too broad: it silently excludes entities on ANY resource whose
normal, valid state includes reporting {} -- /alarms/vs/0's {} is
fridge.py's documented no-alarm state, not an absence signal, and it's not
the only one (job_beginning_status, diagnosis_status, sabbath_mode,
defrost_delay, ice_maker_enabled all lost entities on real fixtures under
the broader change). That's the opposite of #127's fix: a real fridge
would have dropped its alarm sensor on first-poll timing, not just its
phantom energy sensors.
Restored the default gate to include on either a stub or a genuinely-empty
rep -- verified byte-identical to the pre-#149 baseline across all 40
fixtures, apart from the 9 deliberately-audited exists_fn sites (energy
meter, self-check error, cooktop burner, range-hood auto-op), which are
unaffected and still fix#127. Added tests/test_entity.py exercising
_is_included directly (previously untested) and fixed two now-stale
"not rep" doc references the review also flagged.
parse_device0_batch used to collapse /device/0's {"href": "..."} "no data
yet" marker into a plain {}, indistinguishable from a resource the device
had actually polled and confirmed empty. Every exists_fn using the "not
rep or ..." stub carve-out (and entity._is_included's default field-gate)
then treated both the same way, creating phantom always-"unknown" entities
for any resource a model simply doesn't support (e.g. GSzabados's fridge's
/energy/consumption/vs/0).
is_stub_rep() now recognizes only the literal {"href": ...} marker as a
stub; a genuine {} is treated as the device's real (if empty) answer and
gates the entity off like any other missing field. Updated the energy
meter, self-check error, cooktop burner, and range-hood auto-operation
exists_fn call sites, plus three golden fixtures that had baked the
phantom-entity behavior in as "expected".
Finishes PR #91's contribution (pedroperosin) with the requested review
changes applied, on our own branch:
- Preset resolution is now fully dynamic, read from each unit's own
/mode/convenient/vs/0 supportedModes instead of a static per-model
table -- any board's convenient modes (including WindFree's Nano/
NanoSleep) surface without code changes. Unlabelled codes across
existing fixtures (longwind, motionindirect, motiondirect, drycomfort)
plus the two new WindFree ones are added to en.json/nl.json.
- 'Auto' now maps to HVACMode.AUTO instead of HEAT_COOL: these are
single-setpoint "device decides" units, not two-setpoint heat+cool
ones. 'Fan' is added alongside 'Wind' as a second FAN_ONLY spelling;
a new _device_code_for_hvac() picks the code from the unit's own
supportedModes since the flat map can't disambiguate two device codes
mapping to one HA value.
- Detection gains a hyphenated '-RAC-' modelNum fallback (alongside the
existing '_RAC_') for cool-only global RAC variants whose
/otninformation/vs/0 ships no swVersionInfo block.
- Adds a display_light switch sourced from /mode/vs/0's opaque options
blob (inverted Light_On/Light_Off token) for boards with no dedicated
/light/vs/0 switch.
Write-path behavioural change (touches the path iterated on across
#9/#17/#27/#38/#54): power now targets the vendor /power/vs/0 instead of
the OCF /power/0, and _is_on() reads vendor-first. /power/0 is absent on
several known AC boards, so the previous OCF-first read/write pair could
report and act on stale state -- consistent with #53's "can turn on but
not off". Target temperature now picks its channel (OCF pair vs. vendor
items[]) based on which the unit actually reports, reading and writing
the same one.
The vendor temperature write and the new display-light write both carry
only the changed field(s), not the whole resource -- confirmed sufficient
on the wire, the device merges the rest itself. That requires the
coordinator's optimistic cache to do the same merge on the read side so a
setpoint change doesn't blank out current/min/max/unit for the settle
window: common.py gains merge_items_field() next to the existing
merge_options_field(), and async_send_command wires it in for any write
touching x.com.samsung.da.items.
New fixture/golden for the cool-only global RAC variant (TP1X_DA-AC-RAC-
01001, AI_RAC_GLOBAL_COOLONLY_3.0); display_light added to the goldens
for boards that gain the new options-based switch. Regenerated against
current main rather than copied from the original PR, since those
goldens had already shifted (current_temperature_c/humidity from issue
#75).
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
Move the on/off interpretation into a single remote_control_enabled()
in registry/capabilities/common.py so the write-guard added in the
previous commit can't silently drift from the Smart Control binary
sensor's own reading of the same hrefs. Also promotes both
/remotectrl hrefs to poll_tier='warm' so the coordinator's cached
state backing that write guard doesn't lag up to a full 30s cold
summary poll behind the device's actual toggle state.
The switch and select shared a stub-time asymmetry: only the select had a
`not rep` carve-out, so an unfetched-stub rep at the moment platforms are
set up (entity creation runs once, ever) would instantiate a Select, while
flatten() re-evaluates exists_fn every poll against live data -- once the
resource populated to a single-level list, the switch's exists_fn would win
instead and feed the already-created Select a bool through their shared
'ai_energy_level' key, which isn't a valid select option.
Dropped the stub carve-out from the select's exists_fn so both sides
require real, populated data to decide the platform -- on a device that
stubs this cold-tier href on its very first poll, the entity now simply
doesn't appear until a reload, instead of appearing as the wrong widget
type. Added tests for the missing/empty-list supportedAiLevel shapes on
both widgets and a regression test locking in the stub behavior.
Consolidate the AC/power-exclusion rationale (previously spelled out nearly
verbatim in three places) down to one canonical explanation next to
common.POWER, with one-line pointers elsewhere. Dedupe the switch/select
test classes' identical _desc() lookup into one shared helper.
FIRMWARE_UPDATE and ALARMS were already copy-pasted into all 6 device-type
registries by hand; POWER/KIDS_LOCK/REMOTE_CONTROL into 5 of 6. Consolidate
into two bundles in common.py, unpacked via *common.UNIVERSAL / *common.POWER
the same way ignored.IGNORED already is:
- UNIVERSAL: ALARMS, ENERGY_METER, FIRMWARE_UPDATE (moved from fridge.py),
SELF_CHECK (moved from fridge.py), AI_ENERGY_LEVEL, and the kids-lock/
remote-control pairs. Safe everywhere -- discover() only binds a href
actually present in a device's dump, so a capability with no known
conflicting family is a no-op where the href is absent and a real,
wanted entity where it's present. This also broadens AI_ENERGY_LEVEL,
ENERGY_METER, and SELF_CHECK to device types they weren't confirmed on
before, on the same reasoning.
- POWER: just POWER_GENERIC/POWER_VS_FALLBACK, applied to the 5 non-AC
registries. Airconditioner keeps its own opt-out: its climate entity
already owns /power/0 and /power/vs/0 via a bare, no-entity claim
(airconditioner.COVERAGE), and a real power capability on the same
href would make _build() raise (a href with multiple caps requires
every cap to have rt_filter or match_fn; the bare COVERAGE cap has
neither).
Full test suite (327 tests, all 6 device-type golden fixtures) passes
unchanged -- none of the newly-broadened capabilities bind on any existing
fixture, confirming the no-op reasoning held in practice, not just theory.
Issue #40: /energy/ailevel/vs/0 was unbound on a plain washer. The
capability already existed for fridges but was gated off entirely on
single-level hardware (the common case), so it's moved to common.py
(cross-family, like fridge + washer now) and split into two entities:
a switch when supportedAiLevel has exactly one entry (aiLevel is really
just an on/off toggle there), and a select otherwise, with '0' (off)
synthesized back into the select's options since supportedAiLevel never
lists it but it's a real observed value.
Also drops the translation_key/strings.json entries -- aiLevel's raw
digit values already render fine untranslated, and translating a
handful of levels can't cover devices with more.
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 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.
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.
Registry changes from dict[str,Capability] to dict[str,list[Capability]].
discover() gains pattern_caps parameter (fallback for unmatched hrefs)
and gates each candidate on rt_filter and match_fn before binding.
Duplicate hrefs in registry allowed only if all caps set a discriminator.
OCF rt is a schema category shared by many unrelated resources.
The stable unique resource address is the href. Update Capability,
discovery, common capabilities, tests, and plan accordingly.