Compare commits

...
320 Commits
Author SHA1 Message Date
Marc Billow eb938205ab Merge pull request #215 from mbillow/claude/issue-214-device-registration-d2smcv
fix(subdevices): don't materialize a slot whose only live state is a meter
2026-07-30 13:25:14 -05:00
Marc Billow 5e86f147d4 fix(subdevices): don't materialize a slot whose only live state is a meter
Issue #214: a single-split ARTIK051_KRAC_18K showed up in HA as two air
conditioners. Its /device/1 answers the same unused-slot shape the Pattern A
reporter's /device/2 does -- every operational rep empty {} -- but also
reports a populated /energy/consumption/vs/1. Running discovery against the
reporter's own quoted subdevice block reproduces their diagnostics exactly
(21 bound entities, the same six hrefs), and of those 21 the only primary
entity with a non-None value is energy_kwh: a lifetime kWh total was the
sole thing passing discover_partitioned's liveness gate and materializing
the phantom.

A single-split AC has one compressor and one energy meter, so a
whole-appliance running total appearing under a second index is the
appliance's own bookkeeping, not evidence that hardware is installed at that
slot. Exclude cumulative meters (HA's total/total_increasing state classes,
plus the energy/water/gas device classes for the descriptors that
deliberately declare no state class) from the gate. The gate never applies
to MAIN, and across the whole fixture corpus every device has at least one
non-meter live primary, so no existing device's entities change -- verified
by the golden for the new fixture being identical to the plain KRAC one.

Also implement async_remove_config_entry_device. A subdevice's HA device
outlives the discovery that created it, so a phantom materialized by an
earlier release stays in the registry with no way to delete it from the UI
-- which is the state the second reporter on that issue is in, with a
refrigerator whose diagnostics now report no subdevices at all. Devices the
entry currently provides still refuse removal. No automatic pruning:
enumeration is one-shot and a real sibling can miss a poll (issue #205 on
the reference hardware), so auto-removal would discard a live subdevice's
name, area and automations on a transient miss.

The new fixture's /device/1 seed is the reporter's verbatim capture; its
master half comes from the corpus's other KRAC unit, with the deviations
spelled out in seeds_note.

Claude-Session: https://claude.ai/code/session_01HzUMLnSWBT64o4BQkVEzXp
2026-07-30 18:23:47 +00:00
Marc Billow 5f5aecbe7d Merge pull request #206 from mbillow/fix/subdevice-flat-href-fallback-205
fix(subdevices): fall back to per-href probing when a prefixed subdevice has no /device/0 Collection
2026-07-29 22:07:47 -05:00
Marc Billow ca27bf7f6e docs: de-identify reporter usernames repo-wide, add PII rule to skill
Extended the earlier de-identification (jhkwon19) to the other issue #177
reporter (HJcom), who was named in ~20 spots across coordinator.py,
diagnostics.py, subdevices.py, identity.py, capabilities/airconditioner.py,
a fixture's seeds_note, and several test docstrings/function names. Swapped
all of it for "the reporter"/"the Pattern A reporter", matching the
convention already established for the other reporter.

Added a rule to the adding-device-support skill (end of "Lock it in"):
don't put a reporter's name or GitHub username in code comments,
docstrings, seeds_note, or test/function names -- that prose ships and
sits in git history indefinitely, unlike an issue thread or a
release-notes thank-you. Use "the reporter" / "issue #NNN's reporter" /
a pattern label instead.
2026-07-30 03:06:06 +00:00
Marc Billow ce92d71a92 docs: de-identify the reporter's username in code comments
Comments/docstrings across subdevices.py, coordinator.py, the two fac_bora
fixtures, and their tests named the issue #177/#205 reporter directly.
Swapped to "the reporter"/"the Pattern B reporter" throughout -- no
behavior or test-assertion changes, string content only. HJcom (the
Pattern A reporter) is unaffected.
2026-07-30 02:50:49 +00:00
Marc Billow 8600985a36 review: address Opus findings on the subdevice flat-href fallback
- coordinator: _poll_subdevice_flat_hrefs called sess.pace() outside its
  try block and re-read self._session instead of using the caller's
  already-None-checked reference -- a session closed mid-poll (async_close()
  doesn't hold _session_lock) could crash the whole poll cycle instead of
  just dropping that one sibling. Now takes sess from the caller and guards
  pace() the same as get().
- coordinator: skip flat hrefs already covered by the hot/warm sub-poll
  tiers -- those are refreshed every few seconds by _run_subpolls already,
  so re-fetching them again on the once-per-summary-poll flat pass only adds
  GETs, not freshness. Matters because, unlike the Collection path (always
  one GET), a flat subdevice's summary-poll cost scales with its href count.
- subdevices.py module docstring: corrected an overclaim inherited from PR
  #199 that GET /<uuid>/device/0 had been "confirmed live" on jhkwon19's
  unit. Only an individual /information/vs/0 read was ever actually
  confirmed; the Collection endpoint itself has never been observed to
  answer on any known unit, which is exactly what issue #205 exposes.
- SKILL.md: fixed the numofsubdevice cross-check formula to match what
  coordinator.py actually compares (len(materialized) + 1, not
  len(subdevices) + len(subdevices_skipped)).
- Documented, not yet guarded against: a firmware that echoes state back
  under any unrecognized prefix instead of 4.04ing could pass the flat probe
  and the liveness gate, materializing a phantom duplicate of the master.
  Every board seen so far genuinely 4.04s on paths it doesn't own.
- Added test coverage the review flagged as missing: a materialized (not
  just skipped) flat subdevice re-polling end-to-end through to
  canonical_resources, the hot/warm skip itself, zero-master-hrefs and
  two-UUID no-cross-contamination edge cases, and a golden file for the
  #205 fixture (SKILL.md's "fixture + golden + test" discipline).
2026-07-30 02:44:26 +00:00
Marc Billow e78d941af3 fix(subdevices): fall back to per-href probing when a prefixed subdevice has no /device/0 Collection
Issue #205 shows the UUID-prefixed pattern's own reference device
(TP2X_FAC_BORA_21K) doesn't always answer /<uuid>/device/0, contrary to
what the pattern was built against. When that Collection GET comes back
empty, enumerate_subdevices now probes every href the master itself
answered this cycle individually under the UUID prefix, keeping whichever
ones respond. Subdevice gains a flat_hrefs field for this, and the
coordinator re-polls those hrefs individually each cycle instead of
re-fetching a Collection batch that doesn't exist.

Built a fixture from the reporter's real #205 diagnostics dump: the
fallback finds a candidate through the one href already confirmed live
under this UUID (/information/vs/0, from the #177 thread), and
discover_partitioned's liveness gate correctly holds it back since that
href alone binds no entity -- honest current state, not a guessed
resolution.
2026-07-30 02:25:26 +00:00
Marc Billow 7c31bb1682 chore: bump version to 0.17.0 2026-07-30 01:28:42 +00:00
Marc Billow 2ff39d0576 Merge pull request #204 from mbillow/claude/triage-issues-196-195-lm4wki
fix(water_purifier): route AILITE_DA-REF-WATERPURIFIER boards correctly (#196)
2026-07-29 20:23:21 -05:00
Marc Billow fb2d267632 fix(water_purifier): route AILITE_DA-REF-WATERPURIFIER boards correctly (#196)
The AILITE water-purifier board (RWP70F15ANW) spells its modelNum
'...-REF-WATERPURIFIER-...', so the board-token scan hit the bare 'REF'
token before ever reaching 'WATERPURIFIER' and misrouted the device to
the refrigerator registry, whose resource surface shares almost nothing
with a water purifier -- hence the incomplete-coverage warning. Add a
documented carve-out for this one token co-occurrence and a matching
TestBoardTokenAmbiguity exception.

Also bind the water-purifier hrefs this board additionally exposes:
cup-detection status, the settings/sound/{mode,output,volume} trio (read
live, since this board's own supportedModes vocabulary differs from both
laundry's and air_purifier's hardcoded/live sets), and last-pour
statistics.

Separately, gate hot_water_temperature off when the device doesn't report
a supportedHotTemperatures list (only a hotwaterRange/hotwaterLevel pair
with no confirmed write contract) -- previously an empty options list
plus a live current value rendered as 'unknown' in HA, the second bug
reported in #196. The existing water_purifier_coffee fixture (#107) turns
out to hit the same shape, so its golden drops the entity too.

Issue #195 (TP1X_REF_21K) needed no change: its diagnostics show zero
unbound hrefs and the model already routes to the refrigerator registry,
matching the maintainer's own comment on the issue.
2026-07-29 23:23:56 +00:00
Marc Billow 1053ae421b Merge pull request #202 from firstof9/fix/fan-zerodivision-issue-201
fix(fan): fall back to settableMin/MaxFanSpeed when supportedFanSpeed is omitted (#201)
2026-07-29 18:06:41 -05:00
firstof9@gmail.com 7aa14d161c style(fan): extract _MAX_FAN_SPEED_FIELD constant 2026-07-29 16:06:12 -07:00
Marc Billow c7d265074f Merge pull request #203 from mbillow/claude/rename-unit-subdevice-4wbe6s
rename: unit/sub-unit -> subdevice, matching OCF terminology
2026-07-29 17:59:50 -05:00
Marc Billow 9e85395b44 rename: unit/sub-unit -> subdevice, matching OCF terminology
"Unit"/"sub-unit" from #199's multi-indoor-device support wasn't OCF
idiomatic -- OCF calls each component of a composite device a
"subdevice" (see subdeviceIdList), so rename SubUnit -> Subdevice
throughout: the registry module, coordinator state, BoundEntity's
subdevice field, diagnostics keys (subdevices/subdevices_skipped/
subdevice_probes), entity unique_id prefixes (unit1_/sub_<uuid>_ ->
subdevice1_/subdevice_<uuid>_), device-name fallback labels, golden
fixtures, tests, README, and the adding-device-support skill.

Breaking change to entity unique_ids and diagnostics keys, acceptable
since this hasn't been released yet.
2026-07-29 22:57:11 +00:00
firstof9@gmail.com bc667e486c fix(fan): fall back to settableMin/MaxFanSpeed when supportedFanSpeed is omitted (#201)
Fixes ZeroDivisionError in fan platform when supportedFanSpeed is missing on microwave vent fans (e.g. ME8000T).
2026-07-29 15:50:15 -07:00
Marc Billow 79ac6f199d Merge pull request #199 from mbillow/claude/multi-device-subdevice-patterns-1xrtuh
Support multi-indoor-unit ("2-in-1") systems (#177)
2026-07-29 14:42:27 -05:00
Marc Billow 9442248c20 Merge origin/main into multi-device sub-unit support
Conflict was purely additive: both sides appended golden-regression
tests at the same point in the file, and each side's last test shared
the single trailing assert block. Kept every test from both sides, each
with its own copy of that assert.

One real semantic merge on top of that. #136 (on main) remodelled the
legacy ARTIK051 board's beep from a buzzer_volume Number to a beep
Switch, and HJcom's ARTIK051_DONGLE_FAC_18K is exactly that board
generation (is_legacy_board -> True), so its golden -- written before
that change existed -- still expected buzzer_volume. Regenerated it:
buzzer_volume/unit1_buzzer_volume -> beep/unit1_beep on the master and
the second indoor unit alike, with nothing else moving and still no
unit2_ keys. That is main's intended behavior reaching the sub-unit for
free, which is the point of binding siblings through the same registry.

967 passing. Re-ran the corpus-wide unique_id audit over all 57
fixtures (main added five this branch had never seen): no collisions
within a platform.
2026-07-29 19:40:32 +00:00
Marc Billow 0d03318878 docs(skill): teach adding-device-support the multi-unit shapes (#177)
The skill is what tells the next person how to read a diagnostics dump,
and this branch changed the dump. Without these edits it describes the
old shape and, in one place, leads somewhere that fails silently.

The trap: on a multi-unit appliance a sibling's coverage gap appears in
unbound_hrefs as the *real* href it was seen on -- /foo/vs/1, or
/<uuid>/foo/vs/0. The skill's own rule is "every href must resolve, or
the repair fires", so the natural next move is to bind the href in front
of you. Binding runs against each unit's canonical view, so a registry
entry for an indexed or prefixed href matches nothing on any device: no
error, no entity, gap still open. Section 8 now says registry hrefs are
always canonical and nothing under capabilities/ or by_type/ should ever
mention a unit index.

Section 1 documents the four new blocks (sub_units, sub_units_skipped,
sub_unit_probes, multidevice) and that `resources` is now this unit's
own. Section 2 notes that a sibling's block is canonicalized precisely
so it drops into the standalone-discovery recipe unchanged -- the reason
that canonicalization exists is invisible unless stated. Section 10
covers the fixture's optional oic_res/seeds/probes keys, _load_device_full,
and why a multi-unit golden carries prefixed keys while the master's stay
bare.

Section 11 is new: the ordered triage for "one of my units is missing",
which is the read that would have turned #177 from days of archaeology
into a few minutes -- probes first (did we look?), then the skipped
candidates' own reps (did we reject it, and was that right?), then the
board's own count, then which pattern the board uses.

Section 5's "don't guess" rule also needed a boundary. It reads as
covering all speculative traffic, but this codebase deliberately probes
hrefs no dump contains -- read_identity and enumerate_sub_units both do.
A RETRIEVE is non-mutating and a 4.04 is tolerated throughout that path;
it's guessing a *write* against live hardware, or inventing an entity
from a field you can't explain, that the rule is actually about.
2026-07-29 19:34:06 +00:00
Marc Billow c181a8b447 feat(subdevices): support multi-indoor-unit systems (#177)
Samsung 2-in-1 air conditioners put more than one logical indoor unit
behind a single IP and a single DTLS session. Only the unit the config
entry was set up against was ever discovered; the second one -- a whole
physical appliance the user can see in SmartThings -- had no entities at
all. Two reporters turned out to have two different mechanisms:

  ARTIK051_DONGLE_FAC_18K -- indexed siblings. /oic/res registers the
  whole tree discoverable and lists three complete parallel resource
  sets whose trailing path segment is the index (/mode/vs/0, /mode/vs/1,
  ...), on OCF-standard and vendor hrefs alike. /device/0's batch
  carries only the index-0 hrefs, so a sibling is reachable only through
  its own /device/<n> collection.

  TP2X_FAC_BORA_21K -- UUID-prefixed tree. /oic/res hides the appliance
  tree entirely (which is why a direct /device/1 probe returns nothing
  on this board). /subdevices/vs/0 carries subdeviceIdList instead, and
  that UUID appears as a literal href prefix; /<uuid>/information/vs/0
  was confirmed live to return the wall unit's own model and serial
  (TP2X_FAC_BORA_RAC_21K) against the master's TP2X_FAC_BORA_21K.

The detection signals don't overlap on either board, so no
disambiguation is needed -- enumeration checks both and takes what
answers.

Both patterns are the same thing underneath: a logical unit is a seed
collection path to poll plus an href transform between the canonical
href the registry knows and the actual on-the-wire href. That is the
whole abstraction (SubUnit), applied at four boundaries -- discovery,
the coordinator, the adapter, and the platforms. Capabilities, the
registry and the climate composite stay written against canonical hrefs
and are untouched.

Uniqueness comes from a key_prefix inside the flattened state key, so
the master unit's keys are byte-identical to every release before this
and every existing golden file is an unchanged regression guard. Each
sub-unit gets its own device-registry entry linked by via_device and
named from its own /information/vs/<n>, so it lands in its own room
rather than crowding the master's device page.

A sub-unit materializes only when it yields at least one primary
(non-diagnostic) entity with a populated value. That gate is not
decoration: the reporter's /device/2 is an unused slot that SmartThings
shows disabled, yet it answers with a full 14-href batch, and it
flattens to exactly one non-None value -- a diagnostic alarm_code
derived from an empty /alarms/vs/2. Without the entity-category filter
it becomes a phantom third climate card. The rule is deliberately
domain-agnostic rather than a list of HVAC hrefs, so a multi-drum
washer (#19) gets the same treatment with no new curation. Units that
answer but fail the gate are logged and reported in diagnostics, so a
genuinely missing unit stays diagnosable from a dump.

Enumeration fetches things that must not then be treated as appliance
state. A rejected candidate's seed has to be read to evaluate the gate,
but only units that pass are polled again, and StateCache has no
eviction -- so discovery runs before the first cache apply and those
reps are held aside for diagnostics rather than frozen into the cache
forever. /multidevice/vs/0 is probed on every device regardless of
family, so merging it into the resources dict would have reached
discovery on any board whose registry doesn't ignore that href -- only
the air conditioner one does -- raising a spurious coverage-gap repair
for a washer or fridge whose firmware answers it. It is corroborating
metadata (numofsubdevice, confirmed read-only) and now lives beside the
resources rather than in them.

Diagnostics reports each unit separately: top-level `resources` is this
unit's own and only its own, which is what the module docstring and the
adding-device-support skill have always claimed it was, and each
sibling or rejected candidate carries its own reps canonicalized so a
block reads exactly like the master's instead of needing to be
de-indexed by hand.

Fixtures are real captures. The ARTIK051_DONGLE_FAC_18K one is entirely
verbatim, both sibling seeds and the hand-read /multidevice/vs/0
included. The TP2X_FAC_BORA one has a real device0, oic_res and
sub-unit /information/vs/0, with the remainder of that unit's tree
constructed and documented as such in seeds_note; /<uuid>/device/0 is
the one part of that pattern still inferred rather than observed, and
can't be tested through the debug panel because a Collection returns a
list.
2026-07-29 19:14:51 +00:00
Marc Billow 3437addc46 Merge pull request #198 from mbillow/claude/issue-triage-ujtam3
Morning issue triage: #191, #192, #193, #190, #186, #136, #183, #56
2026-07-29 13:14:35 -05:00
Marc Billow bd3ca80d3c review: address Opus code-review findings on this branch
- config_flow: the #192 port-rescue made the "every port refused" fast-fail
  permanently unreachable (PREFERRED_PROBE_PORTS is always non-empty and
  always rescued), so removed the dead branch instead of leaving it as
  misleading dead code. A dead host now fails via the handshake loop's own
  error, which carries the real per-port reason.
- oven._has_option: added the is_stub_rep carve-out cooktop.py's identical
  per-token exists_fn already has on the same kind of href, so a not-yet
  sub-polled /mode/vs/0 doesn't permanently exclude energy_saving/
  cooktop_on_alert before their first real fetch lands.
- airconditioner.ENERGY_METER_LEGACY: build via dataclasses.replace() like
  its ENERGY_METER_GENERIC sibling, instead of hand-copying href/poll_tier
  (which would silently drift if common.ENERGY_METER ever gains a field).
- Fixed two stale comments: is_legacy_board()'s docstring still listed
  Volume among tokens needing legacy-only gating, though 'beep' now applies
  unconditionally across board generations; and common.py's UNIVERSAL
  invariant comment didn't mention that airconditioner also now excludes
  ENERGY_METER from the wholesale bundle.
- #191 (CAC token): added the fixture/golden/capability-test coverage the
  AVT token in the same branch got, including an honestly-documented list
  of the ten hrefs this board generation doesn't cover yet.
- fridge.cooler_temperature_setpoint: dropped the hardcoded "N °C" state
  labels -- the resource's own unit field isn't necessarily Celsius on a
  different model reporting the same href, and the options themselves are
  already read live via options_field, so a static per-value label risked
  asserting the wrong unit for a future device sharing this capability.
- airconditioner._legacy_cumulative_power_kwh: parse with float (matching
  common.wh_to_kwh's own numeric parsing) instead of this module's
  integer-only _int, so a decimal-formatted reading doesn't raise.
- test_config_flow: the port-rescue test bound the real 49154 directly,
  which could collide with an actually-running service on some machine;
  now monkeypatches PREFERRED_PROBE_PORTS to an OS-assigned port instead.
- oven.py: consolidated six byte-for-byte-identical single-token options
  write_fns (lamp/sound/fast_preheat/natural_steam/energy_saving/
  cooktop_on_alert) into one _option_switch_write(prefix) factory.

845 tests passing (up from 841 -- 4 new CAC coverage tests).
2026-07-29 18:04:23 +00:00
Marc Billow a003650d31 perf(common): poll power state on the warm tier (#56)
/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.
2026-07-29 13:33:26 +00:00
Marc Billow 36240b44e6 fix(oven): gate fast_preheat/natural_steam on their own tokens (#183)
Both switches were shipped unconditionally (no exists_fn) as an unverified
guess -- the module docstring already flagged them as "unproven." Every
range/oven fixture in the corpus, including the new issue #183 dump,
reports neither fastpreheat_* nor NaturalSteam_* in /mode/vs/0's options at
all, so both were phantom controls: always read as off, and toggling them
wrote a token the firmware never recognized in the first place. That
matches the reporter's exact complaint ("doesn't appear to do anything").

Also bound two tokens confirmed present on this dump but never modeled at
all: EnergySaving_On (the 120-hour energy-saving standby from the app) and
BurnerOnAlert_Off (cooktop-on alert), following the same single-token
options-merge pattern as the existing lamp/sound/fast_preheat switches.

Child lock, the setpoint mismatch, and the missing cook-start control from
this issue are not code bugs -- see the issue comment for what was verified
and what still needs more information from the reporter.
2026-07-29 13:26:39 +00:00
Marc Billow 487ac748bd fix(airconditioner): model legacy-board beep as a switch, not a volume (#136)
Every ARTIK051_KRAC_18K-generation unit confirmed on hardware (three units
across two reporters) only ever carries Volume_100 or Volume_Mute in
/mode/vs/0's options -- never an intermediate value -- so the existing
buzzer_volume Number (0-100, step 10) modeled a control this firmware
doesn't have. Worse, its write path could never produce the literal
'Mute' token needed to actually turn the beep off, since it only ever
wrote a plain integer string. The 'beep' switch already used on newer
boards is the correct model here too; it's no longer gated off the legacy
board generation, and the Number entity is removed.

The WindFree-preset-not-applying report earlier in this issue self-resolved
per the reporter's own follow-up testing, so no code change was needed for
that part.
2026-07-29 13:16:09 +00:00
Marc Billow b5c5c056f4 feat(fridge): expose discrete cooler temperature setpoint (#186)
RT42DG6630B1FZ is a single-door "cooler only" fridge that reports its
setpoint on /temperature/definite/cooler/vs/0 -- a vendor resource outside
both TEMP_CURRENT_GENERIC's '/temperature/current/' and TEMP_SETPOINT's
'/temperature/desired/' href prefixes, so it was entirely unbound and the
setting stayed app-only. Its supportedList (1/2/3/4/7 °C) isn't a
contiguous range, so this is modeled as a select over the device's own
live options rather than a NumberDesc that would let a user pick an
unsupported value like 5 or 6.
2026-07-29 13:11:07 +00:00
Marc Billow 74f34b91a5 feat(registry): route AVT-WW-TP1-23 air purifier boards (#190)
AVT-WW-TP1-23-AXX500 (AX053B810HGD) reported device_type 'unknown' with
empty oneUiVersion, falling back to common caps. It's the same BESPOKE
Cube Air lineage as A-VTWW-TP2-21-COMMON (issue #151), just with the
'-WW-' delimiter shifted one letter left ('A-VTWW-' -> 'AVT-WW-'), which
splits into an 'AVT'/'WW' token pair the existing whole-token 'VTWW' entry
can't see. Added 'AVT' as its own board token onto the same air_purifier
registry -- the resource surface (wind/strength fan, HEPA filter, air
quality sensors, alarms) already matched with zero unbound hrefs once
routed there, so no new capabilities were needed.
2026-07-29 13:06:36 +00:00
Marc Billow d83733a670 fix(airconditioner): scale legacy ARTIK051 cumulativePower correctly (#193)
ARTIK051_KRAC_18K-generation boards report /energy/consumption/vs/0's
cumulativePower in centiwatt-hours, not the plain Wh every other AC board
family reports -- confirmed against the reporter's own SmartThings-app
reading (raw 117430000 vs. the app's authoritative 1,174.30 kWh is exactly
a /100000 factor, not the shared wh_to_kwh's /1000 alone). Split
common.ENERGY_METER into generic/legacy variants on the airconditioner
registry, discriminated by the existing is_legacy_board() check, so every
other AC family keeps the unmodified shared capability.
2026-07-29 13:04:33 +00:00
Marc Billow e8df8f7b0c fix(config_flow): always retry historically-confirmed DTLS ports (#192)
The liveness sweep's ICMP-based verdict isn't trustworthy on every network
path -- a segregated-VLAN report showed it calling three closed ports live
while missing the one port a concurrent nmap scan found genuinely
open|filtered, which also happened to be one of our two historically
confirmed DTLS ports. Rather than trust a "not live" verdict against that
prior, always give PREFERRED_PROBE_PORTS a real handshake attempt even when
the sweep excludes them, bounded to at most those two extra attempts.

This is a stop-gap for the reported failure mode, not a full fix for the
sweep's underlying unreliability -- left a comment on the issue with the
diagnosis and flagged the sweep itself for a deeper redesign.
2026-07-29 12:53:56 +00:00
Marc Billow 7fe9e2a975 fix(registry): route TP1X_DA-AC-CAC boards to airconditioner (#191)
The 0.16.0 device-type simplification dropped oneUiVersion detection on
the assumption every device it typed was already reachable via a modelNum
board token. Cassette AC units (TP1X_DA-AC-CAC-01001_0000) were the one
exception -- they only ever resolved through oneUiVersion's "Air
conditioner" string, since 'CAC' was never added to the board-token
table -- so they silently fell back to common caps and lost their
climate entity.
2026-07-29 12:50:21 +00:00
Marc Billow 119a4f443c Bump version to 0.16.0 2026-07-29 04:15:41 +00:00
Marc Billow 9505405e45 Merge pull request #187 from mbillow/claude/issue-triage-429jtb
diagnostics: speculatively probe /device/1 and /device/2
2026-07-28 23:10:07 -05:00
Marc Billow 10d5c81d9a feat(diagnostics): speculatively probe /device/1 and /device/2
/oic/res's baseline-Interface response only lists resources with the
discoverable policy bit set, and a real dump (issue #177 follow-up,
TP1X_REF_21K) confirms /device/0's whole x.com.samsung.da.* tree is
registered without it -- so a second logical Device's Collection, if
one exists, would be just as invisible to /oic/res as /device/0 is.
Probe /device/1 and /device/2 directly instead: a plain non-mutating
RETRIEVE, tolerated-404 same as every other speculative read in this
module. Parsed with the same parse_device0_batch used for /device/0
itself, and folded into identity.raw so diagnostics can tell "checked,
found nothing" apart from "never checked".
2026-07-29 04:08:19 +00:00
Marc Billow 2b67dda5dc Merge pull request #185 from mbillow/claude/issue-triage-429jtb
diagnostics: capture /oic/res discovery links
2026-07-28 22:54:33 -05:00
Marc Billow 0c8d39fb73 feat(diagnostics): capture /oic/res discovery links
/oic/res is OCF's baseline resource-discovery endpoint: a unicast
RETRIEVE returns every href/Collection the connection hosts, not just
the one /device/0 seed path the coordinator polls. Relevant to the OCF
"Composite Device" model (issue #177) -- a single physical unit sharing
one IP/session across more than one logical Device, each exposed as its
own Collection resource (same rt shape as our own /device/0). Nothing
routes on this yet; captured alongside the existing /oic/p and /oic/d
reads so a report from a multi-unit device shows us whether its
firmware actually implements that model before any code assumes it does.
2026-07-29 03:53:27 +00:00
Marc Billow 03958dd994 Merge pull request #184 from mbillow/claude/issue-triage-429jtb
Issue triage: absence/motion AC sensors (#173), microwave reminders (#181), Python 3.13 pin
2026-07-28 22:37:55 -05:00
Marc Billow a7aea3764b feat(airconditioner): make absence/motion-detect enable switches writable
status on both /mds/absencepowersaving/vs/0 and
/option/motiondetectwind/stateful/vs/0 is a bare On/Off boolean, the same
shape already shipped writable elsewhere in this file (MUTE_ONCE,
AUTO_CLEAN, AIR_PURIFY) without a live-confirmed write either -- worst
case a wrong token no-ops. The paired mode selects (switchPowerSaveMode,
motion-detect modes) stay read-only: their behavioral effect on live HVAC
isn't inferable from the dump, same reasoning as ANOMALY_LOAD's mode field.
2026-07-29 03:34:18 +00:00
Marc Billow a6eb1c62d4 feat(microwave): add filter reminder / end signal reminder switches (#181)
Both FilterRemind_*/RemindBeep_* option-array tokens are already
present -- and both On and Off already confirmed -- on the existing
ME7500D fixtures (issue #152), so this is a straight sibling of the
Sound/Lamp switches rather than new discovery work. Gated with
exists_fn like Lamp since the MW7300B combi dump has neither token.

Doesn't address the rest of issue #181 (power-level slider,
non-reported cooking modes, 3-level light, child lock, send-to-
microwave) -- those need write-contract confirmation this dump
doesn't carry.
2026-07-29 02:55:47 +00:00
Marc Billow 90d79f117a build: pin minimum Python to 3.13 in pyproject.toml
README.md and requirements-dev.txt already document that the test
harness needs Python 3.13+ (pytest-homeassistant-custom-component
doesn't resolve below it), but only in prose. Add requires-python so
pip fails fast with a clear message on an older interpreter instead of
a wall of "Requires-Python >=3.13" version-list noise.
2026-07-29 02:52:38 +00:00
Marc Billow 2b24152276 feat(registry): cover absence-power-saving and motion-detect-wind on AC (#173)
Lennox-branded heat pump on the Samsung RAC board family (modelNum
TP1X_LNX-AC-RAC-01001_0000) already routes correctly via the existing
'-RAC-' token, but its dump has two resources no prior AC fixture
carried: /mds/absencepowersaving/vs/0 and
/option/motiondetectwind/stateful/vs/0. Bind both as read-only
sensors, matching the CURRENT_LIMIT/ANOMALY_LOAD precedent -- nothing
in the dump confirms write safety on live HVAC hardware.
2026-07-29 02:52:33 +00:00
Marc Billow 6f21699c37 Merge pull request #182 from mbillow/claude/device-detection-simplify-58rvo5
Simplify device-type detection: token table in, oneUiVersion out
2026-07-28 21:28:49 -05:00
Marc Billow 40c7033b1b docs(readme): update device-type routing and test setup
Three things this branch left stale.

"Adding a new appliance type" step 4 told contributors to key the registry
on the lowercased suffix of oneUiVersion and pointed at _type_key() for the
transform. Neither exists any more, and oneUiVersion no longer routes at
all. Describe the board-token table instead, including the two rules that
keep it a table: whole-token matching covers every delimiter spelling, and
an entry must name the specific device type rather than the board family
that contains it.

The repo-layout line for identity.py said "Reads device identity for type
detection". It has never fed type detection -- it reads /oic/p and /oic/d
for the HA device registry, and now also carries OCF's device-type
declaration into diagnostics.

The test setup installed pytest-homeassistant-custom-component and
homeassistant unpinned on top of requirements-dev.txt, which already pulls
both in at matching versions, and used whatever `python3` resolves to. On
3.12 or older nothing resolves and the install fails outright with a wall of
version-conflict output that doesn't name the real cause. Say 3.13+, drop
the redundant install, and note CI runs 3.14.
2026-07-29 02:27:00 +00:00
Marc Billow c9620b47b3 fix(diagnostics): redact the OCF device name; log description on unknown type
Two things a review of this branch turned up.

/oic/d's `n` is free text the owner sets from the SmartThings app, so it may
carry a person's name. Nothing in the /device/0 dump has ever exposed it --
it only became reachable when diagnostics started reporting /oic/d earlier
in this branch, which would have started carrying it into public issue
reports. Redact it. `rt`, the device-type signal the block exists for, is
untouched, and no /device/0 resource uses a bare 'n' key, so nothing else
changes.

The unknown-device-type warning logged only modelNum. That line is what a
user pastes into an issue, and modelNum alone can't identify a washer from a
dryer -- both report the shared DA_WM_ laundry board, and detection reads
the consumer-model code out of `description` for exactly that reason. Log
both fields.
2026-07-29 02:20:37 +00:00
Marc Billow cafd7d5afa refactor(registry): drop oneUiVersion from device-type detection
oneUiVersion looks like the signal you'd want -- the device naming its own
type, '7.0 Dishwasher' -- and it was the first thing detection consulted. It
never earned the position:

- Only 7 of 49 fixtures report it at all.
- All 7 resolve to the same registry from their modelNum board token alone.
- No device-support issue has ever been fixed by adding a mapping for it.
  Every one went through modelNum. The alias keys it needed in
  _REGISTRY_BY_KEY ('airpurifier', 'air_conditioner', 'hood') were
  speculative when the registries were first written and never used since.

So it bought a key-normalizing helper (_type_key), a lookup with a suffix
fallback (for_device), three alias keys, and a second config-flow step whose
only reason to exist was phrasing a sentence about oneUiVersion -- for a
signal that has never once been decisive.

Remove it from detection. It stays in diagnostics, where it's genuinely
useful: it names the firmware generation ('7.0 Air conditioner' is Tizen
Lite), which matters when triaging an issue.

Detection order was also duplicated in four places -- the coordinator, the
config flow's probe, the golden-regression harness, and the skill -- which
is how the harness and the shipped order drift apart. Collapse it into
by_type.resolve(resources), and call that everywhere.

The two "appliance type not recognized" config steps become one. They
differed only in whether they blamed a missing oneUiVersion, which is not a
distinction a user can act on, and never was.

Verified by the full suite (795 passing), including every golden regression
-- so entity output is byte-identical for all 49 device fixtures.

TestOneUiVersionIsNotConsulted locks in the premise rather than just the
outcome: for every dump that reports a oneUiVersion, the model strings alone
must still reach a registry. If a future device breaks that, the test says
so instead of the device silently losing half its entities.

Also note in requirements-dev.txt that Python 3.13 resolves the pinned
harness floor -- 3.12 and older resolve nothing and fail the whole install.
2026-07-29 02:14:13 +00:00
Marc Billow a863df1e59 refactor(registry): match board families by token table, not substring ladder
for_device_by_model() had grown to 21 sequential `if key is None` branches
and 102 comment lines against 59 lines of code -- 33 of the repo's 243
commits have touched this file. Most of that bulk came from one wrong
primitive: substring matching on a delimited string.

Samsung spells the same board family with either delimiter, so '_RAC_' and
'-RAC-' each needed their own rule, and 'ARTIK051_DONGLE_REF' (issues #77,
#83) matched no '_TOKEN_' spelling at all because REF lands at the end of
the pipe-prefix with no trailing underscore -- which is what
_model_num_segments() existed to work around. Which field a rule searched
(modelNum, or modelNum + description) was historical accident. Collisions
like WAC vs WA were resolved by one `if` physically preceding another,
invisible in the code and explained at length in prose.

Tokenize on any non-alphanumeric run, upper-case, and look the tokens up in
a flat table. Every delimiter spelling collapses to one entry, both fields
go through the same matcher in a documented order (modelNum, then
description, then the fuzzy consumer prefix), and specificity is a property
of the table rather than of line ordering.

Two behaviours are preserved deliberately:

- modelNum is matched before description, which is what keeps the legacy
  gas cooktop correct: it reports 'ARTIK051_GB_CT_001' (CT) alongside
  'ARTIK051_GLOBAL_COOKTOP' (COOKTOP, which otherwise means induction).
  It is the only known device whose two fields disagree.
- _consumer_model_key still splits on '_' only. Widening it to '-' would
  read the dishwasher's 'ADW-WW-RTL-24-AILITE' board segment as a bare 'WW'
  washer.

Verified identical: all 49 device fixtures resolve to the same registry
before and after, and every existing for_device_by_model test case passes
unchanged. The table also picks up two families that previously depended on
oneUiVersion alone (TP1X_DA-AC-AIR air purifiers, ADW dishwashers), so they
now survive firmware that omits it.

TestBoardTokenAmbiguity guards the one property the flat lookup needs --
that no real model string contains two tokens naming different device types
-- across the whole fixture corpus, so a newly added dump exercises it
automatically.

The skill gains a section on routing: what each detection stage is for, the
rules for adding a token (name the specific type, never the board family;
never add a delimiter spelling; two-letter tokens are a last resort), when
to reach for the consumer prefix or a resource signature instead, and the
measured stake -- an unrouted device loses roughly half its entities.
2026-07-29 01:57:10 +00:00
Marc Billow 288ba02cc5 feat(diagnostics): capture /oic/p and /oic/d identity
Device-type detection currently parses board part numbers out of
/information/vs/0's modelNum. OCF has a standard field for exactly this
question -- /oic/d's `rt` -- and read_identity() already fetches the
resource, but kept only `n` and threw the rest away. No captured dump has
ever included it either: /device/0 batch responses don't carry /oic/d, and
diagnostics didn't report it, so there's no evidence on whether real
hardware populates it usefully.

Keep `rt` as DeviceIdentity.device_types, keep both raw payloads whole
(we don't yet know which of their fields identify a type), and surface
them in diagnostics so incoming issue reports answer the question.

Nothing routes on it yet.

/oic/d and /oic/p identify the unit with bare two-letter keys -- 'di' and
'pi' -- as sensitive as the serial number redact.py already covers but far
too short to match on: 'di' alone is a substring of 'condition', 'display'
and 'dispenser'. Add a whole-key match alongside the substring rules.
2026-07-29 01:56:52 +00:00
Marc Billow 668aec401a Merge pull request #174 from firstof9/fix/microwave-issue-172 2026-07-28 17:57:45 -05:00
firstof9@gmail.com 1f331f3aa6 fix(registry): route microwaves without /information/vs/0 to microwave registry (#172)
Issue #172: Samsung Microwave units (ME8000T-/AA0) omit /information/vs/0 and have empty oneUiVersion, falling back to unknown device type. Route via /oven/vs/0 and MicroWave modes in supportedModes.
2026-07-28 13:29:57 -07:00
Marc Billow 498da49817 Merge pull request #171 from mbillow/claude/issue-triage-gating-gf6bg4
Fix unsound gating from #170, cover remaining fan-speed icons
2026-07-28 14:10:22 -05:00
Marc Billow bbf3f3f833 Revert unsound air-quality gating from #170, cover remaining fan-speed icons
An Opus review of merged PR #170 found the cleanLevel-scalar existence
gate on AIR_QUALITY doesn't hold up as a general rule: three fixtures
in this repo (air_purifier_device.json, air_purifier_vtww_device.json,
range_hood_device.json) carry genuinely populated Dust/FineDust/
SuperFineDust readings with no such scalar, so requiring it risks
silently dropping real air-quality readings on AC hardware this repo
hasn't seen yet. Reverted _has_sensor_type to item-type presence only
(as before #170) and moved the #166 fix to enabled_default=False on
all five entities instead -- same conservative, non-existence-gated
treatment already used for tropical_night_mode and the fridge/cooktop
precedents it was modeled on. Golden fixtures and tests updated to
match; the five sensors are bound-but-disabled on windfree/#17-style
boards again rather than unbound.

Also added icons for the AC fan_mode values #170 missed -- the raw
numeric labels ("1".."5") that TP1X_DA-AC-RAC-01001 and the window-AC
board report instead of turbo/max -- and swapped the whole fan-speed
icon family to mdi:fan-speed-1/2/3 for a more purpose-built look than
the generic speedometer, applied consistently to both the AC climate
card and the air purifier fan. Fixed motiondirect/motionindirect to
match core's smartthings integration's arrow pairing (previously
inverted).

Known limitation, not fixed here: enabled_default only affects newly
registered entities. Anyone who already has tropical_night_mode or the
five air-quality sensors enabled from #164 (a narrow window before
this fix, but real) won't see them auto-disable -- they'd need to
disable them by hand in Settings > Devices > Entities. A real fix
needs a one-time entity-registry migration, which this integration has
no existing infrastructure or test coverage for; scoping that felt
like its own follow-up rather than something to bolt on here.
2026-07-28 19:07:59 +00:00
Marc Billow 91b129282b Bump version to 0.15.0 2026-07-28 18:56:07 +00:00
Marc Billow 34fe991858 Merge pull request #170 from mbillow/claude/issue-triage-gating-gf6bg4
Fix AC entity gating from #164, add missing preset/mode icons (#166, #169)
2026-07-28 13:50:53 -05:00
Marc Billow 6d185e4e3d Match WindFree icon to the official smartthings integration's choice
HA core's bundled smartthings integration (the cloud counterpart to
this same Samsung AC feature set) uses mdi:weather-dust for its
wind_free preset rather than a generic windy icon -- a better fit for
a feature about avoiding direct airflow, not blowing harder. Match it
for both the AC climate preset and the air purifier fan preset.
2026-07-28 18:47:10 +00:00
Marc Billow 5f47fc0477 Add per-state icons for the remaining entities that render with none
HA only consults icon-translation state icons when the entity has no
static icon of its own (Entity.icon, if set, always wins -- see
homeassistant.helpers.entity's state_attributes construction). Audited
every entity with a labelled state/state_attributes catalog in
translations/en.json against its descriptor's icon= setting: every
select (cycles, courses, brightness levels, ...) and most sensors
already carry a fixed icon in code, so per-state icons there would be
silently shadowed. The three that don't -- air_purifier_fan's
preset_mode, machine_state, and connection_mode -- get one per value
here.
2026-07-28 18:31:31 +00:00
Marc Billow cfa82e8853 Add icons for AC preset/fan modes not covered by HA's built-ins (#169)
HA's core climate component already ships default icons for common
preset_mode/fan_mode values (eco, away, sleep, auto, low/medium/high,
...), but this integration's own values -- WindFree (nano/nanosleep),
Quiet, Smart, Speed, Long wind, the motion-aware direct/indirect
presets, Dry comfort, 2-Step, and the turbo/max fan speeds some boards
report -- fall outside that vocabulary and rendered with the generic
circle-dot fallback (the icon the #169 screenshot is missing). Adds
icons.json with an icon per value, mirroring the state-label catalog
these same values already have in translations/en.json.
2026-07-28 18:20:10 +00:00
Marc Billow 739881de16 Gate AC tropical night mode and air-quality sensors on real capability signals (#166)
Issue #166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) reported tropical
night mode, clean level, dust, fine dust, odor, and super fine dust
entities showing up even though the reporter's units have no such
physical features. All six were added in #164.

The Sleep_<N> options token backing tropical_night_mode is present in
every AC dump on record regardless of confirmed reality, so there's no
usable signal at boot time -- it's now registered but disabled by
default (matching the precedent already set by fridge.rack_count /
cooktop.paired_hood_model), letting units that do have it opt in.

/sensors/vs/0's item-type list has the same problem (all five types
always listed, permanently zero on this board), but there turned out
to be a real tell: a top-level x.com.samsung.da.cleanLevel scalar is
present only alongside genuinely populated readings on every dump on
record (tp1x_da_ac_rac_01011, the tp1x_da_ac_air air purifier fixture)
and absent on every all-zero ARTIK051_PRAC_20K dump, including both
#166 units and the original windfree/#17 fixtures this capability was
first verified against -- which, per their /information/vs/0, turn out
to be the same board revision as #166's units, so that "verification"
never actually proved a real sensor either. AIR_QUALITY's exists_fn now
requires that scalar, and the windfree/airconditioner golden fixtures
are updated to match (those five entities no longer bind there).
2026-07-28 18:12:02 +00:00
Marc Billow 6080d37f7d Merge pull request #167 from mbillow/claude/issue-triage-138-latest-y90g78
Issue triage batch: oven/AC/microwave/air-dresser fixes, 4 new device types
2026-07-28 10:21:28 -05:00
Marc Billow 71e026b804 Fix hot/warm href tiers dropped for no-entity coverage capabilities
discover() only emitted a BoundEntity per capability *entity*, so a
coverage-only Capability (entities=(), used to mark a href as handled
elsewhere -- e.g. the AC climate card's wind/strength, wind/direction,
temperature/control hrefs) produced zero rows. The coordinator computed
its hot/warm href lists by walking `bound`, so every such href's
poll_tier was silently discarded and it fell back to the ~30s summary
poll only -- no sub-poll cadence and never attempted for OCF OBSERVE.

This is the root cause of issue #166's "up to a minute" lag for
remote-driven fan-speed changes: /wind/strength/vs/0 carries poll_tier
'warm' via airconditioner.COVERAGE but never reached
_hot_hrefs/_warm_hrefs, so it wasn't in the OBSERVE-attempt href list
and only refreshed on the summary poll.

discover() now takes an optional tier_log(href, poll_tier) callback
fired for every href a capability matches, entities or not. The
coordinator uses it directly instead of deriving tiers from `bound`.
2026-07-28 15:08:12 +00:00
Marc Billow 65b0326d62 Merge remote-tracking branch 'origin/main' into claude/issue-triage-138-latest-y90g78
# Conflicts:
#	tests/test_airconditioner_capabilities.py
2026-07-28 14:51:29 +00:00
Marc Billow 6007505e6b Stop surfacing '_OFF'-suffixed placeholder alarm codes (#166)
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).
2026-07-28 14:43:21 +00:00
Marc Billow 5a73a25005 Address independent code review findings on PR #167
Two real bugs, both latent (no shipped fixture exercised them), plus a
consistency gap and a couple of correctness/DRY nits flagged by review:

- async_set_fan_mode resolved a fan_mode label against the static
  _FAN_TO_DEVICE reverse map before checking whether the resulting code is
  actually one of the unit's own supportedModes. A board using non-standard
  wind-strength codes while still spelling a standard-looking label in
  modesName (e.g. codes "31"-"33" named "Low"/"High"/"Turbo") would silently
  write a code ("1"/"3"/"4") the device never advertised. Now validates the
  static hit against the unit's own supported codes before trusting it,
  falling through to the live modesName scan otherwise.

- air_purifier.WIND_STRENGTH_FAN reused key='fan', the same key as FAN in
  the same registry -- BoundEntity's unique_id is built from key alone, not
  href, so a board reporting both hrefs would have one fan entity silently
  shadow the other. Renamed to 'wind_strength_fan' (translation_key
  unchanged). No shipped fixture reports both hrefs today, but the two caps
  living in the same registry made this a real latent hazard, the exact one
  AIRFLOW_GENERIC's own comment already documents and deliberately avoids.

- microwave.py's cooking_mode select still used a static, union-of-all-
  dumps mode list, even though both shipped microwave fixtures already
  report x.com.samsung.da.supportedModes on /mode/vs/0 -- the same shape
  oven._oven_mode_options was just built to prefer over exactly this kind
  of static list (issue #138's follow-up, this same PR's skill update).
  ME7500D advertises 4 modes; the select was offering 11. Applied the same
  live-first, static-fallback pattern.

- Added the issue #152 fixture the microwave lamp fix was missing (the
  SKILL.md step this PR itself added asks for one).

- climate.py's _legacy_airflow rebuilt a 2-key presence dict from
  coordinator.resource()'s truthiness, which collapses "href absent" and
  "href present with an empty {} rep" to the same falsy value -- while
  is_legacy_board (and discover()'s own binding) test key membership, not
  truthiness. Simplified to pass last_resources through directly, matching
  is_legacy_board's actual contract instead of a cheaper approximation of
  it, so the "can never disagree" claim in both docstrings is actually true.

- Hoisted the 'power' payload branch duplicated verbatim across
  _airflow_fan_write/_fan_write/_wind_strength_fan_write into one
  _power_write helper (registry/capabilities/air_purifier.py).

- Removed two now-unused imports (test_air_dresser_capabilities.py,
  test_air_purifier_vtww_fan.py) and replaced a tautological
  code-in-_DEVICE_TO_FAN check with one that actually exercises the live
  climate entity's fan_modes/fan_mode (test_climate_ac_modes.py).

- Fixed a pre-existing (not from this PR) no-op test on main --
  test_registry_reproduces_golden_state_keys_for_induction_cooktop computed
  golden/state_keys and never asserted on them.

756 tests pass.
2026-07-28 14:37:32 +00:00
Marc Billow dc3e1255d1 Ignore fridge /rm/control/vs/0 plumbing href (#165)
TP1X_REF_21K's EU region variant reports a bare resource-monitoring
poll-interval config (minPeriod in ms) the US variant doesn't -- the only
unbound href keeping the coverage-gap repair open. Door sensors, the
reporter's actual ask, were already covered generically by
fridge.DOOR_GENERIC/DOORS_FALLACK.
2026-07-28 14:27:15 +00:00
Marc Billow daa7546208 Add device support for Wind-Free 2-in-1 AC TP2X_FAC_BORA_21K (#150, #153)
This board (a floor-standing + wall-mounted indoor unit pair sharing one
outdoor unit and one local IP) reports no oneUiVersion and carries the
'_FAC_' modelNum token, which no existing routing rule matched -- it fell
back to 'unknown' and exposed nothing but a power switch, with no climate
entity generated at all (both issues' reported symptom).

Once routed to the existing airconditioner registry, it binds cleanly
against the exact same CLIMATE composite every other room-AC family uses --
same Cool/Dry/Wind/AIComfort mode vocabulary, same wind-strength/humidity/
filter/energy resource shapes already modeled. Only two hrefs are unique to
this board: /subdevices/vs/0 (an opaque paired-subdevice id list -- issue
#150 asked whether the second indoor unit can be controlled separately;
it can't through this or any other resource in the dump, the same
"remote device ids, not locally actionable" role as the existing
/remotedeviceinfo/vs/0 ignore) and /runn/vs/0 (a single undocumented int
with no supported-values list to interpret). Both added to _AC_IGNORED
rather than guessed at.
2026-07-28 14:09:21 +00:00
Marc Billow e568145deb Fix microwave lamp switch reading/writing the wrong tokens (#152)
The lamp SwitchDesc was modeled on issue #137's dump, which only ever
showed 'Lamp_Off' -- 'On' was never actually confirmed as the paired
value. Issue #152's ME7500D dump (same TP1X_DA-KS-MICROWAVE-01051 family)
is the first to report a real non-Off value, and it's 'Lamp_High' (a
brightness level), not 'Lamp_On'. So the switch always read as off
regardless of the device's real state, and toggling it on wrote a token
('On') the device has never been observed to accept -- matching the
reported "light control does not have any effect."

value_fn now treats any non-Off/non-absent value as on; write_fn now
writes back 'High'/'Off', the two tokens actually confirmed live, instead
of the never-confirmed 'On'.

The reporter's suspicion about the fan is unconfirmed and this dump's own
/hood/fanspeed/vs/0 shape already matches the no-separate-power case
fan.py's LocalThingsRangeHoodFan handles correctly (issues #137/#142), so
no fan change was needed here. A second dump attached in a comment on this
issue (model ME8000T, a large combi wall-oven with a very different mode
vocabulary) reports its own distinct gap and doesn't resolve to any known
device type at all -- that's a separate, substantial device-support task
left for its own follow-up rather than folded into this fix.
2026-07-28 14:03:56 +00:00
Marc Billow fcd34f73e5 Add device support for BESPOKE Cube Air A-VTWW-TP2-21-COMMON (#151)
This board reports no oneUiVersion and no modelNum token any existing
family routed on, so it fell back to unknown -- exposing nothing but power
even though most of its resources (air quality sensors, HEPA filter,
device-active, diagnosis, plumbing hrefs) are already handled generically
by the existing air_purifier registry via the '-VTWW-' modelNum fallback.

The one genuinely new piece is its fan: this board reports wind strength as
numeric codes ("87"/"89"/"90"/"91") on /wind/strength/vs/0 with a separate
modesName array ("SMART"/"MAX"/"WINDFREE"/"Sleep") giving the actual names,
unlike the existing TP1X_DA-AC-AIR family where supportedModes IS the name
list already. Generalized LocalThingsAirPurifierFan to resolve a mode code
through modesName when present (same live-label pattern as climate.py's AC
wind-strength fix, issue #155) instead of adding a second hardcoded fan
class, and added WIND_STRENGTH_FAN reusing the same 'air_purifier_fan'
translation catalog -- both board generations land on the identical
smart/max/windfree/sleep vocabulary already labelled there.

/mode/convenient/vs/0 is empty on this dump and added to COVERAGE alongside
the existing plumbing hrefs this board also shares with the TP1X_DA-AC-AIR
family.
2026-07-28 13:58:08 +00:00
Marc Billow a3d9cce164 Extend AirDresser support to DA_DF_TP2_20_COMMON (#157)
A different board generation from issue #162's DA_DF_A51_20_COMMON, also
carrying the '_DF_' modelNum token and so already routed into the
air_dresser registry -- but reporting two resources #162's board doesn't:

  /st/airdressercourse/vs/0 -- the course table id (Table_00), read the
    same way washer/dryer read /st/washercourse|dryercourse/vs/0. Wired up
    as AIR_DRESSER_COURSE's table_href and added to the global ignore list,
    mirroring that existing pair exactly.
  /airdresseroption/sanitize/vs/0 -- a genuine on/off setting (not covered
    by any existing capability), added as AIR_DRESSER_SANITIZE.

Introducing table_href means the course select's translation_key is now
always the table-lookup callable, so the bare 'air_dresser_cycle' catalog
entry added for #162 (only ever used when no table_href was passed) is
unreachable in every case and is removed -- both boards fall back to the
shared 'cycle' entry until their course tables get named, same as
washer/dryer's own precedent for an unrecognized table.
2026-07-28 13:50:59 +00:00
Marc Billow defeffd58d Merge pull request #164 from mbillow/pr-129-ac-additive-entities
Add beep, tropical night mode, filter hours/threshold, and air-quality entities for AC
2026-07-28 08:46:06 -05:00
Marc Billow 053e15bba6 Add device support for Samsung AirDresser DA_DF_A51_20_COMMON (#162)
This board reports no oneUiVersion and no modelNum token any existing
family routed on, so it fell back to the global unknown-device CAPABILITIES
set -- exposing only power/child-lock/start-stop-pause/delay/energy/machine
state, with /course/vs/0, /diagnosis/vs/0, and /washer/vs/0 all unbound and
no course/mode select at all (the actual reported gap).

Every one of those resources turns out to already be handled by the shared
laundry machinery: /diagnosis/vs/0 reuses dishwasher.DIAGNOSIS, and
/course/vs/0's cycle select works unmodified through laundry.cycle_options'
existing supportedOptions fallback (this board has no /wm/editcourse/vs/0
at all, so editCourseList never populates). /washer/vs/0 gets a new minimal
AIR_DRESSER_SETTINGS capability (wrinkle_prevent only) rather than reusing
dryer.DRYER_SETTINGS wholesale, since this device never reports
dryLevel/dryTime/dryerType at all and binding them would ship three
permanently-unavailable sensors.

Course codes aren't identified yet (no code->name mapping was reported), so
they render as their raw codes until named in translations, same as
dryer.py's precedent for unidentified codes.
2026-07-28 13:45:49 +00:00
Marc Billow aa9cdd83b7 Read AC fan speeds from the device's own modesName, not just 0-4 (#155)
TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN) reports /wind/strength/vs/0
supportedModes as "0"/"31"-"35" instead of the "0"-"4" scale climate.py's
_DEVICE_TO_FAN was built from. Only "0" matched, so fan_mode/fan_modes
silently dropped every speed but Auto -- exactly the reported symptom.

Rather than hardcoding a second numeric scale, codes _DEVICE_TO_FAN doesn't
cover now fall back to the device's own modesName label (parallel-indexed
with supportedModes), mirroring how preset_mode already resolves dynamically
off a device's own supportedModes instead of a per-model table. Boards using
the standard 0-4 scale are unaffected -- _DEVICE_TO_FAN is still tried
first, so existing auto/low/medium/high/turbo labels don't change.
2026-07-28 13:38:42 +00:00
Marc Billow 57e06c5ccb De-duplicate the legacy ARTIK051 AC board-generation test (#161)
capabilities/airconditioner.py's is_legacy_board() (renamed from the
private _is_legacy_board -- it's now a cross-module helper) and
climate.py's _legacy_airflow() implemented the same "does this board have
/airflow/vs/0 but no /wind/strength/vs/0" test independently, one via
literal href strings and the other via coordinator.resource() truthiness.
is_legacy_board() now uses the module's own HREF_AIRFLOW/HREF_WIND_STRENGTH
constants, and _legacy_airflow() delegates to it via a minimal two-key
presence dict (cheaper than a full last_resources snapshot copy) instead of
re-implementing the check, so the token entities and the climate card's
legacy read/write paths can't drift apart on which board generation is in
play.
2026-07-28 13:31:37 +00:00
Marc Billow 5c962801b4 Fix _tropical_night_value, which had the same stale _option_token assumption
Same issue as the beep fix in the previous commit: this also assumed
_option_token returned the full 'Sleep_<N>' token and tried to split
off the prefix itself. With the canonical value-half _option_token,
that always returned None. Read the value directly instead.
2026-07-28 13:29:49 +00:00
Marc Billow c8597532b0 Stop collapsing a genuine fivepercentHumidity=0 reading to unknown (#160)
#146's zero-as-"not measuring" carve-out was meant for ARTIK051 boards'
plain x.com.samsung.da.humidity field, which only populates while Air
monitoring is briefly on and zeroes out afterward. It was accidentally
applied to fivepercentHumidity too, which every other AC board relies on
and which has never been documented getting stuck at zero -- so a real 0%
reading on those boards silently became "unknown". Only the humidity
fallback field now collapses 0; fivepercentHumidity passes 0 through as a
real reading.
2026-07-28 13:28:46 +00:00
Marc Billow 00c1d78373 Merge main into ac-additive-entities, resolve conflicts with #146
Both PRs independently modeled the same /mode/vs/0 Volume_*/Sleep_*
option tokens: #129 as beep/tropical_night_mode, #146 (already merged)
as buzzer_volume/good_sleep gated to the legacy ARTIK051 board
generation. They also each defined a helper named _option_token with
different return semantics (full token vs. value half) in
non-overlapping parts of the file, so git didn't flag it as a
conflict even though the second definition silently shadowed the
first.

Keep a single _option_token (value-half, the one already used by
buzzer_volume/good_sleep/spi/etc.), adjust beep's read/write to that
convention, and gate beep/tropical_night_mode off the legacy board so
they don't duplicate buzzer_volume/good_sleep on ARTIK051_KRAC-class
devices. Added a regression test locking in the gate.
2026-07-28 13:28:20 +00:00
Marc Billow 1cdad7f84c Read oven cook modes live from the device instead of a hardcoded list
Follow-up to the issue #138 fix: rather than hand-adding
ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
to oven._OVEN_MODES, read them from the device's own /mode/vs/0
supportedModes when it reports one, falling back to the static
NV7000BS-era guess only when it doesn't. Matches the adding-device-support
skill's preference for device-reported option lists over hardcoded ones,
and the SelectDesc's write validation now checks the same live list it
displays instead of a separate static tuple.
2026-07-28 13:26:41 +00:00
Marc Billow 1fb27c30ae Skill: call out preferring dynamic select options over hardcoded lists
Prompted by issue #138's fix, which extended oven._OVEN_MODES with newly
confirmed modes instead of reading them from the device's own
supportedModes field via options_field -- the pattern laundry.py already
uses for buzzer/finish sound. Document that preference so future
device-support work reaches for options_field/a callable first and treats
a static tuple as a last resort, not the default.
2026-07-28 13:18:46 +00:00
Marc Billow 123455a873 Add missing oven cook modes for NE63A6511SS/AA range (issue #138)
The range/oven-combo device in issue #138 (NE63A6511SS/AA, no
/information/vs/0) already resolves cleanly to the range registry via the
issue #74 for_device_by_resources fallback, with zero unbound hrefs -- the
reporter was just on an older release (0.11.1) predating that fix.

However its /mode/vs/0 supportedModes advertises ConvectionRoast, KeepWarm,
BreadProof, AirFryer, Dehydrate, SelfClean, and SteamClean, none of which
were in oven._OVEN_MODES. Since range.py reuses oven.OVEN_MODE's SelectDesc
wholesale, those modes were silently rejected by the mode select's write
validation and missing from its options. Extend the confirmed mode list and
lock in a scrubbed fixture, golden, and test for this dump.
2026-07-28 13:12:40 +00:00
Marc Billow f5be651430 Merge pull request #146 from perseus177/artik051-krac-legacy-ac
Support ARTIK051_KRAC_18K air conditioners (issue #136)
2026-07-28 08:00:48 -05:00
blka 1bbecfa5c3 Drop /information/vs/0 version entities per review
mbillow's follow-up review: exposing read-only Software/Firmware (and
Outdoor/Touch IC) version strings is "data for the sake of exposing it" --
no user control, just clutter, and every other registry leaves
/information/vs/0 in the global ignore as identity plumbing. Conforming to
that stance rather than expanding the entity surface for no user story.

- by_type/airconditioner.py: revert to *ignored.IGNORED (no _IGNORED_LESS_INFO
  filter); drop airconditioner.INFO from the registry.
- capabilities/airconditioner.py: remove the INFO capability, the _info_version
  /_info_items_of_type /_has_info_version helpers, and HREF_INFORMATION.
- translations/{en,nl}.json: drop software_version, firmware_version,
  firmware_version_2/3, outdoor_unit_version, touch_ic_version.
- tests: drop the six INFO tests; golden regenerated for 8 AC + dehumidifier.
625 tests pass.
2026-07-28 10:07:39 +02:00
Marc Billow 6b28272647 Enhance README with GitHub badges
Added badges for GitHub stars, watchers, releases, and validations.
2026-07-27 23:26:01 -05:00
Marc Billow cc570533a0 Merge pull request #149 from mbillow/claude/ticket-127-device-coverage-xsb0m4
Distinguish not-yet-fetched stub reps from confirmed-empty ones (issue #127)
2026-07-27 22:25:38 -05:00
Marc Billow 6239706065 Keep entity._is_included's default gate permissive on confirmed-empty reps
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.
2026-07-28 03:18:23 +00:00
Marc Billow 33786ad584 Distinguish not-yet-fetched stub reps from confirmed-empty ones (issue #127)
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".
2026-07-28 02:57:30 +00:00
Marc Billow e60fdf26d2 Merge pull request #148 from mbillow/claude/triage-issues-144-147-yv80ab
Fix water-purifier hot-water lock mislabel, add range-hood after-run support
2026-07-27 21:31:31 -05:00
Marc Billow 9011f84787 Model after_run_progress as a percentage, fix Dutch after-run-progress name
runningProgress's own field name states its domain, so restore unit='%' and
state_class='measurement' rather than leaving it an opaque passthrough --
that hedge made sense for activationState (no supported-values list, no
write contract to invent) but not here, where the name itself is the
evidence.

"Nadraaien voortgang" also wasn't idiomatic Dutch (nouns don't stack that
way); "Voortgang nadraaien" matches how the rest of the catalog compounds
these names.
2026-07-28 02:29:01 +00:00
Marc Billow 0640ac3477 Fix hotwater_lock key-collision hazard, address Opus review of triage fixes
An Opus review of the previous commit found that giving the switchHotwater
fallback and LOCK.hotwater_lock the same key introduced a real bug:
adapter.flatten() (the source of coordinator.data, which every switch's
is_on reads) only ever honours exists_fn, never entity.py's implicit
own-field-presence default that gates plain registration. With only one
side of the pair gated, both descriptors still wrote the same key into the
flattened state dict, and whichever was processed last -- decided by
device-reported href order, not correctness -- silently won. Reproduced
with the existing coffee fixture: reversing resource order flipped
hotwater_lock from correct (False) to a stuck True.

Fixed by gating both sides symmetrically via a shared tri-state helper that
also treats an unfetched /status/lock/vs/0 stub as "outcome pending" rather
than "confirmed absent" -- otherwise the stub window let both descriptors
pass exists_fn at once, which would have registered two switch entities
with the same unique_id. The fallback also re-asserts its own field's
presence, a check it used to get for free before it shared LOCK's key.

Also addresses two smaller findings from the same review, both in the
range-hood after-run capability (#147): runningProgress's unit='%' was a
guess from a single "0" sample with no supported-values/range field to
confirm the domain -- inconsistent with treating activationState as
read-only for the same "don't guess" reason -- so it's now a bare
passthrough sensor; and entity_category='diagnostic' was dropped from the
two read entities since after-run is a feature the user actively watches
and cancels via the (correctly uncategorized) button, not passive
diagnostics.
2026-07-28 02:25:42 +00:00
Marc Billow b47dabeb31 Merge pull request #143 from mbillow/claude/microwave-fanspeed-coverage-nmvc86
Microwave vent fan and kimchi-refrigerator compartment coverage (#137, #142, #26)
2026-07-27 21:13:03 -05:00
Marc Billow cc3ce12aa4 Fix range hood fan power targeting and kimchi mode write validation
- _speed_zero_is_off now keys off the hood resource's own
  settableMinFanSpeed/supportedFanSpeed fields instead of asking whether
  the device has any power resource at all, so a combi appliance's cavity
  /power/0 can no longer be toggled off by turning off just the vent fan.
- async_turn_on() no longer resets an already-running fan to its lowest
  speed when called without a percentage.
- _has_separate_power() reads through the O(1) resource cache instead of
  copying the full resource snapshot on every property access.
- _kimchi_mode_write rejects values the compartment didn't advertise in
  supportMode instead of writing them blind.
- kimchi_ripening_status no longer lowercases its value, since it has no
  enum catalog entry to translate the lowercased token back through.
- Documented why KIMCHI_DOOR_GENERIC isn't deduped against the /doors/vs/0
  aggregate fallback on the one fixture that reports both.

Adds regression tests for the combi-appliance power targeting, the
already-on turn_on no-op, kimchi mode write validation, and a kimchi
select display/write casing round-trip; a translation-coverage guard for
kimchi_zone_mode codes mirroring the existing AC preset one.
2026-07-28 02:10:01 +00:00
Marc Billow 5055d6bb72 Fix water-purifier hot-water lock mislabel, add range-hood after-run support
Water purifier (#144, #145): /favorite/hotwater/vs/0's switchHotwater field
is a Locked/Unlocked hot-water lock, not a "favorite enabled" flag. It's the
same lock as LOCK.hotwater_lock, just surfaced through this href on boards
that don't populate /status/lock/vs/0's hotwaterLock -- the two now share
the hotwater_lock key/translation, gated so only one is ever active.

Range hood (#147): binds /afterrun/vs/0 (after-run activation state,
progress, and a cancel button), clearing the last unbound href for
AHD-WW-TP1-22-COMMON.
2026-07-28 02:00:57 +00:00
perseus177 55c7b88a8f fix(airconditioner): label the 2Step preset and test presets by their HA value
The legacy Comode codes resolve through the same dynamic resolver as a real
convenient resource, so Nano lands on the existing 'nano' preset (already
labelled WindFree) rather than on a 'windfree' value of its own -- the new
tests asserted the label instead of the value. 2Step had no catalog entry in
either language and would have surfaced as the raw code.

Also updates the existing five-percent-humidity test, which reached into the
descriptor's field/value_fn directly, to the rep_fn the fallback needs, and
covers the fallback itself.
2026-07-28 02:38:28 +02:00
Marc Billow 265d94eded Add kimchi-refrigerator compartment coverage (issue #26)
TP2X_REF_20K-class 3-compartment kimchi refrigerators report each
compartment's storage mode and ripening status/timer on
/status/kimchi/<slot>/vs/0, plus a top-compartment door sensor on
/kimchidoors/top/vs/0 -- all previously unbound. Bind them as pattern
capabilities (fridge.KIMCHI_ZONE, fridge.KIMCHI_DOOR_GENERIC), deriving
the per-compartment entity key and display name from the href's
top/middle/bottom segment, the same way DOOR_GENERIC/TEMP_CURRENT_GENERIC
already do.

Storage-mode option labels were translated directly from the reporter's
own SmartThings app screenshots rather than guessed from the raw device
codes or their English paraphrase, confirming the on-screen option order
matches supportMode's array order (including the freezer triplet's
-19/-21/-17°C -> Standard/Strong/Weak mapping).

Also tighten FLEX_ZONE's exists_fn: this device's /mode/vs/0 also
populates modes/supportedOptions, but with a token shape that never
overlaps (a "_[n]:[n]" suffix supportedOptions carries that modes never
repeats), so the existing "supportedOptions is nonempty" check let the
entity bind anyway and get stuck permanently on "unknown". Requiring an
actual resolvable value keeps it working for the RF9000/Bespoke-class
fridges it was built for while leaving it absent here.
2026-07-28 00:35:21 +00:00
perseus177 3032b0c032 test(airconditioner): lock in the ARTIK051_KRAC_18K surface
Fixture is a scrubbed diagnostics dump from the unit the writes and
calibrations were verified on; the issue #136 unit is the same model with a
slightly different token set (no Spi, FilterTime_5460, OutdoorTemp_81), which
the presence gating handles the same way.

Covers the pieces the golden's state_keys can't: that the token entities stay
off newer boards, that an absent token yields no entity, that humidity's zero
reads as unknown, that fan/swing/preset read and write through /airflow/vs/0
and the Comode token, and that a board with /wind/* and /mode/convenient/vs/0
still takes the resource paths.

Refs #136
2026-07-28 02:28:59 +02:00
perseus177 5b30099c42 feat(airconditioner): fan, swing, presets and option-token settings on ARTIK051 boards
This board generation predates every AC dump the registry was built from and
differs in three ways, all handled here behind presence checks so no other
family's behaviour changes:

* No /wind/* resources at all. Fan speed and vane direction share a single
  /airflow/vs/0 resource, whose speedLevel uses the same 0-4 scale as
  _DEVICE_TO_FAN and whose direction uses the same codes as _DEVICE_TO_SWING,
  so the existing maps are reused rather than duplicated. The resource reports
  no supportedModes, so the full scale is offered.
* No /mode/convenient/vs/0. The convenient-mode preset is a Comode_* token in
  /mode/vs/0's options, synthesised into a convenient-shaped rep so the
  existing dynamic preset resolver keeps working unchanged. Codes were learned
  by driving one unit through its cloud integration and reading the token back
  each time: Nano is what the app calls WindFree, plus Quiet/Comfort/2Step/
  Speed (Fast Turbo) and Off.
* Several settings that newer boards expose as dedicated resources are options
  tokens here: SPI, auto clean, air monitoring, beep volume, Good Sleep,
  outdoor temperature and filter time. Writes reuse option_write's single-token
  merge, the same mechanism the display light already uses on this href.

Newer families carry some of the same tokens *alongside* dedicated resources
for those settings, so the token entities are gated on this generation's
resource shape (/airflow/vs/0 present, /wind/strength/vs/0 absent -- the same
test the climate entity's fan/swing fallback uses, so the two can never
disagree). Without that gate they duplicated auto clean on TP1X/TP2X boards
and applied a calibration from this board to theirs.

Two calibrations, both from hardware rather than from the token names:
OutdoorTemp is offset by 55 (token 75 against a 20.3 C outdoor thermometer in
the same install, token 74 against a 19.4 C forecast; Fahrenheit fits far
worse), and FilterTime is tenths of an hour (token 1710 while the official
Samsung app displayed "171 hours 0 minutes" for the same unit's filter).
Whether filter time counts up or down is deliberately not claimed: it was seen
rising while the unit ran, which contradicts the app's "remaining" wording.

Humidity now falls back to the plain x.com.samsung.da.humidity field where
fivepercentHumidity is absent, still as one entity rather than two, and 0 reads
as "not measuring" rather than 0% -- on this board the field only carries a
reading (51%, matching the same unit's cloud integration) while Air monitoring
is on, which the unit switches back off by itself after about a minute.

Fan, swing, preset, SPI and beep-volume writes were confirmed by read-back on
hardware. Good Sleep's upper bound is a guess (only 0 has been observed), and
/airflow/0 -- the OCF-standard mirror of the vendor resource -- is ignored
rather than modelled, since air_purifier.py found the opposite reliability
ordering between these two hrefs on its own family.

Refs #136
2026-07-28 02:28:33 +02:00
perseus177 75f2be7aaf fix(registry): resolve ARTIK051_KRAC_18K to the airconditioner registry
Room air conditioners on the ARTIK051 board (ARTIK051_KRAC_18K, issue #136)
report no oneUiVersion and carry a '_KRAC_' token in modelNum. The existing
'_RAC_' check can't see it -- the 'K' sits between the underscore and 'RAC' --
and the consumer-prefix fallback only covers washers/dryers/dishwashers, so
these units fell back to 'unknown': 8 of their 19 resources ended up unbound
and the device exposed nothing but a power switch.

Same ARTIK051 board family as the '_TVTL_' air purifier handled just below.
2026-07-28 02:28:13 +02:00
Marc Billow e9281aaead Bind microwave built-in vent fan's /hood/fanspeed/vs/0 (issues #137, #142)
Combi microwave units report their vent fan on the same resource shape a
standalone range hood uses, so reuse range_hood.HOOD_FAN directly in the
microwave registry. Unlike a standalone hood, this board has no sibling
/power/0 or /power/vs/0 resource, so LocalThingsRangeHoodFan now falls
back to treating fan speed 0 as the off state when no separate power
resource is present. Also gate HOOD_FAN's automatic_operation sensor on
field presence, since this board doesn't report it.
2026-07-28 00:18:38 +00:00
Marc Billow c19800ac00 Merge pull request #135 from QuiteYellow/fix/stale-dtls-session-fixed-source-port
Bind a fixed DTLS source port per device to evict stale sessions
2026-07-27 18:40:36 -05:00
Marc Billow ed5e8e57c8 Merge pull request #140 from mbillow/claude/v0-13-0-issue-triage-9zfjbj
Split microwaves into their own device type instead of the oven registry
2026-07-27 17:10:10 -05:00
Marc Billow f063666291 Merge pull request #139 from mbillow/claude/air-purifier-fan-controls-ananzq
Add real fan-speed control for ARTIK051_TVTL air purifiers (issue #56)
2026-07-27 17:06:59 -05:00
Marc Billow 5af9d951c6 Simplify README microwave row label 2026-07-27 22:05:54 +00:00
Marc Billow a1f14cd633 Split microwaves into their own device type instead of the oven registry
Microwaves (combi and plain) were routed onto the oven registry (issue
#121), which meant entities carried oven-flavored keys (oven_state,
oven_mode, oven_setpoint) and inherited oven-specific behavior that's
wrong for this family: a 30-270C setpoint range instead of this family's
actual 40-200C, a cooking-mode list missing MicroWave/MicroWaveGrill/
MicroWaveConvection/KeepWarm entirely, and a lamp switch that read/wrote
the oven's 'UpperLamp' option token instead of this family's 'Lamp' token.

Adds a microwave device type (by_type/microwave.py,
capabilities/microwave.py) that reuses the oven board family's shared
operational-state/door/connected/recipe-cook capabilities but defines its
own cooking-mode, setpoint, and cavity capabilities with the corrected
bounds/vocabulary, plus a new power_level sensor for the cavity's Watt
setting that was previously unexposed.
2026-07-27 22:05:54 +00:00
Marc Billow 2be742489b Fix airflow fan power-href preference and unique_id collision risk
Opus review of the previous commit caught two real bugs. The airflow fan's
power writes preferred /power/vs/0, copied from the TP1X fan class -- but
that order is only harmless there because TP1X never reports /power/0 at
all. This family's dumps carry both hrefs, and the power_switch entity is
unconditionally bound to /power/0 when present, so the fan was writing to
a different resource than power_switch reads/writes, leaving the two
entities disagreeing until the next poll. Flipped to prefer /power/0,
matching the range hood's fan and common.POWER_GENERIC.

Also renamed the new FanDesc's key from 'fan' to 'airflow_fan': BoundEntity
unique_ids are derived from key alone, not href, so it collided with
air_purifier.FAN's own 'fan' key on the (currently unobserved, but
unenforced) possibility of a board reporting both.

Added a platform-level test file covering the power-href preference and
percentage<->speed-code mapping, mirroring test_range_hood_fan.py's harness.
2026-07-27 21:48:23 +00:00
Marc Billow d4583b3240 Add real fan-speed control for ARTIK051_TVTL air purifiers (issue #56)
/airflow/0's speed was left read-only because the first round of diagnostics
wasn't conclusive (0 for both Auto and High, 3 for Low/Medium and Sleep) --
likely because all five dumps were captured within about a minute of each
other, faster than this integration's own poll cycle could settle each
change. A second round, captured 60-90s apart per setting on two independent
units, confirmed a clean monotonic 0-4 mapping across Auto/Sleep/Low/Medium/
High instead.

Builds an ordered-speed fan off that confirmed range, the same SET_SPEED
shape as the range hood's fan -- this board never self-reports a
supportedModes-style label list, so there's no named-preset table to
preserve, just percentage steps over the raw code. /airflow/vs/0's vendor
speedLevel stays a read-only fallback since it was unreliable in that same
second round.
2026-07-27 21:32:44 +00:00
blka b18500dec3 Merge upstream/main into ac-additive-entities
Conflicts resolved:
- capabilities/airconditioner.py: keep my NumberDesc/SelectDesc imports +
  upstream's hoist of _filter_usage_percent -> common.filter_usage_percent
  (local def removed by upstream's auto-merge; AIR_FILTER.air_filter_usage
  now references the common helper). Upstream's ANOMALY_LOAD, HREF_WIND_OSCILLATION
  and oscillation climate-write kind kept; my beep/tropical/INFO/AIR_QUALITY/
  threshold-Select/filter-hours kept. Both change sets coexist.
- translations/{en,nl}.json: switch section -- keep both my 'beep' and
  upstream's display/pet_filter_activation/auto_empty/dustbin_auto_close/
  uvc_intensive_mode entries.

Regenerated the 8 AC + dehumidifier goldens (+ the 2 new upstream AC fixtures
ara_ww_tp1_22, windfree_oscillation) against the merged code. 631 tests pass.
2026-07-27 22:36:06 +02:00
blka 51fa1c5ea8 Address review: per-item versions, writable threshold, unit_fn, gating
Per mbillow's review on PR #129 (CHANGES_REQUESTED). Items 1-7 + smaller.

1. INFO: expose each /information/vs/0 version item per (type, ordinal) instead
   of collapsing to a single first-Firmware value. Boards carry 1-3 Firmware
   items (separate MCUs) plus an Outdoor unit and (window AC) a Touch IC item;
   each is a distinct version string. _info_version(items, type_, ordinal)
   + _has_info_version exists_fn gate, so an item with no number (tp2x_rac_20k's
   second Firmware) suppresses the entity rather than binding unknown.

2. air_filter_threshold is locally writable, not cloud-only -- confirmed live
   on ARTIK051_PRAC: POST filterDesiredUsage=700 to /filter/airdustfilter/vs/0
   -> 2.04, read-back 700, persists; restored to 500. Converted from a
   read-only sensor to a SelectDesc keyed to the device's
   supportedFilterDesiredUsage enum (options_field), with a write_fn that
   POSTs the scalar field. Only binds where the enum is advertised; boards
   without it leave this writable field unexposed rather than guess the valid
   set (don't-guess).

3. air_filter_usage_hours uses unit_fn reading filterCapacityUnit ('Hour'->'h')
   instead of a hardcoded unit, so a board advertising a different unit doesn't
   mislabel a duration statistic.

4. air_filter_usage_hours state_class is total_increasing, not measurement --
   filterUsage is a lifetime hour counter that resets on filter replacement.

5. INFO and air_filter_threshold now carry exists_fn (AIR_QUALITY already did),
   so they don't bind a permanently-unknown entity when their item/field is
   absent. Also fixes the caww_tp2 golden nuance (filterDesiredUsage absent
   -> no threshold key, matching what HA would actually create).

6. AIR_QUALITY: CleanLevel is corroborated as numeric by a top-level
   x.com.samsung.da.cleanLevel scalar (tp1x_da_ac_rac_01011 reports both as
   '1'), so clean_level is now an int measurement; odor/dust/fine_dust stay
   string diagnostics. Reinstated the 2-element-array ambiguity note (Dust/
   FineDust/SuperFineDust report ['0','0']; v[1] meaning unconfirmed, v[0]
   taken as the reading). SuperFineDust is now modeled for consistency with
   Dust/FineDust (same shape), rather than skipped without reason.

7. _beep_write restores the last non-Mute Volume level on 'On' instead of
   forcing Volume_100, so an intermediate setting (e.g. Volume_50 set via
   the cloud) survives an off/on cycle; falls back to 100 when no prior level.

Smaller: test_air_quality now asserts the tp1x_da_ac_rac_01011 clean_level==1
non-zero reading (the one value_fn-regression catch in the corpus); renamed
by_type's _AC_IGNORED -> _IGNORED_LESS_INFO to resolve the two-meaning
collision with the capabilities module's _AC_IGNORED (href strings); golden
regenerated for the 8 AC fixtures + dehumidifier.

612 tests pass.
2026-07-27 22:22:56 +02:00
Jack Nagy 0ebcfb00ff fix: bind a fixed DTLS source port per device to evict stale sessions
When HA restarts without a clean DTLS close_notify (crash, host reboot),
the appliance keeps an orphaned DTLS association keyed to the client's
(IP, source port). Reconnecting from a fresh ephemeral port looks like a
new peer, so the device holds the orphan until its own timer reaps it,
which is 5 to 15 min on always-on appliances (fridges), during which the
new session's first reads hang. This is the root cause behind the repeated
"DTLS handshake timeout" reconnect storms on always-on devices (#119).

Bind a deterministic source port per device so every reconnect re-handshakes
over the same 5-tuple, which the device must treat as a rebooted peer and
evict the old association for (RFC 6347 §4.2.8). Recovery drops from a
device-timer wait to a single handshake.

The port must be stable across restarts and unique per device on the HA host
(the library socket is unconnected, so a shared source port would cross-
deliver datagrams). _local_source_port() uses the host's last IPv4 octet as
the offset from DTLS_LOCAL_PORT_BASE (unique on a /24), with a CRC32 fallback
for non-IPv4 hosts.

Requires smartthings-local >= 0.1.1, which adds DtlsCoapSession(local_port=).
The fix is backwards compatible upstream: local_port defaults to None
(previous ephemeral-port behaviour).

Root-caused and verified upstream in QuiteYellow/SmartThings-Local#14
(bench-verified on oven + dryer, field-verified on an always-on fridge
across repeated restarts).
2026-07-27 19:17:39 +01:00
Marc Billow 7c68d478af Bump version to 0.13.0 2026-07-27 14:54:26 +00:00
Marc Billow 3c15ca810b Merge pull request #132 from mbillow/claude/triage-issues-111-127-on5l50
Triage issues #111-#131: AC/oven/air-purifier detection fixes, new vacuum-station type, reconnect log noise
2026-07-27 09:46:11 -05:00
Marc Billow e9eb38740d Deduplicate ISO-timestamp parsing and document the new device type (review follow-up, issue #131)
- vacuum_station._parse_iso_utc was a verbatim copy of
  water_purifier._parse_iso_utc; promoted to common.parse_iso_utc and
  pointed both families at it. Also made it tzinfo-aware rather than
  unconditionally overwriting with UTC -- harmless today since every
  dump seen is a bare or Z-suffixed UTC timestamp, but a board that
  ever emits a real offset would otherwise have it silently clobbered.
- Added the new vacuum_station type to the README's supported-appliance
  table, and noted that combi microwaves route through the oven
  registry.
2026-07-27 14:43:02 +00:00
Marc Billow f37c9ec4ef Fix air-purifier fan power write and silent preset-mode rejection (review follow-up, issue #130)
- The fan's power write hardcoded /power/vs/0 while is_on already read
  /power/0 as a fallback -- a board reporting only the OCF resource
  would show correct state but silently no-op on every turn-on/off.
  Mirrors LocalThingsRangeHoodFan's existing _power_payload pattern:
  target whichever power href the board actually reports.
- async_set_preset_mode fell off the loop silently on an unmatched
  mode with no log and no error, unlike the rest of this codebase's
  write-rejection handling. Logs a warning now.
- Deduplicated HEPA_FILTER's usage-percent calculation, which was an
  inline reimplementation of airconditioner._filter_usage_percent;
  promoted the shared logic to common.filter_usage_percent and pointed
  both families at it.
- Gave air_purifier.SOUND_MODE its own translation_key instead of
  defaulting to the same catalog entry laundry.SOUND_MODE uses. That
  entry's state table is {voice, tone, mute}; this board's is
  {mute, buzzer} -- sharing it left 'buzzer' with no label.
2026-07-27 14:42:34 +00:00
Marc Billow d26e7b957b Fix unreachable reconnect-warning threshold (review follow-up, issue #119)
The 60s/5-reconnect window from the original fix could never actually
fire: consecutive reconnect attempts are never closer together than one
summary poll interval (30s) plus the 5s reconnect pause, so at most ~2
timestamps can ever land inside a 60s window regardless of how unhealthy
the connection is. That silently downgraded every reconnect to INFO
permanently, including the persistently-broken case the change was
supposed to still surface at WARNING.

Widen to a 300s window with a threshold of 3, which is reachable under
sustained failures and still a reasonable proxy for the README's
"actually broken" case.
2026-07-27 14:41:43 +00:00
Marc Billow b31f4f0c40 Merge pull request #128 from pedroperosin/fix/climate-set-temperature-hvac-mode
Honour hvac_mode in the AC climate set_temperature
2026-07-27 09:30:19 -05:00
Marc Billow 8b44fd5710 Add device support for the stick-vacuum clean/auto-empty station (issue #131)
A-VSKR-TP1-22-VS9500AL connects successfully but its dump shows no
vacuum-body state at all -- no suction level, no battery, no cleaning
mode -- only the clean/auto-empty station's own dustbag, dustbin
auto-empty settings, and UV-C sanitizing-cycle status. This strongly
suggests the WiFi/DTLS module lives in the station, not the handheld
stick, so the station is the only "device" this integration's local
API can reach at all.

New vacuum_station device type (these hrefs share nothing with any
existing family, so there's no shared-href ambiguity to resolve
against another type) routed via a new '-VSKR-' modelNum fallback.
Binds with zero unbound hrefs: dust-bag full/usage sensors, auto-empty
and dustbin auto-close switches, a discharging-time select, and
clean-station status including UV-C intensive mode, operation time,
and finished/emitted timestamps. A couple of fields with unconfirmed
exact semantics (stick_status, dustbag_usage's unit) are exposed as
plain diagnostic values rather than an asserted binary/percentage
meaning.
2026-07-27 14:11:06 +00:00
Marc Billow 4e1eb7d03d Add fan-mode control for TP1X_DA-AC-AIR air purifiers (issue #130)
This newer board family reports fan modes (Smart/Max/Mid/WindFree/Sleep)
directly on /mode/vs/0's top-level modes/supportedModes fields, unlike
the older ARTIK051_TVTL family this registry already supported, which
packs everything into an options[] array with no usable fan-speed
selector at all (see the module docstring's Comode_Off finding). Both
board generations share the /mode/vs/0 href, so the existing MODE
capability and a new FAN capability are discriminated by a match_fn
checking for the top-level supportedModes field, rather than adding a
new device type.

The fan entity only exposes PRESET_MODE, not an ordered percentage --
WindFree/Smart/Sleep are named behaviors, not "faster/slower" positions
relative to Max/Mid, matching how the AC family's own named convenient
modes are modeled as a preset rather than a speed number.

Also picked up the rest of this board's previously-unbound hrefs while
in there (display, HEPA filter, panel status, pet-filter mode, sound
settings), reusing airconditioner.DISPLAY_LIGHT and
airconditioner.MUTE_ONCE for the two hrefs identical to the shared
DA-AC- board family, since the "incomplete capability coverage" repair
was firing on more than just the fan gap the issue described.
2026-07-27 14:08:32 +00:00
Marc Billow e44fef7085 Route combi microwaves onto the existing oven registry (issue #121)
TP1X_DA-KS-MICROWAVE-01041 (MW7300B) reports no oneUiVersion and an
unrecognized consumer token, so it fell back to 'unknown' and only got
common capabilities. It shares the same '/oven/vs/0' cavity resource
and '/mode/vs/0' cook-mode shape (Convection/AirFryer/Grill/MicroWave*)
as the wall oven already supported, so this reuses that registry via a
new '-MICROWAVE-' modelNum fallback rather than adding a new device
type. The only href it didn't already cover was /recipe/cook/vs/0, an
empty quick-recipe-display blob bound with no entity per the 'don't
guess' rule.
2026-07-27 13:41:36 +00:00
Marc Billow 6f10a3b796 Support 2-axis wind oscillation and overload-protection status on newer WindFree boards (issue #126)
Newer TP1X_DA-AC-RAC-01011_0000 firmware (Bespoke AI WindFree Deluxe,
AR60H10D1JWNME) drops /wind/direction/vs/0 entirely and reports swing
via a separate vertical/horizontal Swing|Fix pair on
/wind/oscillation/vs/0 instead, which left swing_mode/swing_modes
silently empty and the href unbound. climate.py now falls back to the
oscillation resource when /wind/direction/vs/0 is absent, mapping the
same off/vertical/horizontal/both vocabulary the existing swing control
already uses.

Also binds the board's /anomalyload/vs/0 overload-response resource as
read-only diagnostic sensors (operation state + mode) -- the same
"don't guess" precedent as the existing CURRENT_LIMIT capability, since
nothing in the dump confirms the exact behavioral difference between
its 'Alarm' and 'PowerSaving' modes or whether toggling it is safe on
live HVAC hardware.

Most of the other gaps this issue reported (Fan-only mode, WindFree
preset labels, target-temperature channel selection, power on/off via
the vendor resource) turned out to already be fixed by the just-merged
cool-only global RAC work.
2026-07-27 13:41:04 +00:00
Marc Billow 2c8a765036 Downgrade a lone poll-failure reconnect to info (issue #119)
Samsung's firmware occasionally drops the DTLS session briefly --
normal appliance-side behavior per the README's "Known device
behavior" section -- so the coordinator recovering from that on its
own doesn't need a WARNING. Only escalate once reconnects pile up
within a trailing 60s window (5+), matching the README's own "more
than a handful per minute" definition of an actually broken
connection.
2026-07-27 13:40:32 +00:00
Marc Billow f62b4aeec8 Detect ARA-WW-TP1-22-COMMON wall-mount RACs as airconditioner (issues #115, #116, #117, #120)
AR10/13/18BYEAAWKNME report no oneUiVersion and no '_RAC_'/'-RAC-'
token at all, so for_device_by_model() fell through to 'unknown' and
every href went uncovered. The board carries the same TP1X-class
resource surface as every other room AC already supported (mode/
convenient/wind/temperature/power/filter/humidity), so this reuses the
existing airconditioner registry via a new 'ARA-WW-' modelNum fallback
rather than adding a new device type -- confirmed against all four
reporters' dumps binding cleanly with zero unbound hrefs.
2026-07-27 13:40:12 +00:00
Marc Billow 46dd91ce32 Add regression coverage for NE8300D range (issue #112)
TP1X_DA-KS-RANGE-0102X already resolves via the '-RANGE-' modelNum
fallback and /cooktopmonitoring/vs/0 already binds through
range.COOKTOP_MONITORING, so this model binds with zero unbound hrefs
today -- add a fixture to lock that in.
2026-07-27 13:39:51 +00:00
Marc Billow 085e80c92c Add regression coverage for WA55A7700AV washer (issue #111)
The DA_WM_TP1_21_COMMON board family already routes through the 'WA'
consumer-model-prefix fallback and binds cleanly against the washer
registry with zero unbound hrefs, but no fixture locked that in for
this specific board generation -- add one.
2026-07-27 13:39:30 +00:00
Pedro a9f21982e7 Honour hvac_mode in the AC climate set_temperature
HA's set_temperature service forwards an optional hvac_mode to the entity, and
the entity is expected to apply it. The AC climate entity ignored it and only
wrote the setpoint, so a set_temperature call carrying hvac_mode (e.g. a
dashboard "turn on to Auto 24" button) set the temperature but never changed the
mode or powered the unit on. Apply the mode first -- which powers the unit on
when it was off -- then the setpoint.
2026-07-27 09:57:31 -03:00
blka 3da01f80d0 feat(airconditioner): add beep, tropical night, filter hours/threshold, air-quality, sw/fw version
Layer the ARTIK051_PRAC additive entities onto the upstream registry on a
fresh branch (additive-only; the round-1 display-light/mute-once/WindFree
work was independently shipped upstream and is not redone here).

New entities, all driven by single-token option_write or item reads:
- beep (switch): Volume_Mute/Volume_100 option token; single-token merge --
  a full options RMW reverts on ARTIK051_PRAC. Cloud counterpart:
  samsungce.airConditionerAudioFeedback (on/off only; level is cloud-only).
- tropical_night_mode (number 0-16): Sleep_<N> option token. Cloud:
  custom.airConditionerTropicalNightMode.
- air_filter_usage_hours / air_filter_threshold (duration sensors, h):
  raw filterUsage count and filterDesiredUsage alarm threshold. Threshold
  SET is cloud-only (samsungce.dustFilterAlarm); local read-only.
- clean_level / odor / dust / fine_dust (diagnostic sensors): /sensors/vs/0
  items[]. No unit advertised on the resource, so no device_class until a
  populated reading + unit is observed (don't-guess rule). SuperFineDust
  intentionally not modeled.
- software_version / firmware_version (diagnostic sensors):
  /information/vs/0 items[]. The href is globally ignored as identity
  plumbing; the AC registry drops that ignore so INFO is the sole cap on it.

Translations: en.json + nl.json (every-language-mirrors-english invariant).
Golden regression: regenerated the 8 AC fixtures + dehumidifier (reuses
AIR_FILTER) for the new state keys.

608 tests pass.
2026-07-27 14:43:07 +02:00
Marc Billow ef23f5ec32 Merge pull request #114 from mbillow/claude/pr91-finish
Add cool-only global RAC support: WindFree, Auto mode, and display light
2026-07-27 00:35:48 -05:00
Marc Billow 5547c401ed Address independent review findings on the RAC finish-up branch
- Pin the FAN_ONLY reverse-write fallback to 'Wind' (the original single
  spelling) instead of letting it silently flip to 'Fan' just because
  'Fan' was added second to the dict -- _device_code_for_hvac() resolves
  the code from a unit's own supportedModes first, so this dict is only
  a fallback for a unit reporting none at all, and that fallback
  shouldn't change behavior as an unintended side effect of insertion
  order.
- Add direct tests for _preset_to_ha() (pure function, previously
  untested) and the FAN_ONLY fallback pin.
- Cross-reference the AC family's inverted Light_On/Light_Off polarity
  against air_purifier.py's plain-polarity use of the same token name on
  the same resource name, so a future refactor doesn't assume they're
  the same thing.
- Fix a one-column continuation-line misalignment and a stale PR-number
  reference in a comment.
2026-07-27 05:11:58 +00:00
Marc Billow fa8f1b8675 Add cool-only global RAC support: WindFree, Auto mode, display light
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).
2026-07-27 05:00:06 +00:00
Marc Billow af00c2b938 Merge pull request #110 from mbillow/claude/device-support-issue-107-coffee
Add coffee-maker capabilities to the water purifier registry (issue #107)
2026-07-26 23:40:03 -05:00
Marc Billow 1177f0ed64 Merge pull request #109 from mbillow/claude/device-support-issue-106-wa-washer
Add WA (top-load washer) consumer-model prefix (issue #106)
2026-07-26 23:38:19 -05:00
Marc Billow 35775c3d4a Add coffee-maker capabilities to the water purifier registry (issue #107)
Some TP2X_WATERPURIFIER_20K units are coffee-capable and expose five
resources issue #90's original dump never had:
/favorite/coffee/vs/0, /favorite/hotwater/vs/0,
/brand/recipe/info/vs/0, /coffee/custom/recipe/vs/0,
/recipe/coffee/vs/0, and /recipe/coffee/deletion/vs/0.

Adds FAVORITE_HOTWATER (a switch + select pair on
/favorite/hotwater/vs/0, mirroring the existing FAVORITE_CAPACITY
pattern) and COFFEE (a switch + status sensor on
/favorite/coffee/vs/0). The remaining four hrefs are static
capability-advertisement blobs or empty on every dump seen so far --
no live "current recipe" or "current custom slot" field to expose --
so they're added to the ignored coverage list per the 'don't guess'
rule rather than modeled speculatively.

Confirmed against the issue #107 diagnostics dump with zero unbound
hrefs.
2026-07-27 04:19:19 +00:00
Marc Billow 8ac1d11d17 Add WA (top-load washer) consumer-model prefix (issue #106)
WA8000T reports no oneUiVersion and used the 'WA' consumer-model
prefix, unmapped in _CONSUMER_PREFIX_TO_KEY (only WW/WD/WF/WV were
covered), so it fell into the unknown-device-type fallback.

Adding a bare 'WA' entry collided with the unrelated '_WAC_' (Window
Air Conditioner, issue #87) board-family token: some devices report
description == modelNum, so 'WAC' shows up as its own description
segment and 'WAC'[:2] == 'WA' matched the new washer prefix before the
more specific '_WAC_' modelNum check ever ran. Fixed by reordering
for_device_by_model() to check board-family modelNum tokens first and
the fuzzier 2-letter consumer-model-prefix scan only as a fallback,
rather than patching the prefix matching itself (an earlier attempt --
requiring a digit immediately after the prefix -- broke issue #79's
real DVE50A8800 case, which has no digit there either). Added a
regression test pinning the WAC/WA disambiguation directly.

Confirmed against the issue #106 diagnostics dump with zero unbound
hrefs.
2026-07-27 04:15:08 +00:00
Marc Billow b3a9192088 Merge pull request #98 from mbillow/claude/device-support-issue-86-cooktop
Add standalone induction-cooktop support (issue #86)
2026-07-26 23:04:51 -05:00
Marc Billow 634f367e96 Merge remote-tracking branch 'origin/main' into claude/device-support-issue-86-cooktop
# Conflicts:
#	custom_components/localthings/registry/by_type/__init__.py
2026-07-27 04:04:19 +00:00
Marc Billow 482e238a7a Merge pull request #102 from mbillow/claude/device-support-issue-77-freezer
Device support issue #77: freezer
2026-07-26 23:03:59 -05:00
Marc Billow f31cee206b Merge remote-tracking branch 'origin/main' into claude/device-support-issue-77-freezer
# Conflicts:
#	custom_components/localthings/registry/by_type/__init__.py
#	tests/test_by_type.py
2026-07-27 04:03:38 +00:00
Marc Billow 14b5a7cb68 Merge pull request #96 from mbillow/claude/device-support-issue-88-dehumidifier
Add device support for Samsung dehumidifiers (issue #88)
2026-07-26 23:00:33 -05:00
Marc Billow 8628ae6864 Merge remote-tracking branch 'origin/main' into claude/device-support-issue-86-cooktop
# Conflicts:
#	custom_components/localthings/registry/by_type/__init__.py
#	custom_components/localthings/translations/en.json
#	custom_components/localthings/translations/nl.json
2026-07-27 04:00:15 +00:00
Marc Billow 38263eaa72 Merge remote-tracking branch 'origin/main' into claude/device-support-issue-88-dehumidifier
# Conflicts:
#	custom_components/localthings/registry/by_type/__init__.py
#	tests/test_by_type.py
#	tests/test_golden_regression.py
2026-07-27 03:58:53 +00:00
Marc Billow ff6e1ecff4 Rename gas cooktop registry's display name to avoid induction_cooktop confusion
Renames DeviceRegistry.name from 'cooktop' to 'gas_cooktop' for the
NA9300K-class gas-cooktop registry (PR #23), so diagnostics/device-info
labels no longer collide with the unrelated induction_cooktop family
(issue #86) -- two different OCF surfaces that happen to share the
English word "cooktop".

Safe rename: _REGISTRY_BY_KEY's 'cooktop' lookup key is unchanged, so
all three existing detection paths (oneUiVersion "Cooktop" exact
match, the legacy ARTIK051 modelNum rule, and the resource-signature
fallback) keep routing real devices exactly as before. Entity
unique_ids are built from device serial + entity key, not registry
name, so existing entities are unaffected. Only the DeviceInfo.name
and diagnostics device_type strings change, both cosmetic.
2026-07-27 03:56:32 +00:00
Marc Billow 9ccaa3f01d Merge pull request #95 from mbillow/claude/device-support-issue-90-water-purifier
Add device support for Samsung water purifiers (issue #90)
2026-07-26 22:53:40 -05:00
Marc Billow 78f1bb0f92 Fix unclosed PROBE_STATUS capability from a bad merge conflict resolution
The merge of main into this branch dropped PROBE_STATUS's closing
),\n) and glued issue #74's COOKTOP_MONITORING addition directly onto
its entities tuple, leaving an unclosed paren (SyntaxError on import,
breaking Pytest and Hassfest CI). Restores the closing parens; no
functional change.
2026-07-27 03:46:55 +00:00
Marc Billow 5e60f9b953 Fix stale Window AC golden fixture (current_temperature_c, humidity)
PR #75 (WindFree AC) added CURRENT_TEMPERATURE/HUMIDITY capabilities
to the shared airconditioner registry, which every AC device picks up
-- including the Window AC from PR #87. Both branches built their
golden fixtures independently against their own base commit before
either landed, so neither saw the other's addition; once both merged,
the Window AC's fixture went stale. The two extra keys are real,
working sensors from #75's work, not a regression.
2026-07-27 03:45:32 +00:00
Marc Billow 3b0f4f8b66 Merge branch 'main' into claude/device-support-issue-90-water-purifier 2026-07-26 22:06:20 -05:00
Marc Billow 2aaaad71a8 Merge branch 'main' into claude/device-support-issue-88-dehumidifier 2026-07-26 22:05:24 -05:00
Marc Billow 4719e3aa80 Merge pull request #97 from mbillow/claude/device-support-issue-87-window-ac
Add device support for Bespoke Window AC (issue #87)
2026-07-26 22:03:36 -05:00
Marc Billow 84d73105cf Merge branch 'main' into claude/device-support-issue-86-cooktop 2026-07-26 21:58:21 -05:00
Marc Billow d0e03b026c Merge pull request #99 from mbillow/claude/device-support-issue-83-fridge-detection
Catch the 'Nothing(SVC)' placeholder serial in both fallback sites (i…
2026-07-26 21:55:33 -05:00
Marc Billow d2f0a5c976 Merge pull request #100 from mbillow/claude/device-support-issue-80-cycle-labels
Add confirmed washer/dryer cycle code labels (issue #80)
2026-07-26 21:53:21 -05:00
Marc Billow 48b34c9e31 Delete test_issue_80_cycle_codes_are_labelled
Removed test for issue 80 regarding cycle codes.
2026-07-26 21:53:06 -05:00
Marc Billow 0b6eda0cdc Merge branch 'main' into claude/device-support-issue-80-cycle-labels 2026-07-26 21:51:23 -05:00
Marc Billow 285d2de397 Merge pull request #101 from mbillow/claude/device-support-issue-79-dryer
Fix dryer detection when description pairs two model numbers (issue #79)
2026-07-26 21:45:12 -05:00
Marc Billow 8b45d8cbf6 Merge branch 'main' into claude/device-support-issue-77-freezer 2026-07-26 21:40:51 -05:00
Marc Billow f3b0ef4733 Merge pull request #103 from mbillow/claude/device-support-issue-75-windfree-ac
Add humidity/temperature sensors and horizontal swing for AC (issue #75)
2026-07-26 21:37:59 -05:00
Marc Billow d20d4e2158 Merge pull request #104 from mbillow/claude/device-support-issue-74-range
Add range/oven detection for boards missing /information/vs/0 (issue …
2026-07-26 21:33:25 -05:00
Marc Billow d321fe72cd Merge pull request #94 from mbillow/claude/device-support-issue-93-ac-modes
Device support issue 93: ac modes
2026-07-26 21:30:46 -05:00
Marc Billow 9b09f1aee3 Merge pull request #70 from vmvarga/feat/wd7000b-fridge-fixes
feat: laundry firmware-flag gating, fridge vendor temp writes, course…
2026-07-26 21:25:26 -05:00
Marc Billow 45931332d4 Rework AIComfort as an HVACMode.AUTO + preset overlay, add unmapped-mode warning (issue #93)
AIComfort isn't a distinct thermodynamic operation like Cool/Dry/Heat --
it's an AI-driven overlay on top of the device's own 'Auto' behavior,
confirmed by A-CAWW-TP2-20-COMMON reporting both 'Auto' and 'AIComfort'
as separate, mutually-exclusive entries in /mode/vs/0's supportedModes.
Modeled the idiomatic HA way instead of a flat _DEVICE_TO_HVAC entry:
hvac_mode reports AUTO and a new 'ai_comfort' preset carries the
distinction. Entered/left only via the preset (writes the primary mode
resource, not the convenient one) -- there's no dedicated HVACMode
value for it, so it's not offered in the hvac_mode dropdown directly.

Also adds a once-per-(href, code) warning log when a device-reported
mode has no entry in the relevant map, so a future gap like this one
surfaces in the log instead of silently vanishing -- the exact failure
mode issue #93 called out ("this class of gap is invisible without
diffing against supportedModes").
2026-07-27 02:01:51 +00:00
Marc Billow 8c3250b163 Map AC's AIComfort mode to HVACMode.AUTO (issue #93)
A-CAWW-TP2-20-COMMON (and likely other CAWW/TP2X-class boards) reports
'AIComfort' as a distinct entry in /mode/vs/0's supportedModes,
alongside 'Auto' (already mapped to HEAT_COOL). _read_modes() silently
drops any code missing from _DEVICE_TO_HVAC, so AIComfort was
unreachable -- one of HA's five other AC hvac_modes, unused by this
device family until now.

The other two gaps in issue #93 are already addressed elsewhere and
not duplicated here:
- Fan-only via the 'Fan' device code, and the WindFree/LongWind/
  NanoSleep preset codes, are covered by the still-open PR #91, which
  replaces the static preset table with a fully dynamic resolver over
  the device's own supportedModes.
- The 'Left_And_Right' -> horizontal swing mapping is already on the
  still-open claude/device-support-issue-75-windfree-ac branch.
2026-07-27 01:43:22 +00:00
Marc Billow d53459d047 Add device support for Samsung water purifiers (issue #90)
The TP2X_WATERPURIFIER_20K water purifier reports no oneUiVersion and
its modelNum/description don't match any consumer-prefix or existing
board-family token, so it fell into the unknown-device-type fallback
with only common capabilities. Add a new water_purifier registry,
routed via a 'WATERPURIFIER' modelNum/description fallback rule.

Models dispense settings (type/temperature/capacity/pouring status),
sterilize and filter status, favorite-capacity presets, and the three
water/buzzer locks. Per the adding-device-support skill's "never
hard-code the one dump's values" rule: dispense-capacity bounds and
step come live from the device's own desiredCapacityRange/
capacityResolution fields (range_field/step_fn), not a hardcoded
constant, and the hot-water-temperature control is a select over the
live supportedHotTemperatures list rather than a number with invented
bounds, since only a few discrete temperatures are selectable.

/mode/vs/0 and /automation/waterpurifier/vs/0 are left unmodeled: the
former carries an opaque wizard-workflow token with no coherent
current-value contract, the latter is a static support-flags blob with
no live setting to expose.

Confirmed against the issue #90 diagnostics dump with zero unbound
hrefs. Updates the README's supported-appliance-types table for the
new device type.
2026-07-27 01:40:05 +00:00
Marc Billow 89429039fb Add device support for Samsung dehumidifiers (issue #88)
The AY18CG7500GED dehumidifier (modelNum TP1X_DA_AC_DHM_01001_0000)
shares the DA_AC_ board family with the room-AC models but carries the
'_DHM_' token instead of '_RAC_'/'_PRAC_'/'_WAC_', so it fell into the
unknown-device-type fallback. Add a new dehumidifier registry, routed
via a '_DHM_' modelNum fallback rule, distinct from airconditioner
since target humidity (not temperature) is the primary control and
there's no climate composite.

Reuses airconditioner.py's AUTO_CLEAN/AIR_FILTER/MUTE_ONCE capabilities
directly (identical resource shapes on the shared board family). Adds
a new humidity sensor + target-humidity number pair and an
operating-mode select. Per the adding-device-support skill's
"never hard-code the one dump's values" rule, the target-humidity
number has no hardcoded min/max (falls back to HA's own 0-100 default
for a percentage field) and reads its step live from the device's own
`increment` field rather than a spec-sheet-derived constant. The
operating-mode select's options come live from supportedModes.

/mode/convenient/vs/0 is left unmodeled: only supportedModes is present
on this dump, with no live current-value field to confirm a read/write
contract.

Confirmed against the issue #88 diagnostics dump with zero unbound
hrefs. Updates the README's supported-appliance-types table for the
new device type.
2026-07-27 01:29:25 +00:00
Marc Billow b47eeaf7bc Add device support for Bespoke Window AC (issue #87)
The AW06C7155EWAZ window air conditioner (modelNum
TP1X_DA_AC_WAC_01001_0000) reports no oneUiVersion and uses the '_WAC_'
(Window Air Conditioner) modelNum token instead of the '_RAC_'/'_PRAC_'
tokens already handled by for_device_by_model. Add a fallback rule for
that token, routing it to the existing airconditioner registry.

The device's resource surface (mode/convenient/wind/temperature/power/
filter/humidity) is already fully modeled by the airconditioner
capability set, so this is purely a detection-routing fix -- confirmed
against the issue #87 diagnostics dump with zero unbound hrefs.
2026-07-27 01:17:45 +00:00
Marc Billow a453dc9ece Add standalone induction-cooktop support (issue #86)
TP1X_DA-KS-COOKTOP-01011 (NV8500T-/KO4) is the same board family and
/cooktop/status/vs/0 resource shape as issue #44's range combo, minus
the oven -- but its modelNum uses the hyphenated '-COOKTOP-' token,
which for_device_by_model's existing '_COOKTOP' check (underscore-
delimited, matching the unrelated NA9300K gas-cooktop family in
cooktop.py) doesn't match. The device fell back to 'unknown' with only
energy/alarms from the global fallback.

Add the hyphenated token check, routing to a new 'induction_cooktop'
registry (by_type/induction_cooktop.py) that reuses range.py's
COOKTOP_STATUS/COOKTOP_SPEC/COOKTOP_SAFETY/PROBE_STATUS and
cooktop.PAIRED_HOOD_STATUS wholesale rather than pulling in range.py's
oven capabilities, which this device has no hrefs for at all.

Along the way, three fields the reporter asked for turned out to be
gaps in the shared range.py capability itself, not just missing
routing -- also present (and previously unmodeled) on issue #44's
original combo-range dump:
- /cooktop/status/vs/0's own `power` and `childLock` fields (distinct
  from common.POWER's /power/0 or /power/vs/0, which a combo range
  additionally carries for the whole appliance) -- childLock gets a
  write_fn (a safe lock toggle, direct single-field PUT), power stays
  read-only (no live device to confirm a remote write wouldn't leave a
  burner active unattended).
- Each burner's `panDetection` field.

New: a Bluetooth meat-probe capability for /bluetooth/probe/status/vs/0
(read-only -- connection, battery, current/target temperature), and
/cooktop/recipe/status/vs/0 is ignored (every field empty on this idle
dump, same treatment as the microwave family's /recipe/cook/vs/0).

Regenerates the range golden fixture (gains cooktop_power/
cooktop_child_lock/burner_N_pan_detected) and adds a dedicated fixture
for the standalone cooktop.
2026-07-27 01:12:16 +00:00
Marc Billow 2c9fda3db1 Catch the 'Nothing(SVC)' placeholder serial in both fallback sites (issue #83)
The ARTIK051_DONGLE_REF firmware family reports the literal string
"Nothing(SVC)" as serialNum on every unit -- non-empty, so the existing
`if not serial` checks in config_flow.py's _probe_and_validate and
coordinator.py's _run_discovery don't catch it. Two such appliances on
one install (a fridge and a freezer, each its own dongle) then collide:
config_flow gives both the same unique_id and rejects the second as
"already configured" (bug 2), and even once that's worked around,
device_serial feeds every entity's unique_id too, so the second
appliance's entities get silently dropped with "does not generate
unique IDs" log lines (bug 4).

Add _is_placeholder_serial (duplicated in both modules rather than
imported, to avoid pulling config_flow into the runtime coordinator's
import graph or vice versa for a two-line check) and treat it the same
as an empty serial: fall back to host/port.

Type detection (bug 1) and the door sensor's field-name gap (bug 3),
also reported in this issue, are already fixed via the
claude/device-support-issue-77-freezer branch, which hit the same
ARTIK051_DONGLE_REF family from a different report -- not duplicated
here.
2026-07-27 01:00:05 +00:00
Marc Billow 5d7789eb4e Add confirmed washer/dryer cycle code labels (issue #80)
Five raw course codes were rendering unlabeled because no translation
entry existed for them, confirmed by the reporter selecting each cycle
on the physical appliance and reading back the raw code from the
entity's state:

- washer_cycle_table_02: '52' Eco Cold, '54' Towels, '60' Self Clean+
  (a WF50A8600AV/US). '54' shares a display name with the existing '24'
  Towels -- a different code on the same table legitimately landing on
  the same label, matching the existing '21'/'65' Colors and
  '27'/'5E' Rinse+Spin pairs, not a duplicate-in-error.
- dryer_cycle_table_03: '01' Normal, '06' Time dry (a DVE50A8600V/A3,
  the same model added in the previous commit's detection fix).

No code changes -- select.py already derives which raw values it
normalizes from the shipped catalog, so labelling a code is purely a
translations/en.json (mirrored to nl.json) addition.
2026-07-27 00:47:16 +00:00
Marc Billow bae1ac4337 Fix dryer detection when description pairs two model numbers (issue #79)
DVE50A8600V/A3 reports description
'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080' -- a paired
listing of two related model numbers (DVE50A8800 and DVE50A8600) joined
by an underscore, rather than the usual single trailing consumer-model
token. for_device_by_model only ever checked the literal last
underscore segment ('8600', which has no recognizable 2-letter prefix
on its own), so the real 'DV' token one segment earlier was never
reached and the device fell back to 'unknown' with only common
capabilities -- no dry level, cycle, or wrinkle-prevent entities.

Replace the single last-segment extraction with _consumer_model_key,
which scans segments from the end and returns the first one that
resolves. Behavior is unchanged for every existing single-token
description (the last segment still matches first); it just keeps
looking when that segment doesn't.
2026-07-27 00:44:23 +00:00
Marc Billow 643aac92f8 Lock in the same ARTIK051_DONGLE_REF fix for the fridge half (issue #78)
RR40M7165WW is the fridge half of the same household dongle setup as
issue #77's freezer -- identical pipe-delimited ARTIK051_DONGLE_REF
modelNum, so the previous commit's detection and door-sensor fixes
already cover it with no further code changes. Add its fixture as a
second, independent regression case: it exercises the 'cooler' instance
segment instead of 'freezer' for both the temperature pattern caps and
DOOR_GENERIC, and notably reports /door/onedoorfreezer/vs/0 despite
being a single-door fridge (shared firmware naming across the product
line, not an actual second compartment) -- worth having its own golden
so that stays working too.
2026-07-27 00:41:25 +00:00
Marc Billow 8194ea9e87 Fix ARTIK051_DONGLE_REF freezer type detection and door sensor (issue #77)
RZ32M713EWW/EE (an ARTIK051-dongle standalone freezer) reports no
oneUiVersion and a pipe-delimited modelNum
('ARTIK051_DONGLE_REF|<rest>') -- REF is the last underscore segment
before the pipe, not wrapped in underscores on both sides like the
'..._REF_...' shape for_device_by_model's substring check expected, so
the device fell through to 'unknown' with only common capabilities:
no door sensor, no temperature sensors, nothing fridge-specific.

Replace the substring check with a segment-based one
(_model_num_segments splits the pipe-delimited prefix on '_') that
catches both shapes. Same root cause, same fix, and same device family
independently reported and root-caused in issue #83 (which also covers
two further bugs -- config-flow/coordinator serial collisions on a
second symptom of this firmware, 'Nothing(SVC)' as a literal serial --
not needed here since this reporter has a single unit; left for #83).

Once routed to the refrigerator registry, fridge.py's existing pattern
capabilities pick up /temperature/current/freezer/0 and
/temperature/desired/freezer/0 for free -- they were never the actual
problem, just unreachable under the 'unknown' fallback (which never
tries pattern capabilities at all). The door sensor needed one more
fix: /door/onedoorfreezer/vs/0 reports the vendor-prefixed
x.com.samsung.da.openState, not the bare openState DOOR_GENERIC read,
so the entity existed but stayed permanently unavailable. Check both
field names via rep_fn.
2026-07-27 00:39:33 +00:00
Marc Billow 3afbe6c6c1 Add humidity/temperature sensors and horizontal swing for AC (issue #75)
Three gaps reported against an ARTIK051_PRAC_20K WindFree unit vs. the
SmartThings integration:

1. Missing WindFree/motion convenient-mode presets -- left alone here.
   PR #91 replaces climate.py's static _DEVICE_TO_PRESET table with a
   generic resolver that reads any preset code straight off the unit's
   own supportedModes, which already covers this (and more generically
   than a per-model dict would) -- adding one here would just conflict.

2. No horizontal oscillation: /wind/direction/vs/0's supportedModes
   includes Left_And_Right, which _DEVICE_TO_SWING had no mapping for.
   Add it to HA's standard 'horizontal' swing constant.

3. No standalone humidity/current-temperature sensors: the climate card
   already reads both internally, but nothing exposed them as entities
   for history/automations. Add CURRENT_TEMPERATURE (OCF
   /temperature/current/0) with a CURRENT_TEMPERATURE_VS vendor fallback
   (same match_fn-gated pair shape as common.py's POWER_GENERIC/
   POWER_VS_FALLBACK), and a HUMIDITY sensor reading /humidity/vs/0's
   fivepercentHumidity field -- the only one of the three
   humidity-shaped fields across /humidity/0 and /humidity/vs/0 that
   isn't permanently stuck at 0 on every dump seen.

Regenerates the five existing AC goldens (all pick up
current_temperature_c; most pick up humidity) and adds a dedicated
fixture from the issue's WindFree dump, whose /humidity/vs/0 actually
has live fivepercentHumidity data.
2026-07-27 00:33:35 +00:00
Marc Billow c72b5a988e Add range/oven detection for boards missing /information/vs/0 (issue #74)
NE63B8411SS reports no oneUiVersion and no /information/vs/0 resource at
all, so neither for_device nor for_device_by_model's modelNum tokens have
anything to key off -- it fell through to the unknown-device fallback,
which also mis-binds /temperatures/vs/0 against fridge.py's setpoint
capability (a collision the oven family's own capabilities are normally
excluded from the global registry specifically to avoid).

Add a resources-based signature to for_device_by_resources(): 'Bake' in
/mode/vs/0's supportedModes is oven/range-exclusive vocabulary, and paired
with the /oven/vs/0 cavity resource it reliably identifies this family
even with no model info at all. Route to 'range' when a cooktop-status
resource is also present, else plain 'oven'.

This board's cooktop half also doesn't expose the per-burner
/cooktop/status/vs/0 array range.py already models -- only the coarser
/cooktopmonitoring/vs/0 summary resource. Add a read-only
COOKTOP_MONITORING capability for it (cooktop running state, warming
center state) rather than leaving it unbound.
2026-07-27 00:23:04 +00:00
vmvarga 2fd8ff8260 merge temp_setpoint 2026-07-26 11:05:13 +02:00
vmvarga 00cf8d676d feat: laundry firmware-flag gating, fridge vendor temp writes, course names 2026-07-25 10:01:56 +02:00
Marc Billow aff7647460 Merge pull request #65 from Metal-Eagle/feature/add-completion-time
Feature/add completion time
2026-07-24 23:46:47 -05:00
Jeroen Hof 705da781f1 Merge branch 'main' into feature/add-completion-time 2026-07-25 06:41:17 +02:00
Jeroen Hof 6de1a4090e i18n: simplify completion time translation in English and Dutch 2026-07-25 06:02:45 +02:00
Jeroen Hof ef75db6697 i18n: update completion time translation in English and Dutch 2026-07-25 05:55:47 +02:00
Jeroen Hof 9e290f27ee refactor: update sensor descriptions for completion time and delay start 2026-07-25 05:24:10 +02:00
Jeroen Hof 37bfff152d refactor: remove unused strings.json file 2026-07-25 05:15:34 +02:00
Jeroen Hof 317b7d0c1b fix: add missing newline at end of en.json file 2026-07-25 05:09:55 +02:00
Jeroen Hof ba8e41d310 Implement code changes to enhance functionality and improve performance 2026-07-25 05:08:18 +02:00
Jeroen Hof 724686c42c i18n: add translations for AI Wash and Jeans 2026-07-25 05:01:40 +02:00
Jeroen Hof edccb6ae3f i18n: add new wash options for AI Wash and Jeans 2026-07-25 04:59:37 +02:00
Jeroen Hof 4a91f62a36 i18n: add new translations for AI Wash and Spijkerbroek 2026-07-25 04:49:40 +02:00
Jeroen Hof 1d4d38db14 Merge branch 'main' into feature/add-completion-time 2026-07-25 04:47:22 +02:00
Marc Billow 35e2c79b14 brand: use updated localthing logo 2026-07-24 20:23:48 -05:00
Marc Billow f185951a8a i18n: missed a quick wash translation 2026-07-24 15:42:24 -05:00
Marc Billow adcb8a0ecb chore: add additional translations provided in #1 dutch 2026-07-24 15:33:31 -05:00
Marc Billow dc86c7f0ef Merge pull request #72 from mbillow/claude/home-assistant-i18n-iwwyvr
feat (i18n): refactor and improve translation support
2026-07-24 15:23:39 -05:00
Marc Billow 30d38d855c Merge branch 'main' into claude/home-assistant-i18n-iwwyvr 2026-07-24 15:23:10 -05:00
Marc Billow ee77e3bda8 chore: add additional translations provided in #1 2026-07-24 15:20:19 -05:00
Marc Billow ea986fe8e5 refactor: adopt cleaner write pattern for options arrays 2026-07-24 15:05:26 -05:00
Marc Billow 10c850f2b9 refactor(i18n): drop the vestigial descriptor name field
Since entity.py started routing named descriptors through the catalog
under desc.key, SamsungEntityDescription.name has been read for its
value nowhere -- only twice as a flag, to decide whether an entity was
translated at all. That left 148 English names duplicated between Python
and translations/en.json with nothing keeping them honest: six had
already drifted, invisibly, because editing the Python side changes
nothing a user sees.

So the field is gone, and translation_key defaults to desc.key. A
descriptor now sets translation_key only to share one catalog entry
across descriptors or to point at a differently-named one, and the
catalog is the only place an entity name exists.

Every descriptor resolves to exactly the translation key, icon, entity
category, enabled-default and gating it did before -- with one
deliberate exception: the hood fan, previously the sole descriptor with
no key at all, now resolves to 'fan'. That is inert, because fan.py sets
_attr_name = None so the entity presents as the device itself.

The three helpers that forwarded a name into a descriptor
(laundry.bool_option_switch, washer._bool_option_switch, air_purifier's
sensor table) lose that parameter. test_translations.py now requires a
catalog entry for every descriptor rather than only translated ones.

Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
2026-07-24 19:45:37 +00:00
Marc Billow 6281b40549 refactor(i18n): make the shipped catalog the single source of truth
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
2026-07-24 19:32:09 +00:00
Marc Billow 0b6cc6aa9f Merge branch 'pr68' into claude/home-assistant-i18n-iwwyvr 2026-07-24 19:24:59 +00:00
Hmmbob 5d86dbe7e8 Resolve runtime translation references 2026-07-24 21:13:07 +02:00
Marc Billow e47a71689d Merge pull request #71 from splitsec2/fix/switch-off-inversion
fix(fridge): switch turn-off silently sends 'On' (truthy-string guard)
2026-07-24 13:50:53 -05:00
Rob Martin 9cf1e46bf6 fix(fridge): stop switch turn-off silently sending 'On'
Six fridge SwitchDesc write_fns built their payload with
`'On' if p else 'Off'`. The switch platform passes the literal
string 'Off' on turn-off, which is truthy, so the guard always
produced 'On' -- turning these switches off silently re-sent On
and they could never be turned off:

  - ICEMAKER_NIGHTTIME (ice.night.status)
  - STATUS_LOCK helper (devicecontrol + device.sound)
  - DEFROST_DELAY (delayDefrost)
  - WELCOME_LIGHTING (status)
  - CABINET_LIGHT dim (light.dimming.status)
  - ICEMAKER_STATUS_FALLBACK (iceMaker)

Compare `p == 'On'` instead, matching the pattern the other
capability files already use. Adds a regression test asserting
every affected write_fn sends 'Off' on 'Off' and 'On' on 'On'.
2026-07-24 12:11:53 -06:00
Hmmbob 82acc05d38 Limit changes to translation support 2026-07-24 18:40:44 +02:00
Hmmbob da49825e2e Harden unknown vendor value fallbacks 2026-07-24 18:12:01 +02:00
Hmmbob b85af10ae1 Test translation coverage and dynamic fallbacks 2026-07-24 18:08:29 +02:00
Hmmbob f9ebc8e286 Make LocalThings UI fully translatable 2026-07-24 18:08:29 +02:00
Hmmbob d0cfaeebc1 Add Dutch translations 2026-07-24 17:29:50 +02:00
Marc Billow 7276ff65a8 Merge pull request #67 from mbillow/claude/arbitrary-href-writes-debug-5wjtm3
feat: add debug options panel for arbitrary resource-href writes (#54)
2026-07-24 10:17:35 -05:00
Marc Billow c731ecefe5 feat: add debug options panel for arbitrary resource-href writes (#54)
Power users can now pick a resource href from a live dropdown, view its
current value, and POST a minimal patch straight to the device -- to pin
down device-specific write behavior without waiting on a new release.
Bypasses the remote-control block and all write_fn/validate_fn logic by
design; the existing remote-control settings toggle moves behind the same
options-flow menu.
2026-07-24 14:02:15 +00:00
Jeroen Hof 365a1c4722 feat: enhance operational capabilities by adding completion_minutes and updating related logic; remove completion_time references 2026-07-24 15:35:17 +02:00
Jeroen Hof 7be91b56e8 Merge remote-tracking branch 'upstream/main' into feature/add-completion-time 2026-07-24 08:23:42 +02:00
Marc Billow 77cc875608 Merge pull request #64 from mbillow/claude/course-list-supportedoptions-fallback
feat: derive washer/dryer/dishwasher cycle list from supportedOptions when editCourseList is empty
2026-07-24 01:18:02 -05:00
Marc Billow ae711038ca Merge pull request #63 from mbillow/claude/issue-56-followup
Drop unexplained Blooming_* diagnostic, confirm remaining air purifier fields (issue #56)
2026-07-24 01:17:33 -05:00
Jeroen Hof 2c283218b0 feat: add completion_minutes and completion_time to state_keys in JSON fixtures 2026-07-24 08:10:17 +02:00
Marc Billow 3b3dd54372 Simplify table-scoped translation key; fix stale resolution; correct docstring
Simplification (feedback: this was overcomplicated): drop the
validated_table gate entirely. cycle_select's table_href now just builds
the translation key directly from whatever course table the device
reports (washer_cycle + Table_02 -> washer_cycle_table_02) instead of
comparing against a hardcoded known-good value and falling back to no key
on any mismatch. A table we haven't shipped translations for yet (e.g.
FlexWash's Table_00) still gets a key built for it -- Home Assistant's own
missing-translation handling takes it from there, the same graceful
fallback already relied on for any individual untranslated code within an
existing table. Adding a newly-confirmed table later is just new
strings.json entries, no code change.

Independent (Opus) review of the prior version caught two real issues,
fixed here regardless of the simplification above:

- translation_key was resolved once at entity construction from whatever
  coordinator.last_resources held at that moment. Discovery can run while
  a sibling resource is still an empty stub (documented precedent: see
  _is_included), so a callable translation_key could permanently bake in
  a stale value for the entity's lifetime. Moved resolution into a
  translation_key property override (Entity.translation_key is a property
  upstream, not a plain attribute), re-evaluated against live coordinator
  data on every access, matching how options/current_option already work.

- The supportedOptions fallback's "smallest passing K wins" docstring
  claimed every larger passing K is an exact multiple of the true one.
  False: the shipped dishwasher fixture has passing K=7 (true) alongside
  10, 14, and 35, none of which are multiples of 7 -- position 0 always
  lands on the same real course code regardless of K, which alone
  satisfies the current-course guard for several unrelated splits.
  Corrected the reasoning to what's actually true (an empirically-matched
  heuristic across six real dumps, not a proof) and added a regression
  test locking in the real dishwasher case so this isn't silently lost.
2026-07-24 05:55:04 +00:00
Jeroen Hof ab874797fc Merge branch 'main' into feature/add-completion-time 2026-07-24 07:48:57 +02:00
Jeroen Hof f5d4e2219a fix: remove redundant course codes from parse_edit_course_list test case 2026-07-24 07:47:53 +02:00
Marc Billow b2c32a90b6 Scope washer/dryer cycle translations to the device's own course table
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.
2026-07-24 05:42:12 +00:00
Marc Billow b2c0517115 feat: derive washer/dryer/dishwasher cycle list from supportedOptions when editCourseList is empty
Some DA_WM_TP1/TP2-class boards populate /wm/editcourse/vs/0 without ever
filling in editCourseList itself (issue #1), so the Cycle select never gets
created even though the device clearly has one (confirmed via SmartThings
app screenshots and a currently-selected course).

/course/vs/0's own x.com.samsung.da.supportedOptions turns out to already
carry the course list, just undocumented: a 1-hex-nibble header followed by
one fixed-width record per course, self-indexed by a course-code first byte
rather than positional like editCourseList. Confirmed against six
independent real-world dumps pulled from open and closed GitHub issues.

cycle_options() now falls back to deriving this when editCourseList is
empty, gated on two checks: the derived codes must all be distinct, and
must include whatever course is currently selected. Larger multiples of
the true record width trivially re-pass both checks too (they're just a
sparser sampling of the same table), so the smallest passing width wins
rather than requiring one unambiguous match.

Also fires on the washer_flexwash fixture, newly creating a Cycle select
there -- unconfirmed against any ground truth for that device (a different,
older board generation with no editCourseList and no screenshots to check
against), flagged for follow-up discussion rather than silently accepted.
2026-07-24 05:06:39 +00:00
Jeroen Hof 86f07abf30 fix: correct expected output for parse_edit_course_list test case 2026-07-24 06:29:53 +02:00
Jeroen Hof b00390c093 fix: correct key for Jeans cycle in strings and translations 2026-07-24 06:27:28 +02:00
Jeroen Hof e913a3c2c0 feat: add new washer cycle options for AI Wash and Jeans 2026-07-24 06:26:24 +02:00
Jeroen Hof b2db4bf357 feat: add completion time and minutes sensors with parsing logic 2026-07-24 06:23:53 +02:00
Marc Billow 39dcf6a7b8 Drop unexplained Blooming_* diagnostic, confirm remaining air purifier fields (issue #56)
Per the five running-state diagnostics dumps (Auto/Sleep/Low/Medium/High)
gathered in the issue thread:
- Blooming_* has no corresponding SmartThings app setting, so it's dropped
  entirely rather than kept as an unexplained diagnostic.
- Comode_* reads 'Off' on all five, ruling out the original guess that it
  was the fan-speed selector -- still exposed read-only, purpose unconfirmed.
- OptionCode_60282 and the missing humidity sensor are confirmed correct as
  already modeled.
- /airflow's speed doesn't map monotonically to the five settings and the
  dumps were all captured within one ~30s poll cycle of each other, so it
  stays read-only pending a cleaner, time-spaced capture.

FilterProgress is untouched here: an earlier pass on this issue read the
thread as confirming 100 means "fresh" and renamed the sensor to filter_life
to match, but that reading was backwards -- the reporter clarified 100
means fully used and needs replacing, which is what filter_progress (the
already-shipped name) already implies. That rename was caught before
merging and is not part of this change.
2026-07-24 04:20:42 +00:00
Marc Billow ed768d7257 Merge pull request #62 from mbillow/claude/issue-52-quick-fix
Recognize A-CAWW-TP2-20-COMMON system air conditioners (issue #52)
2026-07-23 23:08:32 -05:00
Marc Billow efe87381b7 Bump version to 0.11.1 2026-07-24 04:06:31 +00:00
Marc Billow 8e25e6dc71 Recognize A-CAWW-TP2-20-COMMON system air conditioners (issue #52)
New '-CAWW-' modelNum token for multi-indoor-unit commercial AC
installs -- these report no oneUiVersion, same as the other RAC/PRAC
boards. Once routed to the existing airconditioner registry, every
resource in the reporter's dump already binds except one new
SAC-specific installation-topology blob, now ignored.
2026-07-24 04:06:31 +00:00
Marc Billow 60f8fdf488 Merge pull request #61 from mbillow/claude/issue-9-optimistic-write-debug-k4fjdb
feat: add options flow to bypass the remote-control write block per device (issue #54)
2026-07-23 22:40:51 -05:00
Marc Billow dccd6e1209 fix: correct backwards options-flow description wording
The bypass toggle is off by default and turned ON to allow writes with
remote control reported off, but the description said "only turn this
off if..." -- backwards from the actual control. Caught in Opus review.
2026-07-24 03:38:10 +00:00
Marc Billow 037dc9722f feat: add options flow to bypass the remote-control write block per device (issue #54)
The remote-control-off write block was device-wide and unconditional:
whenever a device reports remote control off, every write is rejected
with a user-facing error, on the assumption the device would reject it
anyway. Issue #54 reports a washer where that assumption doesn't hold --
default detergent/softener dosing writes through even with remote
control off, since they apply to the built-in programs too, not just a
custom remote-controlled cycle.

Add a per-device options flow (Settings > Devices & Services > this
device > Configure) with a single toggle, stored in entry.options (not
entry.data) so it doesn't affect the device's identity/unique_id.
coordinator.async_send_command reads it ahead of the existing
remote_control_enabled() check; defaults to False everywhere, so devices
that don't touch this option see no change in behavior.
2026-07-24 03:32:49 +00:00
Marc Billow dffe1ef455 Merge pull request #60 from mbillow/claude/issue-9-optimistic-write-debug-k4fjdb
fix: settle-window fixes for slow-settling, shared-href writes (issue #9)
2026-07-23 22:24:03 -05:00
Marc Billow 49bd4ef0cb Bump version to 0.11.0 2026-07-24 03:22:59 +00:00
Marc Billow f7faf56689 Bump version to 0.10.2 2026-07-24 03:21:16 +00:00
Marc Billow 85cca70b6b fix: don't let an in-progress settle window block a second write's own optimistic apply
Widening the settle window to tens of seconds (previous commit) made a
real gap much more likely to bite: apply() gated every source,
including 'optimistic', on _is_settling. /course/vs/0 backs several
independent washer selects (cycle, detergent quantity, softener
quantity, ...), so picking a second one while the first write's window
was still open -- routine within a ~43s window -- had its own
optimistic value silently dropped from the cache instead of shown,
recreating the exact "write doesn't seem to apply" symptom the guard
exists to prevent, just for whichever write lost the race.

Let source='optimistic' always bypass the gate; poll/sweep/observe
stay gated as before. mark_write_pending still re-arms the window
right after, so the newer write is protected going forward.
2026-07-24 03:14:58 +00:00
Marc Billow 020402c82f fix: size the write-settle window to outlast a slow-settling device (issue #9)
async_send_command's settle guard used DEFAULT_SETTLE_S's fixed few
seconds, which is long enough for fields that update instantly on the
device but not for ones that visibly take a few seconds of internal
validation or hardware movement to catch up. Issue #9's washer packs
cycle/detergent/softener selection into /course/vs/0's shared options[]
array, and that settling time regularly outlasted the fixed window --
same device, same integration, but /washer/vs/0's temperature/spin
fields (plain flags) confirmed instantly while these didn't. The short
window expired before the confirm poll (or the device itself) caught
up, so a stale read landed unprotected and reverted the optimistic
value, only to self-correct again once a later poll saw the real
change -- reading to the user as the write reverting and then
reapplying itself a few seconds later.

Size the window to always outlast the PUT and the confirming refresh's
poll combined, as issues #17/#53 also needed, but without that fix's
early-release mechanism (reverted previously for its own races around
overlapping writes) -- just hold the guard for the full window and let
it expire on its own.
2026-07-24 03:07:37 +00:00
Marc Billow cf18cc3ecc Merge pull request #59 from mbillow/claude/device-support-issue-56-shy2vg
Add Samsung air purifier support (ARTIK051_TVTL-class, issue #56)
2026-07-23 18:50:06 -05:00
Marc Billow 61e6035a36 Merge pull request #58 from mbillow/claude/climate-state-freshness-s5v2bk
fix: apply optimistic writes to their actual target href, not the bound href (issues #17, #53)
2026-07-23 18:47:48 -05:00
Marc Billow 7b155aabc2 Merge remote-tracking branch 'origin/main' into claude/device-support-issue-56-shy2vg
# Conflicts:
#	tests/test_golden_regression.py
2026-07-23 23:47:42 +00:00
Marc Billow 788b8f98a1 Merge pull request #57 from mbillow/claude/issue-55-triage-db1omk
Fix wall oven device-type detection (issue #55)
2026-07-23 18:44:57 -05:00
Marc Billow 83bd158049 fix: apply the optimistic write to its actual target href, not the bound href (issues #17, #53)
An independent review turned up the real bug behind the climate lag:
async_send_command applied the optimistic value and settle guard to
bound_entity.href, but write_fn's path_segs -- the resource actually
POSTed to -- can point somewhere else entirely. The AC's composite
climate entity is bound to /mode/vs/0, yet a power/temperature/fan/
swing/preset command writes to its own sibling resource (/power/0,
/temperature/desired/0, ...), which is also what climate.py reads the
displayed state from. The optimistic value landed on /mode/vs/0
instead, so the resource the entity actually shows stayed stale until
the next unrelated read of it -- surviving the earlier optimistic-apply
fix (issue #27), which applied to the same wrong href.

Derive the write's target from path_segs and apply/guard/log against
that instead. Reverts the previous commit's settle-window-sizing
change on this branch: that was chasing a real but speculative edge
case (a confirm poll slower than the settle window) that a follow-up
review couldn't confirm matches the reported symptom, and its
early-release mechanism had its own races (a debounced refresh that
hadn't actually run yet, overlapping writes to the same href) for
marginal benefit once this fix lands. Simpler to drop it than carry
that risk for a case not in evidence.

Added a coordinator-level regression test against the real AC CLIMATE
capability (not just a synthetic mismatched-path descriptor) sending a
power command and asserting the optimistic value lands on /power/0,
not /mode/vs/0.
2026-07-23 22:42:04 +00:00
Marc Billow 618443db7b Use generic names for shared start/stop/pause entities
The /operational/state/vs/0 href (and its start/pause/stop buttons and
"cycle active" sensor) is shared across the dryer/dishwasher/oven/washer
families, but the entity names hardcoded laundry vocabulary ("Start
cycle", "Cycle active") that doesn't fit an oven's bake/roast session.
Rename to generic "Start"/"Pause"/"Stop"/"Running".

Also fold oven.py's duplicate stop button into a single STOP_BUTTON
constant in operational.py -- both wrote the identical state='Ready'
RMW, so there was no reason for two copies to maintain.
2026-07-23 22:38:48 +00:00
Marc Billow 3ed277a33b Fix wall oven device-type detection (issue #55)
Ovens with modelNum like TP1X_DA-KS-OVEN-0107X report no oneUiVersion
and don't match any consumer-prefix or other fallback token in
for_device_by_model, so the device came back as "unknown" and every
resource fell through to the global capability registry instead of
oven.py's own -- explaining the unbound /connected/vs/0 href, the
unrelated-looking entities, and the non-functional controls reported
in the issue. Add a '-OVEN-' modelNum token fallback, mirroring the
existing '-RANGE-' fallback from issue #44.
2026-07-23 22:38:44 +00:00
Marc Billow 42a80fffe6 Simplify air purifier support by reusing existing helpers
Rewires /mode/vs/0's packed-options parsing (display_light/operating_mode/
blooming_level) onto laundry.py's existing option_value/replace_in_options/
bool_option_exists/bool_option_value instead of hand-rolled reimplementations,
hoists the duplicated int-conversion helper into common.py (shared by
range_hood.py too), collapses the five near-identical AIR_QUALITY sensors
into a table-driven loop, extracts the /consumable/vs/0 item lookup into a
named helper, and moves the humidity ignore list into capabilities/
air_purifier.py's own COVERAGE list to match the airconditioner/range_hood
convention of keeping by_type files as pure composition.

No behavior change; golden state keys and all existing tests are unaffected.
2026-07-23 22:36:47 +00:00
Marc Billow b121d24966 Add Samsung air purifier support (ARTIK051_TVTL-class, issue #56)
Adds a by_type registry for the AX60R5080WD/SE air purifier family, verified
against two independent diagnostics dumps (issue #56 and its comment). Binds
power, alarms, energy, diagnosis (reusing dishwasher.DIAGNOSIS), the dust/
fine-dust/super-fine-dust/odor/clean-level sensors off /sensors/vs/0, filter
progress, a device-active diagnostic, and a display-light switch parsed out
of /mode/vs/0's packed options list.

Fan speed/direction (/airflow/0, /airflow/vs/0) and two other /mode/vs/0
tokens (Comode_*, Blooming_*) are exposed as read-only diagnostics rather
than full controls -- neither dump has a supported-values list to confirm
their write contracts, so they're left for a follow-up once that's
clarified in the issue thread.

Hoists range_hood's items[]-sensor-value helper into common.py
(sensor_item_value) since air_purifier now reads the same /sensors/vs/0
shape.
2026-07-23 22:12:15 +00:00
Marc Billow 615c5d5aa9 fix: size the write-settle window to outlast its own confirm poll (issues #17, #53)
mark_write_pending's window was a fixed few seconds, started before the
PUT and the confirming /device/0 refresh that follows it -- but that
refresh is a full summary poll, which can legitimately take far longer
than that on these AC devices. The window routinely expired while the
confirm poll was still in flight, so a stale read (the device's own
resource tree hadn't caught up to the instant physical change yet)
landed unprotected and reverted the optimistic write, with nothing to
correct it again until the next scheduled summary poll. That's the
20-60s lag both issues report even after the earlier optimistic-apply
fix.

Size the window to cover the PUT and confirm-poll timeouts combined,
and release it as soon as that round trip actually completes (success
or failure) instead of leaving it open for the rest of a now much
longer window.
2026-07-23 22:11:26 +00:00
Marc Billow 30e814f242 Merge pull request #23 from themaanda/contrib/cooktop-range-hood
Add cooktop and range hood support
2026-07-22 22:41:10 -05:00
Marc Billow 13f1214790 Merge branch 'main' into contrib/cooktop-range-hood 2026-07-22 22:39:19 -05:00
Marc Billow 4d21f3b8f2 Merge pull request #51 from mbillow/claude/release-notes-version-bump-1obhfb
Bump version to 0.10.1
2026-07-22 22:31:42 -05:00
Marc Billow 760d797c12 Bump version to 0.10.1 2026-07-23 03:30:16 +00:00
Marc Billow b944549f55 Merge pull request #50 from mbillow/claude/release-notes-version-bump-1obhfb
Add range/cooktop-oven combo to supported appliance types
2026-07-22 22:28:51 -05:00
Marc Billow 94551da4a0 fix: apply writes optimistically before the settle guard (issue #27)
mark_write_pending's settle window was dropping every update for a
just-written href, including the coordinator's own post-write refresh,
because nothing ever wrote the optimistic value into the cache for it
to protect. The write reflected on the device immediately but reverted
in HA until the next 30s summary sweep.
2026-07-23 03:26:00 +00:00
themaanda f7db568a43 Merge remote-tracking branch 'upstream/main' into contrib/cooktop-range-hood
# Conflicts:
#	custom_components/localthings/coordinator.py
#	custom_components/localthings/registry/by_type/__init__.py
2026-07-22 21:59:23 -05:00
themaanda 62caf769f2 Address cooktop and range hood review 2026-07-22 21:54:01 -05:00
themaanda e7daa21931 Merge remote-tracking branch 'upstream/main' into contrib/cooktop-range-hood
# Conflicts:
#	README.md
#	custom_components/localthings/registry/by_type/__init__.py
#	tests/test_by_type.py
#	tests/test_golden_regression.py
2026-07-22 21:53:45 -05:00
Marc Billow f783aa72b9 Add range/cooktop-oven combo to supported appliance types 2026-07-23 02:47:12 +00:00
Marc Billow ed461d82cd Merge pull request #49 from mbillow/claude/release-notes-version-bump-1obhfb
Bump version to 0.10.0
2026-07-22 21:45:15 -05:00
Marc Billow 62d4ed673b Bump version to 0.10.0 2026-07-23 02:44:22 +00:00
Marc Billow e74089a545 Merge pull request #48 from mbillow/claude/issue-44-device-fixes-ejmedf
Add range/cooktop-oven combo support (issue #44)
2026-07-22 21:35:10 -05:00
Marc Billow b2f3e501e5 Merge pull request #47 from mbillow/claude/remote-control-write-validation-axyqqs
feat: reject writes when a device's remote control is disabled
2026-07-22 21:32:43 -05:00
Marc Billow 8ca2c3cb16 Address opus review: Fahrenheit setpoint bounds, OVEN_SPEC coverage, docstring
- NumberDesc gains native_min_fn/native_max_fn/step_fn hooks (mirroring the
  existing unit_fn pattern) so an entity's slider bounds can track the live
  rep instead of staying pinned to whatever unit the descriptor was written
  against. Oven setpoint was hardcoded to Celsius bounds (30-270), which
  silently capped issue #44's Fahrenheit range at 270F -- below a normal
  350F bake temp. Verified Fahrenheit bounds (175-550, step 5) come from
  that dump's /mode/vs/0 Bake modeSpec.
- Wire oven.OVEN_SPEC into the oven registry, not just range -- it was only
  reachable from range before, so a standalone oven reporting
  /oven/spec/vs/0 would have false-tripped the coverage-gap repair.
- Fix a docstring in test_golden_regression.py left over from the
  cooktop.py -> range.py rename.
2026-07-23 02:32:34 +00:00
Marc Billow aec3c5e460 Rename cooktop.py capabilities module to range.py to avoid PR #23 collision
PR #23 independently adds registry/capabilities/cooktop.py for an unrelated
standalone-cooktop product (NA9300K-class, burner state encoded in
/mode/vs/0's options array) -- different hardware and a different OCF
surface than issue #44's oven+cooktop combo range, but the same file path.
Rename ours to range.py to keep both mergeable.
2026-07-23 02:32:34 +00:00
Marc Billow 321f186b0e Add range/cooktop-oven combo support (issue #44)
TP1X_DA-KS-RANGE-0102X (model NSI6DG9100SRAA) reports no oneUiVersion and
previously fell through to the unknown-device fallback, leaving /connected,
/cooktop/spec, /cooktop/settings/status, /cooktop/status, and /oven/spec
unbound. Add a 'range' device registry that reuses the oven family's
cavity/setpoint/mode/operational-state/door capabilities and adds a new
cooktop.py module modeling per-burner power level, state, and hot-surface
entities (gated so unreported burner slots don't appear), plus a hot-surface
auto-shutoff config sensor. Route range/cooktop models to it via a
'-RANGE-' modelNum token, mirroring the existing RAC/PRAC air-conditioner
fallback pattern.

The dryer (DA_WM_TP1_21_COMMON) pause/stop buttons mentioned in the same
issue are working as intended -- the reporter confirmed that's an expected
in-person-only limitation, not a bug.
2026-07-23 02:32:34 +00:00
Marc Billow 2f6b7d54e3 refactor: share remote-control read between sensor and write guard, poll it warm
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.
2026-07-23 02:29:03 +00:00
Marc Billow 6e5fee32b8 feat: reject writes when a device's remote control is disabled
Devices with a /remotectrl href already surface it as a read-only
"Smart Control" binary sensor, but writes weren't checking it before
now. async_send_command now blocks every write (any platform) with a
ServiceValidationError telling the user to enable remote control via
the appliance's manual, ahead of any per-description validate_fn.
2026-07-23 02:15:40 +00:00
Marc Billow 1e9437ef3c Merge pull request #46 from mbillow/claude/issue-27-investigation-ff60pw
fix(fridge,observe): merge partial cache updates, name ice makers dynamically
2026-07-22 20:31:19 -05:00
Marc Billow 36f7aa0b8c refactor: dedup naming helpers, close observe-cache merge race
/simplify pass on the issue #27 fix: extract a shared _snake_to_title
between entity.py and discovery.py, factor discover()'s two binding
loops through one _bind() helper, and close a TOCTOU race the cache
merge introduced -- apply() is the sole path StateCache mutations flow
through, so the read-then-write is now serialized under one lock
instead of two independently-locked calls.
2026-07-23 01:28:47 +00:00
Marc Billow 10ab51c7f3 fix(fridge,observe): merge partial cache updates, name ice makers dynamically
Issue #27: the flex-zone/cooler-drawer select vanished after a device
stopped including supportedOptions on an update for /mode/vs/0.
ObserveManager.apply() handed reps straight to StateCache.apply_rep,
which fully replaces the cached rep -- so a partial update (missing a
field the select's exists_fn/options_field gate on) silently erased
data a fuller update had previously supplied. apply() now merges
incoming reps onto whatever's already cached instead.

Also give ice-maker entities (and any future pattern-cap instance) a
device-given display name instead of the href-derived "Icemaker
One"/"Icemaker Two": Capability.name_field lets a pattern capability
read and normalize an instance name (e.g. iceMaker.name's "CUBED_ICE")
for use as the entity name prefix, independent of the stable
key/unique_id.
2026-07-23 01:28:35 +00:00
Marc Billow 8bf9002eb7 Merge pull request #45 from mbillow/claude/climate-entity-href-observation-2wlnlw
Fix AC climate state lag by promoting consumed hrefs to warm poll tier
2026-07-22 20:14:02 -05:00
Marc Billow fb0f24a175 Fix AC climate state lag by promoting consumed hrefs to warm poll tier
CLIMATE_CONSUMED_HREFS (power, current/target temp, fan, swing, preset)
were bound as no-entity coverage capabilities with the Capability
default poll_tier='cold'. The coordinator only OBSERVE-subscribes and
sub-polls 'hot'/'warm' hrefs, so cold-tier state only refreshed on the
~30s full /device/0 summary sweep -- matching the 20-30s HA lag reported
on issue #17 despite commands landing on the device instantly. Pin them
to 'warm', same as CLIMATE's own primary href, so they get push
notifications (or warm-tier sub-polling as a poll-only fallback).
2026-07-23 01:01:39 +00:00
Marc Billow 5944d14bca Merge pull request #43 from mbillow/claude/ai-level-href-binding-djhmlv
Bind AI energy level on washer; switch/select split, drop translations
2026-07-22 19:46:59 -05:00
Marc Billow 1493467c63 Fix AI energy level platform-flip bug found in Opus review
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.
2026-07-23 00:44:58 +00:00
Marc Billow 61ee2972dc Trim duplicated comments from /simplify review
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.
2026-07-23 00:44:58 +00:00
Marc Billow 3d5263d792 Add common.UNIVERSAL/POWER bundles to stop hand-duplicating capabilities
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.
2026-07-23 00:44:58 +00:00
Marc Billow d6639c99d4 Bind AI energy level on washer; switch/select split, drop translations
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.
2026-07-23 00:44:57 +00:00
Marc Billow 5d9b470d7e Merge pull request #42 from mbillow/claude/translation-cycles-22-dxgozk
Add bubble soak, pre-wash, and intensive switches for washers (#22)
2026-07-22 14:56:26 -05:00
Marc Billow 9800cd0aa8 refactor: lift options[] boolean-toggle machinery into laundry.py
Addresses the reuse finding skipped in the previous /simplify pass:
washer's bubble soak/pre-wash/intensive switches and dishwasher's storm
wash/auto release dry switches were two separate implementations of the
same '<prefix>_On'/'<prefix>_Off' read-modify-write-on-options[] contract.

Moved bool_option_write/bool_option_value/bool_option_exists/
bool_option_switch into laundry.py (same module that already owns
cycle_write/cycle_select for the identical 'Course' token), and pointed
both washer.py and dishwasher.py at it. washer.py keeps only its
washer-specific per-course validate_fn, passed into the shared factory
as a prebuilt callable -- the factory itself has no opinion on validation.

No behavior change; re-verified every existing assertion (washer toggles,
dishwasher storm_wash/auto_release_dry, dosing alarms) plus all golden
state-key sets by hand against the refactored code.
2026-07-22 19:45:57 +00:00
Marc Billow 3469f57080 refactor: simplify washer toggle switches, move validate_fn to coordinator
/simplify pass on the bubble soak/pre-wash/intensive switches:
- Move validate_fn dispatch from switch.py into coordinator.async_send_command,
  next to the existing write_fn getattr -- every platform gets validation for
  free instead of switch.py hand-rolling it alone, and it avoids building the
  full resources snapshot twice per write (switch.py was calling
  coordinator.last_resources twice; the coordinator now snapshots once, and
  only when a validate_fn is actually present).
- Collapse the four per-switch factories (write/value/exists/validate) plus
  the _AVAILABILITY_FIELD side-table into one _bool_option_switch() that
  builds the SwitchDesc directly, so the three call sites read as one line
  each instead of six, and a typo'd prefix can no longer silently KeyError
  against a separate lookup table.
- Rename _dosing_alarm_exists to _option_exists and reuse it for the new
  switches too -- it was already the exact same "is this token present"
  check the toggles need.

No behavior change; re-verified write_fn/rep_fn/exists_fn/validate_fn against
the same fixtures and golden state-key sets as before.
2026-07-22 19:45:57 +00:00
Marc Billow f54daf3608 feat: reject bubble soak/pre-wash/intensive writes on unsupported courses
Add a validate_fn hook to SwitchDesc, checked in switch.py before dispatch
and surfaced as a ServiceValidationError so an unsupported write shows a
real error in the UI instead of the coordinator's silent log-only rejection.

Wired it into the three course-gated washer switches using their
availability bitmaps (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet),
which line up positionally with editCourseList. Turning a toggle off is
never blocked, and the check fails open whenever the course or bitmap can't
be resolved.

Also fixes a bug in _bool_option_write: it took a `p and 'On' or 'Off'`-style
truthy check, but switch.py always calls it with the string 'On' or 'Off' --
both truthy, so every write landed as 'On' regardless of intent.
2026-07-22 19:45:57 +00:00
Marc Billow e49b61e01e feat: add bubble soak, pre-wash, and intensive switches for washers (#22)
A follow-up dump confirmed these ride as plain BubbleSoak_On/Off,
PreWashSetting_On/Off, and IntensiveSetting_On/Off tokens in the same
/course/vs/0 options array as the cycle select, so they're exposed as
self-gating config switches the same way other options-array fields are.

Per-cycle availability (BubbleSoakSet/PreWashAvailableSet/IntensiveAvailableSet)
lines up positionally with editCourseList but isn't used for gating, since
exists_fn only runs once at setup against whatever course happened to be
active then.
2026-07-22 19:45:57 +00:00
Marc Billow dbe70ffc5b Merge pull request #41 from mbillow/claude/translation-cycles-22-dxgozk
fix: add missing washer cycle translations for combo units (#22)
2026-07-22 13:51:16 -05:00
Marc Billow 7d011bfe89 fix: add missing washer cycle translations for combo units (#22)
A washer/dryer combo user's editCourseList carries five Course_XX codes
that weren't named in washer_cycle: 36 (Wash+Dry), 37 (Air Wash),
38 (Cotton Dry), 39 (Synthetics Dry), and 1F (Intense Cold, distinct
from the existing 8F code used by non-combo models).
2026-07-22 18:50:56 +00:00
Marc Billow 9199fb7711 Merge pull request #39 from mbillow/claude/ac-support-triage-3zyjj9
feat: extend AC support for TP2X_RAC_20K and TP1X_DA-AC-RAC-01001_0000
2026-07-22 09:28:41 -05:00
Marc Billow 6611f85958 chore: bump version to 0.9.0
Covers PR #36's TP1X temperature-fallback fix and display-light switch,
plus TP2X_RAC_20K device-type recognition and full TP1X_DA-AC-RAC-01001
capability coverage (issues #37, #38).
2026-07-22 14:25:10 +00:00
Marc Billow 8470853fb6 fix: reconcile AC coverage with PR #36, bind selfcheck/mute-once for TP1X-01011 too
Rebased onto PR #36's merge (temperature fallback for /temperatures/vs/0,
display-light switch). Reconciliation, per Opus review:

- Dropped our duplicate LIGHT capability in favor of PR #36's DISPLAY_LIGHT
  (same href/entity, cosmetic differences only).
- Dropped /option/muteonce/vs/0 and /selfcheck/vs/0 from the ignore list
  PR #36 added -- both have confirmed, cleanly modelable contracts (a plain
  On/Off field, and a documented Start/Cancel self-check action already
  covered by the existing fridge.SELF_CHECK pattern), so keeping our bound
  versions instead of ignoring them. This also picks up mute_once/selfcheck_*
  on PR #36's own TP1X_DA-AC-RAC-01011 fixture -- regenerated its golden.
- Deduped the ~10 housekeeping hrefs both branches independently ignored.
- Added a unit test for climate.py's _temps_vs_item (the temperature-
  fallback selection logic), which had no coverage -- climate.py isn't
  importable in the registry-level AC tests, and the existing "no unbound
  hrefs" tests only exercise binding, not the fallback itself.
2026-07-22 14:20:58 +00:00
Marc Billow e942c822a6 feat: extend AC support for TP2X_RAC_20K and TP1X_DA-AC-RAC-01001_0000
Closes the coverage-gap repair for two more room-AC boards (issues #37,

- TP2X_RAC_20K reports no oneUiVersion and no '_PRAC_' modelNum token, so
  its device type went unrecognized -- add a '_RAC_' fallback token to
  for_device_by_model.
- Bind the resources both dumps actually populate: mute-once (switch),
  display light (switch), self-check (reused from fridge.SELF_CHECK), and
  the circuit-breaker current-limit setting (read-only diagnostic sensors,
  since its write contract isn't confirmed from the dump).
- Ignore the rest as plumbing/opaque/unconfirmed: control-set descriptor,
  keepnormalstate flag, software-reset trigger, AI Sleep handshake,
  absence-monitoring stubs, remote-data-control, remote-temperature,
  reservation ruleset, and the welcome-temperature tracker.

Adds scrubbed fixtures + goldens for both dumps and extends the AC
capability/golden-regression tests to assert zero unbound hrefs.
2026-07-22 14:20:55 +00:00
Marc Billow 4a376a98aa Merge pull request #36 from paddawan2002/fix/tp1x-airconditioner-coverage
Fix AC coverage for TP1X-class firmware (oneUiVersion "7.0 Air conditioner")
2026-07-22 09:12:33 -05:00
paddawan2002 bc9d4f4cf5 Fix AC coverage for TP1X-class (oneUiVersion "7.0 Air conditioner") 2026-07-22 11:26:01 +02:00
Marc Billow 10f2aac0bd Merge pull request #35 from aaearon/feat/fridge-tp1x-ai-energy-selfcheck
feat: add AI energy level select and self-check error sensor for TP1X refrigerator
2026-07-22 02:31:20 -05:00
Tim Schindler 0cc9d8e511 fix: render selfcheck_error as no-value on empty list; document ai_energy_level level labels 2026-07-22 09:04:24 +02:00
Marc Billow b586277031 Merge pull request #34 from aaearon/fix/flaky-observe-resubscribe
test: remove timing race from coordinator observe-mode tests
2026-07-22 01:56:58 -05:00
Tim Schindler 2a0a9f32ee feat: add AI energy level select and self-check error sensor for fridges
Adds two small, additive fridge capabilities:

- AI_ENERGY_LEVEL: a select on /energy/ailevel/vs/0 exposing the AI
  energy-saving level, gated behind supportedAiLevel actually offering
  more than one choice (previously globally ignored since every dump
  seen only reported a single supported level).
- selfcheck_error: a diagnostic sensor on the existing SELF_CHECK
  capability surfacing x.com.samsung.da.error from the last self-check,
  for hardware that reports it.

This does not touch FLEX_ZONE, /mode/vs/0, or /icemaker/status/0.
2026-07-22 08:42:31 +02:00
Tim Schindler 8dadea6796 test: address Copilot review on observe fixture typing and comment
- Parameterize FakeObserveSession.notify_on_subscribe as dict[str, Any] | None
  to match the surrounding fully-typed attributes (the dict is delivered as
  the OBSERVE rep to on_notification); add the typing.Any import.
- Reword the test_coordinator comment: subscribe() delivers a notification
  for every href it subscribes to (when notify_on_subscribe is set), not one.
2026-07-22 08:22:45 +02:00
Marc Billow 2cd93619a4 Merge pull request #33 from mbillow/claude/triage-dump-device-support-1pqxfr
feat: detect FlexWash (WV) washers; add combo dry-level select
2026-07-22 01:17:29 -05:00
Marc Billow 5a9a002a7f chore: bump version to 0.8.0
Covers the #19-#27 fridge/washer coverage-gap batch: FlexWash (WV) and
washer/dryer combo detection, the CV_FDR_ flex-zone fix (also closes #32),
the Cool Select Zone pantry select, and the new energy sensors.
2026-07-22 06:12:17 +00:00
Tim Schindler 0715eb0829 test: remove timing race from coordinator observe-mode tests
The observe tests raced a background thread (5 ms wall-clock sleep)
against the coordinator reaching its resubscribe. try_enter_observe_mode
clears _notified before subscribing, so a notify only counts if it lands
between that clear and the end of the grace sleep. When the intervening
update cycle (reconnect, cache sweep, executor hops) took longer than
5 ms, every notify was cleared and the mode stayed 'poll' —
test_reconnect_from_observe_mode_resubscribes_immediately failed roughly
1 in 7 runs.

Replace the threads with FakeObserveSession.notify_on_subscribe, which
delivers the rep synchronously from subscribe() the way a real device
answers a subscription. That puts the notify inside the grace window by
construction rather than by timing. Tests that require the resubscribe to
fail now set it to None explicitly instead of relying on the absence of a
racing thread.

No production code changed; no assertion weakened, and no sleeps, retries
or timeouts added.

Claude-Session: https://claude.ai/code/session_01PUSU6tDjHjtbExtyXDPT3N
2026-07-22 08:11:18 +02:00
Marc Billow 43542b146f fix: restore empty-stub carve-out on the new energy sensors; dedupe flex-zone set build
Review follow-up (Opus + /simplify) on the previous commit:

- power_energy_kwh/energy_saved_kwh/energy_last_month_kwh/
  energy_this_month_kwh used a plain `field in rep` exists_fn, unlike their
  siblings power_watts/energy_kwh in the same capability. Per
  entity._is_included, an explicit exists_fn bypasses the stub carve-out
  entirely -- an empty {} rep at platform setup (device/0 returned a
  not-yet-fetched stub) would permanently drop these entities for the
  session instead of picking them up once a sub-poll populates the
  resource. Restore the same `not rep or ...` guard used above.
- fridge._flex_zone_current/_flex_zone_write independently rebuilt the same
  supportedOptions set; factor into _flex_zone_supported.
- note in a comment that the flex-zone match assumes at most one
  modes/supportedOptions overlap (true on every dump seen); add the
  missing negative assertion that dry_level self-gates off on a plain
  washer (was only positively asserted on the combo fixture).
2026-07-22 06:11:16 +00:00
Marc Billow 9a899080fa fix: fridge/washer coverage gaps from the #19-#27 dump batch
Six new diagnostics dumps, six gaps closed:

- refrigerator: bind /diagnosis/vs/0 (reuse dishwasher.DIAGNOSIS -- same
  shape) into the refrigerator registry; it was never wired up there,
  tripping the coverage repair on any fridge that reports it (#20, #26).
- refrigerator: add PANTRY_ZONE for the Cool Select Zone pantry compartment
  (/status/pantry/one/vs/0, x.com.samsung.da.mode/supportedOptions) -- same
  shape as BEVERAGE_ZONE but a distinct resource/field set (#20).
- refrigerator: generalize FLEX_ZONE to identify the current mode by list
  membership in supportedOptions instead of a hardcoded
  CV_TTYPE_RF9000A_ prefix check. TP1X/Bespoke-class fridges use a
  CV_FDR_ prefix instead, which the old code didn't recognize -- the
  select existed but always read as unknown, and writing to it would have
  appended a duplicate CV_FDR_ flag rather than replacing the existing one
  (#26, #27; also closes #32).
- common: add energy_saved_kwh (x.com.samsung.da.cumulativeSavedPower) and
  power_energy_kwh (x.com.samsung.da.cumulativeConsumption) to the shared
  energy meter, plus fridge-only energy_last_month_kwh/energy_this_month_kwh
  (monthlyConsumption/thismonthlyConsumption) -- all self-gating on field
  presence (#26).
- by_type: add the WV consumer-model prefix (FlexWash twin washers, e.g.
  WV55M9600AW) to the by-model fallback map. These report no oneUiVersion
  and previously matched no prefix at all, so they fell all the way
  through to the unrecognized-device registry with zero capabilities
  bound (#19).
- washer: add a self-gating dry_level select to WASHER_SETTINGS for
  washer/dryer combo units, which carry a writable dryLevel field
  directly on /washer/vs/0 with no separate dryer resource or course
  (#22).

The reset-water-filter button and "ice type vs. two named icemakers"
requests from #26/#27 are left alone -- no write contract or exclusivity
behavior is evidenced in either dump, and both icemakers report On
simultaneously on the Bespoke unit, so synthesizing a single-select would
be a guess rather than a fix.

Adds scrubbed fixtures + goldens for FlexWash, a washer/dryer combo,
ARTIK051_REF_17K, and TP2X_REF_20K, plus unit tests for the new/changed
capabilities.
2026-07-22 06:11:15 +00:00
Marc Billow 88e630b54f feat: detect FlexWash (WV) washers; add combo dry-level select
- WV consumer-model prefix (FlexWash twin washers, e.g. WV55M9600AW)
  wasn't in the by-model fallback map, so these units fell through to
  the unknown-device registry with zero capabilities bound (#19).
- Washer/dryer combo units carry a writable dryLevel field directly on
  /washer/vs/0 with no separate dryer resource; add a self-gating
  select for it, off supportedDryLevel presence, so plain washers are
  unaffected (#22).
2026-07-22 06:11:15 +00:00
Marc Billow 70a7f861c3 Merge pull request #25 from themaanda/contrib/detect-wf-washers
Detect WF-series washer models
2026-07-22 00:20:36 -05:00
Marc Billow ff034905a2 Merge pull request #24 from themaanda/contrib/tp1x-refrigerator-mirrors
Cover TP1X refrigerator native resource mirrors
2026-07-22 00:20:03 -05:00
Marc Billow ae8bb0b621 Merge pull request #29 from mbillow/claude/bump-version-0.7.0
chore: bump version to 0.7.0
2026-07-21 19:19:04 -05:00
Marc Billow 3688cf24a0 chore: bump version to 0.7.0
Covers the air-conditioner support (#17) and the washer dosing-select /
diagnostics fixes (#9). main was still advertising 0.6.0 despite the AC work
already merging, so this moves it for the next release.
2026-07-22 00:15:22 +00:00
Marc Billow d917d757e9 Merge pull request #28 from mbillow/claude/issue-9-dosing-select-format-and-blocking-fix
fix: normalize washer dosing-select codes; stop blocking the loop in diagnostics (#9)
2026-07-21 19:10:29 -05:00
Marc Billow f4b4a04628 refactor: tidy the AC climate entity (/simplify pass)
Quality-only cleanups on the air-conditioner support, no behavior change:

- climate.py: reuse common.normalize_temp_unit for the C/F unit read (also
  handles the "Celsius"/"Fahrenheit" long forms); collapse the three
  fan/swing/preset read properties into _read_mode/_read_modes and the three
  write setters into _set_mapped, removing the copy-paste.
- Single source of truth for the climate-consumed hrefs: they lived both as
  constants in climate.py and as a list in airconditioner.py. Declare them
  once in airconditioner.py (HREF_* + CLIMATE_CONSUMED_HREFS, which also builds
  the COVERAGE caps) and import them into climate.py, so a new sibling read
  can't drift out of sync with its coverage entry.
2026-07-21 23:59:03 +00:00
Marc Billow c0298d57d9 fix: normalize washer dosing-select codes; stop blocking the loop in diagnostics (#9)
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.
2026-07-21 23:49:30 +00:00
themaanda 0503bda0f3 Detect WF-series washer models 2026-07-21 16:59:16 -05:00
themaanda 6f53b3043b Cover TP1X refrigerator native resource mirrors 2026-07-21 16:58:42 -05:00
themaanda c64f58aaa5 Add cooktop and range hood support 2026-07-21 16:57:45 -05:00
Marc Billow bedc662b47 Merge pull request #18 from mbillow/claude/device-support-triage-17-665d44
feat: air conditioner support with a composite climate entity (#17)
2026-07-21 09:55:35 -05:00
Marc Billow 296535d0f2 feat: air conditioner support with a composite climate entity (#17)
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.
2026-07-21 14:38:14 +00:00
Marc Billow d7fe609159 Merge pull request #16 from mbillow/claude/dryer-support-issue-14
feat: full dryer support + consistent laundry capability surface
2026-07-20 21:17:18 -05:00
Marc Billow 15f836caeb ci: bump actions/checkout and actions/setup-python to v7 (Node 24)
Clears the 'Node 20 is being deprecated' warnings by moving the actions
we pin to their current Node 24 majors (both released 2026-07-20).
hassfest@master and hacs/action@main are third-party and float on their
own branches.
2026-07-21 02:09:55 +00:00
Marc Billow 1576d095c8 ci: run pytest in CI; complete dev requirements; unblock socket test
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.
2026-07-21 02:05:57 +00:00
Marc Billow febc8fa4a0 fix: keep energy sensors on empty-stub reps; gate power on field presence
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.
2026-07-21 01:39:49 +00:00
Marc Billow 9264b912f9 feat: full dryer support + consistent laundry capability surface
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.
2026-07-21 01:15:12 +00:00
Marc Billow 84788174f8 Merge pull request #15 from mbillow/claude/device-port-detection-vp0q8d
feat: auto-detect DTLS port across the full 49152-49160 range
2026-07-20 16:55:31 -05:00
Marc Billow 00c3ebaf75 feat: auto-detect DTLS port across the full 49152-49160 range
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.
2026-07-20 21:52:35 +00:00
Marc Billow 4cc073c88a Merge pull request #12 from mbillow/claude/readme-cleanup-b3ib70
docs: simplify and update README
2026-07-18 23:34:43 -05:00
Marc Billow 91013ece60 fix: translate machine_state sensor values; bump to 0.4.1
machine_state was rendering the raw lowercase OCF values (idle/active/
pause) untranslated in the UI. Add device_class=enum, options, and a
translation_key matching the existing pattern used by ice_making_status
and connection_mode, in both the shared operational.py capability and
oven.py's separate machine_state sensor.

Bump patch version for this fix plus the ice_type 'off' translation and
iot_class correction earlier on this branch.
2026-07-19 04:33:23 +00:00
Marc Billow 795763f3ca fix: add missing ice_type 'off' state translation
The refrigerator fixture's x.com.samsung.da.iceType.supported list
includes "Off" alongside the whiskey_iceball_* values, but strings.json
only translated the whiskey_iceball options, leaving "Off" to render
untranslated in the ice-type select.
2026-07-19 04:18:59 +00:00
Marc Billow a7ed230569 fix: correct iot_class to local_push
Coordinator prefers CoAP OBSERVE push notifications (observe.py) over
polling when the device supports it, falling back to polling only when
observe mode isn't available or drops.
2026-07-19 04:11:01 +00:00
Marc Billow 9fee5b5ec0 docs: simplify smartthings-local intro blurb 2026-07-19 04:07:52 +00:00
Marc Billow f24d6a0ae6 docs: simplify and update README
Add missing washer support to the appliance table, fix stale test-count
and repo-layout claims, trim protocol-internals jargon that belongs to
the smartthings-local library rather than this integration, and point
"Adding a new appliance type" at HA's own diagnostics download instead
of a gitignored local script.
2026-07-19 04:05:37 +00:00
Marc Billow 29b145ccdd Merge pull request #11 from mbillow/claude/issue-9-investigation-mdnwcd
chore: bump manifest version to 0.4.0
2026-07-18 13:27:57 -05:00
Marc Billow 5fbf58af30 chore: bump manifest version to 0.4.0 2026-07-18 18:26:58 +00:00
Marc Billow 9ae59889d4 Merge pull request #10 from mbillow/claude/issue-9-investigation-mdnwcd
fix(washer): stale idle progress%, add detergent/softener dosing entities (#9)
2026-07-18 13:25:33 -05:00
Marc Billow 35f76ce2df feat(washer): add label translations for detergent/softener dosing selects
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).
2026-07-18 18:24:10 +00:00
Marc Billow 2a6bbc9dfb fix(washer): stale idle progress%, add detergent/softener dosing entities (#9)
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.
2026-07-18 18:15:41 +00:00
Marc Billow aee19fd953 Merge branch 'fix-washer-fridge-oven-issues'
* fix-washer-fridge-oven-issues:
  chore: bump manifest version to 0.3.0
  fix(washer,fridge,oven): correct energy/temperature-unit bugs from issues #6/#7
2026-07-15 16:02:05 -05:00
Marc Billow 37bd063c0a chore: bump manifest version to 0.3.0 2026-07-15 15:59:24 -05:00
Marc Billow e875266405 fix(washer,fridge,oven): correct energy/temperature-unit bugs from issues #6/#7
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.
2026-07-15 11:41:21 -05:00
257 changed files with 47538 additions and 1480 deletions
@@ -0,0 +1,470 @@
---
name: adding-device-support
description: >-
Add or extend support for a Samsung OCF appliance in localthings from a
/device/0 diagnostics dump. Use when a device-support issue lands, a device
raises the "incomplete capability coverage" repair, a diagnostics JSON needs
triaging, or you're mapping OCF resources to HA entities. Covers reading dumps,
routing an unrecognized board family to a registry (modelNum board tokens,
resource signatures),
OCF-standard vs vendor hrefs, the diagnostic/config/normal entity taxonomy,
preferring dynamic (device-reported) select options over hardcoded lists,
ensuring every href is bound or ignored, and locking it in with a fixture +
golden + test. Also covers multi-subdevice ("composite") appliances that
expose several logical indoor subdevices over one IP — triaging a missing
second subdevice, and why registry hrefs stay canonical rather than indexed.
---
# Adding device support
localthings maps a Samsung appliance's OCF resources (`/device/0` dump) to Home
Assistant entities. Each resource `href` is handled by a `Capability` that
declares the entities it produces. This skill is the workflow for turning a new
dump into coverage.
## 1. Get the dump and see the gaps
A user's diagnostics download (`config_entry-localthings-*.json`) has, under
`data`:
- `resources`: `{href: rep}` — the parsed `/device/0` snapshot. **This is the
source of truth**, not code comments. On a multi-subdevice appliance this is
the subdevice the config entry connects to and *only* that subdevice;
siblings report their own (see below).
- `unbound_hrefs`: resources that bound to no capability. The
"incomplete capability coverage" repair fires whenever this is **non-empty or
the device type is unrecognized** (`coordinator._update_coverage_gap_issue`).
Multi-subdevice appliances (one IP, one DTLS session, several logical indoor
subdevices — issue #177) add four more, all absent/empty on an ordinary device:
- `subdevices`: one entry per materialized sibling — `kind`/`key`/`seed_path`,
its own `model`, its bound hrefs, and its own `resources`.
- `subdevices_skipped`: candidates whose seed answered but that produced no
live primary state, with the reps the gate actually judged. An unused
SmartThings slot lands here, not in `subdevices`.
- `subdevice_probes`: `{seed_href: found}` for every seed attempted — tells
"checked, nothing there" apart from "never checked".
- `multidevice`: `/multidevice/vs/0`'s rep if the board answers it. Its
`numofsubdevice` is a corroborating count, not a gate.
Goal: make `unbound_hrefs` empty by **binding** the useful resources and
**ignoring** the noise — and surface every genuinely useful sensor/select/switch
along the way.
## 2. Compute coverage without Home Assistant
The `registry/` package is HA-free, so you can drive discovery directly (HA
isn't importable standalone because `localthings/__init__.py` pulls it in — stub
the package to skip that):
```python
import sys, types, json, importlib
cc = types.ModuleType('custom_components'); cc.__path__=['custom_components']; sys.modules['custom_components']=cc
lt = types.ModuleType('custom_components.localthings'); lt.__path__=['custom_components/localthings']; sys.modules['custom_components.localthings']=lt
by_type = importlib.import_module('custom_components.localthings.registry.by_type')
discovery = importlib.import_module('custom_components.localthings.registry.discovery')
adapter = importlib.import_module('custom_components.localthings.registry.adapter')
resources = json.load(open('dump.json'))['data']['resources']
reg = by_type.resolve(resources) # the same entry point the coordinator uses
unbound = []
bound = discovery.discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
state = adapter.flatten(bound, resources) # {entity_key: value}
print('registry:', reg.name, 'unbound:', sorted(unbound))
print('state_keys:', sorted(state))
```
`discover()` binds caps (applies `rt_filter`/`match_fn`); `flatten()` applies
`exists_fn` and produces the final entity values. Use the same routine to
regenerate a golden.
**A sibling subdevice's block runs through this unchanged.** `subdevices[i].resources`
(and `subdevices_skipped[i].resources`) are keyed by *canonical* hrefs —
`/mode/vs/0`, never the `/mode/vs/1` or `/<uuid>/mode/vs/0` that subdevice
actually answers on — precisely so you can paste one into `resources` above and
read the result exactly like the master's. No de-indexing by hand.
## 3. Route the device to a registry — add a row, never a branch
If detection returns `None`, the device falls back to common capabilities and
loses roughly **half** its entities (measured across the fixture corpus: 843 of
1510 bound entities survive). So routing is the first thing to fix, and
`registry/by_type/__init__.py` is deliberately kept boring.
`resolve(resources)` is the only entry point — the coordinator, the config
flow's probe and the golden-regression harness all call it, so the order can't
drift between what ships and what the tests assert. Two stages:
1. **`for_device_by_model(model_num, description)`** — the primary path. Both
fields come from `/information/vs/0`. Board-family tokens are matched
against `modelNum` first, then `description`, then the fuzzy two-letter
consumer-model prefix.
2. **`for_device_by_resources(resources)`** — for boards that report no
`/information/vs/0` at all. Needs a *distinctive* signature.
**`oneUiVersion` is not consulted.** It looks like the obvious signal — the
device naming its own type, `'7.0 Dishwasher'` — and it used to be stage one.
But only a minority of hardware reports it, every device that does is already
typed by its modelNum board token (`TestOneUiVersionIsNotConsulted` checks that
against the whole corpus), and no device-support issue was ever fixed by adding
a mapping for it. Don't reintroduce it as a detection stage; it stays in
diagnostics as a firmware-generation marker (`'7.0 Air conditioner'` means
Tizen Lite), which is useful when triaging.
### Adding a board family
Almost always a one-line addition to `_BOARD_TOKEN_TO_KEY`:
```python
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
```
Matching is on **whole tokens** of the model string, split on any run of
non-alphanumerics and upper-cased. That is what keeps this a table, and it
carries rules:
- **Never add a delimiter spelling.** `'_RAC_'` and `'-RAC-'` are the same
entry, `RAC`. If you find yourself adding a second row for punctuation, the
tokenizer already handled it.
- **Name the specific type, never the board family.** `DA-AC-` prefixes
RAC/WAC/DHM/AIR alike — a bare `'AC'` row would swallow the dehumidifier
and the air purifier. Same for `DA`, `KS`, `WM`, `TP1X`, `ARTIK051`.
`TestBoardTokenTable` asserts these stay out.
- **Never add a token that can co-occur with another.** `_board_family_key`
returns the first hit, which is only safe while no real model string
contains two tokens naming different types.
`TestBoardTokenAmbiguity` checks that invariant against every fixture, so a
new dump exercises it automatically — if it fails, the answer is a narrower
token, not a reordering.
- **Two-letter tokens are a last resort.** `'CT'` (legacy gas cooktop) is the
only one, and it is loose enough to collide by accident.
Reach past the table only when the evidence isn't a board token:
- **Consumer-model prefix** (`_CONSUMER_PREFIX_TO_KEY`) — for washers, dryers
and dishwashers, whose `modelNum` is the shared `DA_WM_` laundry board and
whose real type is only in `description`'s trailing model code
(`..._WA8000T`). Deliberately split on `_` only: widening it to `-` would
read the dishwasher's `ADW-WW-RTL-24-AILITE` board segment as a `WW`
washer. Consulted last because a two-letter prefix is the weakest evidence
here — `WAC` (window AC) starts with `WA` (top-load washer).
- **Resource signature** (`for_device_by_resources`) — only when
`/information/vs/0` is absent entirely. Require **two** independent shapes
(e.g. `/oven/vs/0` present *and* a `MicroWave*` entry in `supportedModes`),
never one, or an unrelated family's `/mode/vs/0` will match.
### If the model string identifies nothing
Check the diagnostics `identity` block before inventing a rule: it carries
`/oic/p` and `/oic/d`, which sit outside the `/device/0` dump.
`identity.device_types` is `/oic/d`'s `rt` — OCF's own device-type
declaration (`oic.d.airconditioner`). Nothing routes on it yet because no
captured dump has ever included it; if real hardware turns out to populate it,
it beats parsing board part numbers and this whole section shrinks. Note in
the issue when a dump has it.
### Sharing a registry vs adding one
Route a new family to an **existing** registry when its resource surface
matches (most AC board families do — verify by checking the dump binds with
zero unbound hrefs). Add a **new** registry only when the resources genuinely
differ: `vacuum_station` earned one because it shares no hrefs with anything
modelled; `microwave` split from `oven` over a distinct mode vocabulary,
setpoint bounds, and a `powerLevel` field.
## 4. OCF-standard vs vendor hrefs (`/x/0` vs `/x/vs/0`)
Samsung appliances run RT-OCF and often expose the **same state twice**:
- `/x/vs/0` — **vendor** resource, `x.com.samsung.da.*` fields.
- `/x/0` — **standard OCF** resource type (`oic.r.*`) with OCF's fixed field
names. Confirmable against the OCF spec: `/power/0` `{value: bool}` is
`oic.r.switch.binary`; `/operational/state/0` is `oic.r.operational.state`.
Newer firmware advertises both as Samsung migrates onto the OCF standard. **There
is no single "always prefer vs / always prefer non-vs" rule** — decide per
resource from the populated dump:
- **Both populated, same state** (power, kids-lock, remote): prefer the
OCF-standard `/x/0`; fall back to `/x/vs/0` when `/x/0` is absent. Encode with
a `match_fn` presence check — see `common.POWER_GENERIC` / `POWER_VS_FALLBACK`.
- **Only vendor populated** (`/energy/consumption/0` is often empty `{}`): use
`/x/vs/0`.
- **Vendor is a superset** (`/operational/state/vs/0` adds fields the OCF one
lacks): build on the vendor resource, ignore the OCF subset.
Course/cycle is **not** an OCF question — there's no standard course resource, so
`/course/vs/0` (and the `/st/*course/vs/0` re-encoding) are both vendor.
## 5. Entity taxonomy — the judgement call
For each field worth exposing, decide the entity kind and category
(`entity_category` on the descriptor):
- **Normal / primary** (no `entity_category`): the things a user acts on or
watches — power switch, machine state, the cycle select, energy sensors.
- **`config`**: user-tunable settings — sound mode, door LED, wash temperature,
buzzer. Shown under the device's Configuration section.
- **`diagnostic`**: read-only status/troubleshooting — alarms, diagnosis, job
beginning status, last-operation source.
Also set `poll_tier` (`hot`/`warm`/`cold`) on the capability for how often it's
sub-polled between summary polls. Pick descriptor types from `entities.py`
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
**Don't guess.** If a field's meaning or write contract is unclear from the dump
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule).
This is a rule about **writes and entities**, not about reading. A speculative
`GET` of an href a dump doesn't contain is fine and the codebase already relies
on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and
`subdevices.enumerate_subdevices` probes `/device/<n>`, `/<uuid>/device/0` and
`/multidevice/vs/0` on every device — and, when a prefixed candidate's own
`/<uuid>/device/0` doesn't answer (issue #205: not guaranteed even on the
board this pattern was built against), every href the master itself
answered this cycle, individually under that UUID's prefix (see §11). A
RETRIEVE is non-mutating and a 4.04 is tolerated everywhere in that path, so
the cost of a wrong guess is one wasted round trip. Guessing a *write*
against live hardware is the thing this rule forbids — as is materializing
an entity from a field you can't explain.
## 6. Select options: read them from the device, don't hardcode
A `SelectDesc`'s `options` should come from the device's own advertised list
whenever the resource carries one, not from a Python tuple typed in from a
single dump. Two dynamic forms already exist in the repo and should be
reached for first:
- `options_field='x.com.samsung.da.supportedModes'` (or whatever the
resource's own supported-values field is called) — reads the live rep on
the capability's own href. See `laundry.py`'s `buzzer_sound`/
`finish_sound` (`options_field='supportedBuzzerSound'`/
`'supportedFinishSound'`).
- `options=<callable>` — for option lists that live on a **different**
resource than the select's own href (e.g. a course table keyed off a
sibling href). See `laundry.cycle_select`'s `options=cycle_options`.
A static `options=(...)` tuple is a coverage gap waiting to happen: the next
dump from a different board generation will report modes/values the tuple
doesn't have, and both the HA options list *and* `write_fn`'s validation (if
it checks the same tuple) will silently reject values the device itself
advertises as supported. That's exactly what happened with `oven._OVEN_MODES`
in issue #138 — a hardcoded list rejected `AirFryer`/`Dehydrate`/
`SelfClean`/etc. even though the device's own `supportedModes` field listed
them. Reach for a static tuple only when the dump genuinely has no
supported-values field to read (e.g. the NV7000BS-class oven dump
`_OVEN_MODES` was inferred before any live oven dump existed — see that
module's docstring), and treat it as an interim best-guess rather than a
permanent design choice: migrate it to `options_field`/a callable the moment
a dump with a real supported-values list surfaces, instead of just adding
the new values to the static tuple.
## 7. Names and enum labels live in translations, never in Python
Descriptors have **no `name` field**. Every entity is named from the shipped
catalog, keyed by `translation_key` — which defaults to the descriptor's own
`key`. So adding `SensorDesc(key='filter_status', ...)` obliges you to add:
```json
"entity": { "sensor": { "filter_status": { "name": "Filter status" } } }
```
to `translations/en.json`. Skip it and the entity ships nameless;
`tests/test_translations.py` fails the build instead.
- **Sentence case** ("Filter status", not "Filter Status"), per HA's style
guide — capitalize only proper nouns and Samsung feature names ("AI Energy
Mode", "Storm Wash+").
- Set `translation_key` explicitly only to **share** one catalog entry across
descriptors, or to point at a differently-named one. Two descriptors on the
same platform with the same `key` already share an entry — intended for
`common.py`'s OCF/vendor fallback pairs, a silent mislabel otherwise.
- Prefer HA's own vocabulary where it fits: a `device_class` gives you
translated states for free (`binary_sensor` door/running, `sensor`
timestamp/enum), so don't restate them.
Selects whose options are raw device codes (course/cycle, code-valued
settings) additionally need those codes labelled:
- `options`/`options_field` supply the **raw** codes; the catalog maps them.
- Add labels under `entity.select.<translation_key>.state.<code>`, code
**lowercased** (e.g. `"16": "Cotton"`). `select.py` derives which values it
normalizes from the catalog itself, so there is no Python list to keep in
sync — a code with no entry simply renders as the raw code, which is the cue
to identify and name it.
`translations/en.json` is the only place any of this lives: there is no
`strings.json` (Home Assistant doesn't read one from a custom integration) and
no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`.
## 8. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
with a one-line reason. Add there only when it's **irrelevant plumbing**
(network/OTA/account housekeeping) or a **duplicate of state exposed via a
friendlier href**.
- **Global vs per-registry ignore:** `ignored.IGNORED` is folded into every
registry. A global ignore **collides** (via `_build`) with any real capability
that binds the same href in some family — e.g. `/course/vs/0` can't be globally
ignored because washers bind it. When only one family should ignore an href
that another binds, scope the ignore to that family's registry.
- **Registry hrefs are always canonical — never index or prefix one.** On a
multi-subdevice appliance, `unbound_hrefs` reports the *real* href a gap was
seen on, so a sibling's gap shows up as `/foo/vs/1` or
`/<uuid>/foo/vs/0`. Do **not** write `Capability(href='/foo/vs/1')` for it.
Binding runs against each subdevice's canonical view, so an indexed or
prefixed href in a registry matches nothing on any device and fails
silently — no error, no entity, and the gap stays open. Fix it on the
`/foo/vs/0` form and every subdevice gets it at once.
(`registry/subdevices.py` owns the canonical ⇄ actual translation; nothing
under `capabilities/` or `by_type/` should ever mention a subdevice index.)
## 9. Reuse before writing new code
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
machinery) before adding a capability. Cross-family reuse is normal — the dryer
registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
shared module rather than copying.
## 10. Lock it in
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
and other PII with placeholders.
A multi-subdevice dump (issue #177) may carry three more top-level keys, all
optional and defaulted for every other fixture — load them with
`conftest._load_device_full` rather than `_load_device`:
- `oic_res`: the raw `/oic/res` link array, which is what enumeration reads
to find `/device/<n>` siblings.
- `seeds`: `{seed_href: raw_batch_list}` — each sibling's own collection
response, in the same `[devcol rep, {href, rep}, ...]` shape as `device0`.
- `probes`: `{href: rep}` for plain Property-map resources belonging to no
batch (e.g. a hand-read `/multidevice/vs/0`).
Add a `seeds_note` saying which parts are verbatim captures and which were
constructed. A fixture that quietly mixes the two is worse than no fixture:
the whole point of the corpus is that it records what hardware actually did.
2. Generate `tests/fixtures/golden/<type>.json` (`{"state_keys": [...]}`) with
the harness in §2. A multi-subdevice fixture's golden carries a sibling's
keys under a prefix (`subdevice1_climate`, `subdevice_<uuid>_climate`)
alongside the unprefixed master keys — that's the entity-ID namespacing,
not a bug.
The master's keys are unprefixed *by design* and must never gain one:
that's what keeps every pre-#177 device's `unique_id` stable.
3. Add the type to `test_golden_regression.py` and write a
`test_<type>_capabilities.py` asserting **zero unbound hrefs** and that the
expected entities exist (and any misleading ones are gated).
4. Run `pytest tests/ -q` — and re-run the golden tests for **other** device
types after any change to `common.py`/`laundry.py`, since they share those.
The new fixture is picked up automatically by the corpus-wide checks (the
`all_device_fixtures` conftest fixture), including
`TestBoardTokenAmbiguity` — so a model string that collides with an existing
board token fails the build rather than silently mistyping someone's
appliance.
**Don't put a reporter's name or GitHub username in code.** Fixture data
gets serials/MACs/other device PII scrubbed per point 1 above — the same
rule applies to the *prose* you write while fixing the issue: comments,
docstrings, `seeds_note`, and test/function names should say "the
reporter," "issue #NNN's reporter," or (when a module already distinguishes
multiple reporters, like `subdevices.py`'s Pattern A/Pattern B) "the
Pattern A reporter," never a real name or handle. That prose ships in the
package and lives in git history indefinitely — unlike an issue thread or a
release-notes thank-you (both fine places to credit someone by name), it's
not somewhere a person would expect to stay named forever. If you're fixing
an issue and about to write `<username>'s board`/`<username>'s dump` in a
comment, stop and swap in a generic reference instead.
## 11. Triage: "one of my subdevices is missing"
For an appliance that exposes several logical indoor subdevices over one IP —
a 2-in-1 air conditioner, plausibly a multi-drum washer (#19). Work down
the dump in this order; each step rules out a different cause.
1. **`subdevice_probes`** — did we even look? Every seed attempted appears
here with what it returned. An absent seed means enumeration never tried
that path; a `false` means it tried and got nothing. On a UUID-prefixed
board whose `/<uuid>/device/0` reads `false` (issue #205 — this isn't
rare, not even on the board the pattern was built against), the report
also carries one probe per href the master itself answered that cycle,
individually under that prefix (`subdevices.enumerate_subdevices`'s flat
fallback) — a `true` there is real, confirmed-live evidence for that one
href, not a guess.
2. **`subdevices`**/**`flat_hrefs`** — for a *materialized* subdevice found
this way, `flat_hrefs` lists exactly which hrefs it's actually being
polled on (individually, no Collection endpoint to batch through) —
compare against the master's own hrefs to see what's still unconfirmed
for that sibling.
3. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
here when its seed(s) answered but it produced no *primary*
(non-diagnostic), non-meter entity with a populated value. Its
`resources` block holds the exact reps the gate judged, so you can check
the call yourself. If every power/mode/temperature rep is `{}`, the
subdevice is an unused slot and the skip is correct — a populated
`/energy/consumption/vs/<n>` alongside them doesn't change that (issue
#214: an appliance's lifetime kWh counter shows up under an unused
slot's index too, and materializing on it produced a phantom duplicate
air conditioner, so cumulative meters are excluded from the gate). If
the *operational* reps are populated, the gate is wrong — that's a bug
worth a fixture. A flat-fallback candidate whose
only confirmed href is `/information/vs/0` (never bound to any entity —
only ever read for device-type resolution) will *always* land here until
more of its hrefs are confirmed live; that's the gate working as
intended, not a bug to chase.
4. **`multidevice.numofsubdevice`** — the board's own count, where it
reports one. `coordinator._run_discovery` compares it against
`len(materialized) + 1` (materialized subdevices plus the master) and
only warns on disagreement — `subdevices_skipped` entries don't count
toward either side, since they never materialized. A strong hint, not
proof; only one board family is known to expose it.
5. **Which pattern is this board?** `identity.resources['/oic/res']` listing
`/device/1`, `/device/2` means indexed siblings. `resources['/subdevices/
vs/0']` carrying a `subdeviceIdList` means a UUID-prefixed tree, and that
same UUID usually shows up as an href prefix in `/oic/res` too — enumerate
whether or not `/<uuid>/device/0` itself answers, per §5's fallback.
Neither present, on a device the owner insists has two subdevices, is the
interesting case — that's a third mechanism and needs a new dump, not a
code guess.
Two things that are *not* the fix: adding a capability for an indexed href
(see §8), and loosening the liveness gate to "any populated entity" — a
rejected slot routinely reports a non-`None` *diagnostic* value off an empty
resource (and, on some boards, a populated appliance-level meter), which is
exactly what the primary-entity and meter filters exist to ignore.
### The mirror image: "I have one subdevice too many"
Same dump, read the other way (issue #214). A duplicate device in HA is
either a candidate that shouldn't have materialized — check `subdevices`
for one whose `resources` are all `{}` except a meter/`/information`, which
is the unused-slot shape from step 3 — or a **leftover registry entry** from
a release that did materialize it. Those two look identical in the HA UI and
are told apart by the dump: a leftover shows `subdevices: []` (or no entry
for that key) while the device is still listed in HA.
Nothing prunes a leftover automatically — subdevice enumeration is one-shot
and a real sibling can miss a poll, so auto-removal would throw away a live
subdevice's name/area/automations on a transient miss. The integration
implements `async_remove_config_entry_device`
(`custom_components/localthings/__init__.py`) instead, which is what puts a
working "Delete device" button on anything this entry no longer provides;
devices it *does* provide refuse removal, since HA would just recreate them.
Tell the reporter to delete the stale device, don't add a pruning pass.
## Key files
- `registry/subdevices.py` — `Subdevice`, enumeration, canonical ⇄ actual href
translation, and the materialization gate for multi-subdevice appliances.
- `registry/discovery.py` — `discover()`, unbound reporting, pattern caps.
- `registry/capability.py`, `registry/entities.py` — the `Capability` and
descriptor shapes (`rt_filter`, `match_fn`, `exists_fn`, `rep_fn`, `write_fn`).
- `registry/capabilities/{common,laundry,fridge,...}.py` — capability defs.
- `registry/capabilities/ignored.py` — the ignore list + its philosophy.
- `registry/by_type/*.py` — per-device-type registries (what to include).
- `registry/registry.py` — the global unknown-device fallback + collision check.
- `tests/test_golden_regression.py`, `tests/fixtures/` — regression harness.
+22 -2
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Run hassfest validation
uses: home-assistant/actions/hassfest@master
@@ -29,9 +29,29 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Run HACS validation
uses: hacs/action@main
with:
category: integration
tests:
name: Pytest
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: "3.14"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt
- name: Run tests
run: pytest tests/ -q
+4 -2
View File
@@ -27,8 +27,10 @@ flows/
# Local-only research tools (not part of the HA integration)
local-tools/
# Claude Code per-project state (permissions allowlist, etc.)
.claude/
# Claude Code per-project state (permissions allowlist, etc.) — but keep
# shared, committed skills under .claude/skills/.
.claude/*
!.claude/skills/
# HA dev instance — persisted config and secrets
ha_config/
+97 -58
View File
@@ -1,32 +1,56 @@
<!-- dark mode -->
<img src="custom_components/localthings/brand/dark_logo@2x.png#gh-dark-mode-only" alt="LocalThings Logo"/>
<!-- light mode -->
<img src="custom_components/localthings/brand/logo@2x.png#gh-light-mode-only" alt="LocalThings Logo"/>
<p align="center">
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/mbillow/localthings" />
<img alt="GitHub watchers" src="https://img.shields.io/github/watchers/mbillow/localthings" />
</p>
<p align="center">
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/mbillow/localthings" />
<img alt="hacs validation" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=HACS%20validation&label=hacs%20validation" />
<img alt="hassfest" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=Hassfest%20validation&label=hassfest" />
<img alt="tests" src="https://img.shields.io/github/check-runs/mbillow/localthings/main?nameFilter=Pytest&label=tests" />
</p>
# LocalThings
**A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN.
> ### Where things live
>
> This project split into two repos partway through development.
>
> - **[`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local)** (PyPI package): the reusable protocol layer. DTLS session handling, CoAP wire encoding, Block2 reads, bounded retry/retransmit, inter-request rate limiting, cert-chain validation. No HA dependency; usable from any Python project.
> - **This repo**: the Home Assistant integration built on top of it. Config flow, a per-device-type capability registry, the polling coordinator, and all the HA entity classes.
>
> `custom_components/localthings/manifest.json` pulls in `smartthings-local` from PyPI like any other HA integration dependency.
This integration uses the [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) library to handle the low-level DTLS/CoAP communication with devices.
### What you get
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's `oneUiVersion` and picks the matching capability registry (dryer, oven, dishwasher, refrigerator) on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
Adding a device just needs a host IP and your CA credentials in the UI. The integration reads the appliance's identity and picks the matching capability registry on its own, so there's no per-model descriptor to write for a new unit of a type that's already supported.
Credential setup is one-time. The first device you add asks for the AC14K_M CA cert and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
Credential setup is one-time. The first device you add asks for a CA certificate and key (see Part 2); every device after that reuses the same stored CA and only asks for the host IP, minting its own per-device leaf cert automatically.
Your state stays on your LAN: HA talks to the appliance over a direct DTLS session, and Samsung's cloud sees nothing from this integration. (The appliance itself still maintains its own connection to Samsung; that's firmware behavior on the device side, not something this integration controls.)
### Supported appliance types
| Type | Registry | Notable capabilities |
|---|---|---|
| Dryer | `by_type/dryer.py` | Power, kids lock, remote control, alarms, energy meter, operational state, door LED, sound mode, dryer settings/course, job-beginning status, diagnosis, firmware-update sensor |
| Oven | `by_type/oven.py` | Power, kids lock, remote control, alarms, cavity state, setpoint, mode, operational state, door, connectivity, firmware-update sensor |
| Dishwasher | `by_type/dishwasher.py` | Power, kids lock, remote control, alarms, energy + water meters, water filter, operational state, cycle options/settings, door LED, sound mode/volume, firmware-update sensor |
| Refrigerator | `by_type/refrigerator.py` | Power, kids lock, remote control, alarms, energy meter, water filter, status lock, door alert, icemaker (nighttime + generic per-compartment), flex zone, refrigeration mode, autofill, welcome/cabinet lighting, Sabbath mode, beverage zone, plus pattern-matched per-compartment temperature/setpoint/icemaker/door capabilities for multi-cavity fridges, firmware-update sensor |
| Type | Registry |
|---|---|
| Air conditioner | `by_type/airconditioner.py` |
| Air purifier | `by_type/air_purifier.py` |
| Dehumidifier | `by_type/dehumidifier.py` |
| Dryer | `by_type/dryer.py` |
| Oven | `by_type/oven.py` |
| Microwave | `by_type/microwave.py` |
| Gas cooktop (read-only burner status) | `by_type/cooktop.py` |
| Range hood | `by_type/range_hood.py` |
| Range | `by_type/range.py` |
| Dishwasher | `by_type/dishwasher.py` |
| Refrigerator | `by_type/refrigerator.py` |
| Washer | `by_type/washer.py` |
| Water purifier | `by_type/water_purifier.py` |
| Vacuum clean/auto-empty station | `by_type/vacuum_station.py` |
| Air dresser | `by_type/air_dresser.py` |
Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with.
Other Tizen RT / DAWIT-family appliances almost certainly speak the same protocol underneath, since the auth path and CoAP primitives are shared across the fleet. Adding a new type means writing a new `by_type/<name>.py` registry file; it doesn't require reverse-engineering the protocol again. See **Adding a new appliance type** below.
@@ -39,23 +63,17 @@ Other Tizen RT / DAWIT-family appliances almost certainly speak the same protoco
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
```
- `49154/udp` or `49155/udp` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. The config flow probes both ports automatically, so you don't need to know which one your device uses.
- Any UDP port in `49152-49160` open|filtered with a DTLS handshake responding: newer firmware (Tizen RT 3.x, DAWIT 3.0+). This is what the integration talks to. Most devices answer on `49154`/`49155`, but some builds bind lower (e.g. `49153`). The config flow sweeps the whole range and auto-detects the live port, so you don't need to know which one your device uses.
- Only `8888/tcp` open (token-based HTTPS): older firmware (roughly 2018-2022). **Not supported here.**
---
## Part 2: One-time setup, get the AC14K_M CA credentials
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA: a cert chain that's been public for years and still ships in current Samsung firmware trust stores. It's required because every Samsung Tizen/RT-OCF appliance's factory ACL grants full CRUDN access (`perm=31` on `href=*`) to whatever identity is chained to that CA, so a cert signed by it is the one thing that lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
The config flow (Part 3) needs a **CA certificate and CA private key** to mint each device's leaf cert itself. Specifically, it needs the `AC14K_M` intermediate CA — a cert chain that's been public for years and still ships in current Samsung firmware trust stores. Every Samsung Tizen/RT-OCF appliance trusts identities chained to that CA with full access by default, so a cert signed by it is what lets HA talk to your appliance without Samsung's cloud in the loop. HA doesn't need the *device's* original cert or key, only something `AC14K_M` has signed, and it mints that itself once you give it the CA.
This repo doesn't include the needed CA bundle. For an example of how to obtain it, including fetching the AC14K_M cert and key and verifying they pair, see the `smartthings-local` protocol project's [`setup_cert.py`](https://github.com/QuiteYellow/SmartThings-Local/blob/main/setup_cert.py). However you obtain the CA cert and key, paste their PEM contents into the HA config flow's "CA Certificate (PEM)" and "CA Private Key (PEM)" fields in Part 3. You only need to do this once, since every appliance you add afterward reuses the same stored CA.
### Why this works
- Every Samsung Tizen/RT-OCF appliance has a factory-baked ACE in `/oic/sec/acl` granting the AC14K_M-chained identity `perm=31` on `href=*`.
- TizenRT iotivity derives the peer ID via `memmem(subject_dn, "uuid:")`, which is RDN-agnostic, so a cert with the UUID in any RDN authenticates the same way.
- You don't need the original keyholder's private key. The config flow mints its own key and has `AC14K_M` sign the leaf: different key, same identity, same access.
---
## Part 3: Add the integration in Home Assistant
@@ -64,7 +82,7 @@ This repo doesn't include the needed CA bundle. For an example of how to obtain
2. Restart HA.
3. **Settings > Devices & Services > Add Integration > LocalThings.**
4. First device: paste the appliance's IP, plus the contents of the CA private and public key from Part 2.
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, probes ports `49154`/`49155`, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
5. The flow fetches the current UUID from Samsung's cloud gateway, mints a leaf cert signed by your CA, sweeps the `49152-49160` range to find the live DTLS port, and confirms the device answers `/device/0`. On success it creates the config entry and detects the device type automatically.
6. Every subsequent device only asks for the host IP; the stored CA credentials are reused to mint that device's leaf cert.
Entities appear under one HA device per appliance, named `Samsung Appliance (<ip>)` initially. Rename freely: the config entry is keyed on the device's serial, not the name.
@@ -80,20 +98,21 @@ docker compose up -d --build
docker compose logs -f
```
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step (which needs outbound network access at exactly the moment the integration loads, and repeats on every container recreate). Re-run with `--build` whenever the pinned `smartthings-local` version changes.
The `Dockerfile` builds on the official `home-assistant/home-assistant:stable` image and pre-installs `smartthings-local`, so the dependency is present at container start instead of depending on HA's own runtime pip-install step. Re-run with `--build` whenever the pinned `smartthings-local` version changes.
`docker-compose.yml` sets `network_mode: host`, which is required since DTLS is UDP and won't traverse Docker's bridge NAT to reach LAN appliances, and bind-mounts `custom_components/localthings/` read-only into `ha_config/custom_components/`. Bump `custom_components.localthings` to `debug` in `ha_config/configuration.yaml` for verbose protocol logging.
### Tests
```sh
python3 -m venv .venv
python3.13 -m venv .venv # 3.13 or newer; see below
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/pip install pytest-homeassistant-custom-component homeassistant
.venv/bin/pytest tests/ -q
```
`requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
`requirements-dev.txt` already pulls in Home Assistant and pytest at matching versions, so there's nothing to install alongside it. Use Python 3.13 or newer: pip resolves the newest `pytest-homeassistant-custom-component` your interpreter supports, and on 3.12 or older nothing resolves and the install fails outright. CI runs 3.14.
A large suite covering registry composition, discovery, entity descriptors, and golden-file regression against captured device dumps. `requirements-dev.txt` pins `smartthings-local` the same way `manifest.json` does, so tests exercise the real published protocol layer rather than a vendored copy.
---
@@ -101,29 +120,36 @@ python3 -m venv .venv
```
custom_components/localthings/
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
__init__.py async_setup_entry / async_unload_entry
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
coordinator.py DataUpdateCoordinator: polling, stale-state fallback, write dispatch
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py
One module per HA platform
strings.json / translations/ Config-flow copy + entity state translations
manifest.json Requirements (incl. the smartthings-local PyPI dep), version, domain
__init__.py async_setup_entry / async_unload_entry
config_flow.py UUID fetch, leaf cert minting, port probing, config entry creation
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
One module per HA platform
catalog.py Reads the shipped translation catalog (which keys/states exist)
translations/ Config-flow copy + entity name/state translations, one file per
language; en.json is the source of truth (no strings.json —
Home Assistant never reads one from a custom integration)
registry/
batch.py /device/0 batch response parsing
capability.py Capability dataclass (href, entities, transforms)
entities.py Per-platform entity descriptor dataclasses
discovery.py Binds a device's live resources to registered capabilities
adapter.py Flattens bound entities into HA-ready state
identity.py Reads device identity (serial, oneUiVersion) for type detection
capabilities/ Shared + per-family Capability definitions (common, dryer, oven,
dishwasher, fridge, laundry, operational)
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
tests/ 80+ tests: registry composition, discovery, entity descriptors,
golden-file regression against captured device dumps
requirements-dev.txt Test deps, including the smartthings-local package
docker-compose.yml / ha_config/ Local HA dev environment
registry.py Builds the global capability registry, validates href collisions
capability.py Capability dataclass (href, entities, transforms)
entities.py Per-platform entity descriptor dataclasses
discovery.py Binds a device's live resources to registered capabilities
adapter.py Flattens bound entities into HA-ready state
identity.py Reads /oic/p + /oic/d (manufacturer, model, OCF device type)
redact.py Strips account/identity data before diagnostics leave HA
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
cooktop, range_hood, dryer, oven, dishwasher, fridge, washer,
laundry, operational, ignored)
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
behavior, and golden-file regression against captured device dumps
requirements-dev.txt Test deps, including the smartthings-local package
docker-compose.yml / ha_config/ Local HA dev environment
```
---
@@ -141,21 +167,34 @@ support for hardware the maintainers don't have.
## Adding a new appliance type
1. Capture the appliance's `/device/0` response to see what resources/fields it exposes. An authenticated `DtlsCoapSession` from `smartthings_local.protocol.dtls_session` GET is enough; `local-tools/probe_device.py` wraps this.
1. Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources.
2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type.
3. Create `registry/by_type/<name>.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern.
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform).
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, then route devices to it by adding the board-family token from their `modelNum` to `_BOARD_TOKEN_TO_KEY` — a single row, e.g. `'VSKR': 'vacuum_station'`. Tokens are matched whole (the model string is upper-cased and split on any run of non-alphanumerics), so one entry covers every delimiter spelling Samsung uses: `TP1X_DA-AC-RAC-01001` and `TP2X_RAC_20K` both resolve on `RAC`. Name the specific type, never the board family that contains it — `DA-AC-` prefixes RAC/WAC/DHM/AIR alike, so a bare `AC` row would swallow the dehumidifier and the air purifier. If the board is shared across types (washers and dryers both report `DA_WM_`), add the consumer-model prefix from `description` to `_CONSUMER_PREFIX_TO_KEY` instead. If the device omits `/information/vs/0` entirely (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
`oneUiVersion` is deliberately not consulted — see `resolve()` in that file for why.
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
No config-flow or coordinator changes are needed. Device-type detection and entity wiring are fully driven by the registry.
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
---
## Known DTLS behavior
## Known device behavior
Samsung's RT-OCF DTLS stack occasionally closes sessions actively, usually right after a Block2 GET or in the seconds after a POST. Retry/retransmit bounds and inter-request pacing live in the `smartthings-local` protocol layer (tuned against measured per-firmware request-rate ceilings); reconnect-with-backoff and stale-state fallback live in this repo's `coordinator.py`. From HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`.
Samsung's firmware occasionally drops the DTLS session briefly — this is normal appliance-side behavior, not a bug. The integration reconnects automatically, and from HA's perspective a brief reconnect looks like an entity holding its last value for one poll cycle rather than going `unavailable`. When an appliance supports it, the integration prefers push-based updates (instant, via `observe.py`) over polling, falling back to polling otherwise.
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN: Samsung's RT-OCF DTLS allows only one active session per peer.
If reconnects become persistent (more than a handful per minute), something's actually wrong. Check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN — only one active session per appliance is allowed at a time.
### Multi-subdevice ("2-in-1") air conditioner systems
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
Two on-the-wire shapes are supported, both keyed off what the appliance itself reports:
- **Indexed siblings** — the device answers a `/device/1`, `/device/2`, ... collection alongside its own `/device/0`, mirroring every resource at that index.
- **UUID-prefixed tree** — the device reports a sibling's id in `x.com.samsung.da.subdeviceIdList`, and that id doubles as a literal href prefix for the sibling's own resource tree.
A candidate that answers but never produces any real, user-facing state (an unused slot some installs report alongside a genuine second subdevice) is silently skipped rather than turned into a phantom entity — check diagnostics' `subdevices`/`subdevices_skipped` blocks if a subdevice you expect to see isn't showing up, and file an issue with that diagnostics download attached.
---
@@ -163,7 +202,7 @@ If reconnects become persistent (more than a handful per minute), something's ac
Patches are welcome, especially:
- New `by_type/` registries for appliance families not yet covered (washer, AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
- New `by_type/` registries for appliance families not yet covered (AC, microwave, etc.) on the same Tizen RT 3.x firmware family.
- Confirmation or refutation of compatibility on additional models within an already-supported type.
- Protocol-level fixes, which belong upstream in [`smartthings-local`](https://github.com/QuiteYellow/SmartThings-Local) rather than here. HA-side fixes (entities, config flow, coordinator, registry) belong in this repo.
+39
View File
@@ -6,6 +6,7 @@ import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
@@ -25,6 +26,44 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry,
) -> bool:
"""Allow deleting a device this entry no longer provides (issue #214).
Defining this at all is what makes Home Assistant offer the "Delete
device" action for our devices; without it a device registry entry
belonging to a loaded config entry can never be removed from the UI. That
matters because a subdevice's HA device outlives the discovery that
created it: a candidate that materialized under an older release (issue
#214's phantom second air conditioner, born from an unused /device/1 slot
reporting the appliance's energy counter -- see
registry/subdevices.py's liveness gate) leaves a device entry behind that
nothing recreates and nothing cleans up once the gate stops materializing
it. Same for a sibling that a firmware update stops exposing.
Removal is refused for devices this entry *does* currently provide --
HA would recreate them on the next entity add, so allowing it would look
like the delete silently failed. Deliberately no automatic pruning at
discovery time: subdevice enumeration is one-shot and a sibling can fail
to answer for a poll (issue #205 is exactly that on the reference
hardware), so auto-removal would throw away a real subdevice's name,
area and automation references on a transient miss. The user gets the
button; the integration doesn't guess.
"""
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(
entry.entry_id
)
if coordinator is None:
# Entry not loaded (or already unloaded) -- nothing is claiming this
# device, so there's nothing to protect it from being removed.
return True
live = set(coordinator.device_info.get('identifiers') or set())
for subdevice in coordinator.subdevices:
live |= set(coordinator.device_info_for(subdevice).get('identifiers') or set())
return not (device.identifiers & live)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 146 KiB

+49
View File
@@ -0,0 +1,49 @@
"""The integration's own shipped translation catalog, as data.
Home Assistant loads exactly one file per language for a custom integration:
``translations/<lang>.json``. It does not read ``strings.json`` and it does
not resolve ``[%key:...%]`` references -- both of those belong to Core's
build tooling (``script/translations``), which custom integrations never run
through. So ``translations/en.json`` is not a generated artifact here, it is
the source of truth, and English is the one catalog required to be complete
(hassfest validates it for custom integrations).
That makes the English catalog the authority on *which* translation keys and
states exist, which is something the Python side genuinely needs to know:
* ``select`` has to decide whether to normalize a raw Samsung option into
a lowercase state key (so Home Assistant can look it up) or leave the
vendor's own casing alone as a readable fallback.
* ``laundry.cycle_select`` has to decide whether a device-reported course
table has translations at all before keying off it.
Reading it from the catalog instead of restating it in Python means adding a
state or a course table is a one-file change, and the two can't drift.
"""
from __future__ import annotations
import json
from pathlib import Path
# Read at import, not lazily: custom integrations are imported in an executor
# thread, so this stays off the event loop no matter who asks first.
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
).get('entity', {})
def has_entity_translation(platform: str, translation_key: str) -> bool:
"""Whether `platform`.`translation_key` has an entry in the catalog."""
return translation_key in _ENTITY_CATALOG.get(platform, {})
def translated_states(platform: str, translation_key: str) -> frozenset[str]:
"""The state keys translated for `platform`.`translation_key`.
Empty when the entity has no translation at all, and also when it has a
translated *name* but deliberately no state table (a course list whose
codes we can't map, for instance) -- callers treat both the same way:
leave the device's value untouched.
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get('state', ())) if entry else frozenset()
+630
View File
@@ -0,0 +1,630 @@
"""Climate platform for Local Things.
The first composite entity in this integration: a single HA climate card that
unifies several OCF resources of a Samsung air conditioner. Unlike every other
platform here (one descriptor -> one resource field), a climate entity reads
power, HVAC mode, current/target temperature, fan (wind) strength, swing (wind
direction) and the convenient-mode preset from *different* resources.
It binds one primary `BoundEntity` (the `/mode/vs/0` capability) so the registry
still tracks it, and reads the sibling resources straight from the coordinator
snapshot via `coordinator.resource(href)` -- the same cross-resource read that
`number.py` (live range/unit) and `select.py` (options callable) already do.
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and
applies the optimistic value/settle guard to that same href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
back for, power, mode, temperature and wind resources alike.
"""
from __future__ import annotations
import logging
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACMode,
PRESET_NONE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ClimateDesc
# The AC's canonical resource hrefs live in the capability module (the single
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
# POWER_VS_FALLBACK.
from .registry.capabilities.airconditioner import (
HREF_MODE as MODE_HREF,
HREF_POWER as POWER_HREF,
HREF_POWER_VS as POWER_VS_HREF,
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
HREF_TEMPS_VS as TEMPS_VS_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
HREF_AIRFLOW as AIRFLOW_HREF,
is_legacy_board,
)
from .registry.capabilities.common import normalize_temp_unit
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
# --- device code <-> HA value maps -----------------------------------------
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
# driven by the power resource).
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
'Cool': HVACMode.COOL,
'Dry': HVACMode.DRY,
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
# reverse write can't rely on this map alone (two codes, one HA value) --
# _device_code_for_hvac() resolves the code from the unit's own
# supportedModes, so this is only a fallback for a unit reporting no
# supportedModes at all. 'Fan' is listed first so the {v: k} reverse
# comprehension below has 'Wind' win that fallback (last-key-wins),
# preserving the original single-spelling behavior rather than silently
# flipping it when 'Fan' was added.
'Fan': HVACMode.FAN_ONLY,
'Wind': HVACMode.FAN_ONLY,
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool"
# and implies a two-setpoint heat+cool range these single-setpoint units
# (including cool-only models) don't have.
'Auto': HVACMode.AUTO,
'Heat': HVACMode.HEAT,
}
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
# the idiomatic HA way instead: hvac_mode reports AUTO (same as the plain
# 'Auto' code maps to) and a dedicated 'ai_comfort' preset carries the
# distinction a bare hvac_mode can't. Not reachable via async_set_hvac_mode --
# entered/left only through the preset, since there's no dedicated HVACMode
# value for it to write back to.
_AI_COMFORT_MODE = 'AIComfort'
PRESET_AI_COMFORT = 'ai_comfort'
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
# a clean match exists so they auto-localize; "turbo" is custom (translated).
_DEVICE_TO_FAN: dict[str, str] = {
'0': 'auto',
'1': 'low',
'2': 'medium',
'3': 'high',
'4': 'turbo',
}
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
_DEVICE_TO_SWING: dict[str, str] = {
'Fix': 'off',
'All': 'both',
'Up_And_Low': 'vertical',
'Left_And_Right': 'horizontal', # issue #75
}
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
# above (off/vertical/horizontal/both), just read from/written to a pair
# of fields rather than a single one.
def _oscillation_swing(rep: dict) -> str | None:
vertical = rep.get('vertical')
horizontal = rep.get('horizontal')
if vertical is None and horizontal is None:
return None
v = vertical == 'Swing'
h = horizontal == 'Swing'
if v and h:
return 'both'
if v:
return 'vertical'
if h:
return 'horizontal'
return 'off'
def _wind_strength_label(code, rep: dict) -> str:
"""Human label for a /wind/strength/vs/0 code from the device's own
modesName array (parallel-indexed with supportedModes), lowercased for
HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue
#155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the
"0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors
preset_mode's dynamic code->str resolution. Falls back to the raw code
lowercased when modesName is absent or misaligned."""
supported = rep.get('x.com.samsung.da.supportedModes') or []
names = rep.get('x.com.samsung.da.modesName') or []
if code in supported and len(names) == len(supported):
return str(names[supported.index(code)]).lower()
return str(code).lower()
# Preset (convenient mode): resolved dynamically from the device's own
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
# exposed as its lowercased self and labelled in translations
# (entity.climate.airconditioner.state_attributes.preset_mode.state.<code>),
# so any board's convenient modes surface without code changes, and an
# unlabelled code just renders as its raw value until a label is added.
# (Samsung's WindFree still-air cooling shows up here as the 'Nano'/
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
# translation label, not a hard-coded mode.)
def _preset_to_ha(code) -> str:
return PRESET_NONE if code == 'Off' else str(code).lower()
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
LocalThingsClimate(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, ClimateDesc) and _is_included(b, coordinator)
)
def _first(value):
"""Samsung `modes` is a single-element list on some resources, a scalar on
others. Return the first element of a list, else the value itself."""
if isinstance(value, (list, tuple)):
return value[0] if value else None
return value
def _num(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _temps_vs_item(rep: dict) -> dict:
"""First item of the vendor `/temperatures/vs/0` items[] array.
Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g.
model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard
/temperature/current/0 + /temperature/desired/0 pair; it reports current
and target under a single `/temperatures/vs/0` resource whose
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
increment/unit. Returns {} when absent, so callers fall through cleanly.
"""
items = rep.get('x.com.samsung.da.items')
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
"""Composite climate entity for a Samsung air conditioner."""
# translation_key comes from the ClimateDesc (base __init__ sets
# _attr_translation_key from bound.desc), resolving the state_attributes
# translations under entity.climate.airconditioner.
# Modern climate entities opt out of the deprecated auto-added TURN_ON/OFF.
_enable_turn_on_off_backwards_compatibility = False
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
# Primary/main entity for the device: no name suffix, just the device name.
self._attr_name = None
self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
# (href, raw device code) pairs already logged by _warn_unmapped --
# these properties are read on every coordinator refresh, so an
# un-deduped warning would spam the log for any device with a
# genuinely unrecognized code.
self._warned_unmapped: set[tuple[str, str]] = set()
# -- resource helpers ---------------------------------------------------
# Preset codes on legacy ARTIK051 boards, learned by driving the same unit
# through its cloud integration and reading the local token back each time:
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
_LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed')
def _legacy_convenient(self) -> dict:
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
/mode/vs/0's options, for boards that have no convenient resource."""
options = self._rep(MODE_HREF).get('x.com.samsung.da.options') or []
for option in options:
if isinstance(option, str) and option.startswith('Comode_'):
return {_MODES_FIELD: [option.split('_', 1)[1]],
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)}
return {}
def _legacy_airflow(self) -> dict:
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel to
use -- i.e. this board has no /wind/strength/vs/0.
Delegates the board-generation test to is_legacy_board (the same
test capabilities/airconditioner.py's token entities are gated on)
instead of re-implementing it. Uses self._resources (this unit's own
canonical view, issue #177 -- see LocalThingsEntity._resources)
rather than a two-key presence dict built from coordinator.resource()'s
truthiness -- resource() collapses "href absent" and "href present
with an empty {} rep" to the same falsy value, while is_legacy_board
(and discover()'s own binding) test key membership, not truthiness. A
presence dict built from truthiness alone would disagree with the
token entities on a board reporting a genuinely empty /airflow/vs/0,
silently reintroducing the drift this delegation exists to prevent.
Reads the actual href through self._rep rather than
coordinator.resource() directly -- on a subdevice (a legacy-board
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
canonical AIRFLOW_HREF must be translated through this bound
entity's own subdevice first, exactly like every other sibling read
below.
"""
if not is_legacy_board(self._resources):
return {}
return self._rep(AIRFLOW_HREF)
def _legacy_preset(self) -> bool:
"""Whether presets come from the Comode_* token rather than a resource.
Gated on the same board test as _legacy_airflow, not on the convenient
rep being empty alone: newer boards carry Comode tokens too, so a
momentarily empty /mode/convenient/vs/0 there must not silently switch
the preset read (and write) over to the token path.
Deliberately reads the *raw* href (translated through this bound
entity's own subdevice, not through self._rep) rather than going
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
is exactly the legacy_convenient() rep this method is deciding
whether to use, so routing through it here would make the resource
never look empty and this always resolve to the wrong side.
"""
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
return (not self.coordinator.resource(convenient_href)
and bool(self._legacy_airflow()))
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants --
translated through this bound entity's own subdevice (issue #177) to
the real, on-the-wire href before the single-href cache lookup
(identity for MAIN, so a device with no subdevices reads exactly the
href it always did)."""
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
return self._legacy_convenient()
return rep
def _is_on(self) -> bool:
# Prefer the vendor /power/vs/0 (present on every observed board and
# the resource writes target -- see airconditioner._climate_write).
# The OCF /power/0 is absent on many boards and a stale mirror on
# some, so reading it first showed pre-write state after a power
# toggle (issue #53: "can turn on but not off").
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
if power is not None:
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
def _warn_unmapped(self, href: str, code: str) -> None:
"""Log once per (href, code) when a device-reported mode has no
entry in the relevant device<->HA map, so a real device gap surfaces
in the log instead of silently vanishing (issue #93)."""
key = (href, code)
if key in self._warned_unmapped:
return
self._warned_unmapped.add(key)
_LOGGER.warning(
"%s: device mode %r on %s has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id, code, href,
)
def _read_mode(self, href: str, mapping: dict):
"""Current mode of a wind/convenient resource, mapped to its HA value."""
raw = _first(self._rep(href).get(_MODES_FIELD))
if raw is not None and raw not in mapping:
self._warn_unmapped(href, raw)
return mapping.get(raw)
def _read_modes(self, href: str, mapping: dict) -> list[str]:
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
supported = self._supported(href)
for c in supported:
if c not in mapping:
self._warn_unmapped(href, c)
return [mapping[c] for c in supported if c in mapping]
# -- temperature --------------------------------------------------------
def _ocf_temp_authoritative(self) -> bool:
"""True when the OCF /temperature/{current,desired}/0 pair is the
authoritative temperature channel -- signalled by
/temperature/current/0 being present. Those boards honour reads/
writes on /temperature/desired/0 and ignore the vendor
/temperatures/vs/0; boards without the pair (only a desired stub, or
nothing) are the reverse. Confirmed on live units of both kinds."""
return bool(self._rep(TEMP_CURRENT_HREF))
def _temps_vs(self) -> dict:
"""Vendor `/temperatures/vs/0` items[0] (empty {} when absent)."""
return _temps_vs_item(self._rep(TEMPS_VS_HREF))
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMP_DESIRED_HREF).get('units')
if raw is None:
raw = self._temps_vs().get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
@property
def current_temperature(self):
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
return v
@property
def target_temperature(self):
# Read from the same channel writes go to (see async_set_temperature):
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
# /temperatures/vs/0 otherwise -- with the other as fallback.
ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
vs = _num(self._temps_vs().get('x.com.samsung.da.desired'))
if self._ocf_temp_authoritative():
return ocf if ocf is not None else vs
return vs if vs is not None else ocf
def _range(self) -> list | None:
r = self._rep(TEMP_DESIRED_HREF).get('range')
if isinstance(r, (list, tuple)) and len(r) == 2:
return r
item = self._temps_vs()
lo = _num(item.get('x.com.samsung.da.minimum'))
hi = _num(item.get('x.com.samsung.da.maximum'))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
def min_temp(self) -> float:
r = self._range()
return float(r[0]) if r else super().min_temp
@property
def max_temp(self) -> float:
r = self._range()
return float(r[1]) if r else super().max_temp
@property
def target_temperature_step(self) -> float:
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
or 1.0)
# -- hvac mode ----------------------------------------------------------
@property
def hvac_mode(self) -> HVACMode:
if not self._is_on():
return HVACMode.OFF
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
if device == _AI_COMFORT_MODE:
return HVACMode.AUTO
if device is not None and device not in _DEVICE_TO_HVAC:
self._warn_unmapped(MODE_HREF, device)
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
@property
def hvac_modes(self) -> list[HVACMode]:
modes = [HVACMode.OFF]
for m in self._supported(MODE_HREF):
if m == _AI_COMFORT_MODE:
continue
mapped = _DEVICE_TO_HVAC.get(m)
if mapped is None:
self._warn_unmapped(MODE_HREF, m)
continue
if mapped not in modes:
modes.append(mapped)
return modes
# -- fan / swing / preset ----------------------------------------------
@property
def fan_mode(self):
airflow = self._legacy_airflow()
if airflow:
return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel')))
rep = self._rep(WIND_STRENGTH_HREF)
code = _first(rep.get(_MODES_FIELD))
if code is None:
return None
return _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep)
@property
def fan_modes(self) -> list[str]:
if self._legacy_airflow():
# This resource carries no supportedModes, so the full scale is offered.
return list(_DEVICE_TO_FAN.values())
rep = self._rep(WIND_STRENGTH_HREF)
modes = []
for code in self._supported(WIND_STRENGTH_HREF):
mode = _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep)
if mode not in modes:
modes.append(mode)
return modes
def _swing_via_direction(self) -> bool:
"""True when WIND_DIRECTION_HREF is the swing channel to use --
signalled by its presence. Boards without it (issue #126) report
the 2-axis oscillation resource instead; see _oscillation_swing."""
return bool(self._rep(WIND_DIRECTION_HREF))
@property
def swing_mode(self):
airflow = self._legacy_airflow()
if airflow:
return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction'))
if self._swing_via_direction():
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
@property
def swing_modes(self) -> list[str]:
if self._legacy_airflow():
return list(_SWING_TO_DEVICE.keys())
if self._swing_via_direction():
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
if self._rep(WIND_OSCILLATION_HREF):
return list(_SWING_TO_DEVICE.keys())
return []
@property
def preset_mode(self):
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE:
return PRESET_AI_COMFORT
code = _first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD))
return _preset_to_ha(code) if code is not None else None
@property
def preset_modes(self) -> list[str]:
modes = [_preset_to_ha(c) for c in self._supported(CONVENIENT_HREF)]
if _AI_COMFORT_MODE in self._supported(MODE_HREF):
modes.append(PRESET_AI_COMFORT)
return modes
# -- writes -------------------------------------------------------------
def _device_code_for_hvac(self, hvac_mode: HVACMode):
"""Device mode code for an HA hvac_mode, chosen from this unit's own
supportedModes -- fan-only is 'Wind' on some boards and 'Fan' on
others, so the reverse map alone can't pick the code this unit
accepts."""
for code in self._supported(MODE_HREF):
if _DEVICE_TO_HVAC.get(code) == hvac_mode:
return code
return _HVAC_TO_DEVICE.get(hvac_mode)
async def async_set_temperature(self, **kwargs) -> None:
# HA's set_temperature service forwards an optional hvac_mode here; honour
# it (set the mode first -- that also powers the unit on when it was off),
# matching the climate contract other integrations follow. Without this a
# set_temperature call carrying hvac_mode (e.g. a dashboard "turn on to
# Auto 24" button) set the setpoint but never changed mode or powered on.
hvac_mode = kwargs.get('hvac_mode')
if hvac_mode is not None:
await self.async_set_hvac_mode(hvac_mode)
if hvac_mode == HVACMode.OFF:
return
temp = kwargs.get('temperature')
if temp is None:
return
# OCF-pair boards write /temperature/desired/0; vendor boards write
# /temperatures/vs/0 (see airconditioner._climate_write).
kind = 'temperature_ocf' if self._ocf_temp_authoritative() else 'temperature'
await self.coordinator.async_send_command(self._bound, (kind, temp))
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self.coordinator.async_send_command(self._bound, ('power', False))
return
device = self._device_code_for_hvac(hvac_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ('mode', device))
async def async_turn_on(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', True))
async def async_turn_off(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
"""Map an HA fan/swing/preset value back to its device code and write it."""
device = mapping.get(value)
if device is not None:
await self.coordinator.async_send_command(self._bound, (kind, device))
async def async_set_fan_mode(self, fan_mode: str) -> None:
if self._legacy_airflow():
level = _FAN_TO_DEVICE.get(fan_mode)
if level is not None:
await self.coordinator.async_send_command(
self._bound, ('fan_legacy', level))
return
supported = self._supported(WIND_STRENGTH_HREF)
device = _FAN_TO_DEVICE.get(fan_mode)
# A static hit is only trustworthy if this unit's own supportedModes
# actually includes that code -- a board can use non-standard codes
# (issue #155's "31"-"35") while still spelling a standard label
# ("Low"/"High") in modesName, in which case _FAN_TO_DEVICE.get would
# return a plausible-looking code ('1'/'3') the device never
# advertised at all. Fall through to the live scan whenever the
# static guess isn't actually one of this unit's own codes.
if device is None or (supported and device not in supported):
rep = self._rep(WIND_STRENGTH_HREF)
for code in supported:
if _wind_strength_label(code, rep) == fan_mode:
device = code
break
if device is not None:
await self.coordinator.async_send_command(self._bound, ('fan', device))
async def async_set_swing_mode(self, swing_mode: str) -> None:
if self._legacy_airflow():
code = _SWING_TO_DEVICE.get(swing_mode)
if code is not None:
await self.coordinator.async_send_command(
self._bound, ('swing_legacy', code))
return
if self._swing_via_direction():
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
return
if self._rep(WIND_OSCILLATION_HREF):
await self.coordinator.async_send_command(
self._bound, ('oscillation', swing_mode))
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode == PRESET_AI_COMFORT:
# Writes the primary mode resource, not the convenient one --
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
return
# Reverse-resolve against the unit's own supportedModes (codes aren't
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
for code in self._supported(CONVENIENT_HREF):
if _preset_to_ha(code) == preset_mode:
kind = 'preset_legacy' if self._legacy_preset() else 'preset'
await self.coordinator.async_send_command(self._bound, (kind, code))
return
+274 -25
View File
@@ -2,16 +2,24 @@
from __future__ import annotations
import datetime
import json
import logging
import re
import selectors
import socket
import ssl
import time
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
ObjectSelector,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
TextSelectorConfig,
TextSelectorType,
@@ -22,7 +30,9 @@ from .const import (
CONF_HOST, CONF_PORT,
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
PROBE_PORTS,
CONF_BYPASS_REMOTE_CONTROL,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
PROBE_GET_TIMEOUT_S,
)
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
@@ -120,12 +130,101 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
return fullchain_pem, leaf_key_pem
def _order_candidates(ports: list[int]) -> list[int]:
"""Order live ports so the historically known DTLS ports are tried first."""
preferred = [p for p in PREFERRED_PROBE_PORTS if p in ports]
rest = sorted(p for p in ports if p not in PREFERRED_PROBE_PORTS)
return preferred + rest
def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
"""Fast UDP liveness sweep to narrow the range before the DTLS handshake.
UDP is connectionless, but a *connected* UDP socket surfaces the ICMP
port-unreachable that a closed port returns as ECONNREFUSED on its next
recv. So we send one probe datagram per port and watch for that error:
* ECONNREFUSED -> port is closed (device actively rejected it)
* silence / any data -> port may be live (open|filtered); a candidate
This is the in-process equivalent of ``nmap -sU``: it lets us take a
nine-port range down to the one or two ports actually worth a full DTLS
handshake + /device/0 GET, and bounds the total wait to ``timeout``
instead of stalling on every dead port when a firewall swallows the ICMP
replies.
"""
sockets: dict[int, socket.socket] = {}
sel = selectors.DefaultSelector()
# A single byte is enough to provoke an ICMP port-unreach from a closed
# port; a real DTLS ClientHello is unnecessary just to test for life.
probe = b"\x00"
try:
for port in ports:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
try:
sock.connect((host, port))
sock.send(probe)
except OSError:
sock.close()
continue
sockets[port] = sock
sel.register(sock, selectors.EVENT_READ, port)
# Ports drop out of the selector as they refuse; whatever is still
# registered when the deadline passes is silent-but-live (a candidate).
deadline = time.monotonic() + timeout
while sel.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
break
for key, _ in sel.select(timeout=remaining):
try:
# Data back means live; ECONNREFUSED (or any other socket
# error) means the port is closed/unusable — rule it out.
key.fileobj.recv(1)
except OSError:
sel.unregister(key.fileobj)
live = [key.data for key in sel.get_map().values()]
finally:
sel.close()
for sock in sockets.values():
try:
sock.close()
except OSError:
pass
# The sweep's ICMP-based verdict isn't reliable on every network path --
# issue #192 captured a segregated-VLAN device where it called three
# ports live that a concurrent nmap scan showed as closed, while the
# port nmap found genuinely open|filtered (49154, one of our historically
# confirmed ports) never showed up as live at all. Rather than trust a
# wrong "not live" verdict on a port we already have strong prior
# evidence for, always give the historically-confirmed ports a real
# handshake attempt too. Bounded cost: at most len(PREFERRED_PROBE_PORTS)
# extra handshakes, only when the sweep disagrees with the prior.
rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in live]
return _order_candidates(live + rescued)
def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
serial` check here (and the equivalent one in coordinator.py's
`_run_discovery`) doesn't catch it, and two such units get the same
config-entry unique_id / entity unique_ids and collide (issue #83).
"""
return serial.strip().lower().startswith('nothing')
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device, for_device_by_model
from .registry.by_type import resolve as resolve_registry
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
try:
@@ -146,8 +245,20 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc
_LOGGER.debug("Leaf cert minted successfully")
candidates = _find_live_ports(
host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S
)
# No early "every port refused" fast-fail here: _find_live_ports always
# rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never
# empty as long as that table is non-empty and within PROBE_PORT_RANGE --
# both true today, which made this branch permanently unreachable. A
# genuinely dead host now fails via the handshake loop's own error below,
# which carries the actual per-port timeout/refusal reason instead of a
# generic "no live port found" message.
_LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates)
last_exc = None
for port in PROBE_PORTS:
for port in candidates:
sess = None
try:
sess = DtlsCoapSession(
@@ -157,7 +268,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
)
sess.connect()
sess.start_reader()
code, payload = sess.get(['device', '0'], timeout=15.0)
code, payload = sess.get(['device', '0'], timeout=PROBE_GET_TIMEOUT_S)
if code != 0x45 or not payload:
raise CannotConnect(f"port {port}: unexpected code {code:#04x}")
body = cbor2.loads(payload)
@@ -169,27 +280,14 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
.get('/information/vs/0', {})
.get('x.com.samsung.da.serialNum', '')
)
if not serial:
if not serial or _is_placeholder_serial(serial):
serial = f"{host}:{port}"
one_ui_version = (
resources
.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', '')
)
info_resource = resources.get('/information/vs/0', {})
recognized_registry = (
for_device(one_ui_version) if one_ui_version else None
) or for_device_by_model(
info_resource.get('x.com.samsung.da.modelNum', ''),
info_resource.get('x.com.samsung.da.description', ''),
)
recognized_registry = resolve_registry(resources)
return {
"port": port,
"serial": serial,
"leaf_cert_pem": fullchain_pem,
"leaf_key_pem": leaf_key_pem,
"one_ui_version": one_ui_version,
"device_type_recognized": recognized_registry is not None,
}
except CannotConnect:
@@ -215,6 +313,13 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._ca_key_pem: str = ""
self._pending_info: dict | None = None
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> LocalThingsOptionsFlow:
return LocalThingsOptionsFlow()
def _create_entry(self, info: dict) -> FlowResult:
return self.async_create_entry(
title=f"Samsung Appliance ({self._host})",
@@ -269,33 +374,177 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if has_creds:
schema = vol.Schema({vol.Required(CONF_HOST): _TEXT})
reuse_note = "CA credentials will be reused from the existing device."
step_id = "user_reuse"
else:
schema = vol.Schema({
vol.Required(CONF_HOST): _TEXT,
vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
})
reuse_note = ""
step_id = "user"
return self.async_show_form(
step_id="user",
step_id=step_id,
data_schema=schema,
errors=errors,
description_placeholders={"reuse_note": reuse_note},
)
async def async_step_user_reuse(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the localized host-only form for additional appliances."""
return await self.async_step_user(user_input)
async def async_step_confirm_unknown_type(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Shown only when the probe already knows the device type is unrecognized."""
if user_input is not None:
return self._create_entry(self._pending_info)
return self.async_show_form(
step_id="confirm_unknown_type",
data_schema=vol.Schema({}),
)
class LocalThingsOptionsFlow(config_entries.OptionsFlow):
"""Per-device options: the remote-control-off write-block override
(issue #54) plus a debug panel for writing an arbitrary body to an
arbitrary resource href, so a user can pin down device-specific write
behavior without waiting on a new release.
The remote-control override exists because most devices reject writes
outright while remote control is off and a clear error beats a silent
device-side rejection -- but not every model actually enforces that,
so this lets a user who's confirmed their device accepts writes anyway
turn the block off for just that device rather than it being
hardcoded on for everyone. The debug panel goes further: it bypasses
that block (and every write_fn/validate_fn) entirely, sending exactly
the body the user types to whatever href they pick.
"""
def __init__(self) -> None:
self._debug_href: str = ""
self._debug_result: tuple[int, dict] | None = None
def _coordinator(self):
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
return self.async_show_menu(
step_id="init",
menu_options=["settings", "debug_write"],
)
async def async_step_settings(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="settings",
data_schema=vol.Schema({
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
default=self.config_entry.options.get(
CONF_BYPASS_REMOTE_CONTROL, False
),
): bool,
}),
)
async def async_step_debug_write(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
if user_input is not None:
self._debug_href = user_input["href"]
return await self.async_step_debug_edit()
hrefs = sorted(coord.last_resources.keys())
return self.async_show_form(
step_id="debug_write",
data_schema=vol.Schema({
vol.Required("href"): SelectSelector(SelectSelectorConfig(
options=hrefs,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)),
}),
)
def _show_debug_edit_form(
self, href: str, current: dict, errors: dict[str, str], payload,
) -> FlowResult:
return self.async_show_form(
step_id="debug_edit",
data_schema=vol.Schema({
vol.Required(
"payload", default=(payload if payload is not None else {}),
): ObjectSelector(),
}),
errors=errors,
description_placeholders={
"one_ui_version": self._pending_info["one_ui_version"] or "(none reported)",
"href": href,
"current_value": (
json.dumps(current, indent=2, ensure_ascii=False)
if current else "{}"
),
},
)
async def async_step_debug_edit(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
href = self._debug_href
current = coord.resource(href)
if user_input is not None:
payload = user_input.get("payload") or {}
if not isinstance(payload, dict) or not payload:
return self._show_debug_edit_form(
href, current, {"payload": "empty_payload"}, payload
)
try:
code, new_rep = await coord.async_raw_write(href, payload)
except Exception: # noqa: BLE001 - surfaced to the user below
_LOGGER.exception("debug raw write failed for %s", href)
return self._show_debug_edit_form(
href, current, {"base": "write_failed"}, payload
)
self._debug_result = (code, new_rep)
return await self.async_step_debug_result()
return self._show_debug_edit_form(href, current, {}, None)
async def async_step_debug_result(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
code, new_rep = self._debug_result or (0, {})
return self.async_show_menu(
step_id="debug_result",
menu_options=["debug_write", "finish"],
description_placeholders={
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
"new_value": (
json.dumps(new_rep, indent=2, ensure_ascii=False)
if new_rep else "{}"
),
},
)
async def async_step_finish(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
# Close the flow without altering saved options.
return self.async_create_entry(data=dict(self.config_entry.options))
+40 -2
View File
@@ -1,6 +1,9 @@
DOMAIN = "localthings"
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time"]
PLATFORMS = [
"sensor", "binary_sensor", "switch", "number", "select", "button",
"time", "climate", "fan",
]
CONF_HOST = "host"
CONF_PORT = "port"
@@ -9,7 +12,42 @@ CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
PROBE_PORTS = [49154, 49155]
# Options-flow key (entry.options, not entry.data): lets a user override the
# device-wide remote-control-off write block for a specific device (issue
# #54). Some devices report remote control off yet still accept certain
# writes (e.g. default detergent/softener dosing on a washer, applied even
# to the built-in programs) -- the block exists to give a clear error
# instead of a silent device-side rejection, but that assumption doesn't
# hold for every model. Defaults to False (block stays on) everywhere it's
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
# been seen as low as 49153, so we sweep the whole range for a live UDP port
# before attempting the (expensive) DTLS handshake.
PROBE_PORT_RANGE = list(range(49152, 49161))
# Ports we've historically seen complete a DTLS handshake. When more than one
# port in the range looks live, these are tried first.
PREFERRED_PROBE_PORTS = [49154, 49155]
# Per-port timeout for the cheap UDP liveness sweep. Closed ports return an
# ICMP port-unreachable almost immediately; a live-but-silent port is only
# detected by this timeout elapsing, so keep it short.
LIVENESS_PROBE_TIMEOUT_S = 1.5
# Deadline for the blockwise /device/0 GET during the config-flow probe. The
# slowest device observed returns a full dump in ~8s, so 10s leaves headroom
# without stalling setup; it matches the per-resource read timeout elsewhere.
PROBE_GET_TIMEOUT_S = 10.0
# Base for the local (client-side) DTLS source port, distinct from the
# destination probe ports above. See coordinator._local_source_port for why a
# fixed per-device source port matters and how the per-device offset is
# derived. Base mirrors the upstream smartthings-local reference bridge.
# Requires smartthings-local >= 0.1.1.
DTLS_LOCAL_PORT_BASE = 49700
SUMMARY_INTERVAL_S = 30.0
+662 -46
View File
@@ -2,9 +2,11 @@
from __future__ import annotations
import asyncio
import ipaddress
import logging
import threading
import time
import zlib
from datetime import timedelta
from typing import Any
@@ -12,6 +14,7 @@ import cbor2
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.device_registry import DeviceInfo
@@ -20,23 +23,33 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
from smartthings_local.ocf.state_cache import StateCache
from .registry.batch import parse_device0_batch
from .registry.by_type import for_device, for_device_by_model
from .registry.discovery import discover, BoundEntity
from .registry.by_type import resolve as resolve_registry
from .registry.capabilities.common import (
merge_items_field,
merge_options_field,
remote_control_enabled,
remote_control_required_for_write,
)
from .registry.discovery import BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.identity import read_identity, DeviceIdentity
from .registry.subdevices import (
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
normalize_seed_batch,
)
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
DTLS_LOCAL_PORT_BASE,
)
_LOGGER = logging.getLogger(__name__)
_SEED_PATH = ['device', '0']
class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This
integration doesn't use per-capability observation hooks, so this is a
@@ -48,6 +61,45 @@ class _NoOpDescriptor:
_RECOVERY_RETRY_S = 600.0 # re-attempt observe mode this often while polling
def _local_source_port(host: str) -> int:
"""Deterministic UDP source port for this device's DTLS socket.
Binding the same source port on every (re)connect keeps the client on one
5-tuple, so the appliance evicts an orphaned session left by a previous run
(unclean shutdown -> no DTLS close_notify) at handshake time per RFC 6347
§4.2.8, instead of holding it for 5-15 min while the new session's reads
hang. See DTLS_LOCAL_PORT_BASE in const.py. Requires smartthings-local
>= 0.1.1 (the version that added DtlsCoapSession(local_port=...)).
The port must be stable across restarts and unique per device on this HA
host: the library's socket is unconnected (recvfrom), so two devices
sharing a source port would mis-demux each other's datagrams. For the usual
dotted-IPv4 host we use the last octet as the offset (unique on a /24);
anything else folds a stable CRC32 into the same 256-wide window.
"""
try:
offset = int(ipaddress.IPv4Address(host)) & 0xFF
except (ipaddress.AddressValueError, ValueError):
offset = zlib.crc32(host.encode()) & 0xFF
return DTLS_LOCAL_PORT_BASE + offset
def _is_placeholder_serial(serial: str) -> bool:
"""True for a non-empty serialNum that isn't actually a real identity.
The ARTIK051_DONGLE_REF firmware family reports the literal string
'Nothing(SVC)' for every unit -- non-empty, so the plain `if not
serial` check below doesn't catch it, and `device_serial` feeds both
the HA device-registry identifier and every entity's unique_id
(entity.py), so two such units on the same install silently collide
and the second one's entities get dropped (issue #83). Mirrors the
identical helper in config_flow.py's `_probe_and_validate` -- kept
separate rather than imported to avoid pulling the config-flow module
into the runtime coordinator's import graph for a two-line check.
"""
return serial.strip().lower().startswith('nothing')
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Manages one Samsung appliance: session, discovery, polling."""
@@ -63,12 +115,38 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
_OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S
_RECONNECT_PAUSE_S: float = 5.0
# A single reconnect is normal appliance-side behavior (see the
# README's "Known device behavior" section) -- Samsung's firmware
# drops the DTLS session briefly every now and then, and the
# coordinator recovering from that on its own isn't something a user
# needs to see at WARNING. Only escalate once reconnects pile up
# within a trailing window (issue #119).
#
# The window can't be a literal 60s: consecutive reconnect attempts are
# never closer together than one summary poll interval (SUMMARY_INTERVAL_S,
# 30s) plus _RECONNECT_PAUSE_S, so at most ~2 can ever land inside a 60s
# window regardless of how unhealthy the connection is -- a threshold of
# 5 there could never fire, silently downgrading every reconnect
# (including a persistently broken one) to INFO forever. 300s/3 instead:
# reachable under normal polling, and 3 reconnects inside 5 minutes is
# still a reasonable proxy for the README's "actually broken" case.
_RECONNECT_WARN_WINDOW_S: float = 300.0
_RECONNECT_WARN_THRESHOLD: int = 3
# A block-level ACK timeout on the summary GET doesn't prove the
# session is dead (see _poll_once) — require this many in a row
# before treating it as one. A single slow transfer on an otherwise
# fine session shouldn't tear down a working OBSERVE subscription.
_POLL_TIMEOUT_LIMIT: int = 3
# Timeouts for the two network round trips a write triggers: the PUT
# itself (_do_put), then the confirming full /device/0 summary poll
# async_send_command requests right after (_poll_once). Named here
# (rather than left as inline literals) so the write-settle window
# below can be sized to always outlast both — see async_send_command.
_POST_TIMEOUT_S: float = 8.0
_POLL_TIMEOUT_S: float = 35.0
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
# Per-device logger (module logger scoped to this device's host) so
# every log line — including the base DataUpdateCoordinator's own
@@ -87,6 +165,40 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._identity: DeviceIdentity | None = None
self._discovered = False
self.bound = []
# Sibling indoor subdevices discovered on this connection (issue
# #177) -- candidates set once, at first discovery, by
# _enumerate_subdevices_blocking; narrowed by _run_discovery to the
# ones that actually produced live primary state (see
# subdevices.discover_partitioned). MAIN itself is never in this list
# (see subdevices.canonical_view's docstring for why that's safe):
# it's the *other* subdevices sharing this DTLS session, if any.
self.subdevices: list[Subdevice] = []
# Candidates _run_discovery's gate rejected (an unused SmartThings
# slot that still answers its seed, e.g. the issue #177 reporter's
# /device/2) -- surfaced in diagnostics alongside the materialized
# ones so a report shows what was found and why it didn't become an
# entity.
self._skipped_subdevices: list = []
# Those rejected candidates' raw reps, kept aside for diagnostics
# only (see _live_subdevice_resources). They are deliberately not in the
# state cache: nothing polls them again, so anything applied there
# would sit frozen at its first-discovery value while looking as
# live as every other href in `last_resources`.
self._skipped_subdevice_resources: dict[str, dict] = {}
# /multidevice/vs/0's rep, if this board answers it -- a plain
# subdevice count that corroborates the liveness gate without deciding it.
# Deliberately outside `resources`; see _enumerate_subdevices_blocking.
self._multidevice: dict = {}
# What each subdevice probe found, keyed by the seed href attempted --
# surfaced in diagnostics so a report can tell "checked, nothing
# there" apart from "never checked" (the same posture the
# speculative-probe code this replaced documented in identity.py).
self._subdevice_probes: dict[str, bool] = {}
# canonical_resources() memo, keyed by (subdevice.kind, subdevice.key).
# Invalidated in _on_cache_changed -- climate.py reads this on every
# property access (is_legacy_board and friends), so it must not
# rebuild an O(hrefs) view from scratch on every single property.
self._canonical_cache: dict[tuple[str, str], dict] = {}
self._cache = StateCache(_NoOpDescriptor())
self._cache.set_on_change(self._on_cache_changed)
self._observe = ObserveManager(self._cache, logger=self._log)
@@ -106,6 +218,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.one_ui_version: str = ''
self._consecutive_poll_timeouts = 0
self._unbound_hrefs: list[str] = []
self._reconnect_times: list[float] = []
# ------------------------------------------------------------------
# Session management (all blocking — must run in executor)
@@ -115,6 +228,79 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def last_resources(self) -> dict:
return self._cache.snapshot()
def resource(self, href: str) -> dict:
"""A single href's current rep. Cheaper than `last_resources.get(href)`
for callers that only need one href — `last_resources` copies every
tracked href's rep to build the snapshot dict, while this is a
direct O(1) cache lookup."""
return self._cache.get(href) or {}
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
"""`subdevice`'s own view of the live snapshot, rewritten into the
canonical hrefs (issue #177) the registry/platforms are written
against -- see subdevices.canonical_view. A platform property that
needs the *whole* resources dict (as opposed to one href via
`resource()`/`last_resources.get(href)`) must use this instead of
`last_resources`, or a sibling subdevice's own `/mode/vs/1` would leak
into MAIN's canonical `/mode/vs/0` view (or vice versa) under
exists_fn/is_legacy_board-style checks that scan the whole dict.
Memoized per cache generation: climate.py calls this on every
property read (is_legacy_board and friends), and building it is
O(hrefs) -- _on_cache_changed clears the memo whenever the
snapshot actually changes, not on every property access.
"""
view_key = (subdevice.kind, subdevice.key)
cached = self._canonical_cache.get(view_key)
if cached is not None:
return cached
view = canonical_view(subdevice, self._cache.snapshot(), self.subdevices)
self._canonical_cache[view_key] = view
return view
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
"""DeviceInfo for one logical subdevice sharing this connection
(issue #177) -- the master's own (unchanged) device_info for MAIN, or
a linked child device for a discovered subdevice.
Identifiers derive from the *master's* serial (device_serial) plus
this subdevice's stable key, never from whatever serial the
subdevice itself reports (or fails to) -- deterministic across
reconnects whether or not this subdevice's own identity resource
(/information/vs/<n>, or /<id>/information/vs/0) answered on the
poll that first created the HA device. `serial_number` is set from
that resource when present anyway -- it's informational, not an
identifier.
"""
if subdevice.kind == 'main':
return self.device_info
info = self.canonical_resources(subdevice).get('/information/vs/0', {})
model_num = info.get('x.com.samsung.da.modelNum', '')
model = model_num.split('|', 1)[0] if model_num else ''
serial = info.get('x.com.samsung.da.serialNum') or None
base_name = self.device_info.get('name') or 'Samsung Appliance'
if model:
label = model.replace('_', ' ').title()
else:
# This poll never got (or never will get) the subdevice's own
# identity resource -- fall back to a generic per-subdevice label
# rather than leaving the device unnamed. 'Subdevice <n>' only
# makes sense for an indexed subdevice (the key is a small
# ordinal); UUID-prefixed subdevices are never more than one per
# connection today, so there's no ordinal to show.
label = (
f'Subdevice {subdevice.key}' if subdevice.kind == 'indexed'
else 'Secondary Subdevice'
)
return DeviceInfo(
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
via_device=(DOMAIN, self.device_serial),
name=f"{base_name} {label}",
manufacturer=self.device_info.get('manufacturer') or 'Samsung',
model=model or None,
serial_number=serial,
)
@property
def observe_mode(self) -> str:
return self._observe.mode
@@ -126,7 +312,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem,
on_notification=self._observe.on_notification)
on_notification=self._observe.on_notification,
local_port=_local_source_port(host))
sess.connect()
sess.start_reader()
self._session = sess
@@ -162,6 +349,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
into a single push instead of one hass.add_job per href."""
if not changed:
return
self._canonical_cache.clear()
with self._push_pending_lock:
if self._push_pending:
return
@@ -196,7 +384,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# would have succeeded, not a dead session. 35s gives a slow
# blockwise transfer room to actually finish instead of
# generating a TimeoutError every cycle.
code, payload = sess.get(_SEED_PATH, timeout=35.0)
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
except TimeoutError:
raise
except Exception as e:
@@ -209,7 +397,83 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
body = cbor2.loads(payload)
except Exception as e:
raise RuntimeError(f"poll cbor decode: {e}") from e
return parse_device0_batch(body) if isinstance(body, list) else {}
result = parse_device0_batch(body) if isinstance(body, list) else {}
# Refresh every already-enumerated sibling subdevice's seed collection
# on this same summary poll (issue #177) -- without this, a subdevice's
# climate card would show only its enumeration-time snapshot forever.
for subdevice in self.subdevices:
result.update(self._poll_subdevice_seed(subdevice))
return result
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
"""GET one subdevice's seed Collection and return its batch,
normalized to real hrefs. A sibling failing to answer is a debug
log, never a failed poll -- the master must not go unavailable
because a sibling timed out or dropped off (e.g. the issue #177
reporter's /device/2, a SmartThings-unused component that may not
always respond). Blocking -- called from _poll_once, already in
executor."""
sess = self._session
if sess is None:
return {}
if subdevice.flat_hrefs:
return self._poll_subdevice_flat_hrefs(subdevice, sess)
try:
code, payload = sess.get(list(subdevice.seed_path), timeout=10.0)
if code == 0x45 and payload:
body = cbor2.loads(payload)
if isinstance(body, list):
return normalize_seed_batch(subdevice, parse_device0_batch(body))
except Exception as e:
self._log.debug("subdevice %s seed poll failed: %s", subdevice.key, e)
return {}
def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]:
"""Re-poll a flat-mode prefixed subdevice's hrefs individually
(issue #205) -- it has no Collection endpoint to batch-refresh
through (see enumerate_subdevices' fallback), so each canonical
href confirmed at enumeration time gets its own GET under the
subdevice's prefix. A href failing to answer this cycle just drops
out of the result, same "never let a sibling's flakiness fail the
master's poll" posture as the Collection path above.
Takes `sess` from the caller (already None-checked there) rather
than re-reading self._session -- async_close() can null that
without holding _session_lock, and pace()/get() both need a live
session on every iteration, not just the first.
Skips any href already covered by the hot/warm sub-poll tiers
(self._hot_hrefs/_warm_hrefs, in the same actual/on-the-wire form
this method builds) -- those are already refreshed every 3s/6s by
_run_subpolls, strictly more current than this once-per-summary-poll
pass could offer, so re-fetching them here would only add GETs
without adding freshness. A subdevice with many confirmed hrefs
(unlike a Collection batch, which is always one GET regardless of
count) is otherwise a summary-poll cost that scales with its href
count."""
skip = set(self._hot_hrefs) | set(self._warm_hrefs)
result: dict[str, dict] = {}
first = True
for href in subdevice.flat_hrefs:
actual = subdevice.to_actual(href)
if actual in skip:
continue
try:
if not first:
sess.pace()
first = False
path = [s for s in actual.strip('/').split('/')]
code, payload = sess.get(path, timeout=10.0)
if code == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
result[actual] = rep
except Exception as e:
self._log.debug(
"subdevice %s flat href %s poll failed: %s",
subdevice.key, href, e,
)
return result
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
"""GET individual hrefs sequentially. Does not reconnect on failure. Blocking."""
@@ -268,39 +532,159 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Discovery (runs once on first successful poll)
# ------------------------------------------------------------------
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
"""One-time (first discovery only) probe for sibling indoor subdevices
sharing this connection (issue #177) -- see
registry.subdevices.enumerate_subdevices for the two detection
patterns. Blocking -- runs in executor, under the session lock
(shares the same DTLS session _poll_once just used this cycle).
Sets self.subdevices to every *candidate* the probes turned up
(self._subdevice_probes as a side effect too) and returns `resources`
merged with whatever each candidate's seed returned, so this cycle's
_run_discovery sees every candidate's state without a second poll
round trip. `_run_discovery` is what narrows self.subdevices down to
the ones that are actually live (see discover_partitioned) -- this
method doesn't know how to tell an unused SmartThings slot (the
issue #177 reporter's /device/2) from a real sibling, only that
something answered.
"""
if self._session is None:
self._connect_session()
sess = self._session
if sess is None:
return resources
oic_res = self._identity.raw.get('/oic/res', []) if self._identity else []
probes: dict[str, bool] = {}
subdevices, extra = enumerate_subdevices(
sess, resources, oic_res,
probe_log=lambda href, found: probes.__setitem__(href, found),
)
self.subdevices = subdevices
self._subdevice_probes = probes
# /multidevice/vs/0 is corroborating metadata, not appliance state,
# and it is probed on *every* device -- so it must not join the
# returned resources dict. Two things go wrong if it does. It would
# reach discovery on families whose registry doesn't ignore that
# href (only the AC one does), binding to nothing and raising a
# spurious "incomplete capability coverage" repair for every washer
# or fridge whose firmware happens to answer it. And it is fetched
# once here and never polled again, so applying it to the state
# cache would freeze it there exactly like a rejected candidate's
# reps (see _live_subdevice_resources). Kept aside for diagnostics and
# for the numofsubdevice cross-check in _run_discovery instead.
self._multidevice = extra.pop('/multidevice/vs/0', {})
return {**resources, **extra}
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
"""`resources` minus every href belonging to a candidate subdevice the
liveness gate rejected (issue #177).
Called once, between _run_discovery and the first cache apply, so a
rejected slot's reps are seen by the gate and then dropped rather
than frozen into the cache forever -- see the call site. The reps
themselves are kept in _skipped_subdevice_resources for diagnostics,
which is the only thing that still wants them.
"""
if not self._skipped_subdevices:
return resources
kept: dict[str, dict] = {}
skipped: dict[str, dict] = {}
for href, rep in resources.items():
bucket = skipped if any(
skip.subdevice.owns(href) for skip in self._skipped_subdevices
) else kept
bucket[href] = rep
self._skipped_subdevice_resources = skipped
return kept
def _run_discovery(self, resources: dict[str, dict]) -> None:
one_ui = (resources.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
self.one_ui_version = one_ui
# Reported for diagnostics only -- it names the firmware generation
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
# an issue. It does not route: only a minority of hardware reports it
# at all, and every device that does is already typed by its modelNum.
self.one_ui_version = (resources.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
info = resources.get('/information/vs/0', {})
reg = for_device(one_ui) if one_ui else None
if reg is None:
reg = for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
)
unbound: list[str] = []
if reg is not None:
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
log=unbound.append)
self.device_type_name = reg.name
hot, warm = set(), set()
def _tier_log(href: str, tier: str) -> None:
if tier == 'hot':
hot.add(href)
elif tier == 'warm':
warm.add(href)
model_num = info.get('x.com.samsung.da.modelNum', '')
description = info.get('x.com.samsung.da.description', '')
# Partitioned discovery (issue #177): the main pass binds every href
# owned by no subdevice; one further pass per *candidate* subdevice
# binds its own canonical view, resolving its own device type from
# its own /information/vs/0 when it reports one and falling back to
# the master's registry otherwise. See subdevices.discover_partitioned
# -- it also gates each candidate down to whether it actually
# produced live primary state (the issue #177 reporter's /device/2,
# an unused SmartThings slot, answers its seed but never does), so
# self.subdevices below is narrowed to the ones that passed, not
# every candidate _enumerate_subdevices_blocking found. For a device
# with no candidates (self.subdevices == []) this is exactly the
# single discover() call this method used to make.
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, self.subdevices, resolve_registry, CAPABILITIES,
log=unbound.append, tier_log=_tier_log,
)
self.subdevices = materialized
self._skipped_subdevices = skipped
for skip in skipped:
self._log.info(
"subdevice %s (%s) answered its seed but produced no live "
"primary state; not materialized (hrefs=%s)",
skip.subdevice.key, skip.subdevice.kind, list(skip.hrefs),
)
# Corroborating signal, not a gate (DESIGN-177.md section 4):
# /multidevice/vs/0's numofsubdevice is a plain count the issue
# #177 reporter's board reports independently of the liveness gate
# above. Log, don't raise, on a disagreement -- only this one board
# family is known to expose the resource at all, so a mismatch is a
# "look into this" signal for triage, not proof either side is
# wrong.
numofsubdevice = self._multidevice.get(
'x.com.samsung.da.numofsubdevice')
if numofsubdevice is not None:
try:
reported = int(numofsubdevice)
except (TypeError, ValueError):
reported = None
subdevice_count = len(materialized) + 1 # +1 for the master itself
if reported is not None and reported != subdevice_count:
self._log.debug(
"/multidevice/vs/0 reports numofsubdevice=%r but %d "
"subdevice(s) materialized (including the master)",
numofsubdevice, subdevice_count,
)
if device_type_name is not None:
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
else:
self._log.warning("unknown device type oneUiVersion=%r; using common caps", one_ui)
bound = discover(resources, CAPABILITIES, log=unbound.append)
self.device_type_name = None
# Both fields: detection reads each of them (board token, then
# consumer-model code), and this line is what a user pastes into
# an issue -- modelNum alone doesn't identify a washer or dryer.
self._log.warning(
"unknown device type modelNum=%r description=%r; using common caps",
model_num, description,
)
self.device_type_name = device_type_name
self.bound = bound
self._unbound_hrefs = unbound
serial = info.get('x.com.samsung.da.serialNum', '')
if not serial:
if not serial or _is_placeholder_serial(serial):
serial = self._entry.data[CONF_HOST]
self.device_serial = serial
ident = self._identity
device_type = reg.name.title() if reg else 'Appliance'
model_num = info.get('x.com.samsung.da.modelNum', '')
device_type = device_type_name.replace('_', ' ').title() if device_type_name else 'Appliance'
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
mfr = (ident.manufacturer if ident else '') or 'Samsung'
@@ -311,22 +695,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
manufacturer=mfr,
model=model,
)
self._update_coverage_gap_issue(reg is None, unbound, name)
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
hot, warm = set(), set()
for be in bound:
tier = be.capability.poll_tier
if tier == 'hot':
hot.add(be.href)
elif tier == 'warm':
warm.add(be.href)
self._hot_hrefs = sorted(hot)
self._warm_hrefs = sorted(warm)
self._discovered = True
self._log.info(
"discovered %d entities (serial=%s) hot=%s warm=%s",
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
[su.key for su in self.subdevices],
)
def _update_coverage_gap_issue(
@@ -405,6 +783,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._consecutive_poll_timeouts += 1
return self._consecutive_poll_timeouts < self._POLL_TIMEOUT_LIMIT
def _reconnect_is_frequent(self) -> bool:
"""True once this cycle's reconnect is the Nth within the trailing
warn window -- see _RECONNECT_WARN_WINDOW_S/_RECONNECT_WARN_THRESHOLD."""
now = time.monotonic()
self._reconnect_times = [
t for t in self._reconnect_times
if now - t < self._RECONNECT_WARN_WINDOW_S
]
self._reconnect_times.append(now)
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
# ------------------------------------------------------------------
# DataUpdateCoordinator hook
# ------------------------------------------------------------------
@@ -431,7 +820,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._consecutive_poll_timeouts = 0
# One reconnect attempt — pause briefly so the device can
# clean up its DTLS session state before we knock again.
self._log.warning("poll failed, reconnecting: %s", e)
# A lone reconnect is routine (see the README's "Known
# device behavior" section); only warn once they're piling
# up within the trailing window.
if self._reconnect_is_frequent():
self._log.warning("poll failed, reconnecting: %s", e)
else:
self._log.info("poll failed, reconnecting: %s", e)
await self.hass.async_add_executor_job(self._close_session)
await asyncio.sleep(self._RECONNECT_PAUSE_S)
try:
@@ -462,7 +857,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._observe.downgrade_to_poll()
just_downgraded_from_observe = True
if not self._discovered:
# One-time (issue #177): find out whether this connection has
# sibling indoor subdevices before the first discovery pass, and
# fold their seed resources into this cycle's snapshot so
# discovery sees every subdevice's state on the very first poll
# rather than waiting a cycle. Runs under its own session-lock
# scope (the poll above already released the lock) since it
# shares the same DTLS session.
async with self._session_lock:
resources = await self.hass.async_add_executor_job(
self._enumerate_subdevices_blocking, resources
)
source = 'sweep' if self._discovered else 'poll'
first_cycle = not self._discovered
if first_cycle:
# Discovery runs *before* the apply loop below, not after it, so
# a rejected candidate's resources never reach the state cache
# at all (issue #177). Enumeration has to fetch every candidate's
# seed to evaluate the liveness gate, but only the subdevices that
# pass it are ever polled again -- applying the rest would freeze
# ~14 hrefs per rejected slot into the cache on this one cycle
# and leave them there forever, indistinguishable from live
# state in `last_resources` and in the diagnostics dump built
# from it. StateCache has no eviction, so the only way to keep
# them out is to not put them in. Safe to reorder: _run_discovery
# reads the dict passed to it and never the cache, and
# log_sweep_discrepancies below can't fire on a first cycle
# (observe mode is only ever attempted after discovery).
self._run_discovery(resources)
resources = self._live_subdevice_resources(resources)
sweep_mismatch = False
if self._observe.mode == MODE_OBSERVE:
# A sweep/cache mismatch never tears down a still-live OBSERVE
@@ -478,8 +903,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for href, rep in resources.items():
self._observe.apply(href, rep, source=source)
if not self._discovered:
self._run_discovery(resources)
if first_cycle:
await self._attempt_observe_mode()
elif just_downgraded_from_observe:
await self._attempt_observe_mode()
@@ -502,30 +926,222 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def async_send_command(self, bound_entity: BoundEntity,
payload: Any) -> None:
"""Write a value to the device. Fire-and-forget style."""
"""Write a value to the device. Fire-and-forget style.
A description-level validate_fn (currently SwitchDesc only) runs
here rather than per-platform, so rejecting a write with a
user-facing message -- as opposed to write_fn's silent no-op below
-- is available to every platform for free. The remote-control
check runs first and applies to every platform unconditionally,
ahead of any description-specific validate_fn -- unless the user has
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
#54: some devices accept certain writes, e.g. a washer's default
dosing levels, even while reporting remote control off, so the
block's assumption doesn't hold for every model), or the laundry
firmware flag isModelSettingWithoutSC declares settings writable
without Smart Control (cycle start/pause/stop on /operational/state
still require it)."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
if write_fn is None:
return
href = bound_entity.href
rep = self._cache.get(href or '') or {}
result = write_fn(payload, rep, href)
resources = self._cache.snapshot()
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
if (
not bypass_remote_control
and remote_control_required_for_write(resources, href or '')
and not remote_control_enabled(resources)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="remote_control_disabled",
)
validate_fn = getattr(desc, 'validate_fn', None)
if validate_fn is not None:
error = validate_fn(payload, rep, resources)
if error:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=error,
)
try:
result = write_fn(payload, rep, href, resources)
except TypeError:
result = write_fn(payload, rep, href)
if result is None:
self._log.warning("write_fn rejected payload %r for %s", payload, href)
return
path_segs, body = result
# The write's actual target, not necessarily bound_entity.href. Most
# descriptors write to the same resource they're bound to, but a
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 --
# drives writes to several sibling resources via path_segs
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that
# write_fn picks per payload (see airconditioner._climate_write).
# Applying the optimistic value and settle guard below to
# bound_entity.href instead of this target protected the wrong
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
# merge while the resource the climate entity actually displays from
# (e.g. /power/0) never got one, so HA kept showing the pre-write
# state until the next real read of that resource -- the 20-60s lag
# in issues #17/#53, which survived the earlier optimistic-apply fix
# (issue #27) because that fix applied to the wrong href too.
#
# write_fn's path_segs are canonical (issue #177) -- a subdevice's
# ClimateDesc is bound to its own *actual* /mode/vs/1 (or
# /<id>/mode/vs/0) href, but _climate_write only knows the canonical
# sibling hrefs (e.g. ['power', 'vs', '0']). Translate through this
# bound entity's own subdevice so the optimistic apply, the settle
# guard and the POST below all target that subdevice's real resource --
# to_actual is the identity transform for MAIN, so a device with no
# subdevices writes exactly where it always did.
write_href = bound_entity.subdevice.to_actual('/' + '/'.join(path_segs))
path_segs = [s for s in write_href.strip('/').split('/') if s]
# Apply the write optimistically before starting the settle guard,
# not after -- mark_write_pending gates every source (poll, sweep,
# observe) through the same apply(), itself included, so flipping
# this order would have the guard drop the one update it exists to
# protect. Without an optimistic value in the cache for it to hold
# onto, the settle window was just delaying the real device
# confirmation for a few seconds on every write, which read exactly
# like the write being silently reverted (issue #27).
#
# settle_s must outlast the PUT and the async_request_refresh()
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds --
# that refresh is a full /device/0 summary poll, which
# _POLL_TIMEOUT_S itself admits can legitimately take tens of
# seconds on these devices (see _poll_once), and some writes settle
# on the device itself well after that: issue #9's washer packs
# cycle/detergent/softener selection into the same /course/vs/0
# options[] array, and picking a new value there visibly needs a
# few seconds of internal validation/dispenser movement before the
# device's own state agrees -- while /washer/vs/0's temperature/
# spin fields (plain flags, no device-side settling) confirm
# instantly on the same device. A short fixed window expired while
# the confirm poll was still in flight (or before the device had
# caught up internally), so that stale read landed unprotected and
# reverted the optimistic value, self-correcting again only once a
# later poll finally saw the real change -- read by the user as the
# write "reverting, then re-applying itself" a few seconds later.
#
# An earlier attempt at this also released the guard early, right
# after the confirming refresh completed, to avoid shutting out
# unrelated real updates (another automation, the physical remote)
# for the rest of settle_s. That was reverted: releasing the guard
# the moment one round trip finishes doesn't mean the device has
# actually caught up (exactly the slow-settling case above), and it
# introduced its own races around overlapping writes to the same
# href. Simpler and safer to just hold the guard for the full,
# generously-sized window and let it expire on its own.
# write_fn bodies that touch x.com.samsung.da.options or
# x.com.samsung.da.items carry only the changed token(s)/item now
# (issue #54 for options; the AC vendor temperature write for items --
# confirmed sufficient on the wire, the device merges the rest itself),
# not the whole packed array. observe.apply()'s field-level
# {**cached, **rep} merge doesn't know that -- handed the bare
# partial value, it would replace the cached field outright and wipe
# every sibling option/item for the rest of the settle window.
# Pre-merge it here the same way the device does, so the optimistic
# cache entry stays complete; the minimal `body` below is still
# exactly what goes out over the wire.
optimistic_body = body
new_options = body.get('x.com.samsung.da.options')
if isinstance(new_options, list):
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
optimistic_body = {
**optimistic_body,
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
}
# Same fact, items[] shape (e.g. airconditioner._climate_write's vendor
# temperature write, which now carries only {id, desired} -- see that
# module for the write-side half of this).
new_items = body.get('x.com.samsung.da.items')
if isinstance(new_items, list):
cached_items = (self._cache.get(write_href) or {}).get('x.com.samsung.da.items')
optimistic_body = {
**optimistic_body,
'x.com.samsung.da.items': merge_items_field(cached_items, new_items),
}
self._observe.apply(write_href, optimistic_body, source='optimistic')
self._observe.mark_write_pending(
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
)
def _do_put():
sess = self._session
if sess is None:
raise RuntimeError("no session")
self._observe.mark_write_pending(href)
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
self._log.info("PUT %s → code %#04x", href, code)
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
self._log.info("PUT %s → code %#04x", write_href, code)
try:
await self.hass.async_add_executor_job(_do_put)
except Exception as e:
self._log.error("command failed for %s: %s", href, e)
self._log.error("command failed for %s: %s", write_href, e)
else:
await self.async_request_refresh()
# ------------------------------------------------------------------
# Debug raw write (issue #54): a power-user escape hatch for the
# options-flow debug panel, letting a user POST an arbitrary partial
# body to an arbitrary href to pin down device-specific write behavior
# without waiting on a new release. Deliberately bypasses the
# remote-control block and every write_fn/validate_fn above -- that's
# the whole point, so use with care.
# ------------------------------------------------------------------
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
"""Debug primitive: POST an arbitrary patch, then read the href
back for ground truth. Blocking -- runs in executor."""
if self._session is None:
self._connect_session()
sess = self._session
if sess is None:
raise RuntimeError("no session")
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
self._log.warning("DEBUG raw write POST %s %r → code %#04x", href, body, code)
new_rep: dict = {}
try:
sess.pace()
rcode, payload = sess.get(path_segs, timeout=10.0)
if rcode == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
self._observe.apply(href, rep, source='poll')
new_rep = rep
except Exception as e:
self._log.debug("raw write follow-up read failed: %s", e)
return code, new_rep
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
"""Debug-only arbitrary write (issue #54). Bypasses the
remote-control block and all write_fn/validate_fn logic; sends
`body` verbatim as a partial-rep PATCH to `href`. Returns
(coap_code, new_rep) where new_rep is the href's value read back
right after the write. Used by the options-flow debug panel to
help users pin down device-specific write behavior without a new
release."""
if not isinstance(body, dict) or not body:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="debug_payload_empty",
)
path_segs = [s for s in href.strip('/').split('/') if s]
if not path_segs:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="resource_href_required",
)
norm_href = '/' + '/'.join(path_segs)
async with self._session_lock:
code, new_rep = await self.hass.async_add_executor_job(
self._raw_write_blocking, path_segs, body, norm_href
)
# Hasten a full summary poll so entities on other resources catch
# up too -- a debug write can affect siblings, not just its href.
await self.async_request_refresh()
return code, new_rep
+124 -2
View File
@@ -18,6 +18,7 @@ from homeassistant.loader import async_get_integration
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.redact import redact_resources
from .registry.subdevices import MAIN
async def async_get_config_entry_diagnostics(
@@ -26,13 +27,134 @@ async def async_get_config_entry_diagnostics(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
integration = await async_get_integration(hass, DOMAIN)
# importlib.metadata.version() reads the installed package's metadata off
# disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
# is OCF's standard device-type declaration; /oic/res is OCF's
# discovery endpoint, listing every href/Collection the connection
# hosts -- relevant to the "Composite Device" model (issue #177) where
# a single physical device exposes more than one logical subdevice. See
# registry/identity.py.
identity = coordinator._identity
def _seed_diag(su) -> dict:
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0
# Collection, so its state comes from individually-polled hrefs
# instead) has no meaningful seed_path; report the flat_hrefs list
# in its place rather than the misleading bare "/" a joined empty
# tuple would otherwise produce.
return {
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
"flat_hrefs": list(su.flat_hrefs),
}
def _subdevice_diag(su) -> dict:
# One pass over coordinator.bound for both fields below (count and
# the distinct hrefs), and one redaction of this subdevice's canonical
# view -- `model` reads modelNum off the already-redacted `resources`
# rather than redacting /information/vs/0 a second time. modelNum
# itself never matches redact.py's substring rules, so which side of
# redact_resources it's read from doesn't change the value.
matching = [b for b in coordinator.bound if b.subdevice == su]
res = redact_resources(coordinator.canonical_resources(su))
return {
"kind": su.kind,
"key": su.key,
**_seed_diag(su),
"bound_entity_count": len(matching),
"hrefs": sorted({b.href for b in matching}),
"model": res.get('/information/vs/0', {}).get('x.com.samsung.da.modelNum', ''),
# Keyed by this subdevice's *canonical* hrefs, not the real ones
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
# '/<uuid>/mode/vs/0'. That's the form the registry and every
# capability are written against, so a sibling's block can be
# read (or pasted into the skill's standalone-discovery
# recipe) exactly like the master's `resources` above,
# instead of having to be de-indexed by hand first.
"resources": res,
}
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"identity": {
"manufacturer": identity.manufacturer,
"model": identity.model,
"device_types": list(identity.device_types),
"resources": redact_resources(identity.raw),
} if identity is not None else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
# This subdevice's own resources, and only this subdevice's -- what
# the module docstring and the adding-device-support skill have
# always described it as ("the parsed /device/0 snapshot"). On a
# composite device (issue #177) `last_resources` is the union across
# every live subdevice keyed by real hrefs, so reporting it raw here
# would mix a sibling's /mode/vs/1 in with the master's /mode/vs/0
# under no attribution at all. Each sibling reports its own
# resources in its own `subdevices` entry below instead. For a
# device with no subdevices -- almost every device -- this is
# byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
# Sibling indoor subdevices discovered on this connection (issue
# #177) -- per-subdevice kind/key/seed path plus what actually bound
# to it, so a report shows whether a composite device's subdevice
# was found at all and what it resolved to. subdeviceIdList (the
# UUID a prefixed subdevice's key comes from) is deliberately NOT
# redacted here even
# though the field matches redact.py's 'deviceid' substring rule
# elsewhere in `resources` above -- it's an appliance-internal
# pairing id, not account data, and reporting the key is what makes
# this block actionable.
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
# Candidates that answered their seed but that discover_partitioned's
# entity-level liveness gate rejected -- an unused SmartThings slot
# (the issue #177 reporter's /device/2) that still answers a
# same-shaped batch, not a real second subdevice. Reported alongside
# subdevices above so a report shows what was found *and* why it
# didn't become an entity, not just silence where a third climate
# card might otherwise be expected.
"subdevices_skipped": [
{
"kind": skip.subdevice.kind,
"key": skip.subdevice.key,
**_seed_diag(skip.subdevice),
"hrefs": list(skip.hrefs),
# The reps the liveness gate actually judged, canonicalized
# like the materialized subdevices above. These are the one
# thing a reader needs to second-guess a skip ("is my second
# subdevice really absent, or did the gate get it wrong?"),
# and they exist nowhere else in this dump: a rejected
# candidate is never polled again and never enters the state
# cache, so `resources` above cannot contain them by
# construction.
"resources": redact_resources({
canon: rep
for href, rep in coordinator._skipped_subdevice_resources.items()
if (canon := skip.subdevice.to_canonical(href)) is not None
}),
}
for skip in coordinator._skipped_subdevices
],
# What each enumeration probe returned ({} vs a batch), keyed by the
# seed href attempted -- lets a report distinguish "checked, nothing
# there" from "never checked", the same posture the speculative
# /device/1 //device/2 probe this replaced used to document directly
# in identity.py before it moved to registry/subdevices.py.
"subdevice_probes": dict(sorted(coordinator._subdevice_probes.items())),
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
# Reported on its own rather than inside `resources` because it is
# metadata about the connection rather than state of any one
# subdevice -- and because nothing polls it after discovery, so it
# would go stale in there. Its numofsubdevice count is what
# independently corroborates the subdevices/subdevices_skipped split
# above.
"multidevice": redact_resources(coordinator._multidevice),
"integration_version": integration.version,
"smartthings_local_version": pkg_version("smartthings-local"),
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
+91 -11
View File
@@ -8,7 +8,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from .registry.adapter import _key
from .registry.discovery import BoundEntity
from .registry.batch import is_stub_rep
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
@@ -21,30 +22,62 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
require that field to be present in the resource rep so that optional
fields on shared resources don't create phantom entities.
An empty rep ({}) means /device/0 returned a stub for this resource —
the resource exists on the device but data hasn't been fetched yet.
In that case we include the entity so it can be populated by sub-polls.
A stub rep (is_stub_rep — /device/0's "resource exists, no data fetched
yet" marker) is included anyway so it can be populated by sub-polls. A
genuinely empty {} rep is included too by this default gate -- whether
empty means "not populated yet" or "permanently unsupported" needs
per-field domain knowledge this generic gate doesn't have: /alarms/vs/0's
{} is fridge.py's documented *normal* no-alarm state (see
_active_alarm_codes), not an absence signal, and it's far from the only
resource like that. Only a capability whose author has actually verified
a field is genuinely never populated on unsupported hardware opts into
stricter gating with its own is_stub_rep-based exists_fn (see
common.ENERGY_METER, issue #127) -- this default stays permissive.
`bound.href` is already the *actual* href (issue #177 -- see
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
raw snapshot, same rule as everywhere else a whole-resources-dict scan
happens (coordinator.canonical_resources) -- this is a free function, not
an LocalThingsEntity method, so it can't use self._resources.
"""
rep = coordinator.last_resources.get(bound.href)
if rep is None:
return False
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep, coordinator.last_resources)
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
if bound.desc.field:
if not rep: # stub — resource known to exist, data not yet fetched
if not rep or is_stub_rep(rep):
return True
return bound.desc.field in rep
return True # rep_fn or no-field entities (ButtonDesc) are always included
def _derive_name(state_key: str) -> str:
"""Turn a snake_case state key into a title-cased display name.
"""Turn a snake_case state key into a title-cased label.
Strips a trailing instance number of 0 (singleton), promotes any other
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
Entity names themselves come from the translation catalog; this only
builds the {instance_name} placeholder those translations interpolate,
for a device that named its own compartments/ice makers.
"""
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
return name.replace('_', ' ').title().strip()
return _snake_to_title(name).strip()
def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
"""Return the stable vendor/href instance label used in a name placeholder."""
if bound.instance_name:
return bound.instance_name
source = bound.key_override or state_key
suffix = f"_{bound.desc.key}"
if source.endswith(suffix):
source = source[:-len(suffix)]
elif bound.instance and source.endswith(bound.instance):
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
return _derive_name(source)
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
@@ -57,13 +90,60 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._bound = bound
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
self._attr_name = bound.desc.name if bound.desc.name is not None else _derive_name(self._state_key)
self._attr_translation_key = bound.desc.translation_key
if bound.desc.translation_placeholders is not None:
self._attr_translation_placeholders = dict(
bound.desc.translation_placeholders
)
elif bound.desc.use_instance_name:
self._attr_translation_placeholders = {
"instance_name": _instance_display_name(bound, self._state_key)
}
# _attr_name is deliberately left unset: Home Assistant gives an
# explicitly-set name precedence over the translation catalog, so
# setting it here would make every entity untranslatable. Every
# descriptor resolves to a catalog entry (see translation_key below);
# a platform that wants the bare device name instead sets
# _attr_name = None itself, as fan.py does for the hood's main entity.
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
self._attr_entity_registry_enabled_default = bound.desc.enabled_default
@property
def translation_key(self) -> str | None:
"""The descriptor's catalog key, defaulting to its own `key`.
Overrides Entity.translation_key (a property upstream, not a plain
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
table-id-gated resolver -- is re-evaluated against live coordinator
data on every access, not resolved once at construction time.
Discovery runs on the first /device/0 poll, which the entity
registry already documents can hand a sibling resource an empty
stub rep before it's actually been fetched (see _is_included's
docstring) -- a static one-time resolution here would risk baking
in a permanent None (no translation) for the entity's whole
lifetime if that stub hadn't populated yet, even once the real
value arrives on a later poll.
"""
tk = self._bound.desc.translation_key
if callable(tk):
return tk(self._resources)
return tk if tk is not None else self._bound.desc.key
@property
def _resources(self) -> dict:
"""This entity's own subdevice's canonical resources view (issue
#177) -- see coordinator.canonical_resources. Every platform
property that needs the *whole* resources dict, as opposed to one
href via `coordinator.resource(href)`, must read through this
instead of `coordinator.last_resources`, or a sibling subdevice's own
actual hrefs would leak into (or be missing from) this entity's
view. For MAIN (every device with no subdevices) this is exactly
`coordinator.last_resources`."""
return self.coordinator.canonical_resources(self._bound.subdevice)
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info
return self.coordinator.device_info_for(self._bound.subdevice)
+403
View File
@@ -0,0 +1,403 @@
"""Fan platform for Samsung range hoods and air purifiers.
Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
below since each needs different HA fan semantics: the range hood's fan
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
Medium/High (issue #56) are both an ordered set of numeric levels
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
air_purifier.py's module docstring, with no named-mode list to preserve
since this board never self-reports one. The TP1X air-purifier family's
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles
both hrefs, the only difference being whether the label comes straight
from supportedModes or from a parallel modesName array (see
_label_for_code)."""
from __future__ import annotations
import logging
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.air_purifier import HREF_AIRFLOW
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
from .registry.capabilities.air_purifier import HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF
from .registry.entities import FanDesc
_LOGGER = logging.getLogger(__name__)
POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
_MAX_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMaxFanSpeed'
_OFF_SPEED_CODE = '0'
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
_MODES_NAME_FIELD = 'x.com.samsung.da.modesName'
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = []
for bound in coordinator.bound:
if not (isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)):
continue
if bound.href in (AIR_PURIFIER_FAN_HREF, AIR_PURIFIER_WIND_STRENGTH_HREF):
entities.append(LocalThingsAirPurifierFan(coordinator, bound))
elif bound.href == HREF_AIRFLOW:
entities.append(LocalThingsAirflowFan(coordinator, bound))
else:
entities.append(LocalThingsRangeHoodFan(coordinator, bound))
async_add_entities(entities)
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
"""A hood fan combining sibling power and fan-speed resources.
Some boards that reuse this capability (built-in microwave vent fans,
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
resource at all -- fan speed 0 is itself the off state there, with no
separate power toggle to write. `_speed_zero_is_off` detects that
shape from the hood resource's own settableMinFanSpeed/
supportedFanSpeed fields and switches every method below to drive
off/on purely through the fanSpeed field, including '0' in the
ordered speed codes as the off step instead of assuming every
advertised code is an active speed.
This is deliberately not the same question as `_has_separate_power`,
which only proves *some* power resource exists on the device -- on a
combi appliance (e.g. an over-the-range microwave) that resource can
belong to the cavity, not the vent fan, and toggling it from here
would turn off the whole appliance instead of just the fan.
"""
_enable_turn_on_off_backwards_compatibility = False
@property
def supported_features(self) -> FanEntityFeature:
features = FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
if self.speed_count > 0:
features |= FanEntityFeature.SET_SPEED
return features
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
self._attr_name = None
def _rep(self, href: str) -> dict:
return self.coordinator.resource(href) or {}
def _has_separate_power(self) -> bool:
return bool(self._rep(POWER_HREF)) or bool(self._rep(POWER_VS_HREF))
def _speed_zero_is_off(self) -> bool:
"""Whether fan speed '0' is itself this hood's off step, with no
separate power resource to toggle. The board says so directly:
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
standalone hood's codes start at 14 and it carries a real /power
resource instead, so this is False there."""
rep = self._rep(self._bound.href)
return (
str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
or _OFF_SPEED_CODE in self._all_speed_codes()
)
def _all_speed_codes(self) -> list[str]:
rep = self._rep(self._bound.href)
supported = rep.get(_SUPPORTED_FAN_SPEED_FIELD)
if supported:
return [str(value) for value in supported]
min_s = rep.get(_MIN_FAN_SPEED_FIELD)
max_s = rep.get(_MAX_FAN_SPEED_FIELD)
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
return [str(i) for i in range(mn, mx + 1)]
except (ValueError, TypeError):
pass
return []
def _active_speed_codes(self) -> list[str]:
codes = self._all_speed_codes()
if self._speed_zero_is_off():
# No separate power resource: '0' is the off step, not a speed.
return [code for code in codes if code != _OFF_SPEED_CODE]
# Power is carried by the separate /power resource. fanSpeed
# retains the selected setting while power is off (as the
# lamp's `current` field does), so every advertised code is an
# active ordered speed.
return codes
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
@property
def is_on(self) -> bool:
if self._speed_zero_is_off():
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
return current not in ('', _OFF_SPEED_CODE)
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
return str(
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
).lower() == 'on'
@property
def speed_count(self) -> int:
return len(self._active_speed_codes())
@property
def percentage(self) -> int | None:
if not self.is_on:
return 0
codes = self._active_speed_codes()
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
if not codes or current not in codes:
return None
return ordered_list_item_to_percentage(codes, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
**kwargs,
) -> None:
if self._speed_zero_is_off():
if percentage is not None:
await self.async_set_percentage(percentage)
return
if self.is_on:
# Already running: no percentage given means "just turn on",
# not "reset to the lowest speed".
return
codes = self._active_speed_codes()
if codes:
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
)
if percentage is not None:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs) -> None:
if self._speed_zero_is_off():
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(False),
)
async def async_set_percentage(self, percentage: int) -> None:
if percentage <= 0:
await self.async_turn_off()
return
codes = self._active_speed_codes()
if not codes:
return
if not self._speed_zero_is_off() and not self.is_on:
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
)
code = percentage_to_ordered_list_item(codes, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', code))
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
"""Air-purifier fan: named preset modes, not an ordered percentage --
see air_purifier.FAN's comment for why (Smart/WindFree/Sleep aren't
"faster/slower" than Max/Mid)."""
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
self._attr_name = None
def _rep(self, href: str) -> dict:
return self.coordinator.resource(href) or {}
def _mode_rep(self) -> dict:
return self._rep(self._bound.href)
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this unit actually exposes --
same pattern as LocalThingsRangeHoodFan._power_payload above.
Writing a hardcoded href here would silently no-op on a board that
only reports the other one, even though is_on already falls back
correctly."""
resources = self._resources
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
return 'power', enabled, target
@property
def is_on(self) -> bool:
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
if power is not None:
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
def _label_for_code(self, code) -> str:
"""Lowercased HA preset label for a device mode code.
The TP1X_DA-AC-AIR board (issue #130) reports its named modes
directly as supportedModes ('Smart'/'Max'/...), so the code IS the
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric
wind-strength codes ('87'/'89'/...) with a separate modesName array
(parallel-indexed with supportedModes) giving the actual names --
same shape as climate.py's _wind_strength_label, and coincidentally
the same word set (Smart/Max/WindFree/Sleep), so both board
generations land on identical HA preset values without needing
their own translation catalog entry."""
rep = self._mode_rep()
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
names = rep.get(_MODES_NAME_FIELD)
if names and code in supported and len(names) == len(supported):
return str(names[supported.index(code)]).lower()
return str(code).lower()
@property
def preset_modes(self) -> list[str]:
return [
self._label_for_code(code)
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
]
@property
def preset_mode(self) -> str | None:
modes = self._mode_rep().get(_MODES_FIELD)
code = modes[0] if isinstance(modes, (list, tuple)) and modes else modes
return self._label_for_code(code) if code is not None else None
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
if preset_mode is not None:
await self.async_set_preset_mode(preset_mode)
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(False))
async def async_set_preset_mode(self, preset_mode: str) -> None:
# Reverse-resolve against the unit's own supportedModes -- the
# write needs the raw device code (e.g. 'WindFree', or '90' on the
# modesName-labelled board), not the lowercased HA value.
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
if self._label_for_code(code) == preset_mode:
await self.coordinator.async_send_command(self._bound, ('mode', code))
return
_LOGGER.warning(
"%s: %r is not a valid preset mode (supported: %s)",
self.entity_id, preset_mode, self.preset_modes,
)
_AIRFLOW_SPEED_FIELD = 'speed'
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
# as plain strings, same as _all_speed_codes above, so
# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
# it exactly like the range hood's numeric levels -- no named-preset table
# needed since this board never reports mode names to hang one off of.
_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4')
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
"""ARTIK051_TVTL-class air purifier fan (issue #56): an ordered numeric
speed range, same SET_SPEED shape as LocalThingsRangeHoodFan above."""
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
self._attr_name = None
def _rep(self, href: str) -> dict:
return self.coordinator.resource(href) or {}
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Prefer /power/0 like LocalThingsRangeHoodFan above, NOT
LocalThingsAirPurifierFan's vs/0-first order -- that order is only
harmless for the TP1X board because it never reports /power/0 at
all. This family's dumps carry both hrefs, and common.POWER_GENERIC
(the power_switch entity) is unconditionally bound to /power/0
whenever it's present, so writing here to /power/vs/0 first would
leave power_switch and this fan reading/writing two different
resources -- disagreeing until the next poll refreshes the other
one (the same optimistic-apply lag coordinator.py's own comments
warn about)."""
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
@property
def is_on(self) -> bool:
power = self._rep(POWER_HREF)
if 'value' in power:
return bool(power.get('value'))
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
@property
def speed_count(self) -> int:
return len(_AIRFLOW_SPEED_CODES)
@property
def percentage(self) -> int | None:
if not self.is_on:
return 0
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ''))
if current not in _AIRFLOW_SPEED_CODES:
return None
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
if percentage is not None:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(False))
async def async_set_percentage(self, percentage: int) -> None:
if percentage <= 0:
await self.async_turn_off()
return
if not self.is_on:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', int(code)))
+66
View File
@@ -0,0 +1,66 @@
{
"entity": {
"climate": {
"airconditioner": {
"state_attributes": {
"fan_mode": {
"state": {
"1": "mdi:fan-speed-1",
"2": "mdi:fan-speed-1",
"3": "mdi:fan-speed-2",
"4": "mdi:fan-speed-3",
"5": "mdi:fan-speed-3",
"turbo": "mdi:fan-speed-3",
"max": "mdi:fan-speed-3"
}
},
"preset_mode": {
"state": {
"ai_comfort": "mdi:creation",
"quiet": "mdi:volume-off",
"smart": "mdi:brain",
"speed": "mdi:speedometer",
"nano": "mdi:weather-dust",
"nanosleep": "mdi:sleep",
"longwind": "mdi:weather-windy",
"motiondirect": "mdi:account-arrow-left",
"motionindirect": "mdi:account-arrow-right",
"drycomfort": "mdi:water-percent",
"2step": "mdi:stairs"
}
}
}
}
},
"fan": {
"air_purifier_fan": {
"state_attributes": {
"preset_mode": {
"state": {
"smart": "mdi:brain",
"max": "mdi:fan-speed-3",
"mid": "mdi:fan-speed-2",
"windfree": "mdi:weather-dust",
"sleep": "mdi:sleep"
}
}
}
}
},
"sensor": {
"machine_state": {
"state": {
"idle": "mdi:power-standby",
"active": "mdi:play",
"pause": "mdi:pause"
}
},
"connection_mode": {
"state": {
"observe": "mdi:broadcast",
"poll": "mdi:sync"
}
}
}
}
}
+3 -3
View File
@@ -5,12 +5,12 @@
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/mbillow/localthings",
"iot_class": "local_polling",
"iot_class": "local_push",
"issue_tracker": "https://github.com/mbillow/localthings/issues",
"requirements": [
"cbor2>=5.4.6",
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.0"
"smartthings-local>=0.1.1"
],
"version": "0.2.1"
"version": "0.17.0"
}
+23 -2
View File
@@ -42,16 +42,25 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
if desc.step is not None:
self._attr_native_step = desc.step
@property
def native_unit_of_measurement(self):
desc: NumberDesc = self._bound.desc
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
def _range_from_resource(self) -> list | None:
desc: NumberDesc = self._bound.desc
if not desc.range_field:
return None
rep = self.coordinator.last_resources.get(self._bound.href) or {}
r = rep.get(desc.range_field)
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None
@property
def native_min_value(self) -> float:
desc: NumberDesc = self._bound.desc
if desc.native_min_fn is not None:
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[0])
@@ -61,6 +70,9 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_max_value(self) -> float:
desc: NumberDesc = self._bound.desc
if desc.native_max_fn is not None:
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[1])
@@ -68,6 +80,15 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
return self._attr_native_max_value
return super().native_max_value
@property
def native_step(self) -> float:
desc: NumberDesc = self._bound.desc
if desc.step_fn is not None:
return desc.step_fn(self.coordinator.resource(self._bound.href))
if hasattr(self, '_attr_native_step'):
return self._attr_native_step
return super().native_step
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
+42 -3
View File
@@ -69,6 +69,7 @@ class ObserveManager:
self.last_mode_change_wall = time.time()
self._settle_until: dict[str, float] = {}
self._settle_lock = threading.Lock()
self._cache_lock = threading.Lock()
self.subscribed_hrefs: set[str] = set()
self._notified: set[str] = set()
self._last_notify_ts: float | None = None
@@ -92,11 +93,49 @@ class ObserveManager:
return True
def apply(self, href: str, rep: dict, source: str) -> bool:
"""Gate a StateCache.apply_rep call through the write-settle guard."""
if self._is_settling(href):
"""Gate a StateCache.apply_rep call through the write-settle guard.
Merges the incoming rep onto whatever's already cached for this
href rather than handing it to StateCache.apply_rep verbatim --
apply_rep does a full replace, and Samsung devices don't always
repeat every field on every update (issue #27: a /mode/vs/0
OBSERVE notify -- and, on at least one Bespoke fridge, even the
/device/0 sweep entry for that href -- can carry just `modes`,
omitting `supportedOptions`/`supportedModes` entirely). A full
replace would silently wipe fields a select entity's
exists_fn/options_field gates on the moment one partial update
comes through, even though nothing about the device's actual
supported options changed.
`apply()` is the sole path StateCache mutations flow through in
this component (poll, sweep, and OBSERVE notify all funnel here),
so `_cache_lock` serializes the read-then-write across those
threads (DTLS reader for notifies, executor threads for poll/
sweep) -- without it, two concurrent updates for the same href
could each read the same prior rep and the second writer would
silently lose the first's fields, reintroducing the exact bug
this merge fixes.
`source == 'optimistic'` always bypasses the settle gate below,
even while this href is already settling from an earlier write.
Several independent selects can share one href -- issue #9's washer
packs cycle, detergent, and softener settings onto the same
/course/vs/0 -- so a second, different write to the same href
while the first's settle window is still open (up to
_POST_TIMEOUT_S + _POLL_TIMEOUT_S, tens of seconds -- see
coordinator.async_send_command) is an expected, normal sequence,
not a stale echo of the first write. Gating it the same as a
poll/sweep/observe update would silently drop the user's own
second selection from the cache until the first write's guard
happened to expire, i.e. the exact symptom this guard exists to
prevent, just relocated to whichever write loses the race.
"""
if source != 'optimistic' and self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
return self.cache.apply_rep(href, rep, source=source)
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
return self.cache.apply_rep(href, merged, source=source)
def on_notification(self, href: str, payload: bytes) -> None:
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
@@ -4,19 +4,44 @@ from __future__ import annotations
from typing import Any
from .discovery import BoundEntity
from .subdevices import Subdevice, canonical_view
def _key(b: BoundEntity) -> str:
return f"{b.key_override or b.desc.key}{b.instance}"
# b.subdevice.key_prefix is '' for MAIN, so a device with no subdevices
# (every device this integration shipped before issue #177) gets a
# byte-identical key to before -- a hard regression guard, not a nicety
# (see test_unique_ids.py and every golden file under tests/fixtures/golden/).
return f"{b.subdevice.key_prefix}{b.key_override or b.desc.key}{b.instance}"
def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
"""Map bound entities to their current scalar values."""
"""Map bound entities to their current scalar values.
`exists_fn(rep, resources)` receives that entity's own subdevice's
*canonical* resources view (see subdevices.canonical_view), not the raw
actual-href snapshot -- an exists_fn that scans the whole resources dict
for a sibling href (e.g. is_legacy_board) must judge each subdevice on its
own resources, not see another subdevice's hrefs bleed in under the same
canonical key. Views are built once per distinct subdevice per call, not
once per entity -- O(subdevices), not O(bound entities).
"""
out: dict[str, Any] = {}
# Subdevice is a frozen dataclass (hashable, equal by value), so it can key
# `views` directly -- no need to re-derive an identity for it out of
# (kind, key) first.
all_subdevices = list(dict.fromkeys(b.subdevice for b in bound))
views: dict[Subdevice, dict] = {}
for b in bound:
rep = resources.get(b.href) or {}
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep, resources):
continue
if b.desc.exists_fn is not None:
view = views.get(b.subdevice)
if view is None:
view = canonical_view(b.subdevice, resources, all_subdevices)
views[b.subdevice] = view
if not b.desc.exists_fn(rep, view):
continue
if b.desc.rep_fn is not None:
out[_key(b)] = b.desc.rep_fn(rep)
elif b.desc.field:
@@ -2,8 +2,25 @@
from __future__ import annotations
def is_stub_rep(rep: dict) -> bool:
"""True for the device's "resource exists, no data fetched yet" marker --
an echoed {"href": "..."} with no other fields.
Distinct from a genuinely empty {} rep, which is the device's confirmed
(if empty) answer -- e.g. an unsupported resource on this model that will
never populate. Conflating the two used to make every field-gated entity
on a permanently-empty resource look like a not-yet-fetched stub forever,
creating phantom always-"unknown" entities (issue #127)."""
return isinstance(rep, dict) and set(rep.keys()) == {'href'}
def parse_device0_batch(device0: list) -> dict[str, dict]:
"""Extract {href: rep} from a /device/0 CBOR list response."""
"""Extract {href: rep} from a /device/0 CBOR list response.
A stub rep is passed through unchanged rather than collapsed to {} --
downstream code (entity._is_included, capability exists_fns) uses
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
"""
out = {}
for entry in device0[1:]: # skip [0] (device-level rep)
if not isinstance(entry, dict):
@@ -12,8 +29,6 @@ def parse_device0_batch(device0: list) -> dict[str, dict]:
rep = entry.get('rep')
if not href:
continue
# rep == {"href": "..."} is a stub (resource present, no current data).
# Include it as {} so capabilities still bind and the entity exists.
if isinstance(rep, dict):
out[href] = {} if set(rep.keys()) == {'href'} else rep
out[href] = rep
return out
@@ -1,63 +1,42 @@
"""Per-device-type registries."""
import re
from typing import Optional
from ._base import DeviceRegistry
from . import dishwasher, dryer, oven, refrigerator, washer
from . import (
air_dresser, air_purifier, airconditioner, cooktop, dehumidifier,
dishwasher, dryer, induction_cooktop, microwave, oven, range as _range,
range_hood, refrigerator, vacuum_station, washer, water_purifier,
)
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
__all__ = [
'DeviceRegistry', 'resolve', 'for_device_by_model',
'for_device_by_resources', '_board_tokens',
]
# One entry per registry, no aliases: every key here is reachable from
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_dresser': air_dresser.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
'dehumidifier': dehumidifier.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'induction_cooktop': induction_cooktop.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
'range': _range.REGISTRY,
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'vacuum_station': vacuum_station.REGISTRY,
'washer': washer.REGISTRY,
'water_purifier': water_purifier.REGISTRY,
}
def _type_key(one_ui_version: str) -> str:
"""Convert oneUiVersion string to registry key.
Args:
one_ui_version: String like '7.0 Dishwasher' or 'Oven'.
Returns:
Lowercase key with version prefix stripped and spaces/hyphens converted to underscores.
Examples:
'7.0 Dishwasher' -> 'dishwasher'
'7.0 French Door Refrigerator' -> 'french_door_refrigerator'
'Oven' -> 'oven'
"""
if ' ' in one_ui_version:
# Strip version prefix: everything before and including the first space
suffix = one_ui_version.split(' ', 1)[-1]
else:
suffix = one_ui_version
return suffix.lower().replace(' ', '_').replace('-', '_')
def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
"""Return the DeviceRegistry for the given oneUiVersion string, or None if unknown.
Args:
one_ui_version: Device's oneUiVersion string (e.g., '7.0 Dishwasher').
Returns:
DeviceRegistry if a matching registry exists, None otherwise.
"""
key = _type_key(one_ui_version)
if key in _REGISTRY_BY_KEY:
return _REGISTRY_BY_KEY[key]
# Suffix fallback: e.g. "french_door_refrigerator" ends with "_refrigerator"
for rkey, reg in _REGISTRY_BY_KEY.items():
if key.endswith(f'_{rkey}'):
return reg
return None
# Consumer-model prefix (first two letters of the '_'-delimited token in
# `description` right before any '/board-info' suffix) -> registry key.
# NOT derived from `modelNum` -- washer and dryer share the same 'DA_WM_'
@@ -66,26 +45,254 @@ def for_device(one_ui_version: str) -> Optional[DeviceRegistry]:
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
'WW': 'washer',
'WD': 'washer',
'WF': 'washer',
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106
'DV': 'dryer',
'DW': 'dishwasher',
}
# Board-family token -> registry key, matched against whole tokens of
# `modelNum`/`description` (see `_board_tokens`).
#
# Tokenizing instead of substring-matching is what keeps this a table rather
# than a ladder of hand-written rules. Samsung spells the same board family
# with either delimiter -- 'TP1X_DA-AC-RAC-01001' and 'TP2X_RAC_20K' are the
# same RAC family -- so a substring rule has to be written once per spelling
# ('_RAC_' *and* '-RAC-'), and a token that lands at the end of the
# pipe-prefix with no trailing delimiter ('ARTIK051_DONGLE_REF', issues #77
# and #83) matches no '_TOKEN_' spelling at all. Whole-token matching sees
# every one of those as a single entry.
#
# Entries must name the *specific* device type, never the board family that
# contains it: 'DA-AC-' prefixes RAC/WAC/DHM/AIR alike, so a bare 'AC' entry
# would swallow the dehumidifier and the air purifier. Where two families
# genuinely share a resource surface they share a registry (all the
# air-conditioner spellings below), which is a statement about the hardware,
# not a shortcut.
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
'REF': 'refrigerator',
# Air conditioners. Every one of these is a distinct board family with
# the same resource surface: room (issues #37, #91), package, Korean
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
'RAC': 'airconditioner',
'PRAC': 'airconditioner',
'KRAC': 'airconditioner',
'WAC': 'airconditioner',
'FAC': 'airconditioner',
'CAWW': 'airconditioner',
'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000
'ARA': 'airconditioner',
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a
# next-gen BESPOKE Cube Air board; same
# lineage as VTWW above but the '-WW-'
# delimiter now falls one letter to the
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
# into a token the existing entry can't see
'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR)
'WATERPURIFIER': 'water_purifier', # issue #90
'ADW': 'dishwasher',
'AHD': 'range_hood',
'RANGE': 'range', # issue #44 -- cooktop+oven combo
'OVEN': 'oven', # issue #55 -- wall oven, no burners
'MICROWAVE': 'microwave', # issues #66, #121
'COOKTOP': 'induction_cooktop', # issue #86 -- standalone, no oven
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
# token, and so the loosest entry in this table -- it is only ever
# reached by a device that matched nothing more specific, and its
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
# for the field ordering that makes that resolve correctly.
'CT': 'cooktop',
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
}
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
def _board_tokens(value: str, cut_at: str) -> list[str]:
"""Whole, upper-cased tokens of `value` up to the first `cut_at`.
`cut_at` drops the trailing junk each field carries -- everything after
modelNum's first '|' (a board revision and a capability bitmap, which can
contain anything) and after description's first '/' (a '/DC92-...' board
part number).
"""
head = (value or '').split(cut_at, 1)[0].upper()
return [t for t in _TOKEN_SPLIT_RE.split(head) if t]
def _board_family_key(value: str, cut_at: str) -> Optional[str]:
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
No known modelNum or description yields two *conflicting* board keys, so
which token is found first doesn't matter within one field -- the table is
a flat lookup, not a priority list. Adding an entry that could co-occur
with another (a family token, or one short enough to collide by accident)
would break that property; see this table's comment.
One documented exception (issue #196): AILITE water-purifier boards
spell their modelNum '...-REF-WATERPURIFIER-...', where 'REF' names the
shared cooling-subsystem board, not the refrigerator device type --
'WATERPURIFIER' is the actual, more specific type here. Rather than drop
or rename either entry (both are correct on their own for the model
strings that exist today), this one known co-occurrence resolves to
'water_purifier'; TestBoardTokenAmbiguity's blanket check carries a
matching carve-out for this exact pair.
"""
tokens = _board_tokens(value, cut_at)
if 'REF' in tokens and 'WATERPURIFIER' in tokens:
return 'water_purifier'
for token in tokens:
key = _BOARD_TOKEN_TO_KEY.get(token)
if key is not None:
return key
return None
def _consumer_model_key(description: str) -> Optional[str]:
"""Registry key from the consumer-model token in `description`, or None.
Usually that token is the last '_'-delimited segment before any
'/board-info' suffix (e.g. '..._WW90DG6U25LEU4' -> 'WW90DG6U25LEU4').
But issue #79's dryer pairs two model numbers in one description --
'..._DVE50A8800_8600/DC92-...' -- so the true consumer token
('DVE50A8800') sits one segment *before* the actual last segment
('8600', a bare second model number with no recognizable prefix). Scan
segments from the end and take the first one that resolves, rather
than assuming the last segment is always it.
Splits on '_' only, unlike `_board_tokens` above: these are two-letter
prefixes matched against the *start* of a segment, so widening the split
to '-' as well would start reading board-family segments as consumer
models -- the dishwasher's 'ADW-WW-RTL-24-AILITE' would offer up a bare
'WW' segment and route to washer.
Only a 2-letter *prefix* match -- e.g. 'WAC' (the Window Air Conditioner
board-family token, issue #87) also starts with 'WA' (the top-load-washer
prefix, issue #106) at this granularity. for_device_by_model() consults
the board-family table first and this function only as a fallback, so
that ambiguity resolves correctly without this function needing to know
about unrelated device families.
"""
segments = (description or '').split('/', 1)[0].split('_')
for segment in reversed(segments):
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
if key is not None:
return key
return None
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
"""Fallback device-type detection for hardware that never reports
oneUiVersion (confirmed for washers -- their /otninformation/vs/0 has
no swVersionInfo key at all).
"""Device-type detection from /information/vs/0's model strings.
The primary path: the board named in `modelNum` determines the resource
surface, which is what a registry describes.
Three passes, narrowest evidence first:
1. Board-family tokens in `modelNum`. The most reliable signal -- it names
the board, which determines the resource surface.
2. The same tokens in `description`. Some units carry the board token only
there (a scrubbed or placeholder modelNum, e.g. description
'TP1X_REF_21K'). This runs second so that a device whose two fields
disagree is typed by its modelNum: the legacy gas cooktop reports
'ARTIK051_GB_CT_001' (CT -> gas cooktop) alongside
'ARTIK051_GLOBAL_COOKTOP' (COOKTOP -> induction cooktop), and the
board is right.
3. The consumer-model prefix in `description` (washer/dryer/dishwasher).
Last, because a bare two-letter prefix is the fuzziest evidence here
and would otherwise shadow the specific board tokens above.
Args:
model_num: x.com.samsung.da.modelNum from /information/vs/0.
description: x.com.samsung.da.description from /information/vs/0.
Returns:
DeviceRegistry if the consumer-model code or modelNum resolves to a
DeviceRegistry if the modelNum or consumer-model code resolves to a
known type, None otherwise.
"""
token = (description or '').split('/', 1)[0].rsplit('_', 1)[-1]
key = _CONSUMER_PREFIX_TO_KEY.get(token[:2].upper())
if key is None and '_REF_' in (model_num or ''):
key = 'refrigerator'
key = (
_board_family_key(model_num, '|')
or _board_family_key(description, '/')
or _consumer_model_key(description)
)
return _REGISTRY_BY_KEY.get(key) if key else None
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
"""Detect a device family from a distinctive local-resource signature.
For boards that ship no ``/information/vs/0`` at all, leaving
`for_device_by_model` nothing to read. Some newer cooktops are the
original case: their mode resource still identifies them, carrying a
DeviceType option and multiple per-burner OperationState options.
Require two independent shapes for every signature here, never one, so
an unrelated family's ``/mode/vs/0`` isn't misclassified.
"""
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
has_device_type = any(
isinstance(option, str) and option.startswith('DeviceType_')
for option in options
)
operation_states = sum(
1 for option in options
if isinstance(option, str) and option.startswith('OperationState')
)
if has_device_type and operation_states >= 2:
return _REGISTRY_BY_KEY['cooktop']
if (
'/hood/fanspeed/vs/0' in resources
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
# Oven/range/microwave boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
# absent from the dump, not just empty) can't be matched via
# for_device_by_model's modelNum tokens either. Mode vocabulary alongside
# the oven cavity resource (/oven/vs/0) is a safe signature.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
if '/oven/vs/0' in resources:
if any(
m in supported_modes
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
):
return _REGISTRY_BY_KEY['microwave']
if 'Bake' in supported_modes:
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
return _REGISTRY_BY_KEY['range']
return _REGISTRY_BY_KEY['oven']
return None
def resolve(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
"""Device type for a parsed /device/0 dump, or None if unrecognized.
The single entry point for detection -- the coordinator, the config
flow's probe and the golden-regression harness all call this, so the
order can't drift between what ships and what the tests assert.
Model strings first (`for_device_by_model`), then a distinctive resource
signature (`for_device_by_resources`) for boards that report no
/information/vs/0 at all.
`/otninformation/vs/0`'s oneUiVersion is deliberately not consulted. It
reads like the obvious signal -- the device naming its own type, e.g.
'7.0 Dishwasher' -- but only a minority of hardware populates it, every
device that does is already typed by its modelNum board token, and no
device-support issue has ever been fixed by adding a mapping for it. It
is still reported in diagnostics as a firmware-generation marker.
"""
info = resources.get('/information/vs/0', {})
return for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
) or for_device_by_resources(resources)
@@ -0,0 +1,24 @@
"""AirDresser device registry (issue #162).
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
status, operational state, and diagnosis. air_dresser.py holds the two
pieces specific to this device type: a minimal wrinkle-prevent-only
/washer/vs/0 capability, and the course select's own translation key.
"""
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_dresser',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]),
)
@@ -0,0 +1,57 @@
"""Air-purifier device registry.
Spans three board generations sharing this one registry (see
capabilities/air_purifier.py's module docstring for the per-href
match_fn discriminators that keep them from colliding):
- ARTIK051_TVTL-class (issue #56). Resolved via the 'TVTL' modelNum board
token (see by_type/__init__.py).
- TP1X_DA-AC-AIR-class (issue #130). Resolved via the 'AIR' board token.
Adds real fan-mode control plus
display/HEPA-filter/pet-filter/sound resources the older family never
reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE
for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on
the shared DA-AC- board family.
- A-VTWW-TP2-21-COMMON-class (issue #151). Resolved via the 'VTWW' board
token, added for it. Its fan
is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on
/mode/vs/0 -- see that capability's comment.
- AVT-WW-TP1-23-class (issue #190). A next-gen board in the same VTWW
lineage, resolved via its own 'AVT' board token since the '-WW-' delimiter
falls one letter to the left of 'VTWW's whole-token spelling. Same
resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities
needed.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
contract).
"""
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
air_purifier.FAN,
air_purifier.WIND_STRENGTH_FAN,
air_purifier.DISPLAY,
air_purifier.HEPA_FILTER,
air_purifier.PANEL_STATUS,
air_purifier.PET_FILTER_ACTIVATION,
air_purifier.SOUND_MODE,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
*air_purifier.COVERAGE,
]),
)
@@ -0,0 +1,46 @@
"""Air-conditioner device registry (Samsung ARTIK051_PRAC-class, issue #17).
The first device whose core controls surface as a single composite HA `climate`
entity (see capabilities/airconditioner.py and climate.py). Power/mode/temp/wind
are consumed by that entity rather than exposed as separate switches/selects, so
this registry includes *common.UNIVERSAL but deliberately NOT common.POWER --
on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's
own comment in capabilities/common.py for why it's excluded.
common.ENERGY_METER itself is also excluded from UNIVERSAL here, replaced by
the ENERGY_METER_GENERIC/ENERGY_METER_LEGACY pair -- the legacy ARTIK051 board
generation (issue #193) reports cumulativePower in a different unit than
every other AC family, so this registry needs two mutually-exclusive variants
of that one capability instead of the single shared one every other registry
uses unconditionally.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
"""
from ..capabilities import airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
airconditioner.ENERGY_METER_GENERIC,
airconditioner.ENERGY_METER_LEGACY,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.ABSENCE_POWER_SAVING,
airconditioner.MOTION_DETECT_WIND,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
*airconditioner.COVERAGE,
]),
)
@@ -0,0 +1,26 @@
"""Gas cooktop device registry (NA9300K-class, PR #23).
Named 'gas_cooktop' (not 'cooktop') so its diagnostics/device-info label
doesn't collide with the unrelated induction_cooktop family (issue #86,
by_type/induction_cooktop.py) -- two different OCF surfaces that happen to
share the English word "cooktop". The `_REGISTRY_BY_KEY['cooktop']` lookup
key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
(for_device_by_model) and the resource-signature fallback
(for_device_by_resources) alike.
"""
from ..capabilities import common, cooktop, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='gas_cooktop',
capabilities=_build([
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
]),
)
@@ -0,0 +1,23 @@
"""Dehumidifier device registry (Samsung TP1X_DA_AC_DHM-class, issue #88).
Shares the DA_AC_ board family with airconditioner.py (power, air filter,
auto-clean, mute-once all use the identical resource shapes), so those three
Capability objects are reused directly rather than duplicated.
"""
from ..capabilities import airconditioner, common, dehumidifier, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dehumidifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
*dehumidifier.COVERAGE,
]),
)
@@ -1,16 +1,13 @@
"""Dishwasher device registry."""
from ..capabilities import common, dishwasher, dryer, fridge, ignored, laundry, operational
from ..capabilities import common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dishwasher',
capabilities=_build([
*ignored.IGNORED,
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
@@ -18,10 +15,9 @@ REGISTRY = DeviceRegistry(
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
dryer.JOB_BEGINNING_STATUS,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -2,26 +2,29 @@
Note: dryer devices use port 49155 (not 49154). The config flow handles
this automatically via port probing.
The power/kids-lock/remote-control, buzzer, energy-meter, job-status, and
cycle-select capabilities are the shared laundry ones (laundry.py), the same
objects the washer registry uses -- washer and dryer expose the same
DA_WM_-family surface, so they stay consistent instead of each carrying a
bespoke variant.
"""
from ..capabilities import common, dryer, fridge, ignored, laundry, operational
from ..capabilities import common, dryer, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dryer',
capabilities=_build([
*ignored.IGNORED,
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.JOB_BEGINNING_STATUS,
dryer.DRYER_DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -0,0 +1,30 @@
"""Standalone induction-cooktop device registry (Samsung
TP1X_DA-KS-COOKTOP-class, issue #86) -- the cooktop half of the
range/oven board family (see capabilities/range.py), but with no oven
attached at all.
Reuses range.py's COOKTOP_STATUS/COOKTOP_SPEC/COOKTOP_SAFETY/PROBE_STATUS
wholesale (identical resource shapes to the range combo's cooktop half)
and cooktop.PAIRED_HOOD_STATUS for the Bluetooth-paired range hood some
units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop')
-- that family is the unrelated NA9300K-class gas cooktop (burner state
embedded in /mode/vs/0's options array, a completely different OCF
surface that happens to share the English word "cooktop").
"""
from ..capabilities import common, cooktop as cooktop_caps, ignored
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='induction_cooktop',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]),
)
@@ -0,0 +1,37 @@
"""Microwave device registry (combi and plain microwaves, issues #66/#121).
Shares the oven board family's cavity/cook-cycle resource shape, so the
operational-state, door, cloud-connected, and quick-recipe-display
Capability objects are reused directly from oven.py rather than duplicated.
Cooking mode, setpoint, cavity power level, and lamp are genuinely
different for this family (different mode vocabulary, different setpoint
bounds, an extra powerLevel field, a differently-named lamp option) and are
defined fresh in capabilities/microwave.py -- see that module's docstring.
Some combi units (built-in over-the-range microwaves, issues #137/#142)
also carry the vent fan's `/hood/fanspeed/vs/0` resource, in the exact same
shape a standalone range hood reports it in -- reused directly from
range_hood.py rather than duplicated. Unlike a standalone hood, this dump
has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
case (see its `_speed_zero_is_off` check).
"""
from ..capabilities import common, ignored, microwave, oven, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='microwave',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_SETPOINT,
microwave.MICROWAVE_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_RECIPE_COOK,
range_hood.HOOD_FAN,
]),
)
@@ -1,21 +1,20 @@
"""Oven device registry."""
from ..capabilities import common, fridge, ignored, oven
from ..capabilities import common, ignored, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='oven',
capabilities=_build([
*ignored.IGNORED,
common.POWER,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
fridge.FIRMWARE_UPDATE,
oven.OVEN_SPEC,
oven.OVEN_RECIPE_COOK,
]),
)
@@ -0,0 +1,30 @@
"""Range (oven + cooktop combo) device registry — issue #44.
Reuses the oven family's cavity/setpoint/mode/operational-state/door/
connected capabilities wholesale (a range's oven half is the same OCF
surface as a standalone oven) and adds the cooktop-specific capabilities
for the burner half.
"""
from ..capabilities import common, ignored, oven
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]),
)
@@ -0,0 +1,23 @@
"""Range-hood device registry."""
from ..capabilities import common, ignored, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range_hood',
capabilities=_build([
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]),
)
@@ -1,19 +1,17 @@
"""Refrigerator device registry."""
from ..capabilities import common, fridge, ignored
from ..capabilities import common, dishwasher, fridge, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
*ignored.IGNORED,
common.POWER,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.KIDS_LOCK,
common.REMOTE_CONTROL,
common.ALARMS,
common.ENERGY_METER,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
@@ -23,18 +21,23 @@ REGISTRY = DeviceRegistry(
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.FIRMWARE_UPDATE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.SELF_CHECK,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]),
pattern_capabilities=[
fridge.TEMP_CURRENT_GENERIC,
fridge.TEMP_SETPOINT_GENERIC,
fridge.TEMP_SETPOINT,
fridge.ICEMAKER_GENERIC,
fridge.DOOR_GENERIC,
fridge.KIMCHI_ZONE,
fridge.KIMCHI_DOOR_GENERIC,
],
)
@@ -0,0 +1,22 @@
"""Stick-vacuum clean/auto-empty station device registry (issue #131).
See capabilities/vacuum_station.py's module docstring for why this only
covers the station's own dustbag/dustbin/UV-sanitize state and not any
vacuum-body control (suction, battery, cleaning mode) -- the diagnostics
dump this was built from reports none of that.
"""
from ..capabilities import common, ignored, vacuum_station
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='vacuum_station',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
vacuum_station.DUSTBAG,
vacuum_station.DUSTBAG_USAGE,
vacuum_station.DUSTBIN_SETTING,
vacuum_station.CLEANSTATION_STATUS,
]),
)
@@ -1,26 +1,19 @@
"""Washer device registry."""
from ..capabilities import common, dishwasher, fridge, ignored, operational, washer
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='washer',
capabilities=_build([
*ignored.IGNORED,
washer.POWER_GENERIC,
washer.POWER_VS_FALLBACK,
washer.KIDS_LOCK_GENERIC,
washer.KIDS_LOCK_VS_FALLBACK,
washer.REMOTE_CONTROL_GENERIC,
washer.REMOTE_CONTROL_VS_FALLBACK,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
washer.BUZZER_SOUND,
washer.WASHER_JOB_BEGINNING_STATUS,
common.ALARMS,
common.ENERGY_METER,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
]),
)
@@ -0,0 +1,25 @@
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90).
"""
from ..capabilities import common, ignored, water_purifier
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='water_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
common.WATER_FILTER,
water_purifier.DISPENSE,
water_purifier.STATUS,
water_purifier.FAVORITE_CAPACITY,
water_purifier.FAVORITE_HOTWATER,
water_purifier.COFFEE,
water_purifier.LOCK,
water_purifier.CUP_STATE,
water_purifier.SOUND_MODE,
water_purifier.SOUND_OUTPUT,
water_purifier.SOUND_VOLUME,
water_purifier.STATISTIC_POUR,
*water_purifier.COVERAGE,
]),
)
@@ -1,4 +1,7 @@
from . import common, dishwasher, fridge, ignored, laundry, operational, oven
from . import (
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
range_hood,
)
from ..capability import Capability
@@ -0,0 +1,80 @@
"""Capabilities specific to the AirDresser family (Samsung DA_DF-class,
issues #162/#157).
This board family carried no /information/vs/0 token any existing family
routed on, so it gets its own device type (the 'DF' board token) -- but most
of the resources it exposes are already handled by the shared laundry
machinery:
/washer/vs/0 -> AIR_DRESSER_SETTINGS (wrinkle_prevent only -- a
dedicated capability rather than reusing
dryer.DRYER_SETTINGS wholesale: this board never
populates dryLevel/dryTime/dryerType at all, and
those are permanently-empty-not-just-absent
dryer-only fields on an AirDresser, not merely unset
ones, so binding them here would ship three sensors
that can never read anything on this device type)
/course/vs/0 -> AIR_DRESSER_COURSE (cycle select), table id from
/st/airdressercourse/vs/0 (issue #157's
DA_DF_TP2_20_COMMON reports "Table_00"; issue #162's
DA_DF_A51_20_COMMON has no table resource at all and
falls back to the name-only 'cycle' key, same as an
unrecognized table on washer/dryer). Options come
from laundry.cycle_options -- #157's board populates
/wm/editcourse/vs/0's editCourseList directly; #162's
has no editcourse resource at all and falls through
to cycle_options' supportedOptions decode instead.
Course names aren't identified yet for either table
(no code->name mapping was reported), so they render
as their raw codes until named in translations, same
as dryer.py's unidentified codes.
/diagnosis/vs/0 -> reuses dishwasher.DIAGNOSIS
/airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only;
#162's board doesn't report this resource at all)
"""
from ..capability import Capability
from ..entities import SwitchDesc
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
def _sanitize_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p}
AIR_DRESSER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
),
)
AIR_DRESSER_COURSE = Capability(
href='/course/vs/0',
entities=(
cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew',
table_href='/st/airdressercourse/vs/0'),
),
)
AIR_DRESSER_SANITIZE = Capability(
href='/airdresseroption/sanitize/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
icon='mdi:weather-sunny',
value_fn=lambda v: v == 'On',
write_fn=_sanitize_write),
),
)
@@ -0,0 +1,465 @@
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
(model AX60R5080WD/SE, issue #56).
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
dishwasher.DIAGNOSIS -- identical field/write contract
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
'<Prefix>_<value>' flags into one list -- the same packed-list contract
laundry.py's option_value/option_write already model for /course/vs/0's
options[] (reused directly below, just against this family's own href). Per
issue #56's follow-up (five diagnostics dumps captured with the physical unit
set to Auto/Sleep/Low/Medium/High):
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
real switch, RMW-replacing just that one entry.
NOT the same polarity as the AC family's own
Light_On/Light_Off token on its own /mode/vs/0
(airconditioner._display_light_on) -- that one is
confirmed inverted (Light_Off means the panel is
lit) on live hardware. Same token name, same
resource name, different device type and
opposite meaning -- don't unify them.
Comode_Off -- read 'Off' on *every* one of the five dumps,
including High/Low/Medium/Auto -- confirms this
is NOT the fan-speed selector (ruling out the
original guess); exposed read-only since its
actual purpose is still unconfirmed.
OptionCode_60282 -- confirmed opaque/not user-facing in the
SmartThings app; not modeled (same treatment as
range_hood's OptionCode_* token on the same
href).
Blooming_* -- confirmed to have no corresponding SmartThings
app setting; dropped entirely rather than kept
as an unexplained diagnostic (it did track 1:1
with Sleep mode across the five dumps -- 0 in
Sleep, 6 otherwise -- so it's plausibly an
automatic side effect of sleep mode, e.g. a
display-dimming level, but that's still a guess).
/airflow/0's `speed` is now a real fan-speed control (issue #56 follow-up).
The first round of five dumps above wasn't conclusive -- it read 0 for both
Auto *and* High, and 3 for Low/Medium *and* Sleep, likely because all five
were captured within about a minute of each other, faster than this
integration's own ~30s poll cycle could settle each change. A second round,
captured 60-90s apart per setting on two independent units, confirmed a
clean monotonic mapping instead: Auto=0, Sleep=1, Low=2, Medium=3, High=4.
AIRFLOW_GENERIC below builds an ordered-speed fan off that confirmed 0-4
range -- same SET_SPEED shape as range_hood.py's fan, mapping HA's
percentage steps straight onto the raw code, no named-preset table needed
(unlike the TP1X family's FAN, which exposes real named modes because its
board actually reports a supportedModes list to hang names off of).
/airflow/vs/0's vendor `speedLevel` is NOT used for the same purpose -- it
was unreliable on both units in that second round (Low/Medium collided on
one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below
stays a plain read-only diagnostic even after this change.
"""
from ..capability import Capability
from ..entities import (
BinarySensorDesc, FanDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
)
from .common import filter_usage_percent, int_or_none, sensor_item_value
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
# Newer TP1X_DA-AC-AIR-class boards (e.g. TP1X_DA-AC-AIR-01031_0000, issue
# #130) report fan modes directly on /mode/vs/0's top-level `modes`/
# `supportedModes` fields (Smart/Max/Mid/WindFree/Sleep) instead of packing
# everything into the options[] array the way the older ARTIK051_TVTL
# family above does -- that older family's /mode/vs/0 has no top-level
# supportedModes at all (see the module docstring's Comode_Off finding).
# Both board generations share the /mode/vs/0 href, so FAN and MODE below
# are mutually exclusive via this presence check rather than colliding.
HREF_MODE = '/mode/vs/0'
HREF_AIRFLOW = '/airflow/0'
HREF_WIND_STRENGTH = '/wind/strength/vs/0'
def _has_top_level_modes(rep, resources):
return isinstance(rep.get('x.com.samsung.da.supportedModes'), (list, tuple))
_AIR_QUALITY_SENSORS = (
('dust', 'mdi:blur', 'Dust'),
('fine_dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'mdi:scent', 'Odor'),
('clean_level', 'mdi:air-filter', 'CleanLevel'),
)
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
),
)
def _consumable_state(items, name):
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
unlike AIR_QUALITY's {type, value} shape above."""
for item in items or ():
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
return item.get('x.com.samsung.da.state')
return None
# FilterProgress is a 0-100 percentage counting up as the filter wears --
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
# once this reaches 100, so 100 means fully used, not "brand new." Named
# after the raw field (matching the AC/range_hood filterUsage convention,
# which counts the same direction) rather than "filter life," which would
# imply the opposite direction.
FILTER = Capability(
href='/consumable/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))),
),
)
DEVICE_ACTIVE = Capability(
href='/devicespecificinfo/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
icon='mdi:check-network-outline',
entity_category='diagnostic',
value_fn=lambda v: bool(v)),
),
)
def _power_write(power_href, value):
"""Shared 'power' payload handling for this family's three FanDesc write
functions -- targets whichever power href fan.py's _power_payload picked
(the board may only report /power/0); a hardcoded vendor href here would
silently no-op on such a board even though the entity's own is_on
already falls back to reading it correctly."""
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
return (['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if value else 'Off'})
def _airflow_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'speed':
return ['airflow', '0'], {'speed': int(value)}
return None
# Confirmed via issue #56's second, properly-spaced round of diagnostics
# (two independent units, 60-90s apart per setting): /airflow/0's `speed` is
# a clean, monotonic 0-4 code across Auto/Sleep/Low/Medium/High, so it now
# backs a real ordered-speed fan (fan.py's LocalThingsAirflowFan, same
# SET_SPEED shape as the range hood's) instead of a read-only sensor --
# no named-preset table needed, since HA's percentage steps map onto the
# raw 0-4 code directly, the same way the range hood's numeric levels do.
# `direction` stays a plain diagnostic: every dump seen (both rounds, both
# units) reads 'Off' for it regardless of fan setting, so there's nothing
# confirmed to control there yet.
#
# Keyed 'airflow_fan', not 'fan' -- FAN below (bound to the shared
# /mode/vs/0 href) also uses 'fan', and BoundEntity's unique_id is built
# from key alone (entity.py's _key), not href. FAN and AIRFLOW_GENERIC are
# only *empirically* mutually exclusive (every dump seen has one board
# generation's shape or the other, never both), not architecturally
# enforced the way same-href caps are by _build()'s match_fn check -- a
# same key would collide if a future board ever reported both.
AIRFLOW_GENERIC = Capability(
href=HREF_AIRFLOW,
poll_tier='warm',
entities=(
FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write),
SensorDesc(key='fan_direction', field='direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
# Left exactly as a read-only fallback -- speedLevel is NOT the same
# confirmed-reliable field as /airflow/0's speed above (see module
# docstring): it collided Low/Medium on one unit and stuck at 0 throughout
# on the other in the same properly-spaced round.
AIRFLOW_VS_FALLBACK = Capability(
href='/airflow/vs/0',
match_fn=lambda rep, resources: '/airflow/0' not in resources,
poll_tier='warm',
entities=(
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
icon='mdi:fan',
state_class='measurement', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
),
)
def _light_write(payload, rep, href=None):
# option_write's single-token write is confirmed on a washer's
# /course/vs/0 (issue #54), NOT independently on this family's
# /mode/vs/0 -- extrapolated on the assumption the same vendor field
# merges the same way everywhere. If some unit replaces the field
# outright instead, this would drop Comode/OptionCode alongside it on
# the next light toggle; revisit if a real device report surfaces that.
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Light', payload),
}
MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
match_fn=lambda rep, resources: not _has_top_level_modes(rep, resources),
entities=(
SwitchDesc(key='display_light', icon='mdi:led-on',
entity_category='config',
rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('Light'),
write_fn=_light_write),
# Read-only -- confirmed NOT the fan-speed selector (see module
# docstring), actual purpose still unconfirmed.
SensorDesc(key='operating_mode', icon='mdi:fan',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('Comode')),
),
)
def _fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'mode':
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}
return None
def _first_fan_mode(rep):
"""Representative scalar for the fan entity in the flattened state
(golden/regression), mirroring airconditioner.py's own _first_mode --
the real entity computes its state from live coordinator reads."""
modes = rep.get('x.com.samsung.da.modes')
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
# Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered
# percentage -- WindFree/Smart/Sleep are named behaviors, not
# "faster/slower" positions relative to Max/Mid, so fan.py's entity for
# this only exposes PRESET_MODE, matching how the AC family's own named
# convenient modes are modeled as a preset rather than a speed number.
FAN = Capability(
href=HREF_MODE,
poll_tier='warm',
match_fn=_has_top_level_modes,
entities=(
FanDesc(key='fan', translation_key='air_purifier_fan',
rep_fn=_first_fan_mode, write_fn=_fan_write),
),
)
def _wind_strength_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'mode':
return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}
return None
# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but
# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
# self-describing supportedModes -- x.com.samsung.da.modesName gives the
# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's
# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded
# per-model map. modes here is a bare string ('87'), not a single-element
# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is.
#
# key is 'wind_strength_fan', NOT 'fan' -- FAN above shares this registry
# and also uses a FanDesc; BoundEntity's unique_id is built from key alone
# (entity.py's _key), not href, so two same-key FanDescs in one registry
# would collide if a board ever bound both (see AIRFLOW_GENERIC's own
# comment on this exact hazard -- missed here in the initial cut).
WIND_STRENGTH_FAN = Capability(
href=HREF_WIND_STRENGTH,
poll_tier='warm',
entities=(
FanDesc(key='wind_strength_fan', translation_key='air_purifier_fan',
field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write),
),
)
# ---------------------------------------------------------------------------
# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several
# resources the older ARTIK051_TVTL family never did.
# ---------------------------------------------------------------------------
# Screen/indicator-panel on/off -- distinct from LIGHT below (ambient mood
# light): both report the same {mode, supportedModes: [On, Off]} shape on
# separate hrefs on this dump, so they're two independent physical controls,
# not a duplicate encoding of one.
DISPLAY = Capability(
href='/display/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='display', field='mode',
icon='mdi:monitor',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['display', 'vs', '0'],
{'mode': 'On' if p == 'On' else 'Off'})),
),
)
# Same filterUsage/filterCapacity/filterStatus shape as the AC family's own
# AIR_FILTER (airconditioner.py) -- confirmed normal/wash/replace values not
# seen on this one dump, so the option list there is reused as-is rather
# than re-deriving it from a single sample.
HEPA_FILTER = Capability(
href='/filter/hepafilter/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='hepa_filter_usage', rep_fn=filter_usage_percent,
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='hepa_filter_status', field='x.com.samsung.da.filterStatus',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
# Physical panel/cover status -- meaning of the one value seen ('Close') is
# plausible (the HEPA-filter access cover) but unconfirmed, and no
# supportedStatus list is present to check against -- exposed as a plain
# diagnostic sensor rather than an asserted binary_sensor polarity.
PANEL_STATUS = Capability(
href='/panel/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='panel_status', field='status',
icon='mdi:archive-outline', entity_category='diagnostic'),
),
)
# Pet-care filter mode -- a plain On/Off field with no vendor prefix, same
# convention as airconditioner.MUTE_ONCE.
PET_FILTER_ACTIVATION = Capability(
href='/petfilteractivation/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='pet_filter_activation', field='status',
icon='mdi:paw',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['petfilteractivation', 'vs', '0'],
{'status': 'On' if p == 'On' else 'Off'})),
),
)
# Sound mode/volume shapes look like laundry.py's SOUND_MODE/SOUND_VOLUME at
# a glance, but this board's actual values differ (supportedModes here is
# ['mute', 'buzzer'], not laundry's hardcoded voice/tone/mute; volume range
# is 0-3, not laundry's fixed 0-15) -- reusing those would either reject a
# valid write ('buzzer') or expose the wrong number range, so these are
# separate descriptors reading the live supported values instead of a
# hardcoded table.
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
poll_tier='cold',
entities=(
# Distinct translation_key from laundry.SOUND_MODE's shared
# 'sound_mode' catalog entry -- that one's state table is
# {voice, tone, mute}, but this board's supportedModes is
# {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled
# (falls through to the raw code) since the catalogs don't overlap.
SelectDesc(key='sound_mode', translation_key='air_purifier_sound_mode',
field='mode',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedModes',
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
),
)
# Read-only descriptor of which sound output the unit has -- only one value
# seen, no alternatives to select between.
SOUND_OUTPUT = Capability(
href='/settings/sound/output/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='sound_output', field='deviceType',
icon='mdi:volume-high', entity_category='diagnostic'),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
poll_tier='cold',
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
),
)
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
# been verified against -- covered here (not globally, per ignored.py's
# module docstring) since those hrefs collide with fridge/AC schemas
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
#
# The next six hrefs (issue #130, TP1X_DA-AC-AIR board) are the exact same
# resources, same shapes, same reasoning as airconditioner.py's
# _AC_IGNORED on the shared DA-AC- board family -- duplicated here rather
# than promoted to the global ignored.py list, since that would require
# also removing them from _AC_IGNORED in the same change (a global entry
# colliding with a family-local bare Capability on the same href raises in
# _build()); left as a possible follow-up DRY cleanup.
COVERAGE = [
Capability(href='/humidity/0'),
Capability(href='/humidity/vs/0'),
Capability(href='/airlevelcheck/vs/0'), # periodic air-quality sensing scheduler plumbing
Capability(href='/availablecontrolsets/vs/0'), # opaque hex-encoded control-set bitmap
Capability(href='/da/softreset/vs/0'), # soft-reset trigger plumbing
Capability(href='/keepnormalstate/vs/0'), # internal keep-normal flag
Capability(href='/personality/presence/vs/0'), # presence-personalization plumbing (empty here)
Capability(href='/reserverulesets/vs/0'), # opaque hex-encoded schedule reservation blob
# Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/
# useTimeSetting/functionState) -- every field reads its inert default
# on the only dump seen (times both '00:00:00', useTimeSetting/
# functionState both 'false'). Same "needs a multi-field schedule
# editor" treatment as fridge.py's /defrost/reservation/vs/0.
Capability(href='/dnd/autosleep/vs/0'),
# Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's
# convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN
# above instead.
Capability(href='/mode/convenient/vs/0'),
]
@@ -0,0 +1,980 @@
"""Capabilities for the Samsung air-conditioner family (ARTIK051_PRAC-class).
Resources verified against the issue #17 diagnostics dump (model
ARTIK051_PRAC_20K). This is the first family whose core controls surface as a
single composite HA `climate` entity rather than a scatter of switches/selects:
power (on/off), HVAC mode, current/target temperature, fan (wind) strength,
swing (wind direction), and the convenient-mode preset all live on one climate
card. The climate platform (climate.py) reads those sibling resources from the
coordinator snapshot; here we bind the primary `/mode/vs/0` resource to the
`ClimateDesc` and mark the consumed siblings as covered.
None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`,
`/temperatures/vs/0`, `/humidity/*` collide with fridge/oven hrefs of a
different schema (see capabilities/__init__.py). They live only in the AC
by_type registry.
"""
from dataclasses import replace
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
)
from . import common
from .common import filter_usage_percent, normalize_temp_unit
from .laundry import option_write
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _beep_on(rep):
"""Beep on/off from the `Volume_*` option token: Volume_Mute = off,
Volume_100 (and any non-Mute) = on. None when no Volume_ slot.
_option_token (defined further below, alongside the legacy-board token
helpers) returns the token's *value* half (e.g. 'Mute', '100'), not the
full 'Volume_100' token -- shared with _option_token_num/_option_token_on,
which this module's other options[] readers already rely on.
"""
tok = _option_token(rep, 'Volume')
if tok is None:
return None
return tok != 'Mute'
def _beep_write(payload, rep, href=None):
"""Toggle beep via a single-token /mode/vs/0 options write (option_write's
one-token merge -- a full options RMW reverts on ARTIK051_PRAC). 'On'
restores the last non-Mute level rather than forcing Volume_100, so a
user's intermediate setting (e.g. Volume_50 set via the cloud) survives an
off/on cycle; falls back to 100 when no prior level is known."""
if payload not in ('On', 'Off'):
return None
if payload == 'Off':
token = 'Mute'
else:
prev = _option_token(rep, 'Volume')
token = prev if (prev and prev != 'Mute') else '100'
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Volume', token),
}
def _tropical_night_value(rep):
"""Tropical night mode level (0-16) from the `Sleep_<N>` option token.
_option_token returns the token's value half already (e.g. '16' for
'Sleep_16'), same convention as _beep_on above.
"""
tok = _option_token(rep, 'Sleep')
if tok is None:
return None
return _int(tok)
def _tropical_night_write(value, rep, href=None):
"""Set tropical night level via a single-token `Sleep_<N>` options write.
Samsung cloud counterpart: custom.airConditionerTropicalNightMode (0-16)."""
try:
level = int(round(float(value)))
except (TypeError, ValueError):
return None
if not 0 <= level <= 16:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Sleep', str(level)),
}
def _filter_unit(rep):
"""Unit of the filter-usage fields, normalised from filterCapacityUnit
('Hour' -> 'h'). Wired through unit_fn so a board advertising a different
unit doesn't silently mislabel a duration statistic."""
u = rep.get('x.com.samsung.da.filterCapacityUnit')
return {'Hour': 'h', 'Minute': 'min', 'Second': 's'}.get(u, u or 'h')
def _threshold_write(payload, rep, href=None):
"""filterDesiredUsage is locally writable: a plain scalar POST of the
field to /filter/airdustfilter/vs/0 is 2.04-accepted and persists
(confirmed live on ARTIK051_PRAC: POST 700 -> 2.04, read-back 700). The
Select only surfaces where the device advertises
supportedFilterDesiredUsage, so the valid options are known rather than
guessed; boards without that enum leave this writable field unexposed."""
return ['filter', 'airdustfilter', 'vs', '0'], {
'x.com.samsung.da.filterDesiredUsage': payload,
}
def _sensor_item_value(items, type_):
"""First value of the /sensors/vs/0 item with the given
x.com.samsung.da.type. The resource exposes no unit, so no device_class is
set until a populated reading + unit is observed (the 'don't guess' rule).
Dust/FineDust/SuperFineDust report a 2-element array (['0','0']) while
CleanLevel/Odor report a single element -- the second element's meaning is
unconfirmed, so v[0] is taken as the reading and v[1] is dropped; left as
a string rather than coerced numeric because only CleanLevel has
corroborating evidence (a top-level x.com.samsung.da.cleanLevel scalar)."""
for it in (items or []):
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_:
v = it.get('x.com.samsung.da.value')
if isinstance(v, list) and v:
return str(v[0])
return None
return None
def _has_sensor_type(type_):
"""True when the /sensors/vs/0 items[] array lists an item of this type.
This proves the type is *listed*, not that the reading is real: issue
#166 (ARxxTXFCAWKNEU, board ARTIK051_PRAC_20K) lists all five item types
with permanent zero values on both its units, and the reporter confirmed
none of these sensors are physically present. A top-level
x.com.samsung.da.cleanLevel scalar (separate from the CleanLevel item)
looked like a corroborating "this reading is real" signal at first --
present alongside genuinely populated readings on tp1x_da_ac_rac_01011
and the tp1x_da_ac_air air purifier fixture, absent on every all-zero
ARTIK051_PRAC_20K dump including both #166 units -- but it doesn't hold
up as a general rule: air_purifier_device.json (ARTIK051_TVTL_18K),
air_purifier_vtww_device.json, and range_hood_device.json all carry
genuinely populated, non-AC-family Dust/FineDust/SuperFineDust readings
with no such scalar. So this stays item-type presence only -- the
entities are disabled by default instead (see AIR_QUALITY below) rather
than existence-gated on a signal that would silently drop real readings
on hardware this repo hasn't seen yet."""
def fn(rep, resources):
return any(isinstance(i, dict) and i.get('x.com.samsung.da.type') == type_
for i in (rep.get('x.com.samsung.da.items') or []))
return fn
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
# siblings off the coordinator snapshot; those siblings are marked covered
# (no-entity caps) so discover() reports no gap. Declared once here and
# imported by climate.py, so a new sibling read can't drift out of sync with
# its coverage entry.
# ---------------------------------------------------------------------------
HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE)
HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF
HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off
HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature
HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target)
HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step
HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
# Newer boards (issue #126, TP1X_DA-AC-RAC-01011 WindFree variant) report no
# HREF_WIND_DIRECTION at all and instead carry a 2-axis oscillation resource
# (separate vertical/horizontal Swing|Fix toggles, each with its own angle).
# climate.py falls back to this when HREF_WIND_DIRECTION is absent, the same
# duality pattern as the OCF/vendor temperature channel below.
HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array)
# Legacy ARTIK051 boards (ARTIK051_KRAC_18K, issue #136) carry no /wind/* resources
# at all: fan speed and vane direction sit together in this one resource, as
# x.com.samsung.da.speedLevel (the same 0-4 scale as _DEVICE_TO_FAN) and
# x.com.samsung.da.direction (the same codes as _DEVICE_TO_SWING). Their
# convenient-mode preset is a Comode_* token in /mode/vs/0's options instead of a
# resource of its own -- see climate.py's _legacy_airflow/_legacy_convenient.
HREF_AIRFLOW = '/airflow/vs/0' # legacy fan_mode + swing_mode
CLIMATE_CONSUMED_HREFS = [
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION,
HREF_WIND_OSCILLATION, HREF_CONVENIENT, HREF_AIRFLOW,
]
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _temps_vs_item(rep):
"""First item of the vendor `/temperatures/vs/0` items[] array -- the
Tizen Lite board's only current-temperature source (see climate.py's
identical helper; duplicated rather than imported to avoid a
capabilities<->platform import cycle)."""
items = rep.get('x.com.samsung.da.items')
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
def _temps_vs_current(rep):
return _num(_temps_vs_item(rep).get('x.com.samsung.da.current'))
def _temps_vs_unit(rep):
return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C')
def _first_mode(rep):
"""Representative scalar for the climate entity in the flattened state
(golden/regression). The real entity computes hvac_mode from power + mode."""
modes = rep.get('x.com.samsung.da.modes')
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
def _mode_options(rep):
opts = rep.get('x.com.samsung.da.options')
return opts if isinstance(opts, (list, tuple)) else ()
def _has_display_light_option(rep, resources):
"""True when the panel light state is carried inside /mode/vs/0's options
blob (a `Light_On`/`Light_Off` token) rather than a dedicated /light/vs/0
switch. The two encodings are mutually exclusive across observed boards:
models exposing the /light/vs/0 switch (bound by DISPLAY_LIGHT below)
carry no Light_* option, so this entity only materialises on the boards
that would otherwise have no display-light entity at all."""
return any(isinstance(o, str) and o.startswith('Light_')
for o in _mode_options(rep))
def _display_light_on(rep):
"""Panel display light state from /mode/vs/0's options blob. The token is
INVERTED relative to its name -- confirmed by a live toggle test: with the
panel lit the option reads `Light_Off`, and with it dark it reads
`Light_On` (the flag really encodes "night/display-off mode active"). So
`Light_Off` -> light on, `Light_On` -> light off. Read-only from here; the
write below uses the device's own single-token merge (see option_write)."""
for o in _mode_options(rep):
if isinstance(o, str) and o.startswith('Light_'):
return o == 'Light_Off'
return None
def _display_light_write(payload, rep, href=None):
"""Toggle the panel light via a single-token /mode/vs/0 options write
('SingleCommand_1' is advertised in this family's /configuration/vs/0
airconOptionList; option_write's one-token merge is the same mechanism
air_purifier.py uses for its own Light switch). Polarity is inverted (see
_display_light_on): switching the lamp ON writes 'Light_Off', OFF writes
'Light_On'."""
token = 'Off' if payload == 'On' else 'On'
return (['mode', 'vs', '0'],
{'x.com.samsung.da.options': option_write('Light', token)})
# ---------------------------------------------------------------------------
# Legacy ARTIK051 boards keep several settings that newer boards expose as their
# own resources (/option/*, /electriccurrent/vs/0, ...) as `<Prefix>_<value>`
# tokens inside /mode/vs/0's options blob instead. Reads pull the token apart;
# writes reuse option_write's single-token merge, exactly like the display light.
# ---------------------------------------------------------------------------
def _option_token(rep, prefix):
"""Value part of a `<prefix>_<value>` token in /mode/vs/0's options."""
for option in _mode_options(rep):
if isinstance(option, str) and option.startswith(prefix + '_'):
return option.split('_', 1)[1]
return None
def is_legacy_board(resources):
"""True for the board generation whose airflow lives in /airflow/vs/0.
Newer families carry several of the same option tokens (Sleep, OutdoorTemp,
Autoclean) *alongside* dedicated resources for those settings, so an
ungated token entity would either duplicate an existing one or apply a
scale calibrated elsewhere. (Volume used to be in this list too, back when
it had its own gated buzzer_volume Number for this board generation --
issue #136 replaced that with the unified 'beep' switch, which applies
across every generation and isn't gated here at all.) Every AC dump on
record has one shape or the other: /airflow/vs/0 with no /wind/* at all,
or /wind/strength/vs/0 with no /airflow/vs/0. Same test as climate.py's
_legacy_airflow(), so the entities below and the climate entity can never
disagree about the generation.
"""
return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources
# This legacy ARTIK051 board generation (issue #193, model AR12NXWXCWKNEU /
# ARTIK051_KRAC_18K) reports /energy/consumption/vs/0's cumulativePower in
# centiwatt-hours -- raw value 100x the plain Wh every other AC board family
# (and common.wh_to_kwh's assumed unit) reports. Confirmed against the
# reporter's own SmartThings-app reading: raw '117430000' vs the app's
# authoritative 1,174.30 kWh is exactly a /100000 factor (i.e. /100 on top of
# wh_to_kwh's own /1000), not wh_to_kwh's plain /1000 alone. No other field in
# ENERGY_METER's entities is present on this board's dump, so only
# 'energy_kwh' needs a replacement value_fn here; the rest pass through
# unchanged in case a future legacy dump ever reports them.
def _legacy_cumulative_power_kwh(v):
# float, not _int -- matches common.wh_to_kwh's own numeric parsing
# (float via _num) rather than this module's integer-only _int, so a
# decimal-formatted reading doesn't raise and silently go 'unknown'.
try:
n = float(v)
except (TypeError, ValueError):
return None
return round(n / 100000.0, 2)
ENERGY_METER_LEGACY = replace(
common.ENERGY_METER,
match_fn=lambda rep, resources: is_legacy_board(resources),
entities=tuple(
replace(e, value_fn=_legacy_cumulative_power_kwh)
if e.key == 'energy_kwh' else e
for e in common.ENERGY_METER.entities
),
)
# The non-legacy counterpart to ENERGY_METER_LEGACY above -- identical to
# common.ENERGY_METER, just excluding the legacy board generation so the two
# capabilities can share /energy/consumption/vs/0 in this registry without
# the 'multiple caps need a discriminator' build check tripping (common.
# ENERGY_METER itself has no match_fn, since every *other* registry includes
# it unconditionally and alone).
ENERGY_METER_GENERIC = replace(
common.ENERGY_METER,
match_fn=lambda rep, resources: not is_legacy_board(resources),
)
def _has_option_token(prefix):
return lambda rep, resources: (
is_legacy_board(resources) and _option_token(rep, prefix) is not None)
def _option_token_on(prefix):
return lambda rep: _option_token(rep, prefix) == 'On'
def _option_token_num(prefix, offset=0, divisor=1):
def read(rep):
raw = _option_token(rep, prefix)
try:
return (float(raw) - offset) / divisor
except (TypeError, ValueError):
return None
return read
def _option_switch_write(prefix):
def write(payload, rep, href=None):
return (['mode', 'vs', '0'],
{'x.com.samsung.da.options': option_write(prefix, payload)})
return write
def _option_number_write(prefix):
def write(payload, rep, href=None):
return (['mode', 'vs', '0'],
{'x.com.samsung.da.options':
option_write(prefix, str(int(round(float(payload)))))})
return write
def _humidity(rep):
"""Relative humidity, preferring the 5%-rounded field where it exists.
ARTIK051 boards have no fivepercentHumidity field at all and report the
plain x.com.samsung.da.humidity instead -- and only populate it while the
Air monitoring option is on: the unit measures for roughly half a minute
(51% observed, matching what the same unit's cloud integration reported at
that moment), then zeroes the field and switches Air monitoring back off by
itself. So 0 reads as "not measuring" and is reported as unknown rather than
as 0% humidity, which would poison long-term history.
That zero-as-"not measuring" carve-out is specific to the ARTIK051
fallback field's hardware quirk -- every other board's fivepercentHumidity
has never been documented getting stuck at zero, and collapsing a
genuine 0% reading there to unknown is a regression, not a safeguard
(issue #160). So fivepercentHumidity passes 0 through unchanged; only the
humidity fallback applies the zero-collapse.
"""
if 'x.com.samsung.da.fivepercentHumidity' in rep:
return _num(rep['x.com.samsung.da.fivepercentHumidity'])
if 'x.com.samsung.da.humidity' in rep:
value = _num(rep['x.com.samsung.da.humidity'])
return value if value else None
return None
def _climate_write(payload, rep, href=None):
"""Map a (kind, value) command from the climate platform to the
(path_segs, body) for that one sub-write. `value` is already the raw device
code (the platform maps HA<->device). async_send_command POSTs to path_segs,
so a single desc drives writes across the power/mode/temperature/wind
resources.
Power goes to the vendor `/power/vs/0` (the OCF `/power/0` is absent on
most boards and a non-authoritative mirror where present -- vendor works
on every board). Temperature is board-dependent, so the platform picks the
channel and sends `temperature_ocf` (-> OCF `/temperature/desired/0`,
boards with the full OCF current+desired pair) or `temperature` (-> vendor
`/temperatures/vs/0`, boards without it). Mode/fan/swing/preset are always
the vendor `/x/vs/0` resources.
Each write sends only its own field(s), leaving the resource's other
fields (e.g. /mode/vs/0's opaque `options` blob, or the vendor temperature
item's current/minimum/maximum/unit) untouched -- the device merges the
rest itself, same contract as the options[] array (see
common.merge_items_field / merge_options_field, which keep the
coordinator's optimistic cache complete for the settle window instead of
this write echoing those fields back).
"""
kind, value = payload
if kind == 'power':
return (['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if value else 'Off'})
if kind == 'mode':
return (['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]})
if kind == 'temperature_ocf':
return (['temperature', 'desired', '0'],
{'temperature': int(round(float(value)))})
if kind == 'temperature':
# Vendor items[] array; only one item observed on every AC dump, id
# '0'. See the docstring above for why this doesn't echo current/
# minimum/maximum/unit back at the unit.
return (['temperatures', 'vs', '0'],
{'x.com.samsung.da.items': [
{'x.com.samsung.da.id': '0',
'x.com.samsung.da.desired': str(int(round(float(value))))}]})
if kind == 'fan':
return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value})
if kind == 'swing':
return (['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': value})
if kind == 'oscillation':
# value is the HA swing_mode string ('off'/'vertical'/'horizontal'/
# 'both'); both axes are independent Swing|Fix toggles on this
# resource, so one HA value maps to a pair of fields written
# together (see climate.py's oscillation fallback).
return (['wind', 'oscillation', 'vs', '0'], {
'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix',
'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix',
})
if kind == 'fan_legacy':
return (['airflow', 'vs', '0'],
{'x.com.samsung.da.speedLevel': str(value)})
if kind == 'swing_legacy':
return (['airflow', 'vs', '0'],
{'x.com.samsung.da.direction': value})
if kind == 'preset_legacy':
# Single-token options merge, same mechanism as _display_light_write.
return (['mode', 'vs', '0'],
{'x.com.samsung.da.options': option_write('Comode', value)})
if kind == 'preset':
return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value})
return None
CLIMATE = Capability(
href=HREF_MODE,
poll_tier='warm',
entities=(
ClimateDesc(key='climate', translation_key='airconditioner',
rep_fn=_first_mode, write_fn=_climate_write),
# Display (panel) light switch, only on boards that encode it in
# /mode/vs/0's options instead of a /light/vs/0 switch (see
# _has_display_light_option). Shares the /mode/vs/0 href with the
# climate entity above -- same Capability, so no multi-cap
# discriminator is needed. /mode/vs/0 is OBSERVE-subscribed, so state
# updates on push; writes go through the single-token merge in
# _display_light_write. Shares the switch.display_light translation
# with DISPLAY_LIGHT (the /light/vs/0 switch on other boards) --
# mutually exclusive per href, so only one ever binds for a given unit.
SwitchDesc(key='display_light', rep_fn=_display_light_on,
exists_fn=_has_display_light_option,
write_fn=_display_light_write,
icon='mdi:led-on', entity_category='config'),
# Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100).
# Single-token option_write; a full options RMW reverts on ARTIK051_PRAC.
#
# Applies uniformly across board generations, including legacy
# ARTIK051 boards -- issue #136: this token was originally modeled as
# a 0-100 buzzer_volume Number for legacy boards on the assumption it
# was a real graduated volume level, but every unit confirmed on
# hardware (three units across two reporters) only ever carries
# Volume_100 or Volume_Mute, never an intermediate value, and the
# Number's write path (a plain integer string) could never produce
# the literal 'Mute' token needed to actually turn it off -- writing
# '0' is simply not a token this firmware recognizes. A switch is
# both the correct model and the actual fix.
SwitchDesc(key='beep', rep_fn=_beep_on,
exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None,
write_fn=_beep_write,
icon='mdi:volume-high', entity_category='config'),
# Tropical night mode level (0-16) from the `Sleep_<N>` option token.
# Single-token option_write. Cloud: custom.airConditionerTropicalNightMode.
# Gated off the legacy board for the same reason as beep above -- its
# Sleep_ token is already the good_sleep Number below.
#
# exists_fn only proves the Sleep_ token slot is present, not that
# tropical night mode is a real feature of the unit: issue #166
# (ARxxTXFCAWKNEU) reports Sleep_0 in every dump -- the exact same
# always-there-at-zero shape as the issue #17 dump #164 was verified
# against -- yet the reporter confirmed their remote/app has no
# tropical night mode control at all. Samsung's OCF options[] blob
# carries this scaffolding token regardless of physical capability,
# so there's no reliable signal here to gate on (same 'don't guess'
# rule as elsewhere in this file, just with no signal to guess from).
# Registered but disabled by default, same precedent as
# fridge.rack_count / cooktop.paired_hood_* -- units that do have the
# feature can enable it themselves.
NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value,
exists_fn=lambda rep, resources: (
not is_legacy_board(resources)
and _option_token(rep, 'Sleep') is not None),
write_fn=_tropical_night_write,
native_min=0, native_max=16, step=1,
enabled_default=False,
icon='mdi:weather-night', entity_category='config'),
# Settings that this board generation keeps as options[] tokens.
SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'),
exists_fn=_has_option_token('Spi'),
write_fn=_option_switch_write('Spi'),
icon='mdi:air-purifier', entity_category='config'),
# Shares AUTO_CLEAN's catalog entry rather than duplicating it: same
# feature, different board generation (that one is a /option/autoclean/
# vs/0 field, absent here). Distinct key, so nothing collides if some
# future board ever reported both.
SwitchDesc(key='auto_clean_legacy', translation_key='auto_clean',
rep_fn=_option_token_on('Autoclean'),
exists_fn=_has_option_token('Autoclean'),
write_fn=_option_switch_write('Autoclean'),
icon='mdi:fan-auto', entity_category='config'),
SwitchDesc(key='air_monitoring', rep_fn=_option_token_on('AirMonitoring'),
exists_fn=_has_option_token('AirMonitoring'),
write_fn=_option_switch_write('AirMonitoring'),
icon='mdi:air-filter', entity_category='config'),
# "Good Sleep" timer. 0 = off; the upper bound is a guess (the token
# carries no range hint and only 0 has been observed on hardware), so a
# write above 0 is unverified.
NumberDesc(key='good_sleep', rep_fn=_option_token_num('Sleep'),
exists_fn=_has_option_token('Sleep'),
write_fn=_option_number_write('Sleep'),
native_min=0, native_max=12, step=1, unit='h',
icon='mdi:sleep', entity_category='config'),
# Outdoor temperature, offset by 55. Two calibration points on one unit:
# token 75 while an independent outdoor thermometer in the same install
# read 20.3 C, and token 74 against a 19.4 C forecast. Fahrenheit fits far
# worse (74 F = 23.3 C); the issue #136 unit's 81 gives 26 C in a warmer
# climate, which is also plausible.
SensorDesc(key='outdoor_temperature',
rep_fn=_option_token_num('OutdoorTemp', offset=55),
exists_fn=_has_option_token('OutdoorTemp'),
device_class='temperature', state_class='measurement',
unit='°C', icon='mdi:home-thermometer-outline'),
# Filter time in tenths of an hour: the token read 1710 while the official
# Samsung app displayed "171 hours 0 minutes" for the filter on the same
# unit, and the .0 matching the app's "0 minutes" pins the scale. Whether
# it counts up or down is NOT established -- it was seen rising (171.0 ->
# 171.5) while the unit ran, which contradicts the app's "remaining"
# wording, so the entity is deliberately named neutrally.
SensorDesc(key='filter_time',
rep_fn=_option_token_num('FilterTime', divisor=10),
exists_fn=_has_option_token('FilterTime'),
device_class='duration', unit='h',
state_class='measurement', icon='mdi:air-filter'),
),
)
AIR_PURIFY = Capability(
href='/option/airpurify/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='air_purify', field='x.com.samsung.da.modes',
icon='mdi:air-purifier',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'airpurify', 'vs', '0'],
{'x.com.samsung.da.modes': 'On' if p == 'On' else 'Off'})),
),
)
AUTO_CLEAN = Capability(
href='/option/autoclean/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus',
icon='mdi:spray-bottle',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'autoclean', 'vs', '0'],
{'x.com.samsung.da.settingStatus': 'On' if p == 'On' else 'Off'})),
),
)
AIR_FILTER = Capability(
href='/filter/airdustfilter/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='air_filter_usage', rep_fn=filter_usage_percent,
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
# filterUsage is a lifetime hour counter that only resets on filter
# replacement -- total_increasing so HA's long-term statistics handle
# the reset rather than treating it as a bounded measurement.
SensorDesc(key='air_filter_usage_hours',
field='x.com.samsung.da.filterUsage',
device_class='duration',
state_class='total_increasing',
unit_fn=_filter_unit,
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=_int),
# The alarm threshold (filterDesiredUsage) is a locally writable option:
# see _threshold_write. Surfaces as a Select only where the device
# advertises supportedFilterDesiredUsage; boards without that enum
# leave it unexposed rather than guess the valid set.
SelectDesc(key='air_filter_threshold',
field='x.com.samsung.da.filterDesiredUsage',
options_field='x.com.samsung.da.supportedFilterDesiredUsage',
exists_fn=lambda rep, res: bool(
rep.get('x.com.samsung.da.supportedFilterDesiredUsage')),
icon='mdi:alarm', entity_category='config',
write_fn=_threshold_write,
value_fn=lambda v: str(v) if v is not None else None),
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
DISPLAY_LIGHT = Capability(
href='/light/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='display_light', field='mode',
icon='mdi:led-on',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['light', 'vs', '0'],
{'mode': 'On' if p == 'On' else 'Off'})),
),
)
# Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single
# boolean field, no vendor prefix, mirroring the On/Off convention used
# throughout the rest of this API.
MUTE_ONCE = Capability(
href='/option/muteonce/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='mute_once', field='muteonce',
icon='mdi:volume-mute',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'muteonce', 'vs', '0'],
{'muteonce': 'On' if p == 'On' else 'Off'})),
),
)
# Circuit-breaker current-limit setting (issue #38, TP1X board): `operation`
# toggles the limiter and `modes` picks a level out of `supportedModes`
# (seen as '3'..'9'). No vendor field-name prefix and no unit/label in the
# dump to confirm what the levels mean (amps vs. an abstract tier) --
# exposed read-only per the 'don't guess' rule rather than risking an
# unverified write to live HVAC hardware.
CURRENT_LIMIT = Capability(
href='/electriccurrent/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(key='current_limit_enabled', field='operation',
icon='mdi:current-ac',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
SensorDesc(key='current_limit_level', field='modes',
icon='mdi:current-ac',
entity_category='diagnostic'),
),
)
# Overload-response setting (issue #126, TP1X_DA-AC-RAC-01011 WindFree
# variant): `operation` toggles the feature and `mode` picks 'Alarm' vs
# 'PowerSaving' out of `supportedModes`, with a `savingTime` duration
# ('20'/'40'/'60' minutes) alongside. Plausible shape (compressor-overload
# alarm vs. automatic power throttling), but nothing in the dump confirms
# the exact behavioral difference between the two modes or whether writing
# 'operation' is safe on live HVAC hardware -- exposed read-only per the
# 'don't guess' rule, same precedent as CURRENT_LIMIT above.
ANOMALY_LOAD = Capability(
href='/anomalyload/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(key='overload_protection_active', field='operation',
icon='mdi:flash-alert',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
SensorDesc(key='overload_protection_mode', field='mode',
device_class='enum',
options=('alarm', 'powersaving'),
translation_key='overload_protection_mode',
icon='mdi:flash-alert', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
# Absence-detection power-saving (issue #173, TP1X_LNX-AC-RAC-01001 --
# Lennox-branded heat pump on the RAC board family): `status` is a bare
# On/Off boolean with no vendor prefix, the same shape already shipped
# writable elsewhere in this file (MUTE_ONCE, AUTO_CLEAN, AIR_PURIFY,
# DISPLAY_LIGHT) despite none of those having a live-confirmed write either
# -- worst case a wrong token no-ops, same risk profile as that family, so
# it's a switch rather than a sensor. `switchPowerSaveMode` picks the save
# intensity out of its own supportedSwitchPowerSaveMode list, but *what*
# writing it actually does to a running compressor isn't knowable from the
# dump -- same 'don't guess' read-only treatment as CURRENT_LIMIT/
# ANOMALY_LOAD's mode fields. A third field, `motionState`, also carries a
# supportedMotionState list but its role (a live sensor readout vs. a
# sensitivity setting) isn't distinguishable from the dump, so it's left
# unmodeled entirely.
ABSENCE_POWER_SAVING = Capability(
href='/mds/absencepowersaving/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='absence_power_saving_active', field='status',
icon='mdi:human-greeting-proximity',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['mds', 'absencepowersaving', 'vs', '0'],
{'status': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='absence_power_saving_mode', field='switchPowerSaveMode',
device_class='enum',
options=('eco', 'normal', 'comfort'),
translation_key='absence_power_saving_mode',
icon='mdi:leaf', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
# Avoid-direct-wind-on-motion, a sibling AI feature to ABSENCE_POWER_SAVING
# above on the same dump: `status` is the same bare On/Off shape, promoted to
# a switch for the same reason. `modes` (Direct/Indirect airflow out of
# `supportedModes`) stays read-only -- same reasoning as
# absence_power_saving_mode above.
MOTION_DETECT_WIND = Capability(
href='/option/motiondetectwind/stateful/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='motion_detect_wind_active', field='status',
icon='mdi:motion-sensor',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'motiondetectwind', 'stateful', 'vs', '0'],
{'status': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='motion_detect_wind_mode', field='modes',
device_class='enum',
options=('direct', 'indirect'),
translation_key='motion_detect_wind_mode',
icon='mdi:weather-windy', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
# The climate entity already surfaces current_temperature as a card
# attribute, but that's not enough for history graphs/automations/
# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard-
# with-vendor-fallback shape as common.py's POWER_GENERIC/POWER_VS_FALLBACK
# pair: both share key='current_temperature_c' so only one ever binds
# (match_fn gates the vendor one off when the OCF resource is present).
CURRENT_TEMPERATURE = Capability(
href=HREF_TEMP_CURRENT,
poll_tier='warm',
entities=(
SensorDesc(key='current_temperature_c', field='temperature',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: normalize_temp_unit(rep.get('units'), '°C')),
),
)
CURRENT_TEMPERATURE_VS = Capability(
href=HREF_TEMPS_VS,
poll_tier='warm',
match_fn=lambda rep, resources: HREF_TEMP_CURRENT not in resources,
entities=(
SensorDesc(key='current_temperature_c', rep_fn=_temps_vs_current,
device_class='temperature', state_class='measurement',
unit_fn=_temps_vs_unit),
),
)
# Only the vendor resource's `fivepercentHumidity` (current reading, rounded
# to the nearest 5%) has live data on the issue #75 dump -- its `humidity`
# field, and the OCF-standard /humidity/0 resource entirely, both read a
# stuck "0" there, so /humidity/0 stays ignored per the 'don't guess' rule
# (see _AC_IGNORED below).
#
# ARTIK051 boards (issue #136) have no fivepercentHumidity field at all, and
# there the plain `humidity` field is not stuck: it carries a real reading
# (51%, matching the same unit's cloud integration at that moment) for as long
# as the Air monitoring option is on, which the unit itself switches back off
# after roughly a minute -- so most dumps catch it at 0. Hence _humidity's
# fallback, and hence 0 reading as "not measuring" rather than 0%: on both
# board generations a zero here means no measurement, never dry air.
# /humidity/0 stayed 0 throughout that same observation too.
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='humidity', rep_fn=_humidity,
device_class='humidity', state_class='measurement', unit='%'),
),
)
# /sensors/vs/0 items[] carry live air-quality readings. Removed from
# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href. CleanLevel is
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar
# (tp1x_da_ac_rac_01011 reports both as '1'), so it's a measurement; the others
# are 1- or 2-element arrays with no corroborating scalar, so they stay string
# diagnostics (see _sensor_item_value for the 2-element ambiguity and why only
# v[0] is taken). No unit is advertised on the resource, so no device_class.
#
# exists_fn (_has_sensor_type) only proves the item *type* is listed, not
# that the unit actually carries that sensor: issue #166 (ARxxTXFCAWKNEU,
# board ARTIK051_PRAC_20K) reports all five item types on both its units,
# values permanently '0'/['0','0'] -- yet the reporter confirmed none apply
# to their model. A tighter existence gate was tried (requiring the
# corroborating cleanLevel scalar above) but doesn't hold up as a general
# rule -- see _has_sensor_type's docstring -- and risks silently dropping
# real readings on hardware that reports them without that scalar. So these
# stay bound whenever the type is listed, same as before #166, and disabled
# by default instead (same precedent as fridge.rack_count /
# cooktop.paired_hood_model / this file's own tropical_night_mode): units
# that do have the sensor can enable it themselves.
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='clean_level', field='x.com.samsung.da.items',
icon='mdi:broom', entity_category='diagnostic',
state_class='measurement',
exists_fn=_has_sensor_type('CleanLevel'),
enabled_default=False,
value_fn=lambda items: _int(_sensor_item_value(items, 'CleanLevel'))),
*tuple(
SensorDesc(key=key, field='x.com.samsung.da.items',
icon=icon, entity_category='diagnostic',
exists_fn=_has_sensor_type(type_),
enabled_default=False,
value_fn=lambda items, t=type_: _sensor_item_value(items, t))
for key, icon, type_ in (
('odor', 'mdi:weather-windy', 'Odor'),
('dust', 'mdi:cloud', 'Dust'),
('fine_dust', 'mdi:cloud-outline', 'FineDust'),
('super_fine_dust', 'mdi:weather-fog', 'SuperFineDust'),
)
),
),
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
# These are NOT in the global ignored.IGNORED because several of them
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
# other families' schemas. A no-entity Capability still marks the href as
# bound so discover() reports no coverage gap.
#
# CLIMATE_CONSUMED_HREFS carry the climate card's actual displayed state
# (power, current/target temp, fan, swing, preset) -- the coordinator only
# OBSERVE-subscribes and sub-polls 'hot'/'warm' hrefs (see coordinator.py),
# so leaving these at the Capability default of 'cold' meant every state
# change was invisible until the next full /device/0 summary sweep
# (~30s -- issue #17: instant device response, 20-30s HA lag). Pin them to
# 'warm' -- same tier as CLIMATE's own primary href -- so they get push
# notifications (or, in poll-only mode, the warm sub-poll cadence) instead
# of waiting on the summary sweep.
# ---------------------------------------------------------------------------
_AC_IGNORED = [
# Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor
# resource's usable fivepercentHumidity field instead; this OCF-standard
# one has no corresponding live value confirmed yet.
'/humidity/0',
# Presence-personalization plumbing (empty item list here).
'/personality/presence/vs/0',
# OCF-standard mirror of /airflow/vs/0 ({speed, direction} vs
# {speedLevel, direction}, identical values). The climate entity reads and
# writes the vendor form, which is the one confirmed on hardware here --
# note that air_purifier.py found the opposite ordering on its family, so
# neither form is reliable sight-unseen and this one stays unmodeled.
'/airflow/0',
# --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no
# user-actionable state or no documented write contract, so per the
# 'don't guess' rule they are ignored rather than modeled.
# /option/muteonce/vs/0 and /selfcheck/vs/0 are deliberately NOT here --
# see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in
# the by_type registry, both of which have a confirmed, cleanly
# modelable contract.
'/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing
'/aisleep/vs/0', # AI-sleep feedback state (no actionable control)
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/keepnormalstate/vs/0', # internal keep-normal flag
'/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here)
'/mds/absencestate/vs/0', # motion-detection state (empty here)
'/remotedatacontrol/vs/0', # remote data-control session status
'/remotedeviceinfo/vs/0', # remote paired-device id list (empty didList here)
'/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit)
# Manual airflow-step position (supportedModes Off/80/60/40/Power).
# Overlaps the /wind/direction swing control already on the climate card,
# and the meaning of the numeric steps vs. 'Power' isn't documented in the
# dump -- ignored per the 'don't guess' rule rather than modeled as a
# select whose write could confuse live HVAC hardware.
'/stepcontrol/vs/0',
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/welcome/temperature/vs/0', # welcome-cooling plumbing
# System-AC-only (multi-indoor-subdevice commercial installs, e.g.
# A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation
# topology -- indoor/outdoor unit pairing, per-subdevice serials, MCU
# info. Commissioning-time plumbing, not user-actionable appliance state.
'/sac/installationinfo/vs/0',
# Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing
# *and* a wall-mounted indoor subdevice over one shared local IP, e.g.
# TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id
# list, same "remote device ids, not user-actionable locally" role as
# /remotedeviceinfo/vs/0 above -- true whenever this field is redacted
# (the shipped airconditioner_fac_bora fixture) or absent. When it does
# carry a real id (issue #177's airconditioner_fac_bora_2in1 fixture),
# registry/subdevices.py's enumerate_subdevices reads this same
# subdeviceIdList to reach the second indoor subdevice over this same
# connection instead -- see that module's Pattern B.
'/subdevices/vs/0',
# Undocumented single int (runningMode: 0 on every dump seen), no
# supported-values list to interpret it against -- 'don't guess'.
'/runn/vs/0',
# 2-in-1/multi-indoor-subdevice systems (issue #177, the reporter's
# ARTIK051_DONGLE_FAC_18K): x.com.samsung.da.numofsubdevice, a plain
# corroborating count of indoor subdevices on this connection. Confirmed
# read-only (a write attempt returned CoAP 4.00). Absent from
# /device/0's batch entirely -- registry.subdevices.enumerate_subdevices
# fetches it with its own RETRIEVE and folds it into the resources dict
# for diagnostics, which is why it needs an entry here rather than
# surfacing as an unbound-href gap on every board that has it.
'/multidevice/vs/0',
]
# Built as bare no-entity caps; folded into the AC registry (not global).
# HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded here -- CURRENT_TEMPERATURE
# / CURRENT_TEMPERATURE_VS above already cover those two with real entities.
COVERAGE = [
Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS
if h not in (HREF_TEMP_CURRENT, HREF_TEMPS_VS)
] + [
Capability(href=h) for h in _AC_IGNORED
]
@@ -10,8 +10,13 @@ against live device dumps:
/water/consumption/vs/0 -> x.com.samsung.da.cumulativeWater
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
"""
from datetime import datetime, timezone
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
from ..entities import (
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
)
def _num(v):
@@ -21,33 +26,295 @@ def _num(v):
return None
def _clamp_power(v):
def int_or_none(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def clamp_power(v):
n = _num(v)
return 0.0 if (n is not None and n < 0) else n
def _wh_to_kwh(v):
def wh_to_kwh(v):
n = _num(v)
return round(n / 1000.0, 2) if n is not None else None
def parse_iso_utc(raw):
"""ISO datetime defaulting to UTC when the string carries no timezone
of its own (this integration's convention for other bare ISO datetime
fields -- see washer.py's drum-clean-log comment). A few boards do
ship a 'Z'/offset suffix (fromisoformat parses that natively since
Python 3.11) -- only fill in UTC when parsing left the result naive,
rather than unconditionally overwriting whatever offset was parsed."""
if not raw:
return None
try:
dt = datetime.fromisoformat(raw)
except ValueError:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
def filter_usage_percent(rep):
"""Filter usage as a percentage of rated capacity. Several families
(AC, air purifier) report `filterUsage` as a raw count in
`filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain
value with a '%' unit would be wrong -- normalize to used/capacity.
Returns None when capacity is missing/zero."""
used = _num(rep.get('x.com.samsung.da.filterUsage'))
cap = _num(rep.get('x.com.samsung.da.filterCapacity'))
if used is None or not cap:
return None
return round(used / cap * 100)
def normalize_temp_unit(raw, default='°F'):
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
`default` for any other/missing value. Shared by fridge.py and oven.py,
both of which read a per-device unit off a `/temperature*` resource
instead of assuming one (see fridge.py's module docstring, issue #7)."""
raw = (raw or '').strip().upper()
if raw.startswith('C'):
return '°C'
if raw.startswith('F'):
return '°F'
return default
def _ml_to_l(v):
n = _num(v)
return round(n / 1000.0, 1) if n is not None else None
def _active_alarm_codes(items):
"""Join active alarm codes; skip retained rows Samsung leaves as Deleted,
and any code ending in '_OFF'.
Laundry boards keep a Deleted ErrorCode row in /alarms/vs/0 after the
condition clears (see WD7000B diagnostics). Surface only live alarms so
HA doesn't stick on a stale ErrorCode.
Samsung pre-populates this array with one row per alarm *type* the board
supports, each carrying its own '<Name>_OFF' placeholder code when that
alarm isn't firing -- confirmed across independent device families
(ErrorCode_OFF, FilterAlarm_OFF, OV_E_OFF, CT_E_OFF, WaterTankFull_OFF,
AC_V_0002_OFF all appear in fixtures with no corresponding active
condition). An alarm that's actually firing instead reports a plain,
unsuffixed code (FilterAlarm, DoorA_Opened, SNSF_Reached) -- issue #166's
AC dump has both a FilterAlarm_OFF placeholder and shows what a live
filter alert looks like: code 'FilterAlarm' (no suffix), state
'Created'. Range-hood previously special-cased only the literal
'ErrorCode_OFF' string in its own stricter helper; this generalizes
the same rule to the whole '_OFF' suffix convention.
"""
if not items or not isinstance(items, list):
return 'none'
codes = [i.get('x.com.samsung.da.code') for i in items if i.get('x.com.samsung.da.code')]
codes = [
i.get('x.com.samsung.da.code')
for i in items
if i.get('x.com.samsung.da.code')
and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted'
and not str(i.get('x.com.samsung.da.code', '')).lower().endswith('_off')
]
return ', '.join(codes) if codes else 'none'
KIDS_LOCK = Capability(
def merge_options_field(cached, new_tokens):
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached
x.com.samsung.da.options[]-style array the same way the device itself
merges them: match by prefix, replace if present, append if not.
Confirmed on real hardware (issue #54) that a write only needs to carry
the changed token(s), not the whole array -- see laundry.option_write /
oven._option_write for the write side. This is the read side of that
same fact: coordinator.async_send_command uses it to keep the
optimistic cache entry for the written href complete (every sibling
option still present) during the write-settle window, since the wire
body it applies straight to the cache no longer carries them."""
merged = list(cached or [])
for token in new_tokens or ():
if not isinstance(token, str) or '_' not in token:
continue
prefix = token.split('_', 1)[0]
replaced = False
for i, o in enumerate(merged):
if isinstance(o, str) and o.startswith(prefix + '_'):
merged[i] = token
replaced = True
if not replaced:
merged.append(token)
return merged
def merge_items_field(cached, new_items):
"""Merge a partial x.com.samsung.da.items[]-style write (matched by
x.com.samsung.da.id) into a cached items array -- the read-side
counterpart of merge_options_field above, for the items[] shape instead
of the packed options[] shape.
Confirmed on hardware that a write only needs to carry the array item
with the changed id plus the field(s) being changed; the device merges
the rest itself (same fact as the options[] case, different array --
see airconditioner._climate_write's vendor temperature write). Fields
within the matched item are merged, not replaced outright, so a
setpoint-only write doesn't wipe current/minimum/maximum/unit from the
optimistic cache entry for the settle window. An id with no match in
`cached` is appended."""
merged = [dict(i) if isinstance(i, dict) else i for i in (cached or [])]
for new_item in new_items or ():
if not isinstance(new_item, dict):
continue
item_id = new_item.get('x.com.samsung.da.id')
for i, existing in enumerate(merged):
if isinstance(existing, dict) and existing.get('x.com.samsung.da.id') == item_id:
merged[i] = {**existing, **new_item}
break
else:
merged.append(new_item)
return merged
# /wm/setinfo/vs/0 -- laundry-family firmware capability flags. Present on
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
# life of a given board, so reading them from the /device/0 seed (no dedicated
# poll_tier) is enough.
_SETINFO_HREF = '/wm/setinfo/vs/0'
_POWER_ON_OFF_FIELD = 'x.com.samsung.da.isModelSettingPowerOnOff'
_WITHOUT_SC_FIELD = 'x.com.samsung.da.isModelSettingWithoutSC'
def model_allows_power_on_off(resources: dict) -> bool:
"""True unless firmware explicitly declares remote power on/off unsupported.
`/wm/setinfo/vs/0`.`isModelSettingPowerOnOff` is `"false"` on many laundry
boards (washers/dryers): `/power/0` and `/power/vs/0` still report state,
but CoAP writes are ignored. Absent setinfo (non-laundry families) keeps
the writable switch -- current behavior.
"""
setinfo = resources.get(_SETINFO_HREF)
if setinfo is None:
return True
flag = setinfo.get(_POWER_ON_OFF_FIELD)
if flag is None:
return True
return str(flag).lower() != 'false'
def model_setting_without_sc(resources: dict) -> bool:
"""True when firmware declares settings writable without Smart Control.
`/wm/setinfo/vs/0`.`isModelSettingWithoutSC` is `"true"` on washers/dryers
that accept temperature/spin/cycle-option writes while remote control is
off. Cycle start/pause/stop still need Smart Control on those boards --
the flag name is settings-specific, not a blanket remote-control bypass.
"""
setinfo = resources.get(_SETINFO_HREF) or {}
return str(setinfo.get(_WITHOUT_SC_FIELD, '')).lower() == 'true'
def _power_switch_exists(rep, resources):
return model_allows_power_on_off(resources)
def _power_sensor_exists(rep, resources):
return not model_allows_power_on_off(resources)
def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
multi-value reading to read (index 0 is the raw measurement on every
family seen so far). Shared by range_hood.AIR_QUALITY and
air_purifier.AIR_QUALITY, which read the same resource shape."""
for item in items or ():
if not isinstance(item, dict):
continue
if item.get('x.com.samsung.da.type') != sensor_type:
continue
values = item.get('x.com.samsung.da.value') or ()
if index < len(values):
try:
return int(values[index])
except (TypeError, ValueError):
return None
return None
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
#
# These three controls exist as both a standard OCF resource (/power/0,
# oic.r.switch.binary, plain boolean 'value') and a Samsung vendor resource
# (/power/vs/0, x.com.samsung.da.power) -- Samsung advertises both as its
# firmware migrates onto the OCF standard model. Prefer the OCF-standard href
# when the device exposes it; the '-vs' href (a string-encoded duplicate for
# these three) binds only when the generic href is absent, via match_fn. Older
# firmware has only the '-vs' resource, so the pair is behaviour-identical to a
# lone '-vs' cap there. See the adding-device-support skill's "OCF-standard vs
# vendor" section for why this is preferred-non-vs-with-fallback, not a blanket
# choice. Every device registry lists both caps of each pair.
POWER_GENERIC = Capability(
href='/power/0',
# Neither href of this pair carried a poll_tier before (issue #56's
# follow-up), so power state only ever refreshed on the once-per-30s
# summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot'
# hrefs get -- the same "signal drives real-time state, but sat in the
# slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above.
poll_tier='warm',
entities=(
# Writable when firmware allows remote power; otherwise a read-only
# binary_sensor with the same key keeps HA state without a dead switch.
SwitchDesc(key='power_switch', field='value',
value_fn=lambda v: bool(v),
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})),
BinarySensorDesc(key='power_switch', field='value',
device_class='power',
value_fn=lambda v: bool(v),
exists_fn=_power_sensor_exists),
),
)
POWER_VS_FALLBACK = Capability(
href='/power/vs/0',
match_fn=lambda rep, resources: '/power/0' not in resources,
poll_tier='warm',
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
value_fn=lambda v: v == 'On',
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
BinarySensorDesc(key='power_switch', field='x.com.samsung.da.power',
device_class='power',
value_fn=lambda v: v == 'On',
exists_fn=_power_sensor_exists),
),
)
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
),
)
KIDS_LOCK_VS_FALLBACK = Capability(
href='/kidslock/vs/0',
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
entities=(
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
name='Child lock', device_class='lock',
device_class='lock',
value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'],
@@ -55,23 +322,60 @@ KIDS_LOCK = Capability(
),
)
REMOTE_CONTROL = Capability(
href='/remotectrl/vs/0',
def remote_control_enabled(resources: dict) -> bool:
"""Single source of truth for the /remotectrl on/off signal, mirroring
REMOTE_CONTROL_GENERIC/_VS_FALLBACK's href/field pair and precedence
below. Used both to render the read-only Smart Control binary_sensor
(via those two descriptors) and, from coordinator.async_send_command,
to block writes outright when remote control is off. Both hrefs are
poll_tier='warm' below so that gate reads recent state (subscribed
when observe is live, subpolled every ~6s otherwise) rather than a
once-per-30s cold summary poll. True (assume enabled) when neither
href is present -- most device types don't report this capability
at all."""
generic = resources.get('/remotectrl/0')
if generic is not None:
return bool(generic.get('value'))
fallback = resources.get('/remotectrl/vs/0')
if fallback is not None:
return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() == 'true'
return True
def remote_control_required_for_write(resources: dict, bound_href: str) -> bool:
"""Whether a write to bound_href should be gated on Smart Control.
When isModelSettingWithoutSC is true, laundry firmware accepts settings
writes (wash temp, spin, course options, buzzer, ...) with remote
control off, but cycle start/pause/stop on /operational/state still
need Smart Control. Absent that flag, keep the historical blanket gate.
"""
if not model_setting_without_sc(resources):
return True
href = bound_href or ''
return href.startswith('/operational/state')
REMOTE_CONTROL_GENERIC = Capability(
href='/remotectrl/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
name='Smart Control', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
BinarySensorDesc(key='remote_control', field='value',
device_class='connectivity',
value_fn=lambda v: bool(v)),
),
)
POWER = Capability(
href='/power/vs/0',
REMOTE_CONTROL_VS_FALLBACK = Capability(
href='/remotectrl/vs/0',
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
poll_tier='warm',
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
name='Power',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (['power', 'vs', '0'], {'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
),
)
@@ -80,20 +384,74 @@ ALARMS = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
name='Alarm code', icon='mdi:alert',
icon='mdi:alert',
entity_category='diagnostic', value_fn=_active_alarm_codes),
),
)
# instantaneousPower is a dead field on DA_WM_-class laundry dumps (washers and
# the issue #14 dryer) and on dishwashers too: the literal sentinel '-500',
# unchanged across off/idle/running. clamp_power floors it to a misleading
# "0 W" that reads as a real idle measurement. Gate power_watts out when the
# sentinel is seen -- but only then, so a device reporting a real value (e.g. a
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
# least one washer model; the exists_fn makes that explicit rather than relying
# on the generic field-presence gate.
_DEAD_INSTANTANEOUS_POWER = '-500'
ENERGY_METER = Capability(
href='/energy/consumption/vs/0',
entities=(
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
# an explicit exists_fn otherwise bypasses it, which would drop the
# entity when /device/0 returns a not-yet-fetched stub. A genuinely
# empty {} rep is NOT a stub -- it's the device's confirmed (if empty)
# answer, so it falls through to the normal field/sentinel checks like
# any populated rep. On a populated rep, hide power only for the dead
# sentinel or an absent field.
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
name='Power', device_class='power', state_class='measurement',
unit='W', value_fn=_clamp_power),
device_class='power', state_class='measurement',
unit='W', value_fn=clamp_power,
exists_fn=lambda rep, resources: is_stub_rep(rep) or (
rep.get('x.com.samsung.da.instantaneousPower')
not in (None, _DEAD_INSTANTANEOUS_POWER))),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
name='Energy', device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=_wh_to_kwh),
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativePower' in rep)),
# cumulativeConsumption is a second, independently-varying running
# total alongside cumulativePower -- some fridges (issue #26) report
# both. Self-gates off where only cumulativePower is present. The
# `is_stub_rep(rep) or` keeps the same stub carve-out as power_watts/
# energy_kwh above -- without it, an exists_fn permanently drops the
# entity if setup happens to land on a not-yet-fetched stub.
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeConsumption' in rep)),
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
# Monthly billing-cycle totals -- the completed prior month and the
# in-progress current month. Not ever-increasing (each resets at
# month boundary), so no state_class.
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.monthlyConsumption' in rep)),
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
),
)
@@ -101,7 +459,7 @@ WATER_METER = Capability(
href='/water/consumption/vs/0',
entities=(
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
name='Water consumption', device_class='water',
device_class='water',
state_class='total_increasing', unit='L', icon='mdi:water',
value_fn=_ml_to_l),
),
@@ -112,9 +470,178 @@ WATER_FILTER = Capability(
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
entities=(
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
name='Filter usage', unit='%', state_class='measurement',
unit='%', state_class='measurement',
icon='mdi:filter'),
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
name='Filter status', icon='mdi:filter-check'),
icon='mdi:filter-check',
device_class='enum', options=('normal', 'wash', 'replace'),
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
)),
),
)
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
# additional level(s) the device offers ('1' meaning just "on" on most
# hardware, but multi-level boards have been reported). Verified cross-family:
# fridge (issue #21) and washer (issue #40) both expose this href.
#
# supportedAiLevel is a single-entry list on most captured hardware, where a
# select would offer only one real choice against an implicit "off" -- shown
# as a switch instead. '0' itself is never in supportedAiLevel but has been
# observed live as the off value of aiLevel, so the select synthesizes it
# back in as an explicit option rather than leaving no way to turn off.
#
# No translation_key: aiLevel's values are plain digit strings, and
# select.py's _display() already renders an untranslated numeric string
# as-is -- there's nothing a catalog entry adds that's worth maintaining
# against an unknown, growing number of future levels.
def _ai_energy_supported_levels(rep):
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
be len()-checked as if it were a list."""
sl = rep.get('supportedAiLevel')
return list(sl) if isinstance(sl, (list, tuple)) else []
def _ai_energy_level_options(resources):
rep = resources.get('/energy/ailevel/vs/0') or {}
return ['0', *_ai_energy_supported_levels(rep)]
def _ai_energy_level_write(p, rep, href=None):
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
def _ai_energy_level_switch_write(p, rep, href=None):
levels = _ai_energy_supported_levels(rep)
on_level = levels[0] if levels else '1'
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
AI_ENERGY_LEVEL = Capability(
href='/energy/ailevel/vs/0',
poll_tier='cold',
entities=(
# No is_stub_rep carve-out on either side, unlike most exists_fn
# gates in this file -- entity creation only ever runs once, against
# whichever snapshot happens to be current the moment platforms are
# set up (see entity._is_included / __init__.py's
# async_config_entry_first_refresh-before-forward-entry-setups
# ordering), while flatten() re-evaluates exists_fn every poll
# against live data. Both descriptors share key='ai_energy_level',
# so if a stub carve-out let one of them win at setup time while the
# other wins once real data lands, flatten() would feed the
# instantiated entity a value shaped for the other platform (e.g. a
# bool into a Select). Requiring real, populated data on both sides
# keeps the entity-creation decision and the live-value decision in
# permanent agreement -- the cost is this entity doesn't appear
# until a reload if the device's very first poll stubs this
# cold-tier href, the same reload already required to fix which
# platform got picked in that case.
SwitchDesc(key='ai_energy_level', field='aiLevel',
icon='mdi:leaf',
entity_category='config',
value_fn=lambda v: v != '0',
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) == 1),
write_fn=_ai_energy_level_switch_write),
SelectDesc(key='ai_energy_level', field='aiLevel',
icon='mdi:leaf',
entity_category='config',
options=_ai_energy_level_options,
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) > 1),
write_fn=_ai_energy_level_write),
),
)
FIRMWARE_UPDATE = Capability(
href='/otninformation/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
),
),
)
SELF_CHECK = Capability(
href='/selfcheck/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
# List of error codes from the last self-check; joined for display.
# Not every fridge reports the field, hence the exists_fn.
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.error' in rep),
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
ButtonDesc(key='selfcheck_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
),
)
# ---------------------------------------------------------------------------
# Cross-family bundles, unpacked into every by_type registry's _build([...])
# call the same way ignored.IGNORED is (*common.UNIVERSAL / *common.POWER).
# discover() only binds a capability whose href is actually present in a
# given device's resource dump, so listing one here for a family that
# doesn't expose the href is a no-op, not a phantom entity -- see the
# adding-device-support skill's coverage-discipline section.
#
# UNIVERSAL holds every capability with no known family that both (a) has
# the href and (b) needs to model it some other way -- broadening one of
# these to a new family is a safe, harmless guess (issue #40's AI energy
# level: 2 of 6 families confirmed, blanket-added everywhere else).
#
# POWER is kept separate -- airconditioner is the one family that opts out
# of it entirely. Canonical reason (see by_type/airconditioner.py and its
# test for pointers back here, not restatements): AC's climate entity
# already owns /power/0 and /power/vs/0 via bare, no-entity Capability
# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/
# POWER_VS_FALLBACK cap on the same href would make _build() raise (a href
# with >1 cap must have every cap discriminated by rt_filter/match_fn, and
# the bare COVERAGE cap has neither). Kids-lock/remote-control don't have
# this conflict -- no AC dump has ever reported those hrefs -- so they stay
# in UNIVERSAL.
#
# Airconditioner also partially opts out of UNIVERSAL itself, not just
# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by
# board generation, so by_type/airconditioner.py excludes just that one
# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`)
# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in
# its place -- every other registry still unpacks UNIVERSAL wholesale.
# ---------------------------------------------------------------------------
UNIVERSAL = (
ALARMS,
ENERGY_METER,
FIRMWARE_UPDATE,
SELF_CHECK,
AI_ENERGY_LEVEL,
KIDS_LOCK_GENERIC,
KIDS_LOCK_VS_FALLBACK,
REMOTE_CONTROL_GENERIC,
REMOTE_CONTROL_VS_FALLBACK,
)
POWER = (
POWER_GENERIC,
POWER_VS_FALLBACK,
)
@@ -0,0 +1,186 @@
"""Read-only capabilities for Samsung cooktops.
The first verified device is an NA9300K-class five-burner gas cooktop. Its
local OCF API reports burner state as strings embedded in the
``x.com.samsung.da.options`` array on ``/mode/vs/0``. Heat-producing controls
are intentionally not exposed: the local write contract is unverified and a
cooktop must not be remotely ignited by an automation.
"""
import re
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
def _option_value(options, prefix):
"""Return the value from the first ``<prefix>_<value>`` option."""
marker = prefix + '_'
for option in options or ():
if isinstance(option, str) and option.startswith(marker):
return option[len(marker):]
return None
def _operation_slots(options) -> tuple[int, ...]:
"""Return the numeric burner slots advertised in an options array."""
slots = set()
for option in options or ():
if not isinstance(option, str):
continue
match = re.match(r'^OperationState(\d+)_', option)
if match:
slots.add(int(match.group(1)))
return tuple(sorted(slots))
def _any_burner_active(options):
"""True when any advertised burner slot is not idle."""
states = [
_option_value(options, f'OperationState{slot}')
for slot in _operation_slots(options)
]
states = [state for state in states if state is not None]
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
def _int_or_none(value):
try:
return int(value)
except (TypeError, ValueError):
return None
COOKTOP_POWER = Capability(
href='/power/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key='power_state',
field='x.com.samsung.da.power',
device_class='power',
icon='mdi:stove',
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
# The verified NA9300K exposes physical slots 0, 1, 3, 4, and 5. Declare a
# generous static superset so variants with other layouts are not silently
# omitted; exists_fn hides every slot the live options array does not report.
# Replace this bound with data-driven entity generation when #31 lands.
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
COOKTOP_MODE = Capability(
href='/mode/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key='any_burner_active',
field='x.com.samsung.da.options',
device_class='running',
icon='mdi:fire',
value_fn=_any_burner_active,
),
*(
SensorDesc(
key=f'burner_{slot}_state',
field='x.com.samsung.da.options',
translation_key='burner_state',
translation_placeholders={'number': str(slot)},
icon='mdi:gas-burner',
value_fn=lambda options, slot=slot: _option_value(
options, f'OperationState{slot}'
),
exists_fn=lambda rep, resources, slot=slot: (
is_stub_rep(rep) or _option_value(
rep.get('x.com.samsung.da.options'),
f'OperationState{slot}',
) is not None
),
)
for slot in _SUPPORTED_OPERATION_SLOTS
),
SensorDesc(
key='main_timer_state',
field='x.com.samsung.da.options',
icon='mdi:timer-outline',
value_fn=lambda options: _option_value(options, 'MainTimerState'),
),
SensorDesc(
key='main_timer_current',
field='x.com.samsung.da.options',
icon='mdi:timer-sand',
enabled_default=False,
value_fn=lambda options: _int_or_none(
_option_value(options, 'MainTimerCurrent')
),
),
),
)
COOKTOP_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key='cloud_connected',
field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
PAIRED_HOOD_STATUS = Capability(
href='/bluetooth/hood/status/vs/0',
poll_tier='hot',
entities=(
BinarySensorDesc(
key='paired_hood_connected',
field='connectionState',
device_class='connectivity',
value_fn=lambda value: str(value).lower() == 'connected',
),
BinarySensorDesc(
key='paired_hood_power',
field='power',
device_class='running',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='paired_hood_fan_speed',
field='fanSpeed',
icon='mdi:fan',
value_fn=_int_or_none,
),
BinarySensorDesc(
key='paired_hood_light',
field='lampState',
device_class='light',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='paired_hood_model',
field='micomModelId',
icon='mdi:information-outline',
entity_category='diagnostic',
enabled_default=False,
),
SensorDesc(
key='paired_hood_firmware',
field='firmwareVersion',
icon='mdi:chip',
entity_category='diagnostic',
enabled_default=False,
),
),
)
@@ -0,0 +1,85 @@
"""Capabilities for the Samsung dehumidifier family (TP1X_DA_AC_DHM-class,
issue #88, model AY18CG7500GED).
Same DA_AC_ board family as the room-AC models in airconditioner.py (shared
power/energy/filter/auto-clean/mute-once resource shapes), but target
humidity -- not temperature -- is this device's primary control, and there
is no climate composite: power, mode, and humidity are exposed as separate
entities rather than folded into one card.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc
from .common import int_or_none
def _first_mode(rep):
"""Representative scalar for the operating-mode select. `modes` is a
single-element list on every dump seen so far, mirroring
airconditioner._first_mode's handling of the same field shape."""
modes = rep.get('x.com.samsung.da.modes')
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='operating_mode', rep_fn=_first_mode,
icon='mdi:tune-variant',
options_field='x.com.samsung.da.supportedModes',
write_fn=lambda p, rep, href=None: (
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
),
)
# Target humidity is this device's primary control (the issue-#88 dump's
# equivalent of a thermostat setpoint). No min/max range field is present in
# any dump seen so far -- native_min/native_max are deliberately left unset
# so the number entity falls back to HA's own 0-100 default, the natural
# bound for a percentage field, rather than a bound guessed from one unit's
# spec sheet (see the adding-device-support skill's "never hard-code the one
# dump's values" section). Step comes live from the device's own `increment`
# field.
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', unit='%', state_class='measurement',
value_fn=int_or_none),
NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity',
device_class='humidity', unit='%',
icon='mdi:water-percent',
entity_category='config',
value_fn=int_or_none,
step_fn=lambda rep: int_or_none(rep.get('increment')) or 1,
write_fn=lambda p, rep, href=None: (
['humidity', 'vs', '0'],
{'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})),
),
)
# ---------------------------------------------------------------------------
# Dehumidifier-scoped coverage: vendor plumbing with no user-actionable state
# or no documented write contract, following the same 'don't guess' rule as
# airconditioner._AC_IGNORED (this is the same DA_AC_ board family). Not in
# the global ignored.IGNORED since some of these hrefs collide with other
# families' schemas.
# ---------------------------------------------------------------------------
_DHM_IGNORED = [
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM)
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/keepnormalstate/vs/0', # internal keep-normal flag
'/personality/presence/vs/0', # presence-personalization plumbing (empty item value)
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/sensors/vs/0', # empty {} on this dump
'/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert)
# Only supportedModes ([Off, Sleep]) is present -- no live "current
# value" field on this dump to confirm the read/write contract, so per
# the 'don't guess' rule this is left unmodeled rather than assumed.
'/mode/convenient/vs/0',
]
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
@@ -1,9 +1,14 @@
"""Capabilities specific to dishwasher appliances (DW9000F-class).
Resources verified against the live device dump at 10.0.0.129.
The /course/vs/0 cycle select and its options-array machinery are shared with
washer and dryer in laundry.py; only the dishwasher-specific options (storm
wash, auto release dry) are read locally here.
"""
from ..capability import Capability
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
from .laundry import bool_option_switch, cycle_select
# ---------------------------------------------------------------------------
# /dishwasher/vs/0 — cycle wash/dry settings
@@ -13,13 +18,13 @@ DISHWASHER_SETTINGS = Capability(
href='/dishwasher/vs/0',
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
name='Sanitize', icon='mdi:bacteria',
icon='mdi:bacteria',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
name='Smart Dry', icon='mdi:heat-wave',
icon='mdi:heat-wave',
options_field='x.com.samsung.da.supportedHeatedDry',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
@@ -28,108 +33,22 @@ DISHWASHER_SETTINGS = Capability(
)
# ---------------------------------------------------------------------------
# /course/vs/0 — cycle selection and course options (RMW on options array)
# /course/vs/0 — cycle selection (shared laundry.cycle_select) plus the
# dishwasher-only StormWashZone / AutoDoorRelease toggles that ride in the
# same options array (shared laundry.bool_option_switch, same options[]
# boolean-toggle contract washer's bubble-soak/pre-wash/intensive switches
# use). Course display names live in translations under
# entity.select.dishwasher_cycle (see laundry.cycle_select).
# ---------------------------------------------------------------------------
# Course IDs are uppercase hex strings matching the Course_XX encoding in
# options[]. Display names are not kept here -- they live in
# strings.json/translations under entity.select.dishwasher_cycle.state.<id,
# lowercased>, same as every other device-enum select in this integration
# (see fridge.py's translation_key entities and select.py's _display()), so
# they can be localized instead of hardcoded to English.
#
# No static fallback list is kept here -- a hardcoded course table would
# show options a given dishwasher model doesn't actually have (or hide ones
# it does). The only trustworthy per-device source is the live
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0. When that's
# absent (e.g. never populated until the app's course-edit screen has been
# opened at least once), the cycle select isn't created at all -- see
# CYCLE_OPTIONS's exists_fn below. (x.com.samsung.da.options' MostUsed_*
# entry was considered as a second fallback source, but its bytes beyond
# the first don't correspond to any confirmed course code on hardware we
# have dumps for, so it isn't trustworthy either -- see washer.py's
# _cycle_options docstring for the byte-level evidence.)
def _parse_edit_course_list(raw):
"""'EditCourseList_0E07908683848D808E8F' -> ['0E', '07', ...]."""
if not isinstance(raw, str) or '_' not in raw:
return []
codes = raw.split('_', 1)[1]
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
def _cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _replace_in_options(options, prefix, new_value):
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
for o in options]
def _cycle_write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p),
}
def _storm_wash_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'StormWashZone', p),
}
def _auto_release_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'AutoDoorRelease', p),
}
CYCLE_OPTIONS = Capability(
href='/course/vs/0',
entities=(
SelectDesc(key='cycle', name='Cycle', icon='mdi:dishwasher',
translation_key='dishwasher_cycle',
options=_cycle_options,
exists_fn=lambda rep, resources: bool(_cycle_options(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'Course'),
write_fn=_cycle_write),
SwitchDesc(key='storm_wash', name='Storm Wash+', icon='mdi:weather-lightning-rainy',
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'StormWashZone') == 'On',
write_fn=_storm_wash_write),
SwitchDesc(key='auto_release_dry', name='Auto release dry', icon='mdi:door-open',
exists_fn=lambda rep, resources: any(
isinstance(o, str) and o.startswith('AutoDoorRelease_')
for o in (rep.get('x.com.samsung.da.options') or [])
),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'AutoDoorRelease') == 'On',
write_fn=_auto_release_write),
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
'StormWashZone'),
bool_option_switch('auto_release_dry', 'mdi:door-open',
'AutoDoorRelease', gate_on_presence=True),
),
)
@@ -142,10 +61,10 @@ DIAGNOSIS = Capability(
poll_tier='cold',
entities=(
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
name='Diagnosis status', icon='mdi:stethoscope',
icon='mdi:stethoscope',
entity_category='diagnostic'),
ButtonDesc(key='diagnosis_start', field='', name='Start diagnosis',
payload='Start', icon='mdi:play-circle-outline',
ButtonDesc(key='diagnosis_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
@@ -157,7 +76,7 @@ OPERATION_ORIGIN = Capability(
poll_tier='cold',
entities=(
SensorDesc(key='operation_origin', field='origin',
name='Last operation source', icon='mdi:remote',
icon='mdi:remote',
entity_category='diagnostic'),
),
)
@@ -1,37 +1,16 @@
"""Capabilities specific to the dryer family (Samsung DV5000T-class).
"""Capabilities specific to the dryer family (Samsung DA_WM_TP1/TP2-class).
Resources derived from the old dryer.py descriptor OBSERVE_PATHS and
flatten() implementation:
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
/st/dryercourse/vs/0 -> DRYER_COURSE (dryer_mode SelectDesc)
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
Dryer-specific controls only. The shared laundry surface -- power/kids-lock/
remote-control fallback pairs, buzzer, energy meter, job-beginning-status, and
the /course/vs/0 cycle select -- lives in laundry.py.
Course table captured 2026-05-29 on a DA_WM_TP2_20_COMMON_DV5000T. Other
dryer models may use a different course table; options=() means HA renders
whatever the device reports, and the write_fn validates against this table.
/washer/vs/0 -> DRYER_SETTINGS (dryLevel, dryTime, dryerType, wrinklePrevent)
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
"""
from ..capability import Capability
from ..entities import SelectDesc, SensorDesc, SwitchDesc
# Course table: hex codes -> human names (Table_03, DV5000T-class).
_COURSE_NAMES = {
0x16: 'Cotton',
0x18: 'Synthetics',
0x19: 'Delicates',
0x1A: 'Wool',
0x1B: 'Bedding',
0x1C: 'Shirts',
0x1D: 'Towels',
0x1E: 'Outdoor',
0x1F: 'Mixed Load',
0x20: 'Iron Dry',
0x23: 'Quick Dry 35',
0x24: 'Cool Air',
0x25: 'Warm Air',
0x27: 'Time Dry',
}
_COURSE_CODE_BY_NAME = {name: code for code, name in _COURSE_NAMES.items()}
from ..entities import SensorDesc, SwitchDesc
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
@@ -40,59 +19,40 @@ def _wrinkle_write(p, rep, href=None):
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
def _course_write(p, rep, href=None):
"""Encode a human course name to the Samsung hex-encoded course string."""
code = _COURSE_CODE_BY_NAME.get(p)
if code is None:
return None
return ['st', 'dryercourse', 'vs', '0'], {
'x.com.samsung.da.st.dryerMode': f'Course_{code:02X}',
}
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
DRYER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:water-percent'),
icon='mdi:water-percent'),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
name='Dry time', icon='mdi:timer'),
icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
name='Dryer type', icon='mdi:tumble-dryer'),
icon='mdi:tumble-dryer'),
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
name='Wrinkle prevent', icon='mdi:iron',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
),
)
# /course/vs/0 -- cycle selection, shared with washer/dishwasher via
# laundry.cycle_select (options read live from /wm/editcourse/vs/0, written as
# an RMW on the options array). Course display names live in translations
# under entity.select.dryer_cycle (Table_03, DV5000-class, captured
# 2026-05-29). Codes 0x21 and 0x4C appear in the issue #14 DV90BB5245AES1
# editCourseList but aren't identified yet -- they render as the raw code
# until named. Codes '01' Normal and '06' Time dry were confirmed on a
# DVE50A8600V/A3 (also Table_03) by selecting each cycle on the physical
# appliance and reading back the raw code from the entity's state (issue
# #80). The /st/dryercourse/vs/0 resource re-encodes the same selected
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
DRYER_COURSE = Capability(
href='/st/dryercourse/vs/0',
poll_tier='warm',
href='/course/vs/0',
entities=(
SelectDesc(key='dryer_mode', field='x.com.samsung.da.st.dryerMode',
name='Dryer mode', icon='mdi:tumble-dryer',
options=tuple(_COURSE_CODE_BY_NAME),
value_fn=lambda v: _COURSE_NAMES.get(
int(v.split('_')[1], 16) if isinstance(v, str) and '_' in v else -1
),
write_fn=_course_write),
),
)
JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.jobBeginingStatus',
name='Job beginning status',
entity_category='diagnostic'),
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
table_href='/st/dryercourse/vs/0'),
),
)
@@ -101,6 +61,6 @@ DRYER_DIAGNOSIS = Capability(
poll_tier='warm',
entities=(
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
name='Diagnosis', entity_category='diagnostic'),
entity_category='diagnostic'),
),
)
@@ -2,9 +2,13 @@
Resources verified against the dump at local-tools/dumps/10.0.0.254.json.
Temperature fields in all /temperature/* resources are in Fahrenheit on this
model. Setpoints are NumberDesc with direct-write write_fn — generic caps
derive the CoAP PUT path from href at write time.
Temperature unit is read live from each resource, not assumed: the RF9000B
dump reports Fahrenheit ("units": "F" / "x.com.samsung.da.unit": "Fahrenheit"),
but a TP1X_REF_21K dump (issue #7) reports the same fields in Celsius for the
exact same resources — the device tells you which one it is, it's just never
been read before. See `_temp_unit`/`_temp_item_unit` below. Setpoints are
NumberDesc with direct-write write_fn — generic caps derive the CoAP PUT path
from href at write time.
Multi-instance note: the two door resources (/door/cooler/0 and
/door/freezer/0) and the two ice-maker resources (/icemaker/one/vs/0 and
@@ -15,12 +19,13 @@ import datetime
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc, TimeDesc,
)
from .common import normalize_temp_unit
# Display names for the beverage zone, flex zone, ice type, and
# ice-making-status enums below live in strings.json / translations/en.json,
# ice-making-status enums below live in translations/en.json,
# keyed by the lowercased raw device value — select.py and SensorDesc.options
# normalize to lowercase for HA's translation lookup and map back to this
# original casing before writing to the device.
@@ -33,6 +38,12 @@ def _int(v):
return None
def _temp_unit(rep):
"""'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to
°F (this module's original assumption) if the device omits the field."""
return normalize_temp_unit(rep.get('units'))
# ---------------------------------------------------------------------------
# Temperature (generic — covers /temperature/current/* and /temperature/desired/*)
# ---------------------------------------------------------------------------
@@ -44,26 +55,87 @@ TEMP_CURRENT_GENERIC = Capability(
poll_tier='warm',
entities=(
SensorDesc(key='temperature', field='temperature',
name=None, icon='mdi:thermometer',
device_class='temperature', unit='°F',
translation_key='instance_temperature',
use_instance_name=True, icon='mdi:thermometer',
device_class='temperature', unit_fn=_temp_unit,
state_class='measurement'),
),
)
TEMP_SETPOINT_GENERIC = Capability(
def _temp_setpoint_write(p, rep, href=None, resources=None):
"""Write temperature — prefer vendor /temperatures/vs/0 when available,
fall back to direct OCF /temperature/desired/ write otherwise.
Samsung fridges expose both OCF-standard /temperature/desired/* and vendor
/temperatures/vs/0. On some models only the vendor path commits the change;
on others both work. Using the vendor path when present is always correct.
Item IDs follow the Samsung convention: "0" = Freezer, "1" = Fridge/Cooler.
"""
if not href:
return None
if resources and '/temperatures/vs/0' in resources:
if '/cooler/' in href:
item_id = '1'
elif '/freezer/' in href:
item_id = '0'
else:
return None
return (
['temperatures', 'vs', '0'],
{'x.com.samsung.da.items': [
{'x.com.samsung.da.id': item_id,
'x.com.samsung.da.desired': str(int(round(float(p))))}
]}
)
return (
[s for s in href.strip('/').split('/') if s],
{'temperature': int(round(float(p)))}
)
TEMP_SETPOINT = Capability(
href=None,
href_prefix='/temperature/desired/',
strip_prefix_in_key=True,
poll_tier='warm',
entities=(
NumberDesc(key='setpoint', field='temperature',
name=None, device_class='temperature', unit='°F',
translation_key='instance_setpoint',
use_instance_name=True, device_class='temperature', unit_fn=_temp_unit,
native_min=-20.0, native_max=50.0,
range_field='range', entity_category='config',
write_fn=lambda p, rep, href=None: (
[s for s in href.strip('/').split('/') if s],
{'temperature': int(round(float(p)))}
) if href else None),
write_fn=_temp_setpoint_write),
),
)
# ---------------------------------------------------------------------------
# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only")
# fridges report no /temperature/current|desired/* pair at all (this href
# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/'
# or '/temperature/desired/' prefixes), only this one vendor resource that
# bundles the live desired value together with the *specific* values the
# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue
# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid
# setpoints on this model -- so a NumberDesc with a min/max/step would let a
# user pick an unsupported value; modeled as a select reading its own live
# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above.
# ---------------------------------------------------------------------------
def _definite_cooler_write(p, rep, href=None):
return (['temperature', 'definite', 'cooler', 'vs', '0'],
{'x.com.samsung.da.definite.desired': p})
DEFINITE_TEMPERATURE_COOLER = Capability(
href='/temperature/definite/cooler/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='cooler_temperature_setpoint',
field='x.com.samsung.da.definite.desired',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.definite.supportedList',
write_fn=_definite_cooler_write),
),
)
@@ -76,12 +148,12 @@ ICEMAKER_NIGHTTIME = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='ice_night_mode', field='ice.night.status',
name='Nighttime ice quiet mode', icon='mdi:weather-night',
icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['icemaker', 'nighttime', 'vs', '0'],
{'ice.night.status': 'On' if p else 'Off'})),
{'ice.night.status': 'On' if p == 'On' else 'Off'})),
),
)
@@ -89,6 +161,13 @@ ICEMAKER_NIGHTTIME = Capability(
# Icemaker (generic — covers /icemaker/one/vs/0, /icemaker/two/vs/0)
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
#
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") -- read via name_field, reaching the translated name as the
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two
# ice makers are independent on/off toggles that can both be enabled at once
# (issue #27), so they stay separate entities rather than a single ice-type
# select, but users still want them labeled with the device's own names.
# ---------------------------------------------------------------------------
def _icemaker_write(field):
@@ -101,21 +180,23 @@ ICEMAKER_GENERIC = Capability(
href=None,
href_prefix='/icemaker/',
match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep,
name_field='x.com.samsung.da.iceMaker.name',
poll_tier='warm',
entities=(
SensorDesc(key='making_status',
field='x.com.samsung.da.iceMaker.iceMakingStatus',
name=None, icon='mdi:cube-outline',
use_instance_name=True, icon='mdi:cube-outline',
device_class='enum',
options=('icestatus_stop', 'icestatus_run'),
translation_key='ice_making_status',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state',
name=None, icon='mdi:cube-outline',
translation_key='instance_enabled',
use_instance_name=True, icon='mdi:cube-outline',
value_fn=lambda v: v == 'On',
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired',
name=None, icon='mdi:cube-outline',
use_instance_name=True, icon='mdi:cube-outline',
translation_key='ice_type',
entity_category='config',
options_field='x.com.samsung.da.iceType.supported',
@@ -133,7 +214,7 @@ DOOR_ALERT = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='door_alert', field='alert.door',
name='Door alarm', icon='mdi:bell-alert',
icon='mdi:bell-alert',
translation_key='door_alert',
entity_category='config',
options_field='supportedAlert.door',
@@ -150,7 +231,7 @@ DOOR_ALERT = Capability(
def _status_lock_write(field):
return lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{field: 'On' if p else 'Off'}
{field: 'On' if p == 'On' else 'Off'}
)
@@ -159,12 +240,12 @@ STATUS_LOCK = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='auto_door_opener', field='x.com.samsung.da.ado.devicecontrol',
name='Auto door opener', icon='mdi:door-open',
icon='mdi:door-open',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')),
SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound',
name='Sound', icon='mdi:volume-high',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_status_lock_write('x.com.samsung.da.device.sound')),
@@ -172,12 +253,16 @@ STATUS_LOCK = Capability(
)
# ---------------------------------------------------------------------------
# Defrost delay
# Defrost delay / active-defrost status
#
# /defrost/delay/vs/0 is the writable toggle. /defrost/block/vs/0 just
# reports whether the defrost cycle is currently blocked as a result (a
# derived status, not an independent control) — exposed as a read-only
# diagnostic binary sensor.
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
# defrost. /defrost/block/vs/0 is an unrelated, independently-varying
# status: despite its "block" naming (originally assumed to mean "defrost
# is being withheld"), live dumps confirm DEFROST_BLOCK_ON means the
# defrost cycle is *actively running* right now, seen with defrost_delay
# off -- i.e. "block" refers to the evaporator/coil block being defrosted,
# not a blocking/prevention state. Exposed as a read-only diagnostic
# binary sensor.
# ---------------------------------------------------------------------------
DEFROST_DELAY = Capability(
@@ -185,48 +270,34 @@ DEFROST_DELAY = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost',
name='Defrost delay', icon='mdi:snowflake-off',
icon='mdi:snowflake-off',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['defrost', 'delay', 'vs', '0'],
{'x.com.samsung.da.delayDefrost': 'On' if p else 'Off'})),
{'x.com.samsung.da.delayDefrost': 'On' if p == 'On' else 'Off'})),
),
)
# OCF-native boolean mirror of DEFROST_DELAY. The captured TP1X_REF_21K
# firmware publishes the same state on both hrefs, but only the vendor resource
# above has a confirmed write contract. Bind the native mirror without another
# entity so discovery records it as an intentional duplicate.
DEFROST_DELAY_NATIVE_DUPLICATE = Capability(
href='/defrost/delay/0',
)
DEFROST_BLOCK_STATUS = Capability(
href='/defrost/block/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='defrost_blocked', field='x.com.samsung.da.modes',
name='Defrost blocked', icon='mdi:snowflake-off',
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
icon='mdi:snowflake-melt',
entity_category='diagnostic',
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
),
)
# ---------------------------------------------------------------------------
# Self-check diagnostic
# ---------------------------------------------------------------------------
SELF_CHECK = Capability(
href='/selfcheck/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
name='Self-check status', icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
name='Self-check result', icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
payload='Start', icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
),
)
# ---------------------------------------------------------------------------
# Refrigeration modes (rapid cooling)
# ---------------------------------------------------------------------------
@@ -244,12 +315,12 @@ REFRIGERATION = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge',
name='Rapid fridge', icon='mdi:fridge-industrial',
icon='mdi:fridge-industrial',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')),
SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing',
name='Rapid freezing', icon='mdi:snowflake',
icon='mdi:snowflake',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')),
@@ -271,7 +342,7 @@ AUTOFILL = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='autofill', field='x.com.samsung.da.autofill',
name='Autofill pitcher', icon='mdi:cup-water',
icon='mdi:cup-water',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_autofill_write),
@@ -287,12 +358,12 @@ WELCOME_LIGHTING = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='welcome_lighting', field='status',
name='Welcome lighting', icon='mdi:motion-sensor',
icon='mdi:motion-sensor',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['proximity', 'vs', '0'],
{'status': 'On' if p else 'Off'})),
{'status': 'On' if p == 'On' else 'Off'})),
),
)
@@ -378,15 +449,15 @@ CABINET_LIGHT_ENHANCED = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='day_brightness', field='level.brightness.daytime',
name='Cabinet brightness', icon='mdi:brightness-5',
translation_key='brightness_level',
icon='mdi:brightness-5',
translation_key='day_brightness',
entity_category='config',
options=_NIGHT_BRIGHTNESS_OPTIONS,
write_fn=lambda p, rep, href=None: (
['cabinet', 'light', 'enhanced', 'vs', '0'],
{'level.brightness.daytime': p})),
SelectDesc(key='brightness_level', field='level.brightness.nighttime',
name='Night brightness', icon='mdi:brightness-4',
icon='mdi:brightness-4',
translation_key='brightness_level',
entity_category='config',
options=_NIGHT_BRIGHTNESS_OPTIONS,
@@ -394,12 +465,12 @@ CABINET_LIGHT_ENHANCED = Capability(
['cabinet', 'light', 'enhanced', 'vs', '0'],
{'level.brightness.nighttime': p})),
TimeDesc(key='night_start', field='',
name='Night light start', icon='mdi:clock-start',
icon='mdi:clock-start',
entity_category='config',
rep_fn=_night_start_value,
write_fn=_night_start_write),
TimeDesc(key='night_end', field='',
name='Night light end', icon='mdi:clock-end',
icon='mdi:clock-end',
entity_category='config',
rep_fn=_night_end_value,
write_fn=_night_end_write),
@@ -423,16 +494,16 @@ CABINET_LIGHT = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl',
name='Cabinet light', icon='mdi:fridge-outline',
icon='mdi:fridge-outline',
value_fn=lambda v: v == 'On',
write_fn=_cabinet_light_write),
SwitchDesc(key='cabinet_light_dim', field='light.dimming.status',
name='Brighten gradually', icon='mdi:brightness-auto',
icon='mdi:brightness-auto',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['cabinet', 'light', 'total', 'vs', '0'],
{'light.dimming.status': 'On' if p else 'Off'})),
{'light.dimming.status': 'On' if p == 'On' else 'Off'})),
),
)
@@ -451,7 +522,7 @@ SABBATH = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode',
name='Sabbath mode', icon='mdi:hands-pray',
icon='mdi:hands-pray',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_sabbath_write),
@@ -471,7 +542,7 @@ BEVERAGE_ZONE = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
name='Beverage zone mode', icon='mdi:glass-wine',
icon='mdi:glass-wine',
translation_key='beverage_zone_mode',
entity_category='config',
options_field='roomSupportedModes',
@@ -479,17 +550,64 @@ BEVERAGE_ZONE = Capability(
),
)
# ---------------------------------------------------------------------------
# Pantry / Cool Select Zone -- a convertible compartment toggled between
# wine/deli/drinks temperature presets (issue #20). Same shape as
# BEVERAGE_ZONE (a controllable named sub-zone with a mode + supported-modes
# list) but a distinct resource/field set -- x.com.samsung.da.mode /
# x.com.samsung.da.supportedOptions on /status/pantry/one/vs/0, rather than
# roomDesiredMode/roomSupportedModes on /specialzone/one/vs/0. Only a "one"
# instance has been seen; not generalized to a pattern cap until a second
# instance turns up.
# ---------------------------------------------------------------------------
def _pantry_write(p, rep, href=None):
return ['status', 'pantry', 'one', 'vs', '0'], {'x.com.samsung.da.mode': p}
PANTRY_ZONE = Capability(
href='/status/pantry/one/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode',
icon='mdi:glass-wine',
translation_key='pantry_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
write_fn=_pantry_write),
),
)
# ---------------------------------------------------------------------------
# Flex zone (convertible drawer — /mode/vs/0 on RF9000-class fridges)
#
# x.com.samsung.da.modes holds multiple orthogonal flags in one list.
# The flex zone entry is identified by the CV_TTYPE_RF9000A_ prefix.
# x.com.samsung.da.modes holds multiple orthogonal flags in one list; the
# flex-zone entry is whichever item is also a member of supportedOptions --
# the other flags (WATERFILTER_*, DEFROST_BLOCK_*, the CVN_*_ZONE marker)
# never appear there. The prefix on that item varies by fridge family
# (CV_TTYPE_RF9000A_ on RF9000-class, CV_FDR_ on Bespoke-class -- issue #27 /
# #26, where the old CV_TTYPE_RF9000A_-only match left this entity bound but
# stuck on None), so match by list membership instead of a hardcoded prefix.
# Write replaces only that item; other flags are preserved.
# ---------------------------------------------------------------------------
def _flex_zone_supported(rep):
return set(rep.get('x.com.samsung.da.supportedOptions') or ())
def _flex_zone_current(rep):
# Every dump seen has at most one modes/supportedOptions overlap, so
# "first match" and "strip all matches" (in the write below) agree. If a
# future device ever reports two, this reads the first and the write
# would drop both -- revisit if that turns up.
modes = rep.get('x.com.samsung.da.modes') or []
supported = _flex_zone_supported(rep)
return next((m for m in modes if m in supported), None)
def _flex_zone_write(p, rep, href=None):
modes = list(rep.get('x.com.samsung.da.modes') or [])
modes = [m for m in modes if not m.startswith('CV_TTYPE_RF9000A_')]
supported = _flex_zone_supported(rep)
modes = [m for m in (rep.get('x.com.samsung.da.modes') or []) if m not in supported]
modes.append(p)
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes}
@@ -499,51 +617,150 @@ FLEX_ZONE = Capability(
poll_tier='warm',
entities=(
SelectDesc(key='flex_zone_mode',
field='x.com.samsung.da.modes',
name='Flex zone mode', icon='mdi:thermostat',
icon='mdi:thermostat',
translation_key='flex_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportedOptions',
exists_fn=lambda rep, resources: bool(
rep.get('x.com.samsung.da.supportedOptions')),
value_fn=lambda modes: next(
(m for m in (modes or [])
if m.startswith('CV_TTYPE_RF9000A_')), None),
# A nonempty supportedOptions alone isn't sufficient: the
# kimchi-refrigerator family (issue #26) also populates
# /mode/vs/0's modes/supportedOptions with real data, but
# its tokens carry a "_[n]:[n]" parameter suffix on
# supportedOptions that modes never repeats, so no item
# ever overlaps -- the RF9000/Bespoke-class overlap this
# capability was built for never happens there. Require an
# actual resolvable value instead of just a populated
# list, so this stays absent on that family rather than
# showing a select permanently stuck on "unknown".
exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
rep_fn=_flex_zone_current,
write_fn=_flex_zone_write),
),
)
# ---------------------------------------------------------------------------
# Firmware update
# ---------------------------------------------------------------------------
FIRMWARE_UPDATE = Capability(
href='/otninformation/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
name='Firmware update available',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
),
),
)
# ---------------------------------------------------------------------------
# Generic door pattern capability (href=None — use as pattern_cap only)
# ---------------------------------------------------------------------------
def _door_open_state(rep):
"""Most /door/* resources report bare `openState`, but the
ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83)
reports the vendor-prefixed `x.com.samsung.da.openState` instead. This
capability still binds either way (href_prefix match doesn't care about
field names), but a plain `field=` lookup against the wrong key means
the entity exists and is permanently unavailable -- check both."""
v = rep.get('openState')
if v is None:
v = rep.get('x.com.samsung.da.openState')
return v == 'Open'
DOOR_GENERIC = Capability(
href=None,
href_prefix='/door/',
poll_tier='hot',
entities=(
BinarySensorDesc(key='open', field='openState',
name=None, device_class='door',
value_fn=lambda v: v == 'Open'),
BinarySensorDesc(key='open', rep_fn=_door_open_state,
translation_key='instance_open',
use_instance_name=True, device_class='door'),
),
)
# ---------------------------------------------------------------------------
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi
# units, issue #26) -- top/middle/bottom each report their own storage mode
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, all three in
# an identical shape; modeled as a pattern capability the same way
# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment
# key and {instance_name} from the href's top/middle/bottom segment. Only
# the top compartment's door has been seen reported separately (kimchidoors);
# middle/bottom apparently have no contact switch of their own, so that's
# its own narrower pattern cap rather than assumed universal.
#
# The same state is also mirrored -- packed into single tokens like
# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with
# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads
# for RF9000-class fridges. /status/kimchi/<slot>/vs/0's plain currentMode/
# supportMode fields are unpacked and self-describing, so that's what this
# binds to instead.
#
# Write path is unconfirmed (no live write against a real unit) -- same
# "write the same field back to the entity's own href" convention as
# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
#
# translations/en.json's kimchi_zone_mode state labels were translated
# directly from the reporter's own (Korean-language) SmartThings app
# screenshots, not guessed from the codes or from their English paraphrase.
# Cross-checking the screenshots against supportMode confirms the on-screen
# option order matches the array order everywhere it's verifiable: the top
# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at
# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the
# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry
# ripening list, and 4-entry custom-storage list each line up 1:1 with their
# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a
# colder or warmer preset around the NORMAL setpoint) everywhere that suffix
# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants,
# which weren't directly screenshotted but share the same NORMAL/COLD/WARM
# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy")
# and BUY (구입, "purchased") are also confirmed exact matches, not
# abbreviation guesses.
# ---------------------------------------------------------------------------
def _kimchi_mode_write(p, rep, href=None):
if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()):
return None
return [s for s in href.strip('/').split('/') if s], {
'x.com.samsung.da.currentMode': p,
}
KIMCHI_ZONE = Capability(
href=None,
href_prefix='/status/kimchi/',
strip_prefix_in_key=True,
poll_tier='warm',
entities=(
SelectDesc(key='mode', field='x.com.samsung.da.currentMode',
use_instance_name=True, icon='mdi:fridge-outline',
translation_key='kimchi_zone_mode',
entity_category='config',
options_field='x.com.samsung.da.supportMode',
write_fn=_kimchi_mode_write),
SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus',
use_instance_name=True, icon='mdi:progress-clock',
translation_key='kimchi_ripening_status',
entity_category='diagnostic'),
SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime',
use_instance_name=True, icon='mdi:timer-sand',
translation_key='kimchi_ripening_remaining',
entity_category='diagnostic',
# No dump has this nonzero (ripeStatus is always "Off" so
# far) -- device-reported unit unconfirmed, so this stays
# a bare number rather than asserting minutes or hours.
value_fn=_int),
SensorDesc(key='rack_count', field='x.com.samsung.da.rackCount',
use_instance_name=True, icon='mdi:tray-full',
translation_key='kimchi_rack_count',
entity_category='diagnostic', enabled_default=False,
value_fn=_int),
),
)
KIMCHI_DOOR_GENERIC = Capability(
href=None,
href_prefix='/kimchidoors/',
strip_prefix_in_key=True,
poll_tier='hot',
entities=(
# Not deduped against DOORS_FALLBACK below: on the one reporter
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
# /doors/vs/0 aggregate carries a single generic item (id "4", no
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see)
# that doesn't share this compartment's "top" instance numbering --
# a distinct main-cabinet door, not this kimchi drawer's own contact
# switch reported twice.
BinarySensorDesc(key='open', rep_fn=_door_open_state,
translation_key='instance_open',
use_instance_name=True, device_class='door'),
),
)
@@ -571,7 +788,7 @@ DOORS_FALLBACK = Capability(
poll_tier='hot',
entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door',
device_class='door',
value_fn=lambda items: any(
i.get('x.com.samsung.da.openState') == 'Open'
for i in (items or []))),
@@ -590,18 +807,29 @@ def _temp_item_value(items, keyword):
return None
def _temp_item_unit(items, keyword):
for item in (items or []):
if keyword.lower() in (item.get('x.com.samsung.da.description') or '').lower():
return normalize_temp_unit(item.get('x.com.samsung.da.unit'))
return '°F'
TEMPERATURES_FALLBACK = Capability(
href='/temperatures/vs/0',
match_fn=lambda rep, resources: not _any_temperature_generic(resources),
poll_tier='warm',
entities=(
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
name='Freezer temperature', icon='mdi:thermometer',
device_class='temperature', unit='°F', state_class='measurement',
icon='mdi:thermometer',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: _temp_item_unit(
rep.get('x.com.samsung.da.items'), 'Freezer'),
value_fn=lambda items: _temp_item_value(items, 'Freezer')),
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
name='Fridge temperature', icon='mdi:thermometer',
device_class='temperature', unit='°F', state_class='measurement',
icon='mdi:thermometer',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: _temp_item_unit(
rep.get('x.com.samsung.da.items'), 'Fridge'),
value_fn=lambda items: _temp_item_value(items, 'Fridge')),
),
)
@@ -621,10 +849,49 @@ ICEMAKER_STATUS_FALLBACK = Capability(
poll_tier='warm',
entities=(
SwitchDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker',
name='Ice maker', icon='mdi:cube-outline',
icon='mdi:cube-outline',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['icemaker', 'status', 'vs', '0'],
{'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})),
{'x.com.samsung.da.iceMaker': 'On' if p == 'On' else 'Off'})),
),
)
# OCF-native aggregate mirror of ICEMAKER_STATUS_FALLBACK. On the captured
# TP1X_REF_21K it duplicates both the vendor aggregate and the richer per-unit
# /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so
# keep the proven per-unit/vendor controls and bind this as a duplicate only.
ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability(
href='/icemaker/status/0',
)
# OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out
# in this section: its three fields duplicate two *different* richer
# hrefs (REFRIGERATION's rapidFridge/rapidFreezing and
# DEFROST_BLOCK_STATUS's defrost_active), each absent independently, so a
# single capability-level match_fn can't express it. Gated per-entity
# (exists_fn) instead: rapid_fridge/rapid_freezing back off only when
# REFRIGERATION's href is present; defrost_active only when
# DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so
# these are read-only, unlike REFRIGERATION's switches.
REFRIGERATION_FALLBACK = Capability(
href='/refrigeration/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='defrost_active', field='defrost',
icon='mdi:snowflake-melt',
entity_category='diagnostic',
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources),
BinarySensorDesc(key='rapid_fridge', field='rapidCool',
icon='mdi:fridge-industrial',
entity_category='config',
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
BinarySensorDesc(key='rapid_freezing', field='rapidFreeze',
icon='mdi:snowflake',
entity_category='config',
value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
),
)
@@ -53,6 +53,10 @@ IGNORED: list[Capability] = [
Capability(href='/setting/vs/0'), # supported/selected UI language
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
# Resource-monitoring poll-interval config (a bare minPeriod in
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
# plumbing, not appliance state.
Capability(href='/rm/control/vs/0'),
# Demand Response Load Control — utility-company grid signals; requires
# cloud registration with a utility program we don't support locally.
@@ -85,10 +89,10 @@ IGNORED: list[Capability] = [
# Warranty/service-plan enrollment status — every field reads "Unknown"
# on hardware not enrolled in a Samsung Care+ style program.
Capability(href='/dginformation/vs/0'),
# AI energy-saving level; only one value ('1') is ever in
# supportedAiLevel on hardware seen so far, so there's no real choice
# to expose. Revisit if a device surfaces more than one supported level.
Capability(href='/energy/ailevel/vs/0'),
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
# expose yet. Revisit if a device surfaces it toggled on.
Capability(href='/mode/0'),
# Opaque integer with no supportedModes/options list to interpret it
# against — meaning unclear from the raw resource alone.
Capability(href='/runningmode/vs/0'),
@@ -103,6 +107,15 @@ IGNORED: list[Capability] = [
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
# is literally "Table_02_Course_<same hex code>").
Capability(href='/st/washercourse/vs/0'),
# Dryer counterpart of the above: re-encoding of the course already
# exposed by dryer.DRYER_COURSE at /course/vs/0
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
Capability(href='/st/dryercourse/vs/0'),
# AirDresser counterpart of the above (issue #157): read only for its
# courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no
# entity of its own -- same "no entity, just the table id" role as
# /st/washercourse/vs/0 and /st/dryercourse/vs/0.
Capability(href='/st/airdressercourse/vs/0'),
# Empty on every washer dump seen so far.
Capability(href='/wm/welcomemsg/vs/0'),
# User-saved custom course slots (F1-FA). No controllable/observable
@@ -127,4 +140,12 @@ IGNORED: list[Capability] = [
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href='/operational/state/0'),
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and
# a textData.menu string -- every field empty/zero on the only dump
# seen so far (device idle, no guided-cooking program active). Same
# "don't guess" treatment as the microwave family's /recipe/cook/vs/0.
# Revisit if a dump with an active recipe surfaces.
Capability(href='/cooktop/recipe/status/vs/0'),
]
@@ -1,13 +1,29 @@
"""Capabilities specific to the laundry-family appliances (dishwasher, dryer).
"""Capabilities shared across the laundry family (washer, dryer, dishwasher).
Resources verified against the DW9000F-class dump at 10.0.0.129.
Note: door-LED keys use NO `x.com.samsung.da.` prefix — `setBrightness` and
`setNightLight` — preserved exactly as they appear in the OCF resource rep.
Anything here is bound by more than one laundry registry, so it lives in one
place instead of being copied per family. Device-type-specific controls (wash
temperature, dry level, dishwasher storm-wash, etc.) stay in washer.py /
dryer.py / dishwasher.py; only the genuinely shared laundry surface is here.
Generic OCF controls that aren't laundry-specific (power, kids-lock, remote
control, energy meter) live in common.py, not here.
Resource hrefs seen across laundry dumps:
/doorled/light/vs/0 -> DOOR_LED (door LED brightness / night light)
/settings/sound/*/vs/0-> SOUND_MODE / SOUND_VOLUME
/buzzersound/vs/0 -> BUZZER_SOUND (buzzer + optional finish chime)
/course/vs/0 -> the cycle select + per-family course options
/wm/editcourse/vs/0 -> live editCourseList that drives the cycle options
/wm/jobbeginingstatus/vs/0 -> JOB_BEGINNING_STATUS
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
"""
from datetime import time as dt_time
from ...catalog import has_entity_translation
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SwitchDesc, TimeDesc
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
_LED_LEVELS = ('Low', 'High')
_SOUND_MODES = ('voice', 'tone', 'mute')
@@ -45,30 +61,30 @@ DOOR_LED = Capability(
href='/doorled/light/vs/0',
entities=(
SelectDesc(key='led_brightness', field='setBrightness',
name='Door LED brightness', icon='mdi:brightness-6',
icon='mdi:brightness-6',
entity_category='config',
options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight',
name='Door LED night light', icon='mdi:weather-night',
icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
name='Door LED night brightness', icon='mdi:brightness-4',
icon='mdi:brightness-4',
entity_category='config',
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightBrightness': p})),
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
name='Door LED night start', icon='mdi:clock-start',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
name='Door LED night end', icon='mdi:clock-end',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
@@ -81,7 +97,7 @@ SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
entities=(
SelectDesc(key='sound_mode', field='mode',
name='Sound mode', icon='mdi:volume-high',
icon='mdi:volume-high',
entity_category='config',
options=_SOUND_MODES, write_fn=_sound_mode_write),
),
@@ -91,7 +107,7 @@ SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
entities=(
NumberDesc(key='sound_volume', field='level',
name='Sound volume', icon='mdi:volume-medium',
icon='mdi:volume-medium',
entity_category='config',
native_min=0, native_max=15, step=5,
value_fn=lambda v: int(v) if v is not None else None,
@@ -100,3 +116,289 @@ SOUND_VOLUME = Capability(
{'level': str(int(p))})),
),
)
# ---------------------------------------------------------------------------
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource. Seen on
# washers and DA_WM_TP1 dryers; the dryer dump carries only setBuzzerSound
# (no supportedFinishSound), so finish_sound self-gates off there.
# ---------------------------------------------------------------------------
BUZZER_SOUND = Capability(
href='/buzzersound/vs/0',
entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedBuzzerSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
SelectDesc(key='finish_sound', field='setFinishSound',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
options_field='supportedFinishSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
),
)
# ---------------------------------------------------------------------------
# Cycle selection over /course/vs/0.
#
# The selected course and every other user-tunable option ride in the
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens.
# Confirmed on real hardware (issue #54): a write only needs to carry the one
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
# the device matches by prefix, evicts the stale token, and merges the result
# into the array itself. No read-modify-write of the whole array needed (see
# option_write). The set of *selectable* courses is not hardcoded -- it's read
# live from
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
# never show a course a given model doesn't have or hide one it does. Course
# codes are uppercase hex; display names live in translations under
# entity.select.<translation_key>.state.<id lowercased> so they can be
# localized -- every device-enum select in this integration works this way.
# washer.py's course comment has the byte-level evidence for why the options[]
# MostUsed_* entry is *not* a trustworthy second source.
#
# Some boards populate /wm/editcourse/vs/0 without ever filling in
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
# the same list from /course/vs/0's own supportedOptions in that case; see
# _course_codes_from_supported_options for the byte-level evidence.
#
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
# expose the same /course/vs/0 options contract.
# ---------------------------------------------------------------------------
def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
def parse_edit_course_list(raw):
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
if not isinstance(raw, str) or '_' not in raw:
return []
return hex_pairs(raw.split('_', 1)[1])
def cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
if codes:
return codes
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
def option_value(options, prefix):
"""Find `<prefix>_<value>` in the options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _course_codes_from_supported_options(course_rep):
"""Fallback for an empty/missing editCourseList: derive the selectable
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
supportedOptions is a 1-hex-nibble header followed by one fixed-width
record per selectable course, self-indexed rather than positional --
the first byte of every record is that course's own hex code, just in
the firmware's own internal order, not editCourseList's. Confirmed
against six independent real-world washer/dryer/dishwasher dumps: every
one divides evenly into `header + N * K bytes` with fully unique first
bytes across all N records, at the record's true byte width. (What the
rest of each record encodes is still unconfirmed -- this only uses the
course-code byte.)
Two guards, deliberately conservative rather than guessing further: the
derived codes must (a) all be distinct -- a real course table, not
noise -- and (b) include whatever course is currently selected
(x.com.samsung.da.options' Course_<code> token), which must always be a
member of its own device's valid list. If no split satisfies both, this
returns [] rather than guess.
Among splits that satisfy both, the *smallest* passing K wins, rather
than requiring a single unambiguous one -- more than one K reliably
does pass on real data (e.g. the shipped dishwasher fixture: true
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
7 -- position 0 always lands on the same real course code regardless
of K, which is enough on its own to satisfy the current-course guard
for several unrelated splits). Smallest-K-wins is a heuristic, not a
proof: it matches the confirmed answer on every one of six independent
real-world dumps this was checked against, but a coincidentally
unique, current-course-inclusive *smaller* K is not mathematically
impossible on some future device, and would be picked silently. Not
guarded against further here, since course tables are typically large
enough (double digits) that colliding by chance on both checks is
unlikely, and no device seen so far actually needs it.
"""
raw = course_rep.get('x.com.samsung.da.supportedOptions')
hexstr = raw[0] if isinstance(raw, list) and raw else raw
if not isinstance(hexstr, str) or len(hexstr) < 3:
return []
body = hexstr[1:]
if len(body) % 2:
return []
total_bytes = len(body) // 2
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
for k in range(1, total_bytes + 1):
if total_bytes % k:
continue
n = total_bytes // k
if n < 2:
continue
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
if len(set(firsts)) != n:
continue
if current is not None and current not in firsts:
continue
return firsts
return []
def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment
above cycle_options for why this doesn't read/rewrite the whole array."""
return [f'{prefix}_{new_value}']
def cycle_write(p, rep, href=None):
if not rep.get('x.com.samsung.da.options'):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Course', p),
}
def _table_id(resources, table_href):
rep = resources.get(table_href) or {}
return rep.get('x.com.samsung.da.st.courseTable')
def cycle_select(*, translation_key, icon, table_href=None):
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
The option list, current value, and write path are all shared across
washer/dryer/dishwasher; only the translation is family- (and, for
washer/dryer, board-) specific.
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
suffixes translation_key with the device's own course-table id, read
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
'washer_cycle_table_02'). An absent or unrecognized table id gets the
name-only ``cycle`` translation key while the raw course code remains
visible and writable.
This matters because course codes are NOT guaranteed consistent across
board generations sharing the same /course/vs/0 contract: every code in
washer_cycle_table_02 was confirmed against Table_02-reporting devices
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
Table_00 instead, so the same hex code could mean a different course
there for all we've verified. So a table-specific key is used only when
the shipped catalog actually has one; any other table (Table_00 today,
whatever ships next) falls back to the name-only ``cycle`` key, which
shows the raw course code rather than a label borrowed from another
board generation. Translating a new table is therefore a
translations-only change -- add the ``<family>_cycle_<table>`` entry and
this resolver picks it up.
Left at its default for dishwasher, which has no equivalent table-id
resource in any dump seen and no evidence its course codes vary by
table the way washer/dryer's do -- there's nothing to build a
table-specific key from.
"""
key = translation_key
if table_href is not None:
def key(resources):
table = _table_id(resources, table_href)
if not isinstance(table, str) or not table:
return 'cycle'
candidate = f'{translation_key}_{table.lower()}'
return candidate if has_entity_translation('select', candidate) else 'cycle'
return SelectDesc(
key='cycle', icon=icon, translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
write_fn=cycle_write,
)
# ---------------------------------------------------------------------------
# Plain boolean toggles over /course/vs/0's options[] array: a
# '<prefix>_On'/'<prefix>_Off' token, read-modify-written the same way as
# the 'Course' token above. Shared by washer (bubble soak, pre-wash,
# intensive -- issue #22) and dishwasher (storm wash, auto release dry) --
# both families ride this exact contract, just with different prefixes and
# different presence/validation needs on top.
# ---------------------------------------------------------------------------
def bool_option_write(prefix):
def write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, p),
}
return write
def bool_option_value(prefix):
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
def bool_option_exists(prefix):
return lambda rep, resources: option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
def bool_option_switch(key, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None):
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report
the token at all (washer's bubble soak/pre-wash/intensive); leave False
for a toggle every device in the family reports (dishwasher's storm
wash). validate_fn is passed straight through to SwitchDesc for callers
that need to reject a write against live device state (e.g. washer's
per-course availability check) -- this factory has no opinion on it and
building one, if needed, is the caller's job.
"""
return SwitchDesc(
key=key, icon=icon, entity_category=entity_category,
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
rep_fn=bool_option_value(prefix),
write_fn=bool_option_write(prefix),
validate_fn=validate_fn,
)
# ---------------------------------------------------------------------------
# /wm/jobbeginingstatus/vs/0 -- the "why did the cycle not start" reason
# (e.g. door open, no water). The vendor field is x.com.samsung.da.currentStatus
# on every laundry dump that populates it (washer + DA_WM_TP1 dryer). An
# earlier dryer descriptor read x.com.samsung.da.jobBeginingStatus, but no dump
# ever carried that field, so the dryer sensor was always blank -- fixed by
# sharing this one reader.
# ---------------------------------------------------------------------------
JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
entity_category='diagnostic'),
),
)
@@ -0,0 +1,266 @@
"""Capabilities for the Samsung microwave family (TP1X_DA-KS-MICROWAVE-*
class boards, both combi units and plain microwaves).
Shares the oven board family's cavity/cook-cycle resource shape
(`/operational/state/vs/0`, `/doors/vs/0`, `/connected/vs/0`,
`/recipe/cook/vs/0`) -- those Capability objects are reused directly from
oven.py in by_type/microwave.py rather than duplicated. What's genuinely
different from an oven, and defined fresh here:
* Cooking-mode vocabulary: MicroWave/MicroWaveGrill/MicroWaveConvection/
KeepWarm never appear on an oven's /mode/vs/0, and this family spells
some shared-sounding modes differently than oven.py's own constants
(e.g. 'AirFryer', not oven.py's 'AirFry') -- a distinct SelectDesc and
mode list, not oven.OVEN_MODE.
* Setpoint bounds: this family's Convection/MicroWaveConvection modeSpec
(issue #121's MW7300B dump) reports 40-200 C / step 5, not oven.py's
30-270 C range (verified against a different, bake-oven-class board).
* Cavity: /oven/vs/0 here also carries a `powerLevel` field (100W-900W
on the MicroWave mode's powerListData) that plain ovens don't report --
exposed as its own sensor.
* Lamp: this family's option-array token is bare 'Lamp' (issue #137's
'Lamp_Off'), not oven.py's 'UpperLamp' -- and it's genuinely absent on
the combi dump (issue #121), so it's gated with exists_fn rather than
assumed universal like oven.py's lamp switch. Issue #137's dump only
ever showed 'Off', so 'On' was a guess at the paired value; issue #152's
ME7500D dump is the first to show a real non-Off value, and it's 'High'
(a brightness level, not literally 'On') -- the switch now treats any
non-Off/non-None value as "on" for reads, and writes back 'High'/'Off'
(the two confirmed tokens) rather than the never-confirmed 'On'.
* Filter reminder / end signal reminder: bare 'FilterRemind'/'RemindBeep'
option-array tokens (issue #181), both with On and Off observed live
(issue #152's ME7500D fixtures) -- gated with exists_fn like Lamp since
the MW7300B combi dump has neither.
Note: cooking-mode writes are unproven here, same caveat as oven.py's
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
is also the test.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, normalize_temp_unit
from .laundry import option_value, option_write
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Union of every mode seen across the two known dumps: issue #121's combi
# MW7300B (NoOperation/Autocook/AutocookCustom/Convection/AirFryer/Grill/
# MicroWave/MicroWaveGrill/MicroWaveConvection/Deodorization) and issue #137's
# plain ME7500D (NoOperation/MicroWave/Autocook/KeepWarm). No dump has shown
# every mode below on one device -- the select surfaces whatever a given
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
# never sends just never gets picked.
_MICROWAVE_MODES = (
'NoOperation',
'MicroWave',
'MicroWaveGrill',
'MicroWaveConvection',
'Convection',
'AirFryer',
'Grill',
'Autocook',
'AutocookCustom',
'Deodorization',
'KeepWarm',
)
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
# tempMaxC 200, tempIntervalC 5. No Fahrenheit dump exists for this family;
# unlike oven.py's own SETPOINT_MIN_F/MAX_F/STEP_F (independently verified
# against issue #44's range dump), there's nothing to verify a microwave's
# Fahrenheit bounds against, so this module only exposes the setpoint
# control when the live unit is Celsius (see _microwave_temp_unit below).
SETPOINT_MIN_C = 40
SETPOINT_MAX_C = 200
SETPOINT_STEP_C = 5
def _microwave_temp_unit(rep):
"""Same shape as oven.py's _oven_temp_unit: /temperatures/vs/0 items[]
carries a per-item x.com.samsung.da.unit field. Both known dumps for
this family report 'Celsius'; kept live rather than hardcoded per the
fridge/oven convention (issue #7)."""
items = rep.get('x.com.samsung.da.items') or []
unit = items[0].get('x.com.samsung.da.unit') if items else None
return normalize_temp_unit(unit, default='°C')
def _setpoint_write(p, rep, href=None):
"""RMW write to /temperatures/vs/0 items array -- unproven for this
family (no live write confirmed against a real unit), same "exposed for
fidelity" caveat as the mode select."""
try:
temp = float(p)
except (TypeError, ValueError):
return None
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
return None
items = rep.get('x.com.samsung.da.items')
if not items:
return None
items = [dict(it) for it in items]
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
def _power_level_watts(v):
"""'100W'..'900W' (issue #121) or a bare '0' (issue #137) -> int watts."""
if v is None:
return None
s = str(v).strip()
if s.upper().endswith('W'):
s = s[:-1]
return int_or_none(s)
def _cooking_mode_options(resources):
"""Live mode list from the device's own /mode/vs/0 supportedModes when
it reports one (both known dumps do); the union-of-all-dumps
_MICROWAVE_MODES guess otherwise. Same live-first, static-fallback
pattern as oven._oven_mode_options -- a fixed list here would offer
users modes their own unit doesn't have (issue #152's ME7500D reports
only 4 of _MICROWAVE_MODES' 11)."""
rep = resources.get('/mode/vs/0') or {}
live = rep.get('x.com.samsung.da.supportedModes')
return list(live) if live else list(_MICROWAVE_MODES)
def _mode_write(p, rep, href=None):
valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES
if p not in valid:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Sound', p),
}
def _lamp_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
def _filter_remind_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'FilterRemind') is not None
def _remind_beep_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'RemindBeep') is not None
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
# 'High' and 'Off' are the two tokens actually confirmed on live dumps
# (issues #137/#152) -- 'On' has never been observed and the device
# likely doesn't recognize it (see module docstring).
token = 'High' if p == 'On' else 'Off'
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Lamp', token),
}
def _filter_remind_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('FilterRemind', p),
}
def _remind_beep_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('RemindBeep', p),
}
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
MICROWAVE_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='cavity_state', field='x.com.samsung.da.state'),
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
unit='W', value_fn=_power_level_watts),
),
)
MICROWAVE_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
entities=(
NumberDesc(key='setpoint', field='x.com.samsung.da.items',
device_class='temperature', unit_fn=_microwave_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
value_fn=lambda items: int_or_none(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', unit_fn=_microwave_temp_unit,
value_fn=lambda items: int_or_none(
(items[0].get('x.com.samsung.da.current') if items else None))),
),
)
MICROWAVE_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options=_cooking_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
icon='mdi:track-light',
exists_fn=_lamp_exists,
value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'),
write_fn=_lamp_write),
# issue #181: Filter Reminder / End Signal Reminder toggles,
# confirmed present (both On and Off observed across dumps -- see
# issue #152's ME7500D fixtures) but only on boards that carry the
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
# MW7300B combi dump has neither) rather than assumed universal.
SwitchDesc(key='filter_remind', field='x.com.samsung.da.options',
icon='mdi:air-filter',
entity_category='config',
exists_fn=_filter_remind_exists,
value_fn=lambda opts: option_value(opts, 'FilterRemind') == 'On',
write_fn=_filter_remind_write),
SwitchDesc(key='remind_beep', field='x.com.samsung.da.options',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=_remind_beep_exists,
value_fn=lambda opts: option_value(opts, 'RemindBeep') == 'On',
write_fn=_remind_beep_write),
),
)
@@ -2,7 +2,8 @@
Shared by dryer/dishwasher/oven/washer families.
"""
from datetime import datetime, timezone, timedelta
import math
from datetime import datetime, timedelta, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
@@ -28,17 +29,41 @@ def _int(v):
return None
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)
def _remaining_seconds(raw):
"""'HH:MM:SS' (or 'MM:SS') -> total seconds, or None."""
if not isinstance(raw, str):
return None
try:
parts = [int(p) for p in raw.split(':')]
except (ValueError, TypeError):
return None
if len(parts) == 3:
h, m, s = parts
elif len(parts) == 2:
h, m, s = 0, *parts
else:
return None
return h * 3600 + m * 60 + s
def _delay_hours(v):
"""delayStartTime is a duration until the cycle starts, not a
wall-clock time -- "01:00" means "1 hour from when you press start",
not "1 AM"."""
if not v:
return 0.0
try:
h, m, s = v.split(':')
return int(h) + int(m) / 60 + int(s) / 3600
except Exception:
return None
total_seconds = _remaining_seconds(v)
if total_seconds is None:
return 0.0 if not v else None
return total_seconds / 3600.0
def _format_delay(hours):
@@ -53,60 +78,93 @@ def _delay_field(rep):
wall-clock time -- see _delay_hours). Write back whichever key the
device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior)."""
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime')
return (
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
)
def _finish_time(remaining_str):
if not remaining_str:
def _finish_time(rep):
if not _is_active(rep):
return None
try:
h, m, s = remaining_str.split(':')
total_s = int(h) * 3600 + int(m) * 60 + int(s)
if total_s == 0:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
except Exception:
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
if not total_s:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
def _completion_minutes(rep):
"""Parse remaining time into minutes directly from device payload."""
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
total_s = _remaining_seconds(raw)
if total_s is None:
return None
# Avoid the firmware bug where it freezes at 1 minute post-cycle
if rep.get('x.com.samsung.da.progress') == 'Finish':
return 0
return math.ceil(total_s / 60)
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
# rather than keeping its own copy, since both wrote the identical
# state='Ready' RMW.
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p}))
OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', value_fn=_to_ocf),
device_class='enum',
options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
# so we also gate on progress to avoid a stuck 'Running' indication.
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
# start/pause/stop buttons below) is shared across the dryer/
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
# vocabulary that doesn't fit an oven's bake/roast/etc.
BinarySensorDesc(key='cycle_active', device_class='running',
name='Cycle active',
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)),
SensorDesc(key='progress', name='Progress', icon='mdi:progress-wrench',
SensorDesc(key='progress', icon='mdi:progress-wrench',
rep_fn=lambda rep: (
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
value_fn=_int),
unit='%', state_class='measurement',
rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage'))
)),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
name='Estimated finish',
rep_fn=lambda rep: (
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
or rep.get('x.com.samsung.da.progress') == 'Finish'
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
)),
NumberDesc(key='delay_start_hours', name='Delay start', icon='mdi:timer-plus-outline',
rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
device_class='duration', state_class='measurement',
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes),
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h',
native_min=0, native_max=24, step=1,
rep_fn=lambda rep: _delay_hours(
@@ -115,20 +173,17 @@ OPERATIONAL_STATE = Capability(
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{_delay_field(rep): _format_delay(p)})),
ButtonDesc(key='start', field='', name='Start cycle', payload='Run',
ButtonDesc(key='start', field='', payload='Run',
icon='mdi:play',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', name='Pause cycle', payload='Pause',
ButtonDesc(key='pause', field='', payload='Pause',
icon='mdi:pause',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='stop', field='', name='Stop cycle', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
STOP_BUTTON,
),
)
@@ -24,11 +24,13 @@ but is effectively read-only in practice.
"""
from datetime import datetime, timezone, timedelta
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
SwitchDesc,
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
)
from .common import normalize_temp_unit
from .operational import STOP_BUTTON
# ---------------------------------------------------------------------------
# Constants
@@ -38,9 +40,26 @@ SETPOINT_MIN_C = 30
SETPOINT_MAX_C = 270
SETPOINT_STEP_C = 5
# Verified against issue #44's range dump (NSI6DG9100SRAA, unit reported as
# "Fahrenheit" on /temperatures/vs/0): Bake mode's modeSpec on /mode/vs/0
# reports tempMinF/tempMaxF/tempIntervalF = 175/550/5. Kept as a separate
# constant set rather than converted from the Celsius bounds above, which
# are themselves unverified (no live dump; see module docstring).
SETPOINT_MIN_F = 175
SETPOINT_MAX_F = 550
SETPOINT_STEP_F = 5
# Mode options seen on NV7000BS-class. No dump exists so this list is inferred
# from Samsung documentation and firmware observations. The firmware will
# reject unknown modes; missing entries here are a coverage gap, not a bug.
#
# This is a fallback only, used when a device's own /mode/vs/0 doesn't report
# x.com.samsung.da.supportedModes at all -- see _oven_mode_options/
# _oven_mode_write below. issue #138's range dump (NE63A6511SS/AA) reports
# ConvectionRoast/KeepWarm/BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean
# in its own supportedModes; those are read live rather than added here, per
# the adding-device-support skill's preference for device-reported option
# lists over hardcoded ones.
_OVEN_MODES = (
'NoOperation',
'Bake',
@@ -112,10 +131,39 @@ def _option_value(options, prefix):
return None
def _replace_in_options(options, prefix, new_value):
"""Return a new options list with the `<prefix>_*` slot replaced."""
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
for o in options]
def _has_option(prefix):
"""exists_fn for an options-array switch: bind only when the device's own
options[] actually carries a `<prefix>_<value>` token.
fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as
an unverified guess (see module docstring) -- issue #183's dump (model
NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its
options[] at all, so both switches were phantom controls: always read as
off, and toggling them wrote a token the firmware never recognized in
the first place, hence "does not appear to do anything."
`is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's
identical per-token exists_fn on its own options[]-array href: a stub
/device/0 seed rep (not yet sub-polled) has no options[] at all, and
without this an entity whose token is genuinely present would never get
a first chance to bind, since exists_fn runs before that first real
fetch lands.
"""
return lambda rep, resources: is_stub_rep(rep) or _option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
def _option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write, mirroring
laundry.option_write. NOT independently confirmed on an oven -- issue
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
This extrapolates that same vendor field/contract to the oven's
/mode/vs/0, on the assumption the firmware handles the array the same
way there. If that assumption is wrong for some oven, a device that
replaces the field outright instead of merging would drop every other
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
real device report surfaces that."""
return [f'{prefix}_{new_value}']
# ---------------------------------------------------------------------------
@@ -128,8 +176,9 @@ def _oven_setpoint_write(p, rep, href=None):
temp = float(p)
except (TypeError, ValueError):
return None
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
min_v, max_v, step_v = _setpoint_bounds(rep)
temp_i = int(round(temp / step_v) * step_v)
if not (min_v <= temp_i <= max_v):
return None
items = rep.get('x.com.samsung.da.items')
if not items:
@@ -155,62 +204,37 @@ def _cook_time_write(p, rep, href=None):
}
def _stop_write(p, rep, href=None):
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.state': 'Ready',
}
def _oven_mode_options(resources):
"""Live mode list from the device's own /mode/vs/0 supportedModes when
it reports one; the NV7000BS-era _OVEN_MODES guess otherwise. Mirrors
laundry.py's options_field pattern (buzzer_sound/finish_sound), but
needs the callable form rather than options_field because a static
fallback has to kick in when the device's own field is absent."""
rep = resources.get('/mode/vs/0') or {}
live = rep.get('x.com.samsung.da.supportedModes')
return list(live) if live else list(_OVEN_MODES)
def _oven_mode_write(p, rep, href=None):
if p not in _OVEN_MODES:
valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES
if p not in valid:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'UpperLamp', p),
}
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
}
def _fastpreheat_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'fastpreheat', p),
}
def _naturalsteam_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
if not any(o.startswith('NaturalSteam_') for o in opts):
opts = opts + [f'NaturalSteam_{p}']
else:
opts = _replace_in_options(opts, 'NaturalSteam', p)
return ['mode', 'vs', '0'], {'x.com.samsung.da.options': opts}
def _option_switch_write(prefix):
"""Factory for a single-token on/off options-array write -- lamp, sound,
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
a byte-for-byte copy of this same shape, one per prefix."""
def write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write(prefix, p),
}
return write
# ---------------------------------------------------------------------------
@@ -222,27 +246,28 @@ OVEN_OPERATIONAL_STATE = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', icon='mdi:stove', value_fn=_to_ocf),
icon='mdi:stove',
device_class='enum', options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
name='Cycle active', device_class='running',
device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement',
unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
name='Operation time (minutes)', unit='min',
unit='min',
state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
name='Estimated finish', device_class='timestamp',
device_class='timestamp',
value_fn=_finish_time),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
name='Cook time', unit='min', native_min=0, native_max=1439,
unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
ButtonDesc(key='stop', field='', name='Stop cycle', icon='mdi:stop',
payload='Stop', write_fn=_stop_write),
STOP_BUTTON,
),
)
@@ -251,25 +276,51 @@ OVEN_CAVITY = Capability(
poll_tier='hot',
entities=(
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
name='Cavity state'),
),
),
)
def _oven_temp_unit(rep):
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
same aggregate `/temperatures/vs/0` items[] resource type, which on
fridge hardware carries a per-item `x.com.samsung.da.unit` field
('Celsius'/'Fahrenheit') that was previously hardcoded away (issue #7).
Keeps the verified '°C' default when the field is absent (the original
NV7000BS-class dump this module was written against), but reads it live
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
items = rep.get('x.com.samsung.da.items') or []
unit = items[0].get('x.com.samsung.da.unit') if items else None
return normalize_temp_unit(unit, default='°C')
def _setpoint_bounds(rep):
"""(min, max, step) for the live unit -- see the SETPOINT_*_C/_F
constants above for provenance. Bounds must track the unit shown by
unit_fn (both read the same live rep), or the HA slider's range would
silently mismatch its own displayed unit."""
if _oven_temp_unit(rep) == '°F':
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
OVEN_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
name='Setpoint', device_class='temperature', unit='°C',
device_class='temperature', unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
name='Temperature', device_class='temperature',
state_class='measurement', unit='°C',
device_class='temperature',
state_class='measurement', unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
),
@@ -280,7 +331,7 @@ OVEN_DOOR = Capability(
poll_tier='hot',
entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door',
device_class='door',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)),
@@ -292,38 +343,70 @@ OVEN_CONNECTED = Capability(
poll_tier='warm',
entities=(
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
name='Cloud connected', device_class='connectivity',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
),
)
# Static cavity capability metadata (count/type/supported features) -- no
# per-cavity data varies at runtime on any dump seen so far (issue #44's
# range: single cavity, no supportedFeatureList entries). Bound with no
# entities purely for coverage; revisit if a multi-cavity dump surfaces
# fields worth exposing.
OVEN_SPEC = Capability(href='/oven/spec/vs/0')
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded
# string (language/menu/servingSize/option) with every field blank on the
# only dump seen, and no documented write contract. No entity to bind per
# the 'don't guess' rule; a bare Capability still marks the href covered.
OVEN_RECIPE_COOK = Capability(href='/recipe/cook/vs/0')
OVEN_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
name='Cooking mode', icon='mdi:tune',
options=_OVEN_MODES,
icon='mdi:tune',
options=_oven_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
name='Lamp', icon='mdi:track-light',
icon='mdi:track-light',
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_lamp_write),
write_fn=_option_switch_write('UpperLamp')),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
name='Sound', icon='mdi:volume-high',
icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
write_fn=_option_switch_write('Sound')),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
name='Fast preheat', icon='mdi:fire',
icon='mdi:fire',
exists_fn=_has_option('fastpreheat'),
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_fastpreheat_write),
write_fn=_option_switch_write('fastpreheat')),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
name='Natural steam', icon='mdi:kettle-steam',
icon='mdi:kettle-steam',
exists_fn=_has_option('NaturalSteam'),
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_naturalsteam_write),
write_fn=_option_switch_write('NaturalSteam')),
# 120-hour energy-saving standby (issue #183): confirmed present in
# this unit's options[] (EnergySaving_On) and directly requested --
# unlike fast_preheat/natural_steam above, this token is real on this
# hardware, just previously unbound entirely.
SwitchDesc(key='energy_saving', field='x.com.samsung.da.options',
icon='mdi:leaf', entity_category='config',
exists_fn=_has_option('EnergySaving'),
value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On',
write_fn=_option_switch_write('EnergySaving')),
# Cooktop-on alert (issue #183): also confirmed present
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
# the SmartThings app's own alerting, not local automation.
SwitchDesc(key='cooktop_on_alert', field='x.com.samsung.da.options',
icon='mdi:alert-circle-outline', entity_category='config',
exists_fn=_has_option('BurnerOnAlert'),
value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On',
write_fn=_option_switch_write('BurnerOnAlert')),
),
)
@@ -0,0 +1,212 @@
"""Capabilities for the cooktop half of range/combo appliances (issue #44,
model TP1X_DA-KS-RANGE-0102X).
Not to be confused with PR #23's registry/capabilities/cooktop.py, which
covers an unrelated standalone-cooktop product (NA9300K-class) that encodes
burner state as strings inside /mode/vs/0's options array instead of the
structured /cooktop/status/vs/0 resource this module reads -- two different
OCF surfaces that happen to share the English word "cooktop".
Unlike the rest of the OCF surface, these hrefs use plain camelCase field
names (no `x.com.samsung.da.` prefix) -- `/cooktop/status/vs/0` already
looks like a vendor resource migrated onto OCF-standard-shaped field naming.
`/cooktop/status/vs/0` carries every burner's live state in one `burnerList`
array (indexed by `burnerNumber`, not by a separate href per burner like
fridge ice makers), so per-burner entities are hardcoded up to MAX_BURNERS
and gated by exists_fn against whichever indices the device actually
reports -- harmless over-declaration, per common.py's UNIVERSAL note, since
an index absent from burnerList just never binds.
Write surfaces here are unproven (no live device to verify against, same
caveat as oven.py's RMW writes) -- power level uses the same read-modify-
write pattern already proven safe elsewhere in this codebase (oven setpoint,
icemaker toggles).
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit
# Observed as high as 4 (this issue's dump); user-reported hardware with 5
# burners exists. Kept a little above both since exists_fn gates unused
# slots out -- see module docstring.
MAX_BURNERS = 6
def _burner(burner_list, i):
for b in (burner_list or []):
if b.get('burnerNumber') == i:
return b
return None
def _burner_exists(i):
return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None
def _burner_field_fn(i, field):
return lambda burner_list: (_burner(burner_list, i) or {}).get(field)
def _burner_hot_surface_fn(i):
get_state = _burner_field_fn(i, 'hotSurfaceState')
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
def _burner_pan_detection_fn(i):
return lambda burner_list: bool(_burner_field_fn(i, 'panDetection')(burner_list))
def _power_level_options(resources):
spec = resources.get('/cooktop/spec/vs/0') or {}
return list(spec.get('supportedPowerLevelList') or [])
def _burner_power_level_write(i):
def write(p, rep, href=None):
burner_list = rep.get('burnerList')
if not burner_list:
return None
new_list = []
found = False
for b in burner_list:
if b.get('burnerNumber') == i:
b = dict(b)
b['powerLevel'] = p
found = True
new_list.append(b)
if not found:
return None
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
return write
def _burner_entities(i):
exists = _burner_exists(i)
n = i + 1
return (
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
icon='mdi:knob',
translation_key='range_burner_power_level',
translation_placeholders={'number': str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, 'powerLevel'),
write_fn=_burner_power_level_write(i)),
SensorDesc(key=f'burner_{i}_state', field='burnerList',
icon='mdi:stove',
translation_key='burner_state',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, 'operationState')),
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
device_class='heat',
translation_key='burner_hot_surface',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)),
BinarySensorDesc(key=f'burner_{i}_pan_detected', field='burnerList',
icon='mdi:pot',
translation_key='burner_pan_detected',
translation_placeholders={'number': str(n)},
entity_category='diagnostic',
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i)),
)
def _child_lock_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
COOKTOP_STATUS = Capability(
href='/cooktop/status/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='cooktop_state', field='operationState',
icon='mdi:pot-steam'),
# The cooktop section's own on/off (issue #86) -- distinct from
# common.POWER's /power/0 or /power/vs/0, which some boards in this
# family (the range combo) additionally carry for the whole
# appliance. Read-only: no live device to confirm remotely turning
# a cooktop on wouldn't leave a burner active unattended.
BinarySensorDesc(key='cooktop_power', field='power',
device_class='power', icon='mdi:pot-steam',
value_fn=lambda v: str(v).lower() == 'on'),
# Safe to write -- a lock toggle, not a heat control -- via a
# direct single-field PUT (no RMW needed; unlike burnerList this
# is a lone scalar, not an array of siblings to preserve).
SwitchDesc(key='cooktop_child_lock', field='childLock',
device_class='lock', entity_category='config',
icon='mdi:lock',
value_fn=lambda v: str(v).lower() == 'on',
write_fn=_child_lock_write),
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
),
)
# Static burner-count/power-level-list metadata, read directly by
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
# than exposed through its own entity -- same "informs another capability,
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the
# feature itself is enabled -- not a live "surface is hot right now" alert
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
# verified, so read-only for now.
COOKTOP_SAFETY = Capability(
href='/cooktop/settings/status/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
entity_category='config',
value_fn=lambda v: (v or {}).get('state') == 'on'),
),
)
# Bluetooth meat probe (issue #86). All-idle sentinel values when
# disconnected (operationBurnerNumber -1, temperatures 0) -- no special
# gating on those, matching cooktop.PAIRED_HOOD_STATUS's own precedent of
# showing a disconnected accessory's fields plainly rather than hiding the
# whole capability.
PROBE_STATUS = Capability(
href='/bluetooth/probe/status/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='probe_connected', field='connectionState',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'connected'),
SensorDesc(key='probe_battery', field='batteryPercentage',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic'),
SensorDesc(key='probe_temperature', field='currentTemperature',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
SensorDesc(key='probe_target_temperature', field='targetTemperature',
device_class='temperature', entity_category='diagnostic',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
),
)
# Some range boards (issue #74's NE63B8411SS) report no /cooktop/status/vs/0
# burner array at all -- their local API only exposes this coarse
# monitoring resource for the cooktop half, with no per-burner detail.
# Meaning of `cooktopMonitoring` (a bare "0" on the only dump seen) and
# `warmingCenterState`'s full value set aren't confirmed, so both are
# exposed as plain sensors rather than guessed at as a switch/select --
# `supportedHoodLampStateList` has no corresponding live-state field on
# this resource, so nothing to bind it to yet.
COOKTOP_MONITORING = Capability(
href='/cooktopmonitoring/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
icon='mdi:pot-steam-outline'),
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
icon='mdi:heat-wave', entity_category='diagnostic'),
),
)
@@ -0,0 +1,327 @@
"""Capabilities for Samsung AHD-WW-TP1-22 range hoods.
The verified device exposes its fan, two-level work lamp, washable-filter
status, and particulate sensors as distinct local OCF resources. Fan power
and speed are combined into one HA fan entity by ``fan.py``; lamp power and
brightness remain separate controls because the device advertises them as two
independent fields.
"""
from datetime import datetime, timezone
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc,
ButtonDesc,
FanDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from .common import int_or_none, sensor_item_value
def _timestamp(value):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
except (TypeError, ValueError, OSError):
return None
def _active_alarm_codes(items):
"""Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row.
Unlike ``common._alarm_codes``, the hood retains a deleted alarm row in
its live representation, so this family-specific helper also checks state.
"""
codes = []
for item in items or ():
if not isinstance(item, dict):
continue
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
continue
code = item.get('x.com.samsung.da.code')
if code and str(code).lower() != 'errorcode_off':
codes.append(code)
return ', '.join(codes) if codes else 'none'
HOOD_ALARMS = Capability(
href='/alarms/vs/0',
poll_tier='hot',
entities=(
SensorDesc(
key='alarm_code',
field='x.com.samsung.da.items',
icon='mdi:alert',
entity_category='diagnostic',
value_fn=_active_alarm_codes,
),
),
)
def _hood_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
power_href = args[0] if args else '/power/0'
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
if power_href == '/power/vs/0':
return ['power', 'vs', '0'], {
'x.com.samsung.da.power': 'On' if value else 'Off',
}
return None
if kind == 'speed':
value = str(value)
supported = [
str(code)
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
]
if value not in supported:
return None
return ['hood', 'fanspeed', 'vs', '0'], {
'x.com.samsung.da.hood.fanSpeed': value,
}
return None
HOOD_FAN = Capability(
href='/hood/fanspeed/vs/0',
poll_tier='hot',
entities=(
FanDesc(
key='fan',
field='x.com.samsung.da.hood.fanSpeed',
write_fn=_hood_fan_write,
),
BinarySensorDesc(
key='automatic_operation',
field='x.com.samsung.da.hood.autoOperation',
icon='mdi:fan-auto',
entity_category='diagnostic',
# Absent on the microwave family's built-in vent fan (issue
# #137) -- this board has no auto-ventilation mode, unlike the
# standalone range hood this capability was written for.
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
value_fn=lambda value: str(value).lower() == 'on',
),
),
)
def _lamp_level_write(value, rep, href=None):
code = str(value)
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
if code not in supported:
return None
return ['hood', 'lamp', 'vs', '0'], {
'x.com.samsung.lamp.current': code,
}
HOOD_LAMP = Capability(
href='/hood/lamp/vs/0',
poll_tier='hot',
entities=(
SwitchDesc(
key='lamp',
field='x.com.samsung.lamp.power',
icon='mdi:range-hood',
value_fn=lambda value: str(value).lower() == 'on',
write_fn=lambda payload, rep, href=None: (
['hood', 'lamp', 'vs', '0'],
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
),
),
SelectDesc(
key='lamp_brightness',
field='x.com.samsung.lamp.current',
icon='mdi:brightness-6',
translation_key='range_hood_lamp_brightness',
options_field='x.com.samsung.lamp.range',
write_fn=_lamp_level_write,
),
),
)
HOOD_FILTER = Capability(
href='/filter/hoodfilter/vs/0',
poll_tier='cold',
entities=(
SensorDesc(
key='hood_filter_usage',
field='x.com.samsung.da.filterUsage',
unit='%',
state_class='measurement',
icon='mdi:air-filter',
entity_category='diagnostic',
value_fn=int_or_none,
),
SensorDesc(
key='hood_filter_status',
field='x.com.samsung.da.filterStatus',
icon='mdi:air-filter',
entity_category='diagnostic',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
),
),
SensorDesc(
key='hood_filter_capacity',
field='x.com.samsung.da.filterCapacity',
unit='h',
icon='mdi:timer-outline',
entity_category='diagnostic',
enabled_default=False,
value_fn=int_or_none,
),
),
)
# After Run (issue #147): the hood keeps the fan running at low speed for a
# while after it's switched off, to clear residual cooking smoke -- a
# feature a user actively watches and cancels, not passive diagnostics, so
# none of the three entities below carry entity_category. No
# supported-values list is advertised for activationState, so it's modeled
# read-only (monitoring, not an invented "enable" write) per the 'don't
# guess' rule; runningCancel's only observed value is the command name
# itself ('Cancel'), the same self-describing command-field shape as
# operational.STOP_BUTTON. runningProgress's own name states its domain
# (a percentage of the cycle completed), so it's modeled as one rather than
# left an opaque passthrough -- unlike activationState/runningCancel, there's
# no ambiguous field name or missing-write-contract question here to hedge on.
AFTER_RUN = Capability(
href='/afterrun/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key='after_run_active',
field='x.com.samsung.da.activationState',
icon='mdi:fan-clock',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='after_run_progress',
field='x.com.samsung.da.runningProgress',
unit='%',
state_class='measurement',
icon='mdi:fan-clock',
value_fn=int_or_none,
),
ButtonDesc(
key='after_run_cancel',
field='',
payload='Cancel',
icon='mdi:fan-off',
write_fn=lambda p, rep, href=None: (
['afterrun', 'vs', '0'],
{'x.com.samsung.da.runningCancel': p},
),
),
),
)
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=(
SensorDesc(
key='clean_level',
field='x.com.samsung.da.items',
icon='mdi:air-filter',
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
),
SensorDesc(
key='dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'Dust'),
),
SensorDesc(
key='fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
),
SensorDesc(
key='super_fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
),
),
)
AIR_LEVEL_CHECK = Capability(
href='/airlevelcheck/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(
key='periodic_air_sensing',
field='x.com.samsung.da.periodicSensingActivationState',
icon='mdi:radar',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
),
SensorDesc(
key='air_sensing_state',
field='x.com.samsung.da.sensingState',
icon='mdi:radar',
entity_category='diagnostic',
),
SensorDesc(
key='last_air_sensing_time',
field='x.com.samsung.da.lastSensingTime',
device_class='timestamp',
entity_category='diagnostic',
value_fn=_timestamp,
),
SensorDesc(
key='last_air_sensing_level',
field='x.com.samsung.da.lastSensingLevel',
icon='mdi:air-filter',
entity_category='diagnostic',
),
SensorDesc(
key='automatic_ventilation_state',
field='x.com.samsung.da.autoExeState',
icon='mdi:fan-auto',
entity_category='diagnostic',
),
),
)
AUTO_VENTILATION = Capability(
href='/autoventilation/vs/0',
poll_tier='warm',
entities=(
SensorDesc(
key='auto_ventilation_action',
field='action',
icon='mdi:fan-auto',
),
),
)
# Resource plumbing and opaque feature-negotiation fields that are specific to
# this family. Bare capabilities mark them covered without creating entities.
COVERAGE = [
Capability(href=href)
for href in (
'/power/0',
'/power/vs/0',
'/mode/vs/0',
'/personality/presence/vs/0',
'/availablecontrolsets/vs/0',
'/da/softreset/vs/0',
)
]
@@ -0,0 +1,109 @@
"""Capabilities for the Samsung stick-vacuum clean/auto-empty station
(model A-VSKR-TP1-22-VS9500AL, "Bespoke Jet" clean station, issue #131).
The reporter's diagnostics dump shows no vacuum-body state at all (no
suction level, no battery, no cleaning-mode/room-mapping control, no
docked/undocked status even) -- only the clean station's own dustbag,
dustbin auto-empty settings, and UV-C sanitizing-cycle status. This
strongly suggests the WiFi/DTLS module lives in the station, not the
handheld stick: the station is the only "device" this integration's local
API can see, and the wand's own controls are unrelated hardware not
reachable this way. Modeled as its own device type -- these hrefs don't
overlap with any existing family, so there's no shared-href ambiguity to
resolve against another type (see registry/by_type/__init__.py's
docstring for that rule).
Resources verified against the issue #131 diagnostics dump.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
DUSTBAG = Capability(
href='/component/station/dustbag/vs/0',
poll_tier='warm',
entities=(
BinarySensorDesc(key='dustbag_full', field='x.com.samsung.da.status',
device_class='problem',
icon='mdi:bag-personal',
value_fn=lambda v: v == 'full'),
),
)
# dustbagUsage/dustbagPrevUsage are raw counters with no capacity/resolution
# field alongside them to normalize into a percentage (unlike the AC/range
# families' filterUsage, which always ships filterCapacity) -- exposed as a
# plain diagnostic count rather than guessing a unit.
DUSTBAG_USAGE = Capability(
href='/component/station/dustbagusage/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='dustbag_usage', field='x.com.samsung.da.dustbagUsage',
icon='mdi:counter', entity_category='diagnostic',
state_class='total_increasing', value_fn=int_or_none),
),
)
DUSTBIN_SETTING = Capability(
href='/setting/dustbin/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='auto_empty', field='x.com.samsung.da.autoEmpty',
icon='mdi:delete-empty',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.autoEmpty': 'On' if p == 'On' else 'Off'})),
SwitchDesc(key='dustbin_auto_close', field='x.com.samsung.da.autoClose',
icon='mdi:door-sliding',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.autoClose': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='discharging_time', field='x.com.samsung.da.desiredDischargingTime',
icon='mdi:timer-outline',
entity_category='config',
options_field='x.com.samsung.da.supportedDischargingTime',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.desiredDischargingTime': p})),
),
)
# stickStatus's exact meaning (docked? powered? charging?) isn't confirmed
# from this single On/Off dump -- exposed as a plain diagnostic sensor
# rather than a binary_sensor, so no polarity/semantic is asserted that
# might be wrong.
CLEANSTATION_STATUS = Capability(
href='/status/cleanstation/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cleanstation_status', field='x.com.samsung.da.status',
icon='mdi:home-lightning-bolt', entity_category='diagnostic'),
SensorDesc(key='stick_status', field='x.com.samsung.da.stickStatus',
icon='mdi:broom', entity_category='diagnostic'),
SwitchDesc(key='uvc_intensive_mode', field='x.com.samsung.da.uvcIntensive',
icon='mdi:lightbulb-on-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['status', 'cleanstation', 'vs', '0'],
{'x.com.samsung.da.uvcIntensive': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='uvc_operation_time', field='x.com.samsung.da.uvcOperationTime',
icon='mdi:timer-sand', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='uvc_total_operation_time',
field='x.com.samsung.da.uvcTotalOperationTime',
icon='mdi:timer-sand', entity_category='diagnostic',
state_class='total_increasing', value_fn=int_or_none),
SensorDesc(key='uvc_finished_time', field='x.com.samsung.da.uvcFinishedTime',
device_class='timestamp', entity_category='diagnostic',
value_fn=_parse_iso_utc),
SensorDesc(key='uvc_emitted_time', field='x.com.samsung.da.emittedTime',
device_class='timestamp', entity_category='diagnostic',
value_fn=_parse_iso_utc),
),
)
@@ -2,18 +2,30 @@
front-load washers).
Resources verified against two live WW90DG6U25LEU4 dumps (Table_02 course
family). Washers never report `oneUiVersion` -- see
`registry/by_type/__init__.py`'s `for_device_by_model()` for the fallback
detection this device type requires.
family). Washers share the `DA_WM_` laundry board with dryers, so their
`modelNum` can't tell the two apart -- see `registry/by_type/__init__.py`'s
`_CONSUMER_PREFIX_TO_KEY` for the `description`-based detection this device
type requires.
The shared laundry surface -- power/kids-lock/remote-control OCF+vendor
fallback pairs, buzzer, energy meter, job-beginning-status, and the
/course/vs/0 cycle-select machinery -- lives in laundry.py. Only washer-
specific controls (wash settings, drum-clean tracking, dispenser dosing) are
here; they read washer-only fields off the same shared /course/vs/0 options
array.
"""
from datetime import datetime, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import (
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
option_write,
)
# ---------------------------------------------------------------------------
# Course_XX hex codes. The 23 codes named in strings.json/translations
# under entity.select.washer_cycle.state.<id, lowercased> were captured
# Course_XX hex codes. 23 of the codes named in translations/en.json
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
# positionally against a Slovak-UI user's screenshots of their app's course
@@ -27,16 +39,31 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
# you'd expect to be pinned (default cycle + maintenance cycle),
# corroborating the positional match.
#
# A further 5 codes -- '36' Wash+Dry, '37' Air Wash, '38' Cotton Dry,
# '39' Synthetics Dry, and a second, distinct '1F' Intense Cold (not the
# same code as '8F' above) -- came from a WD90T654DBN/S1 washer/dryer
# combo's editCourseList and were named from that user's app screenshot
# (issue #22). Combo units carry their own course set, so these codes
# don't imply anything about '1F' on a plain washer.
#
# Three more -- '52' Eco Cold, '54' Towels, '60' Self Clean+ -- came from a
# WF50A8600AV/US, verified directly rather than by inference: the reporter
# selected each cycle on the physical appliance and read back the resulting
# raw code from the cycle_select entity's state (issue #80). '54' shares a
# display name with the existing '24' Towels -- a different code on a
# different course table legitimately landing on the same label, not a typo
# (same pattern as '21'/'65' Colors and '27'/'5E' Rinse+Spin above).
#
# No static fallback list of those codes is kept here, deliberately: other
# washer models have a different actual course set (a second dump's active
# course, '65', isn't even in the list above; models with 'AI Wash'/'Mixed
# Load' -- both "applicable models only" per the manual -- would have yet
# another set), so hardcoding one device's list would show/hide the wrong
# options on a different model. _cycle_options() below reads only the live
# options on a different model. laundry.cycle_options() reads only the live
# x.com.samsung.da.editCourseList; if a device doesn't populate that
# resource, the cycle select isn't created at all (see WASHER_COURSE's
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as
# a fallback source (its first byte reliably equals the currently-selected
# resource, the cycle select isn't created at all (see cycle_select's
# exists_fn). x.com.samsung.da.options' MostUsed_* entry was considered as a
# fallback source (its first byte reliably equals the currently-selected
# Course_XX on both dumps we have), but the bytes after that don't
# correspond to any confirmed course code on either device -- e.g. dump 1's
# MostUsed_1C8410923FA67F00000000000000 decodes to
@@ -44,19 +71,6 @@ from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
# so it isn't trustworthy as a list of selectable courses and isn't used.
# ---------------------------------------------------------------------------
def _parse_edit_course_list(raw):
"""'EditCourseList_1C1D211B1E29...' -> ['1C', '1D', '21', '1B', '1E', '29', ...]."""
if not isinstance(raw, str) or '_' not in raw:
return []
codes = raw.split('_', 1)[1]
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
def _cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
return _parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
# ---------------------------------------------------------------------------
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count
#
@@ -70,51 +84,45 @@ WASHER_SETTINGS = Capability(
href='/washer/vs/0',
entities=(
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
name='Wash temperature', icon='mdi:thermometer-water',
icon='mdi:thermometer-water',
entity_category='config',
options_field='x.com.samsung.da.supportedWaterTemperature',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
name='Spin speed', icon='mdi:sync',
icon='mdi:sync',
entity_category='config',
options_field='x.com.samsung.da.supportedSpinLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
name='Rinse cycles', icon='mdi:water-sync',
icon='mdi:water-sync',
entity_category='config',
options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
# Washer/dryer combo units carry a dryLevel field on the wash
# resource itself (no separate dryer device/course) -- see issue
# #22. Self-gates off on plain washers, which never report
# supportedDryLevel.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
icon='mdi:tumble-dryer',
entity_category='config',
translation_key='washer_dry_level',
options_field='x.com.samsung.da.supportedDryLevel',
exists_fn=lambda rep, resources: bool(
rep.get('x.com.samsung.da.supportedDryLevel')),
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
),
)
# ---------------------------------------------------------------------------
# /course/vs/0 -- selected course, read/write (RMW on the options array,
# same shape as dishwasher.CYCLE_OPTIONS._cycle_write).
# /course/vs/0 -- the cycle select is the shared laundry.cycle_select; the
# drum-clean and dispenser-dosing entities below are washer-specific reads off
# the same options array.
# ---------------------------------------------------------------------------
def _option_value(options, prefix):
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _replace_in_options(options, prefix, new_value):
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
for o in options]
def _cycle_write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Course', p),
}
# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course. DrumCleanProposal_<N> is the wash-cycle interval between
@@ -128,8 +136,8 @@ def _cycle_write(p, rep, href=None):
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
def _drum_clean_cycles_remaining(rep):
opts = rep.get('x.com.samsung.da.options') or []
proposal = _option_value(opts, 'DrumCleanProposal')
washed = _option_value(opts, 'WashingTimes')
proposal = option_value(opts, 'DrumCleanProposal')
washed = option_value(opts, 'WashingTimes')
if proposal is None or washed is None:
return None
try:
@@ -139,7 +147,7 @@ def _drum_clean_cycles_remaining(rep):
def _drum_clean_last_cleaned(rep):
raw = _option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
if not raw:
return None
try:
@@ -148,147 +156,208 @@ def _drum_clean_last_cleaned(rep):
return None
# Detergent/softener auto-dispense dosing, from the same options[] array
# (issue #9). '<Prefix>LevelCtrl_<code>' is the selected dose quantity;
# '<Prefix>Level2Ctrl_<code>' is a second dial -- water hardness for
# detergent, concentration for softener -- matching the SmartThings app's
# two-field dispenser screens ("Distributeur de lessive": Quantité + Dureté
# de l'eau; "Distributeur d'adoucissant": Quantité + Concentration, per
# issue #9's screenshots). 'Supported<Prefix>Ctrl_<hexpairs>' lists the
# valid raw codes for its field, same hex-pair shape as EditCourseList.
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
#
# Label mapping (entity.select.{detergent,softener}_quantity /
# detergent_water_hardness / softener_concentration in translations/en.json) is an
# assumed, not cross-device-verified, reading of the single issue #9 dump +
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
# on-screen equivalent -- the app's Quantité picker only offers
# Faible/Moyen/Élevé, i.e. codes 01-03; 00 is assumed to be what
# "Activation" off collapses to) matches DetergentLevelCtrl_3/
# SoftenerLevelCtrl_3 = "Élevé" on both dispensers. Level2Ctrl's 3 codes as
# Soft/Medium/Hard for detergent (Dureté de l'eau: Douce/Moyenne/Dure)
# matches DetergentLevel2Ctrl_2 = "Moyenne". The same 3-code shape as
# 1x/2x/3x for softener concentration does *not* cleanly match
# SoftenerLevel2Ctrl_2 against the screenshot's "3x" -- assumed to be a
# setting the user changed in the app between the dump (issue body) and the
# screenshots (a later comment), not a different code scheme, since it's
# otherwise identical in shape to the detergent side. Revisit if a second
# device's dump contradicts this.
def _supported_level_options(resources, prefix):
rep = resources.get('/course/vs/0') or {}
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
return hex_pairs(raw) if raw else []
def _level_options(prefix):
return lambda resources: _supported_level_options(resources, prefix)
def _dosing_level(prefix):
"""Current dose code, normalized to the `Supported<prefix>` code format.
The device reports the selected level as `<prefix>_<code>` with the code
un-padded (e.g. '3'), but the valid codes -- which are also this select's
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
outside the select's own option list, so HA renders it 'unknown' (issue #9).
Resolve it to the supported code with the same integer value so
current_option matches an option (and its translation)."""
def fn(rep):
opts = rep.get('x.com.samsung.da.options')
raw = option_value(opts, prefix)
if raw is None:
return None
supported_raw = option_value(opts, f'Supported{prefix}')
try:
target = int(raw, 16)
except (TypeError, ValueError):
return raw
for code in hex_pairs(supported_raw) if supported_raw else []:
try:
if int(code, 16) == target:
return code
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
if not rep.get('x.com.samsung.da.options'):
return None
# `p` is the zero-padded supported code the UI selected (e.g. '03');
# the device stores the level un-padded (e.g. '3'), matching how it
# reports it, so write it back in that native shape.
try:
native = format(int(p, 16), 'X')
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, native),
}
return write
def _dosing_low(prefix):
return lambda rep: option_value(
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
# array (issue #22 follow-up on a WD90T654DBN/S1 combo). Each rides as a
# plain '<Prefix>_On'/'<Prefix>_Off' token, confirmed by a dump taken with
# Bubble Soak switched on in the app (BubbleSoak_On) -- the same On/Off shape
# already used by AiOption and KidsLockBypass in this same array, so
# PreWashSetting/IntensiveSetting are assumed to follow suit.
#
# Each also has a differently-named hex-pair availability field that lines up
# positionally with editCourseList: BubbleSoakSet, PreWashAvailableSet,
# IntensiveAvailableSet. On the reporter's dump (course '30' at position 1 of
# 24), all three read 'F0' at that position and the toggle was writable --
# and the same dump's earlier state (course '1C' at position 0, 'BubbleSoak
# Off') decodes to '00' for that course, matching the app graying the
# control out there. 'F0'/'00' is treated as available/unavailable on that
# evidence. exists_fn (device-level presence) still only runs once, against
# the setup-time snapshot, so it isn't a fit for this per-course check --
# validate_fn runs on every write attempt instead (dispatched from
# coordinator.async_send_command, ahead of write_fn), rejecting an on-write
# for a course whose byte isn't 'F0' with a user-facing error rather than
# silently no-opping against the device. The read/write/presence machinery
# itself is laundry.bool_option_switch, shared with dishwasher's storm-wash/
# auto-release-dry toggles -- only this per-course gating is washer-only, so
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
def _bool_option_switch(key, icon, prefix, availability_field):
def validate(p, rep, resources):
"""Reject turning on when the selected course's byte in
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
back to allowing the write whenever the availability data can't be
resolved (unrecognized course, missing/mismatched-length bitmap)
rather than guessing -- a false rejection is worse than an
occasional no-op write."""
if p != 'On':
return None
opts = rep.get('x.com.samsung.da.options') or []
current = option_value(opts, 'Course')
courses = cycle_options(resources)
if not current or current not in courses:
return None
raw = option_value(opts, availability_field)
if raw is None:
return None
pairs = hex_pairs(raw)
if len(pairs) != len(courses):
return None
if pairs[courses.index(current)] != 'F0':
return f"{key}_unavailable_for_cycle"
return None
return bool_option_switch(
key, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate)
WASHER_COURSE = Capability(
href='/course/vs/0',
entities=(
SelectDesc(key='cycle', name='Cycle', icon='mdi:washing-machine',
translation_key='washer_cycle',
options=_cycle_options,
exists_fn=lambda rep, resources: bool(_cycle_options(resources)),
rep_fn=lambda rep: _option_value(
rep.get('x.com.samsung.da.options'), 'Course'),
write_fn=_cycle_write),
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
icon='mdi:washing-machine-alert', unit='cycles',
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0'),
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
icon='mdi:washing-machine-alert',
state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
icon='mdi:calendar-clock', device_class='timestamp',
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
icon='mdi:calendar-clock',
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
),
)
# ---------------------------------------------------------------------------
# /buzzersound/vs/0 -- buzzer volume and (on some units) a separate finish
# chime. Fields have no 'x.com.samsung.da.' prefix in this resource, unlike
# most other washer hrefs.
# ---------------------------------------------------------------------------
BUZZER_SOUND = Capability(
href='/buzzersound/vs/0',
entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
name='Buzzer sound', icon='mdi:volume-high',
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
translation_key='detergent_quantity',
entity_category='config',
options_field='supportedBuzzerSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
SelectDesc(key='finish_sound', field='setFinishSound',
name='Finish sound', icon='mdi:bell-ring',
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
translation_key='detergent_water_hardness',
entity_category='config',
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
options_field='supportedFinishSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
),
)
# ---------------------------------------------------------------------------
# /wm/jobbeginingstatus/vs/0 -- same href as dryer.JOB_BEGINNING_STATUS, but
# a different field name (currentStatus, not jobBeginingStatus).
# ---------------------------------------------------------------------------
WASHER_JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
name='Job beginning status',
entity_category='diagnostic'),
),
)
# ---------------------------------------------------------------------------
# OCF-native / '-vs' fallback pairs for power, kids-lock, remote control.
#
# Same shape as fridge.py's "Aggregate-resource fallbacks": the generic OCF
# href (/power/0, plain boolean 'value') is preferred when present; the
# vendor '-vs' href (richer historically, but for these three controls just
# a string-encoded duplicate) only binds when the generic href is absent
# from this device's resource set, via match_fn. Scoped to washer.py, not
# common.py -- no other device type has been confirmed to expose the
# generic hrefs, so this must not change behavior for dishwasher/dryer/
# oven/refrigerator.
# ---------------------------------------------------------------------------
POWER_GENERIC = Capability(
href='/power/0',
entities=(
SwitchDesc(key='power_switch', field='value',
name='Power',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})),
),
)
POWER_VS_FALLBACK = Capability(
href='/power/vs/0',
match_fn=lambda rep, resources: '/power/0' not in resources,
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
name='Power',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
),
)
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
name='Child lock', device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
),
)
KIDS_LOCK_VS_FALLBACK = Capability(
href='/kidslock/vs/0',
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
entities=(
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
name='Child lock', device_class='lock',
value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'],
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
),
)
REMOTE_CONTROL_GENERIC = Capability(
href='/remotectrl/0',
entities=(
BinarySensorDesc(key='remote_control', field='value',
name='Smart Control', device_class='connectivity',
value_fn=lambda v: bool(v)),
),
)
REMOTE_CONTROL_VS_FALLBACK = Capability(
href='/remotectrl/vs/0',
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
name='Smart Control', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
translation_key='softener_quantity',
entity_category='config',
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
translation_key='softener_concentration',
entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'),
),
)
@@ -0,0 +1,351 @@
"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class,
issue #90, model TP2X_WATERPURIFIER_20K; also AILITE_DA-REF-WATERPURIFIER-class,
issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K).
Resources verified against the issue #90 and #196 diagnostics dumps.
"""
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
DISPENSE = Capability(
href='/setting/waterpurifier/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType',
icon='mdi:cup-water',
options_field='x.com.samsung.da.supportedTypes',
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.desiredType': p})),
# Only a handful of discrete temperatures are selectable (not a
# continuous range) -- a select over the live-reported set, not a
# number with invented bounds.
#
# Newer boards (issue #196, RWP70F15ANW) don't populate
# supportedHotTemperatures at all -- they report a hotwaterRange
# (min/max) and a hotwaterLevel (step count?) instead, with no
# confirmed write contract for values off the old preset list. With
# an empty options_field result, HA's current_option still returns
# the live tempDesiredHotWater, which isn't in the (empty) options
# list and renders as "unknown" -- the exact symptom reported. Gate
# the entity off entirely when the board doesn't report a supported
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.supportedHotTemperatures',
exists_fn=lambda rep, resources: (
is_stub_rep(rep)
or 'x.com.samsung.da.supportedHotTemperatures' in rep),
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.tempDesiredHotWater': p})),
# Bounds and step come live from the device's own
# desiredCapacityRange/capacityResolution fields, not a hardcoded
# constant -- see the adding-device-support skill's "never hard-code
# the one dump's values" section. No unit is set: capacityUnit reads
# "C" on this dump, which can't be right for a volume field, so per
# the 'don't guess' rule the unit is left unset rather than assumed
# to be mL.
NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity',
icon='mdi:cup-water',
value_fn=int_or_none,
range_field='x.com.samsung.da.desiredCapacityRange',
step_fn=lambda rep: int_or_none(
rep.get('x.com.samsung.da.capacityResolution')) or 1,
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})),
BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus',
icon='mdi:cup-water',
value_fn=lambda v: v == 'On'),
),
)
STATUS = Capability(
href='/status/waterpurifier/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status',
icon='mdi:water-pump',
entity_category='diagnostic'),
BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus',
device_class='door',
entity_category='diagnostic',
value_fn=lambda v: v == 'Open'),
SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod',
icon='mdi:calendar-sync',
entity_category='diagnostic'),
SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime',
icon='mdi:timer-outline',
entity_category='diagnostic'),
SensorDesc(key='sterilize_last_time', device_class='timestamp',
entity_category='diagnostic',
rep_fn=lambda rep: _parse_iso_utc(
rep.get('x.com.samsung.da.sterilizeLastTime'))),
SensorDesc(key='sterilize_plan_time', device_class='timestamp',
entity_category='diagnostic',
rep_fn=lambda rep: _parse_iso_utc(
rep.get('x.com.samsung.da.sterilizePlanTime'))),
SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime',
icon='mdi:timer-sand',
entity_category='diagnostic'),
),
)
FAVORITE_CAPACITY = Capability(
href='/favorite/capacity/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity',
icon='mdi:star-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['favorite', 'capacity', 'vs', '0'],
{'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity',
icon='mdi:cup-water',
entity_category='config',
options_field='x.com.samsung.da.capacityList',
write_fn=lambda p, rep, href=None: (
['favorite', 'capacity', 'vs', '0'],
{'x.com.samsung.da.defaultCapacity': p})),
),
)
# Coffee-capable variant (issue #107) -- a "favorite" supported-list select
# for the hot water dispensed alongside brewing, same shape as
# FAVORITE_CAPACITY above.
def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
"""Three-way read of /status/lock/vs/0's hotwaterLock field, favouring
LOCK.hotwater_lock (the primary descriptor) whenever the outcome is
still ambiguous:
- href entirely absent from this device -> definitely no clash, the
switchHotwater fallback below may claim the entity.
- href present but an unfetched stub ({}) -> outcome pending, *not* a
confirmed absence. LOCK's own exists_fn optimistically includes itself
through a stub (matching entity.py's default), so returning True here
too would register both descriptors -- as SwitchDescs sharing one key,
with identical unique_ids -- until the next poll resolves it.
- href present and fetched -> the real answer."""
rep = resources.get('/status/lock/vs/0')
if rep is None:
return True
if not rep:
return False
return 'x.com.samsung.da.hotwaterLock' not in rep
FAVORITE_HOTWATER = Capability(
href='/favorite/hotwater/vs/0',
poll_tier='cold',
entities=(
# Despite the resource/field naming, switchHotwater's value domain is
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
# hot-water lock as LOCK.hotwater_lock below, just surfaced through
# this href on boards that don't populate /status/lock/vs/0's
# hotwaterLock field. Shares that descriptor's key so only one "Hot
# water lock" entity ever appears.
#
# Both halves of this fallback pair need an exists_fn, not just this
# one: adapter.flatten() (the coordinator.data source every entity's
# is_on reads) only ever honours exists_fn, never entity.py's
# implicit "require own field present" default that gates plain
# registration. Two same-keyed descriptors with only one of them
# gated still both land in flatten()'s output dict -- whichever is
# processed last silently wins, decided by device-reported href
# order, not by which one is actually correct. So this exists_fn
# also re-asserts its own field's presence (switchHotwater), the
# gate a bare `field=` used to get for free before it had to share a
# key with LOCK's descriptor.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
'x.com.samsung.da.switchHotwater' in rep
and _status_lock_definitely_lacks_hotwater_field(resources)),
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.favorite.supportedList',
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.favorite.defaultTemperature': p})),
),
)
# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix
# on this resource, unlike the rest of the water-purifier surface.
COFFEE = Capability(
href='/favorite/coffee/vs/0',
poll_tier='warm',
entities=(
SwitchDesc(key='favorite_coffee_enabled', field='favorite.activate',
icon='mdi:coffee-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['favorite', 'coffee', 'vs', '0'],
{'favorite.activate': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='coffee_brew_status', field='brew.status',
icon='mdi:coffee-outline',
entity_category='diagnostic'),
),
)
# Cup-detection status (issue #196, RWP70F15ANW). Only "UnReady" observed;
# the full state domain isn't confirmed, so this stays a plain diagnostic
# sensor rather than an enum with an invented state table.
CUP_STATE = Capability(
href='/cup/state/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cup_state', field='water.cup.state',
icon='mdi:cup-outline', entity_category='diagnostic'),
),
)
# Sound mode/output/volume (issue #196). Shapes echo laundry.py/
# air_purifier.py's same-named hrefs, but this board's own values differ
# from both (supportedModes here is voice/fixedTone/mute, not laundry's
# voice/tone/mute nor air_purifier's mute/buzzer) -- reusing either would
# reject a live-supported value, so these read the device's own supported
# list/range like air_purifier's versions do.
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
poll_tier='cold',
entities=(
SelectDesc(key='sound_mode', translation_key='water_purifier_sound_mode',
field='mode',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedModes',
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
),
)
SOUND_OUTPUT = Capability(
href='/settings/sound/output/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='sound_output', field='deviceType',
icon='mdi:volume-high', entity_category='diagnostic'),
# No confirmed write contract (no sibling field advertising this as
# user-settable) -- surfaced read-only per the 'don't guess' rule.
BinarySensorDesc(key='alarm_in_mute', field='alarmInMute',
icon='mdi:volume-mute',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true'),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
poll_tier='cold',
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
),
)
# Last-pour statistics (issue #196). last.capacity's unit isn't confirmed
# (no sibling unit field on this resource, unlike DISPENSE.dispense_capacity
# which at least has an -- albeit suspect -- capacityUnit) so it's left
# unitless rather than assumed to be mL.
STATISTIC_POUR = Capability(
href='/statistic/pour/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='last_pour_type', field='last.type',
icon='mdi:cup-water', entity_category='diagnostic'),
SensorDesc(key='last_pour_capacity', field='last.capacity',
icon='mdi:cup-water', entity_category='diagnostic',
value_fn=int_or_none),
),
)
LOCK = Capability(
href='/status/lock/vs/0',
poll_tier='warm',
entities=(
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
# above (issue #144); see the comment there for why this half also
# needs an explicit exists_fn now that the two share a key in
# adapter.flatten()'s output. A stub rep ({}) still counts as
# "present" here (matches entity.py's own default for a field-less
# gate) since the alternative -- treating an unfetched resource as
# confirmed-absent -- is what let both descriptors register at once.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.hotwaterLock' in rep),
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})),
),
)
# ---------------------------------------------------------------------------
# Water-purifier-scoped coverage: hrefs with no user-actionable state or no
# confirmed contract, following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_WP_IGNORED = [
# supportedModes carries a single opaque wizard-workflow token
# ('HOMECARE_WIZARD_V2') and modes reports a completely different,
# unrelated value ('WATERFILTER_DISABLE') not even present in
# supportedModes -- internal plumbing, not a real user-facing mode
# select. OCF-standard /mode/0 mirrors the same vendor resource but is
# already covered by the global ignored.IGNORED (fridge's OCF-native
# vacation-mode flag shares that href).
'/mode/vs/0',
# Static support-flags blob (automation.supported.modes/options) -- no
# live "current automation setting" field to expose.
'/automation/waterpurifier/vs/0',
# Coffee-capable variant (issue #107). All four are static
# capability-advertisement blobs or empty -- no live "current recipe" /
# "current custom slot" field to expose, unlike /favorite/coffee/vs/0
# (COFFEE above), which does carry live brew status.
'/brand/recipe/info/vs/0', # revision + max-brand-count metadata
'/coffee/custom/recipe/vs/0', # publisher.support: allowed custom-recipe slot IDs
'/recipe/coffee/vs/0', # same publisher.support shape, no per-recipe content
'/recipe/coffee/deletion/vs/0', # empty {} on this dump
]
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
@@ -15,6 +15,11 @@ class Capability:
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
# Rep field holding this instance's device-given name (e.g. an ice
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
# name prefix in place of the href-derived instance label. Does not
# affect key_override/unique_id -- only what's shown in the UI.
name_field: Optional[str] = None
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
# Rare optional hook — only operational-state-style resources use this.
on_observation: Optional[Callable[[dict, dict], None]] = None
@@ -18,6 +18,7 @@ from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
from .subdevices import MAIN, Subdevice
@dataclass
@@ -27,6 +28,31 @@ class BoundEntity:
desc: SamsungEntityDescription
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
# Which logical indoor subdevice (issue #177) this entity belongs to.
# `href` above is always the *actual*, on-the-wire href for that
# subdevice -- MAIN's
# to_actual is the identity transform, so every device with no subdevices
# behaves exactly as before this field existed.
subdevice: Subdevice = MAIN
def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
_derive_name, which applies the same transform to an href-derived key."""
return s.replace('_', ' ').title()
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
for use as a display-name prefix, or None if the cap doesn't declare
one or the device didn't report it."""
if not cap.name_field:
return None
raw = rep.get(cap.name_field)
if not isinstance(raw, str) or not raw:
return None
return _snake_to_title(raw)
def instance_suffix(href: str) -> str:
@@ -37,12 +63,52 @@ def instance_suffix(href: str) -> str:
return ''
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None,
subdevice: Subdevice = MAIN) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller.
`href` here is the *canonical* href discover() is iterating over;
`subdevice.to_actual` maps it to the real, on-the-wire href the entity
actually reads/writes (identity for MAIN, so single-subdevice devices are
unaffected -- see subdevices.py)."""
return [
BoundEntity(href=subdevice.to_actual(href), capability=cap, desc=desc,
instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name, subdevice=subdevice)
for desc in cap.entities
]
def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
pattern_caps: Iterable[Capability] = (),
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
subdevice: Subdevice = MAIN,
) -> list[BoundEntity]:
"""`tier_log(href, poll_tier)` fires for every href a capability actually
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
in capabilities/*.py) that `_bind()` turns into zero `BoundEntity` rows.
Callers that need a href's poll cadence (the coordinator's hot/warm
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
coverage-only capability's `poll_tier` would otherwise be silently
dropped since it never appears in `bound`.
`resources` is always keyed by *canonical* hrefs -- for a subdevice
(issue #177) that means its own canonical view (see
subdevices.canonical_view), the same shape as a plain single-subdevice
device's resources dict, so registry lookups/rt_filter/match_fn/
instance_suffix all behave identically regardless of which subdevice is
being discovered.
`subdevice` only affects the *href* stamped onto each BoundEntity (via
`_bind`, see above) and the href `log`/`tier_log` report -- both the
real, subscribable/pollable path, not the canonical one the registry is
keyed on.
"""
out: list[BoundEntity] = []
for href, rep in resources.items():
@@ -58,10 +124,10 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
for desc in cap.entities:
out.append(BoundEntity(href=href, capability=cap,
desc=desc, instance=inst))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
matched = True
if tier_log is not None:
tier_log(subdevice.to_actual(href), cap.poll_tier)
if matched:
continue
@@ -78,14 +144,14 @@ def discover(
# Auto-derive key prefix from href segments (skip digits and 'vs')
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
for desc in cap.entities:
key_override = '_'.join(segs) + '_' + desc.key
out.append(BoundEntity(href=href, capability=cap, desc=desc,
instance=inst, key_override=key_override))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
subdevice=subdevice))
matched = True
if tier_log is not None:
tier_log(subdevice.to_actual(href), cap.poll_tier)
break
if not matched and not caps and log is not None:
log(href)
log(subdevice.to_actual(href))
return out
@@ -2,14 +2,21 @@
Frozen dataclasses so the future native HA component can consume them as
EntityDescription subclasses unchanged. Read transforms live in value_fn;
presence gating in exists_fn; write logic in write_fn on command platforms.
presence gating in exists_fn; write logic in write_fn on command platforms;
pre-write rejection (surfaced to the user, not just logged) in validate_fn
where a description declares one.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Optional
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Optional
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
# (payload, rep, resources) -> a translation key, or None to allow the
# write. resources is the coordinator's full href->rep snapshot, for the same
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
# option list).
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
def _identity(v: Any) -> Any:
@@ -20,8 +27,18 @@ def _identity(v: Any) -> Any:
class SamsungEntityDescription:
key: str
field: str = ''
name: Optional[str] = None
translation_key: Optional[str] = None
# Defaults to `key`: entity names and states live in translations/, never
# here, so a descriptor only sets this to share one catalog entry across
# several descriptors, or to point at a differently-named one.
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot and returns the key to use -- for a descriptor shared across
# board generations whose state-code meaning isn't guaranteed consistent
# between them; see laundry.cycle_select's table-id-gated resolver.
translation_placeholders: Optional[Mapping[str, str]] = None
# Dynamic resources such as fridge compartments and ice makers use a
# device-provided or href-derived instance label inside a translated name.
use_instance_name: bool = False
icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
@@ -29,7 +46,7 @@ class SamsungEntityDescription:
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
# (rep, resources): rep is this entity's own href's representation;
# resources is the coordinator's full href->rep snapshot, for gating
# presence on a sibling resource (e.g. washer._cycle_options's source).
# presence on a sibling resource (e.g. laundry.cycle_options's source).
exists_fn: Optional[Callable[[dict, dict], bool]] = None
@@ -38,6 +55,7 @@ class SensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None
state_class: Optional[str] = None
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
options: Optional[tuple] = None # required by HA when device_class == 'enum'
@@ -60,6 +78,7 @@ class SelectDesc(SamsungEntityDescription):
class SwitchDesc(SamsungEntityDescription):
device_class: Optional[str] = None
write_fn: WriteFn = None
validate_fn: ValidateFn = None
@dataclass(frozen=True, kw_only=True)
@@ -72,9 +91,17 @@ class ButtonDesc(SamsungEntityDescription):
class NumberDesc(SamsungEntityDescription):
device_class: Optional[str] = None
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
native_min: Optional[float] = None
native_max: Optional[float] = None
step: Optional[float] = None
# Override native_min/native_max/step from the live rep, when set --
# same "static default, live override" shape as unit_fn, for resources
# whose sane bounds depend on a per-device value (e.g. a temperature
# setpoint reported in Celsius on one device, Fahrenheit on another).
native_min_fn: Optional[Callable[[dict], float]] = None
native_max_fn: Optional[Callable[[dict], float]] = None
step_fn: Optional[Callable[[dict], float]] = None
range_field: Optional[str] = None # resource field containing [min, max] list
write_fn: WriteFn = None
@@ -84,6 +111,23 @@ class TimeDesc(SamsungEntityDescription):
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class ClimateDesc(SamsungEntityDescription):
# A composite entity: it binds one *primary* resource (its href) but the
# climate platform reads sibling resources (power, temperature, wind) from
# the coordinator snapshot and writes to several of them. write_fn takes a
# (kind, value) payload from the platform and returns the (path_segs, body)
# for that one sub-write, so a single desc drives multi-resource writes.
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class FanDesc(SamsungEntityDescription):
# Composite fan entity: reads power from /power/0 and speed/support data
# from its bound href. Payloads are (kind, value), like ClimateDesc.
write_fn: WriteFn = None
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
@@ -92,4 +136,6 @@ PLATFORM_OF: dict[type, str] = {
ButtonDesc: 'button',
NumberDesc: 'number',
TimeDesc: 'time',
ClimateDesc: 'climate',
FanDesc: 'fan',
}
@@ -1,7 +1,7 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d)."""
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Optional
import cbor2
@@ -13,6 +13,8 @@ class DeviceIdentity:
model: str
name: str
serial: Optional[str]
device_types: tuple[str, ...] = ()
raw: dict[str, dict] = field(default_factory=dict)
def _get(sess, path) -> dict:
@@ -26,12 +28,65 @@ def _get(sess, path) -> dict:
return {}
def _get_links(sess, path) -> list:
"""Like _get, but for /oic/res: a baseline-Interface RETRIEVE on it
returns a CBOR array of Link objects (href/rt/if/di/...), not a single
Property map."""
try:
code, pl = sess.get(path, timeout=10.0)
if code == 0x45 and pl:
body = cbor2.loads(pl)
return body if isinstance(body, list) else []
except Exception:
pass
return []
def _device_types(d: dict) -> tuple[str, ...]:
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
In OCF this is the one standardized "what am I" field: alongside the
generic 'oic.wk.d' it carries a concrete type such as 'oic.d.airconditioner'
or a Samsung 'x.com.samsung.da.*' equivalent. Nothing routes on it yet --
device-type detection currently parses board part numbers out of
/information/vs/0's modelNum instead (see registry/by_type/__init__.py) --
because no captured dump has ever included it: /device/0 batch responses
don't carry /oic/d, and diagnostics didn't report it. It's surfaced in
diagnostics so incoming issue reports can tell us whether real hardware
populates it usefully enough to route on.
"""
rt = d.get('rt')
if isinstance(rt, str):
rt = [rt]
if not isinstance(rt, (list, tuple)):
return ()
return tuple(t for t in rt if isinstance(t, str))
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
# RETRIEVE on it returns every Resource/Collection href this endpoint
# hosts, not just the one /device/0 seed path the coordinator polls.
# Relevant for the OCF "Composite Device" model (issue #177: a single
# physical device -- one IP, one /oic/p -- exposing more than one logical
# subdevice, each as its own Collection resource, same rt shape as our own
# /device/0). This is what registry.subdevices.enumerate_subdevices reads
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter's
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
# /device/2 speculative fallback it used to run right here on every
# _connect_session (including every reconnect), moved to that module so
# it only runs once, at first discovery, instead of on every reconnect.
res = _get_links(sess, ['oic', 'res'])
return DeviceIdentity(
manufacturer=p.get('mnmn') or 'Samsung',
model=p.get('mnmo') or '',
name=d.get('n') or '',
serial=serial,
device_types=_device_types(d),
# Kept whole rather than field-by-field: these resources are outside
# the /device/0 dump diagnostics already captures, and we don't yet
# know which of their fields will turn out to identify a device type.
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res},
)
@@ -19,9 +19,26 @@ _SENSITIVE_SUBSTRINGS = (
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
)
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
# with bare one- and two-letter keys that the rules above cannot see, being
# far too short to match on -- 'di' alone is a substring of 'condition',
# 'display', 'dispenser' and plenty of other ordinary appliance fields:
#
# 'di' -- device UUID, 'pi' -- platform UUID. As identifying as the serial
# number above.
# 'n' -- /oic/d's device name. Free text the owner can set from the
# SmartThings app, so it may well carry a person's name. Nothing
# in the /device/0 dump has ever exposed it; it only became
# reachable when diagnostics started reporting /oic/d, and the
# device-type signal we actually want from that resource is `rt`,
# which is not redacted.
_SENSITIVE_EXACT = frozenset({'di', 'pi', 'n'})
def _is_sensitive_key(key: str) -> bool:
lowered = key.lower()
if lowered in _SENSITIVE_EXACT:
return True
return any(s in lowered for s in _SENSITIVE_SUBSTRINGS)
@@ -0,0 +1,611 @@
"""Subdevice ("composite device") support for one physical connection exposing
more than one logical indoor subdevice -- issue #177.
Two reporters, two different board families, two genuinely different
mechanisms for exposing a second indoor subdevice over one IP / one DTLS
session (see DESIGN-177.md section 1 for the full evidence trail; the two
diagnostics dumps this was built against come from the Pattern A and
Pattern B reporters, respectively -- they each filed one of the two
reports this module unifies):
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter's
board). `/oic/res` lists three complete parallel resource sets whose
trailing path segment is the index (`/mode/vs/0`, `/mode/vs/1`,
`/mode/vs/2`, ... on both OCF-standard and vendor hrefs), and `/device/0`'s
batch carries only the index-0 hrefs -- the sibling subdevices are
reachable only via their own `/device/<n>` collection.
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, that reporter's board).
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
same UUID appears as a literal href prefix in `/oic/res`
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on
that reporter's unit is narrower than early issue #177 writeups suggested: a
single individual `GET /<uuid>/information/vs/0` was read by hand through
the debug panel and came back carrying a different model/serial than the
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a
second subdevice exists at that prefix, but not evidence that `GET
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
around) itself returns anything. Issue #205, the same unit on a later
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
So `enumerate_subdevices` tries it first (a future board might genuinely
expose it) and falls back, when it's empty, to probing every href the
master itself answered this cycle individually under the UUID prefix --
the only thing ever actually confirmed to work for this pattern -- on the
assumption that a composite device's siblings share the master's resource
surface. See `Subdevice.flat_hrefs`.
Both are "the same thing wearing different clothes": a logical subdevice is a
seed collection path to poll, plus an href transform between the canonical
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
detection signals don't overlap on either captured board (the Pattern A
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter's has
no `/device/1`), so no disambiguation logic is needed --
`enumerate_subdevices` checks both and materializes any candidate whose
seed answers with a non-empty batch.
A non-empty seed batch is necessary but not sufficient for the *candidate*
to actually be a live second subdevice, though: the Pattern A reporter's
own board also has a `/device/2` -- a third, unused SmartThings slot --
that answers with the exact same 14-href shape as the real `/device/1`
sibling, populated with three constant/echoed/shape-only reps (a region
code identical to every other subdevice's, an /information rep echoing the
*same* model string as subdevice 1, and a /temperatures items[] entry with
an id/description but no current/desired/minimum/maximum reading) and
nothing resembling live climate state. Gating on *resource* shape/hrefs
turned out to be the wrong layer -- it would need per-family domain
knowledge (which hrefs mean "in use" for a washer's second drum, a
fridge's second compartment, ...) baked into a registry field before any
of those families could use this module at all. `discover_partitioned`
instead gates at the *entity* layer, after discovery+flattening: a
candidate is only kept if it produced at least one *primary* (no
`entity_category`), non-meter bound entity whose flattened value isn't
`None` -- e.g. the Pattern A reporter's /device/2 does flatten to an
`alarm_code` value, but that entity is diagnostic-category and derived from
an empty /alarms/vs/2, so it doesn't count. This reuses the same
primary/config/diagnostic taxonomy every registry already declares (see the
adding-device-support skill's entity-taxonomy section) instead of adding a
second, parallel domain-knowledge mechanism.
The meter carve-out is issue #214, and it's the same "an unused slot still
answers *something*" problem one layer further in: that reporter's
single-split ARTIK051_KRAC_18K has a /device/1 whose operational reps are
all empty {} -- the /device/2 shape above -- but which also reports a
populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
that materialized the slot as a phantom second air conditioner. See
`_has_live_primary_entity`.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable, Optional
import cbor2
from .batch import parse_device0_batch
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical subdevice's Collection on this board (moved here from
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
# including every reconnect, for information enumeration only needs once).
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
# guess the write-contract 'don't guess' rule is about. Widen only if a real
# board ever turns out to need more than two siblings.
_SPECULATIVE_DEVICE_INDICES = (1, 2)
@dataclass(frozen=True)
class Subdevice:
"""One logical indoor subdevice reachable over a single physical
connection.
`kind='main'` is the subdevice this config entry actually connects to and
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
identity transform, so every existing single-subdevice device keeps
behaving exactly as it did before this module existed. `'indexed'`/
`'prefixed'` are Pattern A/B above; `key` is the trailing index string
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
Collection href (as path segments) whose batch response
enumerates/refreshes that subdevice.
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
expose its own Collection at `seed_path` (issue #205 -- not even
TP2X_FAC_BORA_21K, the board this pattern was built against, always
does). When set, `seed_path` is meaningless (left as `()`) and this
subdevice's state comes from GETting each of these canonical hrefs
individually under its prefix instead of one Collection batch -- see
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
seed_path: tuple[str, ...]
flat_hrefs: tuple[str, ...] = ()
def to_actual(self, canonical: str) -> str:
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
on-the-wire href for this subdevice."""
if self.kind == 'indexed':
head, sep, tail = canonical.rpartition('/')
# Only the index-0 trailing segment is ours to rewrite --
# deliberately not a "replace any trailing digit" rule, which
# would misread a genuine multi-instance resource (the fridge's
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice's. No
# registry declares a non-zero trailing index today and no
# fixture in the corpus contains one (verified across the whole
# corpus), so the strict rule costs nothing.
if tail == '0':
return f'{head}{sep}{self.key}'
return canonical
if self.kind == 'prefixed':
return f'/{self.key}{canonical}'
return canonical
def to_canonical(self, actual: str) -> Optional[str]:
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
if self.kind == 'indexed':
head, sep, tail = actual.rpartition('/')
if tail == self.key:
return f'{head}{sep}0'
return None
if self.kind == 'prefixed':
prefix = f'/{self.key}'
if actual.startswith(prefix + '/'):
return actual[len(prefix):]
return None
return actual
def owns(self, actual: str) -> bool:
"""True if `actual` belongs to this subdevice's namespace. MAIN never
"owns" anything by this definition -- it gets whatever's left after
every other subdevice's hrefs are excluded (see canonical_view)."""
if self.kind == 'main':
return False
return self.to_canonical(actual) is not None
@property
def key_prefix(self) -> str:
"""Prefix that guarantees a unique entity key/unique_id (see
adapter._key). '' for MAIN -- the master's flattened state
keys must stay byte-identical to every device this integration
shipped before issue #177, so no golden file changes. The full
subdevice UUID is used verbatim (non-alphanumerics stripped, not
truncated or replaced with an ordinal) because it's device-reported
and stable across reconnects/restarts, unlike an ordinal assigned by
enumeration order -- and it never appears in a user-visible string
(see DESIGN-177.md section 6): HA derives the visible entity_id from
the device name + entity name, not from unique_id.
"""
if self.kind == 'indexed':
return f'subdevice{self.key}_'
if self.kind == 'prefixed':
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
return f'subdevice_{slug}_'
return ''
MAIN = Subdevice(kind='main', key='', seed_path=('device', '0'))
def canonical_view(
subdevice: Subdevice, resources: dict[str, dict], subdevices: list['Subdevice'],
) -> dict[str, dict]:
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
and friends are written against.
For MAIN this is the snapshot *minus* every href owned by one of the
other subdevices in `subdevices` -- otherwise a sibling's own `/mode/vs/1`
would leak into the master's view under the same canonical key
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
silently mixing two subdevices' state together. For an indexed/prefixed
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
back through `to_canonical`.
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
False, so including it is harmless.
"""
if subdevice.kind == 'main':
owned_elsewhere = {
href for href in resources
if any(su.owns(href) for su in subdevices)
}
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
return {
canon: resources[actual]
for actual in resources
if (canon := subdevice.to_canonical(actual)) is not None
}
def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[str, dict]:
"""Real, on-the-wire hrefs from one subdevice's seed-collection batch,
normalized so every href actually carries this subdevice's prefix/index.
Indexed subdevices need no change -- the device echoes the real `/x/<n>`
href in its own `/device/<n>` batch (confirmed against the Pattern A
reporter's dump). A prefixed subdevice's batch entries may or may not
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B
reporter's board was never probed live before the subdevice id was
known), so it's added when missing.
"""
if subdevice.kind != 'prefixed':
return batch
prefix = f'/{subdevice.key}'
return {
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
for href, rep in batch.items()
}
def _iter_oic_res_hrefs(oic_res):
"""Flatten /oic/res's raw shape into a flat iterable of link dicts.
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
-- see identity.py's read_identity/_get_links), so that's the shape
handled here. Tolerant of a flat link-list too (nothing in the OCF spec
rules it out, and _get_links' own posture already treats any list-shaped
body as possible) and of anything else by yielding nothing.
"""
for entry in (oic_res or []):
if not isinstance(entry, dict):
continue
if 'links' in entry:
for link in entry.get('links') or []:
if isinstance(link, dict):
yield link
elif 'href' in entry:
yield entry
def _seed_href(path_segs: tuple[str, ...]) -> str:
"""('device', '1') -> '/device/1' -- the leading-slash href form
`probe_log` and diagnostics report, built from the path-segment form
`sess.get` takes."""
return '/' + '/'.join(path_segs)
def _get_raw(sess, path_segs: tuple[str, ...]):
"""GET `path_segs` and CBOR-decode the payload, or None on any
missing/malformed response (a 4.04, a timeout, an empty payload) --
shared tolerated-absence posture for both callers below, which differ
only in which body shape they accept."""
try:
code, pl = sess.get(list(path_segs), timeout=10.0)
if code == 0x45 and pl:
return cbor2.loads(pl)
except Exception:
pass
return None
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
"""GET a Samsung Collection resource and parse it the same way
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
{href, rep}, ...] CBOR list, not a bare Property map."""
body = _get_raw(sess, path_segs)
return parse_device0_batch(body) if isinstance(body, list) else {}
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
`/oic/res` on the Pattern A reporter's board but absent from
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
single Property map, not a [devcol-rep, ...] list."""
body = _get_raw(sess, path_segs)
return body if isinstance(body, dict) else {}
def enumerate_subdevices(
sess,
resources: dict[str, dict],
oic_res_links,
probe_log: Optional[Callable[[str, bool], None]] = None,
) -> tuple[list['Subdevice'], dict[str, dict]]:
"""Discover every sibling indoor subdevice reachable over `sess`'s
connection.
Runs once, at first discovery, in an executor, under the coordinator's
session lock -- every GET here is a plain RETRIEVE (the write-contract
'don't guess' rule doesn't apply to reading an extra resource to find
out whether it's there). Returns the *candidate* subdevices and the resources
already fetched while probing them (already normalized to real hrefs),
so the coordinator's first discovery poll doesn't need to re-poll them.
`probe_log(seed_href, found)` fires for every seed attempted, whether or
not it answered -- so diagnostics (see diagnostics.py's subdevice_probes)
can tell "checked, nothing there" apart from "never checked", the same
posture the speculative-probe code this replaces used to document in
identity.py.
Every candidate whose seed answers with a non-empty batch is returned
here -- this function has no way to tell a real sibling from an unused
SmartThings slot that merely answers the same shape (the Pattern A
reporter's `/device/2`); that requires discovering+flattening the
candidate's own entities first, which is `discover_partitioned`'s job,
not this one's.
See this module's docstring.
"""
subdevices: list[Subdevice] = []
fetched: dict[str, dict] = {}
def _probed(seed_href: str, batch: dict) -> None:
if probe_log is not None:
probe_log(seed_href, bool(batch))
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
'x.com.samsung.da.subdeviceIdList')
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, not a
# crash -- issue #177 is additive, it must never break an already-working
# single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
seed = (sub_id, 'device', '0')
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if batch:
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
continue
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
# pattern was built against -- turns out not to always expose its own
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
# has one" doesn't hold even on the reference hardware. With no
# Collection to seed from and no per-UUID entry in `/oic/res` to
# enumerate hrefs from, the only signal left is that a composite
# device's siblings are the same physical board family as the
# subdevice this config entry already talks to -- so probe every
# href the master itself answered this cycle, individually, under
# this UUID's prefix, and keep whichever ones answer. Each is a
# plain tolerated-404 RETRIEVE, same posture as every other probe in
# this function.
#
# Known gap, not yet guarded against: a firmware that answers *any*
# request under an unrecognized prefix (echoing the master's own
# state back rather than 4.04ing) would pass every one of these
# probes and, if the echoed state also clears discover_partitioned's
# liveness gate, materialize a phantom duplicate of the master
# rather than a real sibling. Every board seen so far genuinely
# 4.04s on paths it doesn't own (issue #205's own unit answered only
# 1 of 31 probes), so this hasn't been built -- the one place it
# could hook in later is comparing a candidate's confirmed reps
# against the master's own values for those same canonical hrefs.
flat_hrefs = []
first = True
for href in sorted(resources):
if not first:
sess.pace()
first = False
actual = f'/{sub_id}{href}'
rep = _get_property(sess, tuple(actual.strip('/').split('/')))
_probed(actual, bool(rep))
if rep:
flat_hrefs.append(href)
fetched[actual] = rep
if not flat_hrefs:
continue
subdevices.append(Subdevice(
kind='prefixed', key=sub_id, seed_path=(),
flat_hrefs=tuple(flat_hrefs),
))
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted({
int(m.group(1))
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_INDEXED_HREF_RE.match(link.get('href', ''))]
if m and int(m.group(1)) >= 1
})
if not indices:
# A board that hides its whole tree from /oic/res (Pattern B's
# reporter board does this too, but it has no /device/<n> to find
# regardless) gives us nothing to enumerate from -- fall back to the
# bounded speculative probe this replaces from identity.py.
indices = list(_SPECULATIVE_DEVICE_INDICES)
for n in indices:
seed = ('device', str(n))
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if not batch:
continue
subdevice = Subdevice(kind='indexed', key=str(n), seed_path=seed)
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
subdevices.append(subdevice)
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
# board lists it in /oic/res but it never appears in /device/0's batch,
# so it needs its own RETRIEVE. It's a plain corroborating count
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
# into the merged resources dict like any other href (see
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
# liveness check decides materialization correctly without it, and only
# this one board family is known to expose it at all. Whether it agrees
# with the number of subdevices actually materialized is the
# coordinator's call to log (it owns the logger; this module doesn't),
# not this function's.
multidevice_seed = ('multidevice', 'vs', '0')
multidevice = _get_property(sess, multidevice_seed)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched['/multidevice/vs/0'] = multidevice
return subdevices, fetched
@dataclass(frozen=True)
class SkippedSubdevice:
"""A candidate `enumerate_subdevices` found whose seed answered, but that
`discover_partitioned`'s entity-level liveness gate rejected -- an
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a
real second subdevice. Kept around (rather than silently dropped) so a
caller can log/report what was skipped and why."""
subdevice: Subdevice
hrefs: tuple[str, ...]
# Sensor kinds whose value is a running total the *appliance* keeps rather
# than a reading of the subdevice's own hardware -- excluded from the
# liveness gate below (issue #214). HA's own running-total state classes
# cover most of them; the consumption device classes catch the rest, since a
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
# monthly totals reset at each billing boundary, so they aren't
# `total_increasing`).
_METER_STATE_CLASSES = frozenset({'total', 'total_increasing'})
_METER_DEVICE_CLASSES = frozenset({'energy', 'water', 'gas'})
def _is_meter(desc) -> bool:
"""True for a cumulative consumption/counter descriptor -- see the two
constants above. Only SensorDesc carries either attribute; everything
else answers False through the getattr defaults."""
return (
getattr(desc, 'state_class', None) in _METER_STATE_CLASSES
or getattr(desc, 'device_class', None) in _METER_DEVICE_CLASSES
)
def _has_live_primary_entity(bound, state: dict) -> bool:
"""True if flattening `bound` (one candidate subdevice's BoundEntity
list) produced at least one non-`None` value for a *primary* entity --
`entity_category` unset, HA's own "the user acts on or watches this"
tier (see the adding-device-support skill's entity-taxonomy section) --
that isn't a cumulative meter (`_is_meter`).
This is the materialization gate itself (see this module's docstring).
Two exclusions, both for the same reason -- the question this answers is
"is a physical subdevice installed at this slot?", and neither kind of
value can speak to it:
- **Non-primary entities.** The Pattern A reporter's `/device/2` does
flatten to one non-`None` value (`alarm_code`), but that entity is
`diagnostic`-category and derived from an empty `/alarms/vs/2` -- a
config/diagnostic entity reading "something" proves nothing about
whether hardware is there.
- **Cumulative meters** (issue #214). An unused slot on the issue #214
reporter's ARTIK051_KRAC_18K reports `/energy/consumption/vs/1` with a
populated `cumulativePower` while every operational rep on it
(`/power/1`, `/mode/1`, `/mode/vs/1`, `/temperature/current/1`,
`/temperature/desired/1`, `/airflow/1`, `/humidity/1`) is empty `{}` --
i.e. exactly the Pattern A `/device/2` shape plus a lifetime kWh
counter. That counter got the slot materialized as a phantom second
air conditioner. A single-split AC has one compressor and one energy
meter, so a whole-appliance total showing up under a second index is
the appliance's own bookkeeping, not evidence of a second indoor unit.
A genuinely installed subdevice reports its own operational state too
(the Pattern A reporter's real `/device/1` reports power, mode, both
temperatures and airflow), and that state is what still passes this
gate.
"""
from .adapter import _key # see discover_partitioned's deferred-import note
return any(
not b.desc.entity_category and not _is_meter(b.desc)
and state.get(_key(b)) is not None
for b in bound
)
def discover_partitioned(
resources: dict[str, dict],
subdevices: list['Subdevice'],
resolve_registry: Callable[[dict], object],
fallback_capabilities: dict,
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
):
"""Bind every href in `resources` (the merged, real-href snapshot -- main
plus every enumerated subdevice's seed) to entities, partitioned by which
subdevice owns it.
Main pass runs over hrefs owned by no subdevice -- otherwise every
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
device's registry claims that literal href) and raise a spurious
coverage-gap repair. Then one pass per *candidate* subdevice over its own
canonical view, resolving that subdevice's own device type from its own
`/information/vs/0` when it reports one (e.g. the Pattern B reporter's
wall subdevice reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
airconditioner), falling back to the master's registry otherwise --
every AC family shares the same resource surface, and a sibling that
fails to answer its own identity resource is still the same appliance
type as the subdevice this config entry was set up against.
Each candidate is discovered and flattened *twice*: once silently to
evaluate `_has_live_primary_entity` (this module's materialization
gate -- see its docstring and this module's own), and, only if that
passes, a second time with `log`/`tier_log` wired so its coverage gaps
and poll tiers actually count. A candidate that fails the gate
contributes nothing at all -- no bound entities, no unbound-href
report, no hot/warm href -- as if it had never answered its seed.
Discovering an unused slot's small, fixed resource set twice at
first-discovery time only is a non-issue; getting a phantom subdevice
silently counted into unbound_hrefs or hot/warm tiers is not.
Returns `(bound, device_type_name, materialized, skipped)`:
- `bound`: the concatenated BoundEntity list (main + every materialized
subdevice).
- `device_type_name`: the *master's* resolved device type (used for
logging/device naming; each subdevice's own resolved type only affects
which capabilities bind its hrefs, not this).
- `materialized`: the subset of `subdevices` that passed the gate, in the
same order -- what the caller should keep as its live subdevice roster
going forward (poll seeds, canonical_resources, device_info_for, ...).
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
"""
# Deferred import: discovery.py imports Subdevice/MAIN from this module at
# module scope, so importing discover() back here at module scope would
# be a circular import. By the time this function actually runs both
# modules are fully loaded. adapter.py imports discovery.py, so the same
# applies to flatten()/_key().
from .adapter import flatten
from .discovery import discover
# Same computation canonical_view does for MAIN (snapshot minus every
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
# owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, subdevices)
reg = resolve_registry(main_view)
caps, pats = (
(reg.capabilities, reg.pattern_capabilities) if reg is not None
else (fallback_capabilities, [])
)
# MAIN is never gated -- the config entry's own physical connection
# always materializes regardless of what its entities' values are.
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, subdevice=MAIN)
device_type_name = reg.name if reg is not None else None
materialized: list[Subdevice] = []
skipped: list[SkippedSubdevice] = []
for su in subdevices:
view = canonical_view(su, resources, subdevices)
su_reg = resolve_registry(view) or reg
su_caps, su_pats = (
(su_reg.capabilities, su_reg.pattern_capabilities) if su_reg is not None
else (fallback_capabilities, [])
)
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
probe_state = flatten(probe_bound, resources)
if _has_live_primary_entity(probe_bound, probe_state):
materialized.append(su)
bound = bound + discover(
view, su_caps, su_pats, log=log, tier_log=tier_log, subdevice=su,
)
else:
skipped.append(SkippedSubdevice(
subdevice=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
))
return bound, device_type_name, materialized, skipped
+45 -12
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from typing import Optional
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
@@ -10,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SelectDesc
from .catalog import translated_states
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
@@ -31,16 +33,37 @@ async def async_setup_entry(
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
def _display(value, desc: SelectDesc):
def _translation_state(value: str, known: frozenset[str]) -> str | None:
"""Return the catalog state `value` normalizes to, else None.
Samsung reports options in whatever casing the resource uses
('Rinse_Hold', 'SpTtypeBeerDrinks', '1b'); Home Assistant looks state
translations up by a lowercase key. Only values the catalog actually
knows are normalized -- an unrecognized (or future) vendor value keeps
its own readable form rather than becoming an untranslatable slug.
"""
direct = value.lower().replace(' ', '_')
if direct in known:
return direct
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
return snake if snake in known else None
def _display(value, translation_key: Optional[str]):
"""Turn a raw device option/state value into what's shown in the UI.
An entity with a translation_key looks its state up in strings.json,
and hassfest requires those keys to be lowercase -- so those values
`translation_key` is the entity's already-resolved key (SelectDesc.
translation_key can itself be a callable -- see entities.py -- so
callers pass the resolved value, e.g. self.translation_key, not
the raw descriptor field).
An entity with a translation_key looks its state up in the shipped
translation catalog, whose state keys are lowercase -- so those values
must be lowercased exactly to match, and the device still expects
that same raw casing back on write (callers map the displayed value
back to raw via _raw_options()).
Everything else has no strings.json lookup, so there's no reason to
Everything else has no catalog lookup, so there's no reason to
destroy the device's own casing. Only two cosmetic fixups apply: a
fully lowercase device-native token (e.g. "voice") is title-cased,
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
@@ -50,8 +73,16 @@ def _display(value, desc: SelectDesc):
"""
if not isinstance(value, str):
return value
if desc.translation_key:
return value.lower()
if translation_key:
known = translated_states('select', translation_key)
if not known:
# No state table for this key: either the entity isn't translated
# at all, or its name is translated but its options deliberately
# aren't (an unrecognized course table, say). Either way the
# opaque device value is the best thing to show.
return value
if translated := _translation_state(value, known):
return translated
if value.islower():
return value.replace('_', ' ').title()
return _CAMEL_BOUNDARY_RE.sub(' ', value)
@@ -63,7 +94,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
super().__init__(coordinator, bound)
desc: SelectDesc = bound.desc
if not desc.options_field and not callable(desc.options):
self._attr_options = [_display(o, desc) for o in desc.options]
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
def _raw_options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
@@ -73,7 +104,9 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
# list decoded from a sibling resource. There is no static
# fallback: when that resource isn't populated the callable
# returns [] and the entity's exists_fn suppresses it entirely.
return list(desc.options(self.coordinator.last_resources) or [])
# This entity's own subdevice's canonical view (issue #177), not
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
return list(desc.options(self._resources) or [])
if desc.options_field:
rep = self.coordinator.last_resources.get(self._bound.href) or {}
return list(rep.get(desc.options_field) or [])
@@ -83,17 +116,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
if desc.options_field or callable(desc.options):
return [_display(o, desc) for o in self._raw_options()]
return [_display(o, self.translation_key) for o in self._raw_options()]
return self._attr_options
@property
def current_option(self):
raw = (self.coordinator.data or {}).get(self._state_key)
return _display(raw, self._bound.desc)
return _display(raw, self.translation_key)
async def async_select_option(self, option: str) -> None:
desc: SelectDesc = self._bound.desc
raw = next(
(o for o in self._raw_options() if _display(o, desc) == option), option
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
option,
)
await self.coordinator.async_send_command(self._bound, raw)
+7 -1
View File
@@ -43,6 +43,13 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
if desc.options:
self._attr_options = list(desc.options)
@property
def native_unit_of_measurement(self):
desc: SensorDesc = self._bound.desc
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
@@ -54,7 +61,6 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
Disabled by default — it's for troubleshooting, not everyday use."""
_attr_has_entity_name = True
_attr_name = 'Connection mode'
_attr_translation_key = 'connection_mode'
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
-134
View File
@@ -1,134 +0,0 @@
{
"entity": {
"select": {
"door_alert": {
"state": {
"1": "Alarm 1",
"2": "Alarm 2",
"3": "Alarm 3",
"4": "Alarm 4"
}
},
"brightness_level": {
"state": {
"33": "Low",
"66": "Medium",
"100": "High"
}
},
"ice_type": {
"state": {
"whiskey_iceball_3": "3 Balls/Day",
"whiskey_iceball_6": "6 Balls/Day",
"whiskey_iceball_9": "9 Balls/Day"
}
},
"beverage_zone_mode": {
"state": {
"sp_ttype_beer_drinks": "Beverage",
"sp_ttype_wine_dessert": "Wine and Dessert"
}
},
"flex_zone_mode": {
"state": {
"cv_ttype_rf9000a_freeze": "Freeze",
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
"cv_ttype_rf9000a_beverage": "Beverage"
}
},
"dishwasher_cycle": {
"state": {
"0e": "AI Wash",
"07": "Pre blast",
"90": "Self clean",
"86": "Normal",
"83": "Express 60",
"84": "Heavy",
"8d": "Pots and pans",
"80": "Delicate",
"8e": "Plastic",
"8f": "Baby Care"
}
},
"washer_cycle": {
"state": {
"1c": "Eco 40-60",
"1d": "Super Speed",
"21": "Colours",
"1b": "Cotton",
"1e": "15' Quick Wash",
"29": "Drum Clean+",
"24": "Towels",
"33": "Bedding",
"28": "Drain/Spin",
"26": "Delicates",
"27": "Rinse+Spin",
"22": "Wool",
"20": "Hygiene Steam",
"23": "Outdoor",
"25": "Synthetics",
"32": "Shirts",
"2f": "Activewear",
"2e": "Baby Care",
"30": "Cloudy Day",
"66": "Denim",
"2d": "Silent Wash",
"8f": "Intense Cold",
"96": "Less Microfiber"
}
}
},
"sensor": {
"ice_making_status": {
"state": {
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
},
"connection_mode": {
"state": {
"observe": "Push (observe)",
"poll": "Polling"
}
}
}
},
"config": {
"step": {
"user": {
"title": "Add Samsung Appliance",
"description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates. {reuse_note}",
"data": {
"host": "IP Address",
"ca_cert_pem": "CA Certificate (PEM)",
"ca_key_pem": "CA Private Key (PEM)"
},
"data_description": {
"host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).",
"ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.",
"ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer."
}
},
"confirm_unknown_type": {
"title": "Appliance type not recognized",
"description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
}
},
"error": {
"cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
"invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
"unknown": "Unexpected error. Check the Home Assistant logs for details."
},
"abort": {
"already_configured": "This device is already configured."
}
},
"issues": {
"device_gap": {
"title": "Incomplete capability coverage for {device_name}",
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,2 +1,5 @@
[project]
requires-python = ">=3.13"
[tool.pytest.ini_options]
asyncio_mode = "auto"
+11 -1
View File
@@ -1,2 +1,12 @@
pytest>=8.0
# Test harness — pulls in home-assistant, pytest, and pytest-socket at the
# matching versions. pip resolves the newest home-assistant your interpreter
# supports: Python 3.13 gets 0.13.316 (the floor below), 3.14 gets newer. On
# 3.12 or older nothing resolves and the whole install fails.
pytest-homeassistant-custom-component>=0.13.316
# Integration runtime deps, needed to import the component under test
# (also declared in custom_components/localthings/manifest.json).
smartthings-local>=0.1.0
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
+98
View File
@@ -16,6 +16,89 @@ def _load_device(name: str) -> dict[str, dict]:
return _resources_from_dump(data)
def _load_device_full(name: str):
"""Like _load_device, but also returns the optional `oic_res`/`seeds`
keys a subdevice-capable fixture (issue #177) may carry alongside
`device0` -- see the two `airconditioner_*` fixtures with a
`seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every
other fixture, so this is safe to call on any fixture in the corpus.
Returns `(resources, oic_res, seeds)` where `seeds` is
`{seed_href: raw_batch_list}` -- the same [devcol-rep, {href, rep}, ...]
shape a real /device/<n> or /<id>/device/0 RETRIEVE returns, ready to
hand to a FakeCoapSession.
A fixture's optional `probes` map (plain Property-map resources that
belong to no batch, e.g. the hand-read /multidevice/vs/0 in the
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
FakeCoapSession answers both shapes off the same href key.
"""
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
resources = _resources_from_dump(data)
seeds = {**data.get('seeds', {}), **data.get('probes', {})}
return resources, data.get('oic_res', []), seeds
class FakeCoapSession:
"""Minimal stand-in for smartthings_local's DtlsCoapSession, backed by a
fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed
href -- plus any `probes` entries, which are plain Property maps rather
than batch lists; both are just CBOR bodies at this layer, and the two
readers in registry.subdevices already type-check what they get back).
Enough surface for registry.subdevices.enumerate_subdevices and
LocalThingsCoordinator's blocking subdevice polls to run against fixture
data without a live device -- same idea as test_identity.py's
FakeSession, but keyed by href string (post path-join) rather than a
path tuple, since callers here pass a `seed_path` tuple straight
through.
"""
def __init__(self, seeds: dict[str, list] | None = None):
self.seeds = seeds or {}
def get(self, path, timeout=None):
href = '/' + '/'.join(path)
body = self.seeds.get(href)
if body is None:
return 0x84, b'' # 4.04 not found -- tolerated absence
import cbor2
return 0x45, cbor2.dumps(body)
def pace(self):
pass
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
"""Run the *whole* subdevice-aware discovery pipeline against fixture
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
exercising this exercises the real code path, not a re-implementation of
it. See the adding-device-support skill's section 2 for the plain
(non-subdevice) equivalent this extends.
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
- `bound`: every BoundEntity, main + every materialized subdevice.
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
discover_partitioned.
- `full_resources`: `resources` merged with every candidate's seed data
(actual hrefs) -- what a coordinator's cache would hold.
- `device_type_name`: the master's resolved registry name.
"""
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.registry import CAPABILITIES
from custom_components.localthings.registry.subdevices import (
discover_partitioned, enumerate_subdevices,
)
sess = FakeCoapSession(seeds)
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
full_resources = {**resources, **extra}
bound, device_type_name, materialized, skipped = discover_partitioned(
full_resources, candidates, resolve, CAPABILITIES,
)
return bound, materialized, skipped, full_resources, device_type_name
def _load_resources(ip: str) -> dict[str, dict]:
"""Legacy IP-based loader — maps known IPs to named fixtures."""
_ip_to_name = {
@@ -41,3 +124,18 @@ def fridge_resources() -> dict[str, dict]:
@pytest.fixture
def washer_resources() -> dict[str, dict]:
return _load_device('washer')
@pytest.fixture
def all_device_fixtures() -> dict[str, dict[str, dict]]:
"""Every scrubbed device dump, keyed by fixture name.
For invariants that must hold across the whole corpus rather than for one
device -- so a newly added dump exercises them automatically.
"""
return {
path.name[:-len('_device.json')]: _resources_from_dump(
json.loads(path.read_text())
)
for path in sorted(FIXTURES.glob('*_device.json'))
}
+209
View File
@@ -0,0 +1,209 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.countryCode": "VN"
}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_01",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"WashingTimes_58",
"DrumCleanProposal_30",
"SpecialFunction_4",
"AvailableDelayTime_0",
"ProgressTimeSet_AA01E0AC00B4B20690",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"GMT_12",
"Alarm_Off",
"SilentSet_0F0FF0F0F0F0F0F0F0F00F0F0F0FF0F0F00FF0",
"Silent_Off",
"WrinklePreventSet_0F0F0F0F0F0F0F0FF0F00F0F0F0F0F0F0F0FF0",
"DelayEndSet_0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0F0F0F0F",
"OneTimeCourse_761F00200000000000000000000000",
"DownloadCourseList_090A0B12100C140D0F",
"EnergyLevelSet_0502020104030204050405020303010302010305",
"MostUsed_01",
"UsagesDB_ok",
"EnergyKW_396",
"DrumCleanLog_2024-01-01T00:00:00",
"TimeSync_NotSupported"
],
"x.com.samsung.da.supportedOptions": [
"001020403051A1B1C0708"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.cumulativePower": "78200",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785272400",
"x.com.samsung.da.cumulativeDateUTC": "1785240000"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000",
"x.com.samsung.da.description": "DA_DF_A51_20_COMMON_DF8600T/DC92-02656A_0018",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02672A230710(E431)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "DA_DF_A51_20_COMMON",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "20060507,20072702",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "REDACTED",
"machineStates": "REDACTED",
"jobStates": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
],
"currentJobState": "None",
"remainingTime": "00:39:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:39:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
]
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "10",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
}
]
}
+282
View File
@@ -0,0 +1,282 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airdresseroption/sanitize/vs/0",
"rep": {
"x.com.samsung.da.sanitize": "Off",
"x.com.samsung.da.supportedSanitize": [
"On",
"Off"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3171000000",
"x.com.samsung.da.countryCode": "KR"
}
},
{
"href": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_22",
"AiOption_On",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"FilterUsage_384019D5",
"WashingTimes_6",
"DrumCleanProposal_30",
"ProgressTimeSet_AA01A4AC00F0B20690",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"WeatherPush_Off",
"GMT_12",
"Alarm_Off",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F00F0F0FF0F0F0F0F00F0F",
"EnergyLevelSet_0502010102010202030303010401040505050503030502040503030303030405",
"UsagesDB_ok",
"DrumCleanLog_2023-04-02T08:08:55|2023-10-02T03:38:19|2024-05-26T03:25:41|2024-10-01T01:07:20|2026-06-16T02:23:29"
],
"x.com.samsung.da.supportedOptions": [
"12261062361020E61020961021261020C61021E61020B61021061020A61061461021361061661022462062562062F62062062040F62042761023061021561021A61021B61021C61022A61022B61022C61022D61022E6102076102086102"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "63600",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1784404800",
"x.com.samsung.da.cumulativeDateUTC": "1784372400"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000",
"x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF9500A/DC92-02888A_0002",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "A00",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02673A250416(F822)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "21020115,23030652",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "19111852,FFFFFFFF"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "REDACTED",
"machineStates": "REDACTED",
"jobStates": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
],
"currentJobState": "None",
"remainingTime": "00:39:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:39:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/st/airdressercourse/vs/0",
"rep": {
"x.com.samsung.da.st.airdresserMode": "Table_00_Course_22",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_22230C10252F0F0E09121E0B0A14131624202730151A1B1C2A2B2C2D2E0708",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_22270F"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/wm/personalcourse/vs/0",
"rep": {
"x.com.samsung.da.courses": [
"F1_00",
"F2_00",
"F3_00",
"F4_00",
"F5_00",
"F6_00",
"F7_00",
"F8_00",
"F9_00",
"FA_00"
],
"x.com.samsung.da.maxCourseNum": "10"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.aiCourse": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "true"
}
},
{
"href": "/wm/welcomemsg/vs/0",
"rep": {}
}
]
}
+381
View File
@@ -0,0 +1,381 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingActivationState": "On",
"x.com.samsung.da.periodicSensingInterval": "600",
"x.com.samsung.da.startSensingOnce": "On",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1785320820",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.autoExeState": "Off",
"x.com.samsung.da.supportedAutoExeState": [
"Off",
"Airpurify",
"Alarm"
],
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "03002300"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "87",
"x.com.samsung.da.supportedModes": [
"87",
"89",
"90",
"91"
],
"x.com.samsung.da.modesName": [
"SMART",
"MAX",
"WINDFREE",
"Sleep"
]
}
},
{
"href": "/filter/hepafilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "30",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "8760",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable"
]
}
},
{
"href": "/dnd/autosleep/vs/0",
"rep": {
"x.com.samsung.da.startTime": "15:00:00",
"x.com.samsung.da.endTime": "23:30:00",
"x.com.samsung.da.visible": "true",
"x.com.samsung.da.useTimeSetting": "true",
"x.com.samsung.da.functionState": "true"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0000000000000000000000000000",
"x.com.samsung.da.id": "VTL",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T10:34:52",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "48656",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeDate": "1785353679",
"x.com.samsung.da.cumulativeDateUTC": "1785321279",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": [
"CountOfSlave_0",
"SlavePower01_NotSupported",
"Pollution_Off",
"SmartSave_Off",
"OptionCode_23418",
"SmartSleep_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"10",
"1"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"9",
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"6",
"1"
]
}
],
"x.com.samsung.da.total": "5"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10238841|70000535001611CC0D10090301080000",
"x.com.samsung.da.description": "AVT-WW-TP1-23-AXX500",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AC0",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02530A260327",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "22120702,22022301",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "2824500000",
"x.com.samsung.da.airconOptionList": [
"WELCOMECARE",
"AI_PURIFY"
],
"x.com.samsung.da.countryCode": ""
}
},
{
"href": "/humidity/0",
"rep": {}
},
{
"href": "/humidity/vs/0",
"rep": {}
},
{
"href": "/devicespecificinfo/vs/0",
"rep": {
"x.com.samsung.da.deviceActive": true
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AVT-WW-TP1-23-AXX500",
"versions": [
"12260327"
],
"visVersion": "260327"
},
{
"type": "Micom",
"modelId": "",
"versions": [
"22120702",
"22022301"
],
"visVersion": "221207"
},
{
"type": "Micom",
"modelId": "",
"versions": [
"22120702",
"22022301"
],
"visVersion": "221207"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "ASUS_2.4G"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+200
View File
@@ -0,0 +1,200 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airflow/0",
"rep": {
"speed": 0,
"direction": "Off"
}
},
{
"href": "/airflow/vs/0",
"rep": {
"x.com.samsung.da.speedLevel": "0",
"x.com.samsung.da.direction": "Off"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm",
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
"x.com.samsung.da.state": "Created"
}
]
}
},
{
"href": "/configuration/vs/0",
"rep": {}
},
{
"href": "/consumable/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.state": "100",
"x.com.samsung.da.name": "FilterProgress"
}
]
}
},
{
"href": "/devicespecificinfo/vs/0",
"rep": {
"x.com.samsung.da.deviceActive": true
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {}
},
{
"href": "/humidity/0",
"rep": {}
},
{
"href": "/humidity/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_TVTL_18K|10193941|7000023C001111C40100000000000000",
"x.com.samsung.da.description": "ARTIK051_TVTL_18K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02059A230513",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "19071703,19071706",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": [
"Comode_Off",
"Blooming_0",
"Light_On",
"OptionCode_60282"
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "false"
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"11",
"0"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"9",
"0"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"0"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"0"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"5",
"0"
]
}
]
}
}
]
}
+466
View File
@@ -0,0 +1,466 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1785157841",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "00000000",
"x.com.samsung.da.periodicSensingActivationState": "On",
"x.com.samsung.da.autoExeState": "Off",
"x.com.samsung.da.supportedAutoExeState": [
"Off",
"Airpurify",
"Alarm"
],
"x.com.samsung.da.startSensingOnce": "Off"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-27T13:18:51",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "3B000100000A0000000000000000",
"x.com.samsung.da.id": "VTL",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "2650000000",
"x.com.samsung.da.airconOptionList": [
"AI_PURIFY",
"WELCOMECARE",
"DR"
]
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/devicespecificinfo/vs/0",
"rep": {
"x.com.samsung.da.deviceActive": true
}
},
{
"href": "/display/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/dnd/autosleep/vs/0",
"rep": {
"x.com.samsung.da.visible": "true",
"x.com.samsung.da.startTime": "00:00:00",
"x.com.samsung.da.endTime": "00:00:00",
"x.com.samsung.da.useTimeSetting": "false",
"x.com.samsung.da.functionState": "false"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1785158301",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativePower": "32428",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/hepafilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "70",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "8760",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable"
],
"x.com.samsung.da.filterStatus": "normal"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-AIR-01031_0000|10268241|70000721001813CC4D1A490301082100",
"x.com.samsung.da.description": "TP1X_DA-AC-AIR-01031_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02660A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02682A25042102,02683A25031801"
}
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AP2",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Smart"
],
"x.com.samsung.da.supportedModes": [
"Smart",
"Max",
"Mid",
"WindFree",
"Sleep"
],
"x.com.samsung.da.options": [
"OptionCode_23418",
"SmartSleep_Off",
"WelcomeCare_On",
"Pollution_Off",
"ProtectOperation_Off",
"UpdateAllow_NotAllowed"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/panel/vs/0",
"rep": {
"status": "Close"
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/petfilteractivation/vs/0",
"rep": {
"status": "Off"
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "36",
"displayCondition": "Enable"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "3F00000000000000000000",
"x.com.samsung.da.id": "VTL",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "1",
"x.com.samsung.da.total": "5",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
}
],
"rt": [
"x.com.samsung.da.sensors"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/settings/sound/mode/vs/0",
"rep": {
"mode": "mute",
"supportedModes": [
"mute",
"buzzer"
]
}
},
{
"href": "/settings/sound/output/vs/0",
"rep": {
"deviceType": "buzzer"
}
},
{
"href": "/settings/sound/volume/vs/0",
"rep": {
"level": "0",
"minLevel": "0",
"maxLevel": "3",
"resolution": "1"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "Micom",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "12250421",
"x.com.samsung.da.currentVersionInfo": "10000000",
"otnStatus": "None",
"flashingProgress": "0",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air purifier",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "AVT-KR-TP1-24-AXXX00",
"versions": [
"12260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "895010268241FFFFFFFF",
"versions": [
"25042102",
"FFFFFFFF"
],
"visVersion": "250421"
},
{
"type": "Micom",
"modelId": "09501026824110268341",
"versions": [
"25042102",
"25031801"
],
"visVersion": "250421"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "Branden_Summit"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+310
View File
@@ -0,0 +1,310 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingActivationState": "Off",
"x.com.samsung.da.periodicSensingInterval": "600",
"x.com.samsung.da.startSensingOnce": "On",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1785195000",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.autoExeState": "Off",
"x.com.samsung.da.supportedAutoExeState": [
"Off",
"Airpurify",
"Alarm"
],
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "15002100"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0000000000000000000000000000",
"x.com.samsung.da.id": "VTL",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3017000000",
"x.com.samsung.da.airconOptionList": [
"WELCOMECARE",
"AI_PURIFY"
],
"x.com.samsung.da.countryCode": ""
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/devicespecificinfo/vs/0",
"rep": {
"x.com.samsung.da.deviceActive": true
}
},
{
"href": "/dnd/autosleep/vs/0",
"rep": {
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true",
"x.com.samsung.da.useTimeSetting": "true",
"x.com.samsung.da.functionState": "true"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "196696",
"x.com.samsung.da.cumulativeDate": "1785245199",
"x.com.samsung.da.cumulativeDateUTC": "1785212799",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/filter/hepafilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "8760",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable"
]
}
},
{
"href": "/humidity/0",
"rep": {}
},
{
"href": "/humidity/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "A-VTWW-TP2-21-COMMON|10231241|70000535001511EC0505090201000000",
"x.com.samsung.da.description": "A-VTWW-TP2-21-COMMON",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "101",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02308A260310",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "21063003,21063002",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": [
"CountOfSlave_0",
"SlavePower01_NotSupported",
"Pollution_Off",
"OptionCode_23418",
"SmartSleep_Off"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "A-VTWW-TP2-21-COMMON",
"versions": [
"12260310"
],
"visVersion": "260310"
},
{
"type": "Micom",
"modelId": "09501023124110231341",
"versions": [
"21063003",
"21063002"
],
"visVersion": "210630"
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "7",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
}
],
"x.com.samsung.da.total": "5"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "87",
"x.com.samsung.da.supportedModes": [
"87",
"89",
"90",
"91"
],
"x.com.samsung.da.modesName": [
"SMART",
"MAX",
"WINDFREE",
"Sleep"
]
}
}
]
}
+476
View File
@@ -0,0 +1,476 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "54",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "Off",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Left_And_Right",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-27T06:07:25",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-27T06:07:25",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "26.0",
"x.com.samsung.da.current": "30.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 30.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 26.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0.000000",
"x.com.samsung.da.cumulativePower": "3193591",
"x.com.samsung.da.cumulativeSavedPower": "158331",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 0.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Dry"
],
"x.com.samsung.da.options": [
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_270",
"OutdoorConnection_Connected",
"OutdoorTemp_88",
"CoolCapa_53",
"WarmCapa_0",
"Light_Off",
"Volume_100",
"StopAutoClean_Idle",
"Autoclean_Off",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_1",
"ResultDiagnosisAI_Normal",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_On",
"DesiredFreezeAlarm_112",
"WashAlarm_Off",
"OptionCode_56440",
"ExtendOptionCode_246413",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"WelcomeCoolingState_On"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"x.com.samsung.da.displaycondition": "Enable"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARA-WW-TP1-22-COMMON|10229641|60010535001511014600083200800000",
"x.com.samsung.da.description": "ARA-WW-TP1-22-COMMON",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "010",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02378A260327",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102296A23012700",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102295A22113000,102299A10000300",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+08:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_COOLONLY_3.0",
"AI_3.0",
"Auto_To_AI"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "64"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 1,
"start": "2026-07-27T05:07:32Z",
"duration": 20,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "1",
"x.com.samsung.da.duration": "20:47:00",
"x.com.samsung.da.drlcStartTime": "2026-07-27T05:07:32Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C016102490C000000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-WW-TP1-22-COMMON",
"versions": [
"11260327"
],
"visVersion": "260327"
},
{
"type": "Micom",
"modelId": "",
"versions": [
"23012700",
"FFFFFFFF"
],
"visVersion": "230127"
},
{
"type": "Micom",
"modelId": "",
"versions": [
"23012700",
"FFFFFFFF"
],
"visVersion": "230127"
}
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Kuala_Lumpur",
"offset": "+08:00",
"DST": "OFF"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "dreamer"
}
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,232 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airflow/0",
"rep": {
"speed": 3,
"direction": "Fix"
}
},
{
"href": "/airflow/vs/0",
"rep": {
"x.com.samsung.da.speedLevel": "3",
"x.com.samsung.da.direction": "Fix"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02016A200825",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "18020800,17120500",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat",
"HOMECARE_WIZARD_V2"
],
"modes": [
"Cool"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat",
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Comode_Off",
"Sleep_0",
"OutdoorTemp_74",
"CoolCapa_25",
"WarmCapa_32",
"Spi_Off",
"Autoclean_Off",
"Light_Off",
"Volume_100",
"AirMonitoring_Off",
"AutocleanProgress_1",
"StopAutoClean_Idle",
"FilterTime_1715",
"FilterAlarmTime_500",
"OptionCode_35882",
"ExtendOptionCode_7",
"RacInfo_None",
"UpdateAllow_NotAllowed"
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 21.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 20.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "20",
"x.com.samsung.da.current": "21",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.unit": "Celsius"
}
]
}
}
]
}
@@ -0,0 +1,357 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airflow/0",
"rep": {
"speed": 3,
"direction": "Fix"
}
},
{
"href": "/airflow/vs/0",
"rep": {
"x.com.samsung.da.speedLevel": "3",
"x.com.samsung.da.direction": "Fix"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+01:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02016A200825",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "18020800,17120500",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat",
"HOMECARE_WIZARD_V2"
],
"modes": [
"Cool"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat",
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Comode_Off",
"Sleep_0",
"OutdoorTemp_74",
"CoolCapa_25",
"WarmCapa_32",
"Spi_Off",
"Autoclean_Off",
"Light_Off",
"Volume_100",
"AirMonitoring_Off",
"AutocleanProgress_1",
"StopAutoClean_Idle",
"FilterTime_1715",
"FilterAlarmTime_500",
"OptionCode_35882",
"ExtendOptionCode_7",
"RacInfo_None",
"UpdateAllow_NotAllowed"
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 21.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 20.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "20",
"x.com.samsung.da.current": "21",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.unit": "Celsius"
}
]
}
}
],
"oic_res": [],
"seeds": {
"/device/1": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airflow/vs/1",
"rep": {}
},
{
"href": "/airflow/1",
"rep": {}
},
{
"href": "/alarms/vs/1",
"rep": {}
},
{
"href": "/temperatures/vs/1",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature"
}
]
}
},
{
"href": "/temperature/current/1",
"rep": {}
},
{
"href": "/temperature/desired/1",
"rep": {}
},
{
"href": "/diagnosis/vs/1",
"rep": {}
},
{
"href": "/energy/consumption/vs/1",
"rep": {
"x.com.samsung.da.cumulativePower": "117520000",
"x.com.samsung.da.cumulativeDate": "1785420000"
}
},
{
"href": "/energy/consumption/1",
"rep": {}
},
{
"href": "/mode/vs/1",
"rep": {}
},
{
"href": "/mode/1",
"rep": {}
},
{
"href": "/power/vs/1",
"rep": {}
},
{
"href": "/power/1",
"rep": {}
},
{
"href": "/information/vs/1",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000",
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02016A200825",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "18020800,17120500",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/1",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/1",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/humidity/1",
"rep": {}
},
{
"href": "/humidity/vs/1",
"rep": {}
}
]
},
"seeds_note": "device0 is the ARTIK051_KRAC_18K capture from airconditioner_artik051_krac_18k_device.json, verbatim -- a real dump, but from a different physical unit than the issue #214 reporter's AR12NXWXCWKNEU (same board family, modelNum differing only in the third segment). The reporter's own master resources were not quoted in the issue, so the master half of this fixture is that unit's stand-in. The /device/1 seed is the reporter's real slot, read off the `subdevices` block of their v0.17.0 diagnostics download (issue #214 comment 5131166356) and re-indexed from the canonical hrefs diagnostics reports back onto the real /x/1 hrefs the board answers on -- every rep is their value verbatim, including the empty {} on every operational resource and the populated /energy/consumption/vs/1, which is the whole point of this fixture. Two deliberate deviations: /information/vs/1 carries *this* fixture's master /information/vs/0 rep rather than the reporter's modelNum, because what their dump shows is the slot echoing its own master's model verbatim (ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000, identical to their master's) and pasting that string against a different unit's master would misrepresent the slot as reporting a *different* model, which is what a real sibling does (see airconditioner_artik051_dongle_fac_18k_device.json's FAC_RAC subdevice); and /file/information/vs/1's +03:00 offset is the reporter's, so it doesn't match this master's +01:00 -- an artifact of the two halves coming from different units, not something the board did. oic_res is empty because the reporter's wasn't quoted either: enumeration therefore reaches /device/1 through the speculative _SPECULATIVE_DEVICE_INDICES probe, which is the path any board that doesn't advertise a sibling in /oic/res gets. Expected outcome: the candidate is found, then correctly held back by discover_partitioned's liveness gate -- a lifetime kWh counter is not evidence of a second indoor unit (issue #214)."
}
@@ -0,0 +1,231 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/airflow/vs/0",
"rep": {
"x.com.samsung.da.speedLevel": "0",
"x.com.samsung.da.direction": "All"
}
},
{
"href": "/airflow/0",
"rep": {
"speed": 0,
"direction": "All"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T12:29:36",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T12:29:36",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "23",
"x.com.samsung.da.current": "23",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 23.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 23.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "117430000",
"x.com.samsung.da.cumulativeDate": "1785337200"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Comode_Off",
"Sleep_0",
"OutdoorTemp_85",
"CoolCapa_35",
"WarmCapa_35",
"Spi_Off",
"Autoclean_Off",
"Light_Off",
"Volume_100",
"AirMonitoring_Off",
"AutocleanProgress_0",
"StopAutoClean_Idle",
"FilterTime_1425",
"FilterAlarmTime_500",
"OptionCode_56378",
"ExtendOptionCode_7",
"RacInfo_None",
"UpdateAllow_NotAllowed"
]
}
},
{
"href": "/mode/0",
"rep": {
"supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat"
],
"modes": [
"Cool"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000",
"x.com.samsung.da.description": "ARTIK051_KRAC_18K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02016A200825",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "17110700,17110600",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000"
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0"
}
}
]
}
+919
View File
@@ -0,0 +1,919 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingInterval": "1800",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1785327477",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "00000000",
"x.com.samsung.da.periodicSensingActivationState": "On",
"x.com.samsung.da.autoExeState": "Alarm",
"x.com.samsung.da.supportedAutoExeState": [
"Airpurify",
"Alarm",
"Sensing"
],
"x.com.samsung.da.startSensingOnce": "Off",
"x.com.samsung.da.autoExeSetting": "On",
"x.com.samsung.da.supportedAutoExeSetting": [
"On",
"Off"
]
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": 0,
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "DA_SAC_M_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000100B4012C0000B20001000000",
"x.com.samsung.da.id": "CAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "4146100000",
"x.com.samsung.da.airconOptionList": [
"AI_KOREA_COOLONLY_4.0",
"HOMECARE_WIZARD_V2",
"CLEAN_OPTION_SUPPORT",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "2026-07-29T07:26:56Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "1019",
"x.com.samsung.da.drlcLevel": "1",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/edgelighting/vs/0",
"rep": {
"status": "On",
"convenientMode": "On",
"operatingOption": "On",
"welcomeLightStatus": "Off",
"colorOption": "3000K",
"mode": "Smart",
"modeSupportedList": [
"Smart",
"High",
"Low"
],
"colorSupportedList": [
"3000K",
"4000K",
"6500K"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1785327589",
"x.com.samsung.da.instantaneousPower": "15",
"x.com.samsung.da.cumulativeSavedPower": "1611",
"x.com.samsung.da.cumulativePower": "84044",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustPM1filter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "2400",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "11"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "180",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "26"
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "59"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00",
"x.com.samsung.da.description": "TP1X_DA-AC-CAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02706A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02555A25052600,02556A24040200"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
}
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AS3",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/light/stateful/vs/0",
"rep": {
"status": "On",
"mode": "Smart",
"supportedModes": [
"Smart",
"Low",
"High"
]
}
},
{
"href": "/mds/absenceclean/vs/0",
"rep": {
"mode": "Off",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {
"status": "Off"
}
},
{
"href": "/mds/absencepowersaving/vs/0",
"rep": {
"status": "On",
"modes": "Cooling",
"switchPowerSaveMode": "Normal",
"supportedModes": [
"Standby",
"Cooling"
],
"supportedSwitchPowerSaveMode": [
"Eco",
"Normal",
"Comfort"
]
}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {
"status": "On",
"absenceTime": "0",
"maxDetectCount": [
"0",
"0",
"0",
"0",
"0",
"0",
"3",
"3",
"3",
"2",
"3",
"0",
"0",
"3",
"0",
"3",
"3",
"3",
"0",
"3",
"1",
"2",
"3",
"3",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"0",
"1",
"0",
"3",
"2",
"3",
"1",
"2",
"1",
"0",
"0",
"0",
"0"
],
"supportedTimes": [
"0",
"30",
"60",
"120"
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Nano",
"LongWind",
"Speed",
"Sleep",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Auto",
"Cool",
"Dry",
"Fan"
],
"x.com.samsung.da.options": [
"OptionCode_512",
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None",
"welcomecare_Off",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_On",
"DesiredFreezeAlarm_112",
"ExtendOptionCode_0",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"OutdoorTemp_55",
"KeyInputPermit_On",
"ModePermit_NoLimit"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "28.5",
"x.com.samsung.da.desired": "28.0",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
],
"defaultSettingStatus": "On"
}
},
{
"href": "/option/motiondetectwind/stateful/vs/0",
"rep": {
"status": "On",
"modes": "Indirect",
"supportedModes": [
"Indirect",
"Direct"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "46"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C3E",
"x.com.samsung.da.id": "CAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/sac/installationinfo/vs/0",
"rep": {
"installationOptions": [
{
"id": "0",
"option": "184449945E0484800017"
},
{
"id": "1",
"option": "28010000010000000100"
},
{
"id": "2",
"option": "50000000000000000001"
},
{
"id": "3",
"option": "30000000000000000000"
}
],
"installationDeviceNum": [
{
"id": "0",
"number": "0"
},
{
"id": "1",
"number": "5"
},
{
"id": "2",
"number": "0"
}
],
"outdoorInfo": [
{
"id": "0",
"info": "02571A250514",
"serial": "**REDACTED**"
},
{
"id": "1",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "2",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "3",
"info": "000000000000",
"serial": "**REDACTED**"
}
],
"mcuInfo": [
{
"id": "0",
"info": "000000000000"
},
{
"id": "1",
"info": "000000000000"
},
{
"id": "2",
"info": "000000000000"
},
{
"id": "3",
"info": "000000000000"
},
{
"id": "4",
"info": "000000000000"
},
{
"id": "5",
"info": "000000000000"
},
{
"id": "6",
"info": "000000000000"
},
{
"id": "7",
"info": "000000000000"
},
{
"id": "8",
"info": "000000000000"
},
{
"id": "9",
"info": "000000000000"
},
{
"id": "10",
"info": "000000000000"
},
{
"id": "11",
"info": "000000000000"
},
{
"id": "12",
"info": "000000000000"
},
{
"id": "13",
"info": "000000000000"
},
{
"id": "14",
"info": "000000000000"
}
]
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "1",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"7",
"1"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
}
]
}
},
{
"href": "/settings/sound/mode/vs/0",
"rep": {
"mode": "voice",
"supportedModes": [
"mute",
"tone",
"voice"
]
}
},
{
"href": "/settings/sound/optimization/vs/0",
"rep": {
"status": "Off",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/settings/sound/output/vs/0",
"rep": {
"deviceType": "speaker"
}
},
{
"href": "/settings/sound/volume/vs/0",
"rep": {
"level": "2",
"minLevel": "0",
"maxLevel": "3",
"resolution": "1"
}
},
{
"href": "/smartsensingcooling/vs/0",
"rep": {
"status": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "0.5"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "28.5",
"x.com.samsung.da.desired": "28.0",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/uvled/vs/0",
"rep": {
"x.com.samsung.da.modes": "On",
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.pushAlarmStatus": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ASA-WW-TP1-24-INFSAC",
"versions": [
"14260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "04001025554110255641",
"versions": [
"25052600",
"24040200"
],
"visVersion": "250526"
},
{
"type": "Micom",
"modelId": "FGCAN2",
"versions": [
"",
""
],
"visVersion": "999999"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "boksil"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
}
]
}
+486
View File
@@ -0,0 +1,486 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200",
"x.com.samsung.da.sleepMode": "Off"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-23T05:13:21",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-23T05:13:21",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000100B4012C0000120000000000",
"x.com.samsung.da.id": "CAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "4131000000",
"x.com.samsung.da.airconOptionList": [
"DR",
"SingleCommand_1.0",
"AI_2.0"
]
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "153.000000",
"x.com.samsung.da.cumulativePower": "239209",
"x.com.samsung.da.cumulativeSavedPower": "7201",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total",
"x.com.samsung.da.saveLocation": "/mnt/usage.db"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "85",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "1000",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "83"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "A-CAWW-TP2-20-COMMON|10241941|600301170016110B40000F2000F2AD00",
"x.com.samsung.da.description": "A-CAWW-TP2-20-COMMON",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "023",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02274A260512",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "02419A25051901,FFFFFFFFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Nano",
"LongWind",
"Speed",
"Sleep",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.workingMode": "Auto",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"AIComfort"
],
"x.com.samsung.da.modes": [
"Auto"
],
"x.com.samsung.da.options": [
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"OutdoorTemp_82",
"Light_Off",
"Volume_100",
"KeyInputPermit_On",
"ModePermit_NoLimit",
"UpdateAllow_NotAllowed",
"DurationOn_419",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "A-CAWW-TP2-20-COMMON",
"versions": [
"14260512"
],
"visVersion": "260512"
},
{
"type": "Micom",
"modelId": "040010241941FFFFFFFF",
"versions": [
"25051901",
"FFFFFFFF"
],
"visVersion": "250519"
},
{
"type": "Micom",
"modelId": "0400FFFFFFFFFFFFFFFF",
"versions": [
"FFFFFFFF",
"FFFFFFFF"
],
"visVersion": ""
}
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C",
"x.com.samsung.da.id": "CAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/sac/installationinfo/vs/0",
"rep": {
"installationOptions": [
{
"id": "0",
"option": "17444980C80171700010"
},
{
"id": "1",
"option": "28010000000000000100"
},
{
"id": "2",
"option": "50000000000000000000"
},
{
"id": "3",
"option": "30000000000000000000"
}
],
"installationDeviceNum": [
{
"id": "0",
"number": "01"
},
{
"id": "1",
"number": "04"
},
{
"id": "2",
"number": "00"
}
],
"outdoorInfo": [
{
"id": "0",
"info": "01900A230418",
"serial": "**REDACTED**"
},
{
"id": "1",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "2",
"info": "000000000000",
"serial": "**REDACTED**"
},
{
"id": "3",
"info": "000000000000",
"serial": "**REDACTED**"
}
],
"mcuInfo": [
{
"id": "0",
"info": ""
},
{
"id": "1",
"info": ""
},
{
"id": "2",
"info": ""
},
{
"id": "3",
"info": ""
},
{
"id": "4",
"info": ""
},
{
"id": "5",
"info": ""
},
{
"id": "6",
"info": ""
},
{
"id": "7",
"info": ""
},
{
"id": "8",
"info": ""
},
{
"id": "9",
"info": ""
},
{
"id": "10",
"info": ""
},
{
"id": "11",
"info": ""
},
{
"id": "12",
"info": ""
},
{
"id": "13",
"info": ""
},
{
"id": "14",
"info": ""
},
{
"id": "15",
"info": ""
}
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "0.5"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "27.0",
"x.com.samsung.da.current": "29.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "0.5",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Up_And_Low",
"Fix",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High"
]
}
}
]
}
+400
View File
@@ -0,0 +1,400 @@
{
"meta": {
"model": "ARTIK051_PRAC_20K",
"device_type": "airconditioner",
"source": "issue #17 diagnostics (scrubbed)",
"note": "First air-conditioner /device/0 dump; first composite HA climate entity."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "Off",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "1",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"All",
"Up_And_Low"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-21T07:20:30"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm",
"x.com.samsung.da.triggeredTime": "2026-07-21T07:20:30",
"x.com.samsung.da.state": "Created"
}
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "24.0",
"x.com.samsung.da.current": "27.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 27.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16.0,
30.0
],
"units": "C",
"temperature": 24.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0",
"x.com.samsung.da.cumulativePower": "1686632",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 0.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto",
"Heat"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"OutdoorTemp_79",
"CoolCapa_50",
"WarmCapa_60",
"Light_Off",
"Volume_100",
"OptionCode_53304",
"ExtendOptionCode_197256",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"0"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"0"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"0",
"0"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"0",
"0"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"0",
"0"
]
}
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000",
"x.com.samsung.da.description": "ARTIK051_PRAC_20K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02181A230313",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "22112400,FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "0000,0000"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_HEATPUMP_3.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/timezone/vs/0",
"rep": {}
}
]
}
@@ -0,0 +1,720 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "AC_V_0002_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3017000000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"ENERGY_2.0",
"AI_2.0",
"DeviceTypeMaster",
"SingleCommand_1"
]
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"energy": 800.0,
"power": 65278.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "800.000000",
"x.com.samsung.da.instantaneousPower": "65278.000000",
"x.com.samsung.da.usageThreshold": "0.000000",
"x.com.samsung.da.cumulativePower": "544088",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "112",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "112",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"112",
"224",
"336",
"448"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "58"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "000",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02337A260424",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2102240021022200",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "2103300110000300"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 5
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Wind"
],
"x.com.samsung.da.options": [
"Operation_Family",
"Blooming_0",
"OnTimer_0",
"OffTimer_0",
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_270",
"welcomecare_Off",
"Panel_Close",
"Weather_Off",
"Volume_100",
"StopAutoClean_Idle",
"DiagnosisAI_Off",
"Display_Off",
"ProgressDiagnosisAI_1",
"ResultDiagnosisAI_Normal",
"Service_Off",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"OutDoorVentil_Off",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"OptionCode_529",
"ExtendOptionCode_16975",
"RacInfo_First",
"RacInfo_None_Second",
"ModelInfo_16K_BORA_VENT2",
"UpdateAllow_NotAllowed",
"EnergySaveIcon_Off",
"DurationOn_0",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"SpeedClean",
"QuietClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"SpeedClean",
"QuietClean",
"Off"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AFA-KR-TP2-21-AF9X00",
"versions": [
"10260424"
],
"visVersion": "260424"
},
{
"type": "Micom",
"modelId": "04511023304110232941",
"versions": [
"21022400",
"21022200"
],
"visVersion": "210224"
},
{
"type": "Micom",
"modelId": "04511022974110229941",
"versions": [
"21033001",
"10000300"
],
"visVersion": "210330"
},
{
"type": "Micom",
"modelId": "045110230741FFFFFFFF",
"versions": [
"22050300",
"FFFFFFFF"
],
"visVersion": "220503"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/runn/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/subdevices/vs/0",
"rep": {
"x.com.samsung.da.subdeviceIdList": [
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 32.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 24.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "24.0",
"x.com.samsung.da.current": "32.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "NotSupported",
"x.com.samsung.da.supportedModes": [
"NotSupported"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "2",
"x.com.samsung.da.supportedModes": [
"0",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Mid",
"High",
"Turbo"
]
}
}
],
"oic_res": [
{
"di": "**REDACTED**",
"links": [
{
"href": "/oic/sec/doxm",
"rt": [
"oic.r.doxm"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/sec/pstat",
"rt": [
"oic.r.pstat"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/d",
"rt": [
"oic.wk.d",
"oic.d.airconditioner"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/oic/p",
"rt": [
"oic.wk.p"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/state/vs/0",
"rt": [
"x.com.samsung.da.hass.state"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/command/vs/0",
"rt": [
"x.com.samsung.da.hass.command"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/chunk/vs/0",
"rt": [
"x.com.samsung.file.chunk"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/EasySetupResURI",
"rt": [
"oic.r.easysetup"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/WiFiConfResURI",
"rt": [
"oic.wk.wifi"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/CoapCloudConfResURI",
"rt": [
"oic.wk.cloudserver"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/DevConfResURI",
"rt": [
"oic.wk.devconf"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/sec/provisioninginfo",
"rt": [
"x.com.samsung.provisioninginfo"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/sec/accesspointlist",
"rt": [
"x.com.samsung.accesspointlist"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
}
]
}
],
"probes": {
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|60010610001500014600081200810000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
}
},
"seeds_note": "device0 and oic_res are real, captured from the reporter's issue #205 report. subdeviceIdList in device0's /subdevices/vs/0 is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] (HA's diagnostics download redacts it, matching redact.py's 'deviceid' substring rule, not because it's account data -- same restoration airconditioner_fac_bora_2in1_device.json documents for the same field), and every other value is otherwise exactly what HA's download redacts (serials/otnDUID etc.) against the same physical TP2X_FAC_BORA_21K unit as airconditioner_fac_bora_2in1_device.json -- same subdeviceIdList UUID, same wall subdevice. Filed specifically because, contrary to the assumption that fixture's seed batch was built on, this unit's /6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0 does NOT answer (subdevice_probes in the report shows it False), so the Collection-batch fallback this fixture exercises (registry.subdevices.enumerate_subdevices' per-href probe, issue #205) is what has to find the subdevice instead. The one probes entry, /6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0, is the same real capture already used in airconditioner_fac_bora_2in1_device.json's seed batch (issue #177 comment 5113518087) -- the only href ever actually confirmed to answer under this UUID prefix. No other /6c2dff6d-ee5c-dad1-6a5e-000000000001/* href has been confirmed live yet, so none are seeded here; this fixture's expected outcome is the candidate being found by the flat-href probe but then correctly held back by discover_partitioned's liveness gate (information alone binds no entity), matching where the real issue stands -- not a materialized climate entity, which would require guessing at unconfirmed hrefs. The probe's serialNum (\"TEST-SUBDEVICE-SERIAL-0000\") is a hand-placed placeholder for the real value, not HA's own redaction output -- same placeholder airconditioner_fac_bora_2in1_device.json uses for the identical field."
}
+809
View File
@@ -0,0 +1,809 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "REDACTED",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "112",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "112",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"112",
"224",
"336",
"448"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"SpeedClean",
"QuietClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"SpeedClean",
"QuietClean",
"Off"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "2",
"x.com.samsung.da.supportedModes": [
"0",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "NotSupported",
"x.com.samsung.da.supportedModes": [
"NotSupported"
]
}
},
{
"href": "/subdevices/vs/0",
"rep": {
"x.com.samsung.da.subdeviceIdList": [
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T04:31:31"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "AC_V_0002_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-29T04:31:31"
}
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "24.0",
"x.com.samsung.da.current": "33.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 33.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 24.0
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "100.000000",
"x.com.samsung.da.instantaneousPower": "65278.000000",
"x.com.samsung.da.usageThreshold": "0.000000",
"x.com.samsung.da.cumulativePower": "544088",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"energy": 100.0,
"power": 65278.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Wind"
],
"x.com.samsung.da.options": [
"Operation_Family",
"Blooming_0",
"OnTimer_0",
"OffTimer_0",
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_270",
"welcomecare_Off",
"Panel_Close",
"Weather_Off",
"Volume_100",
"StopAutoClean_Idle",
"DiagnosisAI_Off",
"Display_Off",
"ProgressDiagnosisAI_1",
"ResultDiagnosisAI_Normal",
"Service_Off",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"OutDoorVentil_Off",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"OptionCode_529",
"ExtendOptionCode_16975",
"RacInfo_First",
"RacInfo_None_Second",
"ModelInfo_16K_BORA_VENT2",
"UpdateAllow_NotAllowed",
"EnergySaveIcon_Off",
"DurationOn_0",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "000",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.serialNumOption": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02337A260424",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2102240021022200",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "2103300110000300"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3017000000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"ENERGY_2.0",
"AI_2.0",
"DeviceTypeMaster",
"SingleCommand_1"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "58"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 2,
"start": "2026-07-29T04:12:57Z",
"duration": 20,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "2",
"x.com.samsung.da.duration": "20:30:00",
"x.com.samsung.da.drlcStartTime": "2026-07-29T04:12:57Z",
"x.com.samsung.da.override": "Off"
}
},
{
"href": "/runn/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 5
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AFA-KR-TP2-21-AF9X00",
"versions": [
"10260424"
],
"visVersion": "260424"
},
{
"type": "Micom",
"modelId": "04511023304110232941",
"versions": [
"21022400",
"21022200"
],
"visVersion": "210224"
},
{
"type": "Micom",
"modelId": "04511022974110229941",
"versions": [
"21033001",
"10000300"
],
"visVersion": "210330"
},
{
"type": "Micom",
"modelId": "045110230741FFFFFFFF",
"versions": [
"22050300",
"FFFFFFFF"
],
"visVersion": "220503"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
}
],
"oic_res": [
{
"di": "**REDACTED**",
"links": [
{
"href": "/oic/sec/doxm",
"rt": [
"oic.r.doxm"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/sec/pstat",
"rt": [
"oic.r.pstat"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/d",
"rt": [
"oic.wk.d",
"oic.d.airconditioner"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/oic/p",
"rt": [
"oic.wk.p"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/state/vs/0",
"rt": [
"x.com.samsung.da.hass.state"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/command/vs/0",
"rt": [
"x.com.samsung.da.hass.command"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/chunk/vs/0",
"rt": [
"x.com.samsung.file.chunk"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/EasySetupResURI",
"rt": [
"oic.r.easysetup"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/WiFiConfResURI",
"rt": [
"oic.wk.wifi"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/CoapCloudConfResURI",
"rt": [
"oic.wk.cloudserver"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/DevConfResURI",
"rt": [
"oic.wk.devconf"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/sec/provisioninginfo",
"rt": [
"x.com.samsung.provisioninginfo"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/sec/accesspointlist",
"rt": [
"x.com.samsung.accesspointlist"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
}
]
}
],
"seeds": {
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|600001110015110006000C1200830000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Cool",
"Dry",
"Wind",
"Auto"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": []
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 26.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 24.0
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Fix"
],
"x.com.samsung.da.supportedModes": [
"Fix",
"All"
]
}
}
]
},
"seeds_note": "device0 and oic_res are real, captured from the reporter's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical device as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room subdevice, distinct from the master's floor subdevice TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this subdevice) so the subdevice's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
}
+446
View File
@@ -0,0 +1,446 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "AC_V_0002_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000404B0C000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "2824500000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"ENERGY_2.0",
"AI_2.0",
"DeviceTypeMaster",
"SingleCommand_1"
]
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"energy": 100.0,
"power": 65278.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "100.000000",
"x.com.samsung.da.instantaneousPower": "65278.000000",
"x.com.samsung.da.usageThreshold": "0.000000",
"x.com.samsung.da.cumulativePower": "1278818",
"x.com.samsung.da.cumulativeDate": "1785243600",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "112",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "112",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"112",
"224",
"336",
"448"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "73"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "000",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.serialNumOption": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02337A260424",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2102240021022200",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "2103300110000300"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"AIComfort"
],
"x.com.samsung.da.options": [
"Operation_Family",
"Blooming_0",
"OnTimer_0",
"OffTimer_0",
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_270",
"welcomecare_Off",
"Panel_Close",
"Weather_Off",
"Volume_100",
"StopAutoClean_Idle",
"DiagnosisAI_Off",
"Display_Off",
"ProgressDiagnosisAI_1",
"ResultDiagnosisAI_Normal",
"Service_Off",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"OutDoorVentil_Off",
"FreezeAlarmSetting_On",
"DesiredFreezeAlarm_112",
"OptionCode_529",
"ExtendOptionCode_16975",
"RacInfo_First",
"RacInfo_None_Second",
"ModelInfo_16K_BORA_VENT2",
"UpdateAllow_NotAllowed",
"EnergySaveIcon_Off",
"DurationOn_0",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"SpeedClean",
"QuietClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"SpeedClean",
"QuietClean",
"Off"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AFA-KR-TP2-21-AF9X00",
"versions": [
"10260424"
],
"visVersion": "260424"
},
{
"type": "Micom",
"modelId": "04511023304110232941",
"versions": [
"21022400",
"21022200"
],
"visVersion": "210224"
},
{
"type": "Micom",
"modelId": "04511022974110229941",
"versions": [
"21033001",
"10000300"
],
"visVersion": "210330"
},
{
"type": "Micom",
"modelId": "045110230741FFFFFFFF",
"versions": [
"22050300",
"FFFFFFFF"
],
"visVersion": "220503"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "REDACTED",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/runn/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/subdevices/vs/0",
"rep": {
"x.com.samsung.da.subdeviceIdList": "REDACTED"
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 31.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 27.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "27.0",
"x.com.samsung.da.current": "31.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "NotSupported",
"x.com.samsung.da.supportedModes": [
"NotSupported"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Mid",
"High",
"Turbo"
]
}
}
]
}
@@ -0,0 +1,626 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed",
"MotionIndirect",
"MotionDirect",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "3",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-28T19:55:08",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm",
"x.com.samsung.da.triggeredTime": "2026-07-28T19:55:08",
"x.com.samsung.da.state": "Created"
}
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "70.0",
"x.com.samsung.da.current": "69.0",
"x.com.samsung.da.maximum": "86",
"x.com.samsung.da.minimum": "47",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Fahrenheit"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
47,
86
],
"units": "F",
"temperature": 69.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
47,
86
],
"units": "F",
"temperature": 70.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0.000000",
"x.com.samsung.da.cumulativePower": "631483",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 0.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.modes": [
"Heat"
],
"x.com.samsung.da.options": [
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_990",
"OutdoorTemp_78",
"CoolCapa_35",
"WarmCapa_35",
"Volume_Mute",
"StopAutoClean_Set",
"Autoclean_On",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"KeyInputPermit_On",
"ModePermit_NoLimit",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"OptionCode_56636",
"ExtendOptionCode_246669",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "48"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_LNX-AC-RAC-01001_0000|10269441|60010523001811014E0048220090A000",
"x.com.samsung.da.description": "TP1X_LNX-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJV",
"x.com.samsung.da.diagSetupid": "AR2",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02646A260327",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102694A24062500",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102472A24042300,102579A10000200",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_HEATPUMP_3.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "45"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000001D6035C016102490C0F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/remotedeviceinfo/vs/0",
"rep": {
"x.com.samsung.da.didList": ""
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-WW-TP1-24-ARXX00",
"versions": [
"11260327"
],
"visVersion": "260327"
},
{
"type": "Micom",
"modelId": "045210269441FFFFFFFF",
"versions": [
"24062500",
"FFFFFFFF"
],
"visVersion": "240625"
},
{
"type": "Micom",
"modelId": "04521024724110257941",
"versions": [
"24042300",
"10000200"
],
"visVersion": "240423"
}
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "America/Halifax",
"offset": "-03:00",
"DST": "ON"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEFFFFFFFF3D564156FFFF2F56FFFF00001F001F00010000001F0000009C00FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencepowersaving/vs/0",
"rep": {
"status": "Off",
"modes": "Cooling_Heating",
"switchPowerSaveMode": "Normal",
"motionState": "Normal",
"supportedModes": [
"Cooling_Heating",
"Standby"
],
"supportedSwitchPowerSaveMode": [
"Eco",
"Normal",
"Comfort"
],
"supportedMotionState": [
"Normal",
"MotionNano",
"MotionSoftOff"
]
}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {
"status": "Off",
"maxDetectCount": [
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254",
"254"
],
"supportedTime": [
"0",
"30",
"60"
]
}
},
{
"href": "/option/motiondetectwind/stateful/vs/0",
"rep": {
"status": "Off",
"modes": "Indirect",
"supportedModes": [
"Direct",
"Indirect"
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "KG_IoT"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
@@ -0,0 +1,595 @@
{
"meta": {
"note": "Samsung Room A/C, model TP1X_DA-AC-RAC-01011 (oneUiVersion '7.0 Air conditioner', Tizen Lite). Scrubbed real /device/0 dump. Adds temperature via /temperatures/vs/0 + display light. Issue #17 (newer model class).",
"issue": 17,
"model": "TP1X_DA-AC-RAC-01011",
"oneUiVersion": "7.0 Air conditioner"
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingInterval": "1800",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1784508715",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "00000000",
"x.com.samsung.da.periodicSensingActivationState": "On",
"x.com.samsung.da.autoExeState": "Alarm",
"x.com.samsung.da.supportedAutoExeState": [
"Airpurify",
"Alarm",
"Sensing"
],
"x.com.samsung.da.startSensingOnce": "Off",
"x.com.samsung.da.autoExeSetting": "Off",
"x.com.samsung.da.supportedAutoExeSetting": [
"On",
"Off"
]
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": 0,
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000A0012C016102490F0F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1784704371",
"x.com.samsung.da.cumulativeSavedPower": "-1",
"x.com.samsung.da.cumulativePower": "75025",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+02:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "42",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "53"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10275643|60010523001911F58E0049320092E900",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02762A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02756C25082500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02669A24092600,02636A10001200"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF"
}
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR4",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed",
"MotionIndirect",
"Nano",
"NanoSleep",
"DryComfort"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": [
"Heat"
],
"workingMode": "Heat",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Heat",
"Wind"
],
"x.com.samsung.da.options": [
"OptionCode_56378",
"Sleep_6",
"ArtificialWorking_Off",
"Off",
"OutdoorTemp_68",
"OutdoorConnection_Connected"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
],
"defaultSettingStatus": "On"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "39",
"displayCondition": "Enable"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "3EFFFFFFFF101E121EFFFF101EFFFF000001001F0001001F001F0000009CACFFFF0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "1",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"0",
"0"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"0",
"0"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"0",
"0"
]
}
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "17.0",
"x.com.samsung.da.desired": "25.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Africa/Johannesburg",
"offset": "+02:00",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Normal",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
}
]
}
+525
View File
@@ -0,0 +1,525 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "1DBF00A0012C3F61024804000000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1150000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"AI_RAC_KOREA_COOLONLY_3.0",
"AI_3.0",
"Auto_To_AI"
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 338.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "338.000000",
"x.com.samsung.da.cumulativePower": "1362848",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "2",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "42"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10250041|6001051C001711014E00482200912000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR1",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.serialNumOption": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02646A260327",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102500A23012700",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102472A22120800,102528A10000100",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Cool"
],
"x.com.samsung.da.options": [
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_280",
"welcomecare_Off",
"OutdoorConnection_Connected",
"OutdoorTemp_86",
"CoolCapa_28",
"WarmCapa_0",
"Volume_100",
"StopAutoClean_Set",
"Autoclean_On",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"OptionCode_56440",
"ExtendOptionCode_246479",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_115",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-WW-TP1-24-ARXX00",
"versions": [
"11260327"
],
"visVersion": "260327"
},
{
"type": "Micom",
"modelId": "045210250041FFFFFFFF",
"versions": [
"23012700",
"FFFFFFFF"
],
"visVersion": "230127"
},
{
"type": "Micom",
"modelId": "04521024724110252841",
"versions": [
"22120800",
"10000100"
],
"visVersion": "221208"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "REDACTED",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": true
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"causeSource": "SMTS"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotedeviceinfo/vs/0",
"rep": {
"x.com.samsung.da.didList": ""
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "1DFFFFFFFF101E121EFFFFFFFFFFFF01000000012F0100000F000000001C00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 28.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 25.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "25.0",
"x.com.samsung.da.current": "28.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "32",
"x.com.samsung.da.supportedModes": [
"0",
"31",
"32",
"33",
"34",
"35"
],
"x.com.samsung.da.modesName": [
"Auto",
"1",
"2",
"3",
"4",
"MAX"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED",
"connectedApSsid": "REDACTED"
}
}
]
}
@@ -0,0 +1,515 @@
{
"meta": {
"model": "TP1X_DA-AC-RAC-01001_0000",
"device_type": "airconditioner",
"source": "user diagnostics (scrubbed)",
"note": "Cool-only global TP1X RAC variant (airconOptionList AI_RAC_GLOBAL_COOLONLY_3.0). Unlike the other TP1X_DA-AC-RAC dumps, its /otninformation/vs/0 ships no swVersionInfo block, so oneUiVersion is empty and detection must fall back to the hyphenated '-RAC-' modelNum token in for_device_by_model. Adds two hrefs absent from the other AC dumps -- /stepcontrol/vs/0 (manual airflow step) and /remotedeviceinfo/vs/0 (empty paired-device list), both ignored -- and exposes Samsung WindFree via the convenient-mode codes Nano/NanoSleep (in place of the Smart code on other boards; confirmed by an off-vs-on dump diff)."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "100",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "wash",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "Off",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "Fix",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-26T15:24:44",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm",
"x.com.samsung.da.triggeredTime": "2026-07-26T15:24:44",
"x.com.samsung.da.state": "Created"
}
]
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "25.0",
"x.com.samsung.da.current": "20.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 20.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
16,
30
],
"units": "C",
"temperature": 25.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "0.000000",
"x.com.samsung.da.cumulativePower": "445938",
"x.com.samsung.da.cumulativeSavedPower": "81528",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"power": 0.0
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Auto"
],
"x.com.samsung.da.options": [
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"OutdoorTemp_84",
"CoolCapa_35",
"WarmCapa_0",
"Light_Off",
"Volume_Mute",
"StopAutoClean_Idle",
"Autoclean_Off",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"OptionCode_52344",
"ExtendOptionCode_230029",
"RacInfo_None",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"WelcomeCoolingState_Off"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"causeSource": "DEFT",
"operationNumber": "0"
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/sensors/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10247441|60010523001811014E00002200D00000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR2",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02646A260327",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102474A23112200",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "102702A24041900,102579A10000200",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"SingleCommand_1",
"DR",
"HOMECARE_WIZARD_V2",
"PRODUCT_GLOBAL",
"AI_RAC_GLOBAL_COOLONLY_3.0",
"AI_3.0",
"Auto_To_AI"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "66"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 1,
"start": "2026-07-26T12:01:41Z",
"duration": 23,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "1",
"x.com.samsung.da.duration": "23:59:00",
"x.com.samsung.da.drlcStartTime": "2026-07-26T12:01:41Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000A0012C0161024904000000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/stepcontrol/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"80",
"60",
"40",
"Power"
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/remotedeviceinfo/vs/0",
"rep": {
"x.com.samsung.da.didList": ""
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-WW-TP1-24-ARXX00",
"versions": [
"11260327"
],
"visVersion": "260327"
},
{
"type": "Micom",
"modelId": "045210247441FFFFFFFF",
"versions": [
"23112200",
"FFFFFFFF"
],
"visVersion": "231122"
},
{
"type": "Micom",
"modelId": "04521027024110257941",
"versions": [
"24041900",
"10000200"
],
"visVersion": "240419"
}
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "America/Sao_Paulo",
"offset": "-03:00",
"DST": "OFF"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": "0",
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
}
]
}
+591
View File
@@ -0,0 +1,591 @@
{
"meta": {
"model": "TP1X_DA-AC-RAC-01001_0000",
"device_type": "airconditioner",
"source": "issue #38 diagnostics (scrubbed)",
"note": "Newer TP1X RAC board (oneUiVersion '7.0 Air conditioner') with a fuller resource set than the ARTIK051/TP2X boards: display light, self-check, mute-once, and a circuit-breaker current-limit setting."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/aisleep/vs/0",
"rep": {
"x.com.samsung.da.displayNightMode": "Off",
"x.com.samsung.da.elapsedTime": "0",
"x.com.samsung.da.requestFeedback": "Off",
"x.com.samsung.da.resultFeedback": 0,
"x.com.samsung.da.statusFeedback": "Idle",
"x.com.samsung.da.sleepTime": "14002200"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00",
"x.com.samsung.da.state": "Deleted"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "FilterAlarm_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-22T12:58:00",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEB700A0012C01610248040F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "2026-07-22T10:59:14Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "1148",
"x.com.samsung.da.drlcLevel": "1",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/electriccurrent/vs/0",
"rep": {
"operation": "Off",
"modes": "3",
"supportedModes": [
"3",
"4",
"5",
"6",
"7",
"8",
"9"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1784725078",
"x.com.samsung.da.cumulativeSavedPower": "-1",
"x.com.samsung.da.cumulativePower": "160922",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "total"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+03:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "30",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010532001911000E0041220090A000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02762A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02669A24092600,02636A10001200"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF"
}
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AR2",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Smart",
"Speed"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.options": [
"OptionCode_54328",
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"Volume_100",
"StopAutoClean_Idle",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"ExtendOptionCode_230024",
"UpdateAllow_NotAllowed",
"DurationOn_120",
"OutdoorTemp_92",
"CoolCapa_50",
"WarmCapa_60",
"Autoclean_Off",
"WelcomeCoolingState_Off",
"WelcomeHeatingState_Off",
"OutdoorConnection_Connected"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.supportedStatus": [
"Start",
"Stop"
],
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.settingStatus": "Off",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "845210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "045210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "04521026694110263641",
"versions": [
"24092600",
"10001200"
],
"visVersion": "240926"
},
{
"type": "Micom",
"modelId": "FGCAN3",
"versions": [
"",
""
],
"visVersion": "999999"
},
{
"type": "Micom",
"modelId": "845210266941FFFFFFFF",
"versions": [
"24092600",
"FFFFFFFF"
],
"visVersion": "240926"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "9"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000000000FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.start": "Cancel",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.result": "NA",
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "26.0",
"x.com.samsung.da.desired": "22.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Amman",
"offset": "+03:00",
"DST": "OFF"
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "All",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "1",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "**REDACTED**"
}
}
]
}

Some files were not shown because too many files have changed in this diff Show More