Compare 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
82 changed files with 11409 additions and 195 deletions
+159 -3
View File
@@ -10,7 +10,9 @@ description: >-
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.
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
@@ -25,11 +27,25 @@ dump into coverage.
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.
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.
@@ -61,6 +77,12 @@ print('state_keys:', sorted(state))
`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
@@ -192,6 +214,19 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
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
@@ -275,6 +310,17 @@ friendlier href**.
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`
@@ -289,8 +335,27 @@ shared module rather than copying.
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.
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).
@@ -303,7 +368,98 @@ The new fixture is picked up automatically by the corpus-wide checks (the
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`).
+11
View File
@@ -185,6 +185,17 @@ Samsung's firmware occasionally drops the DTLS session briefly — this is norma
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.
---
## Contributing
+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:
+31 -11
View File
@@ -254,8 +254,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
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.coordinator.resource(MODE_HREF) or {}).get(
'x.com.samsung.da.options') or []
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]],
@@ -268,18 +267,26 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
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 last_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
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.coordinator.last_resources):
if not is_legacy_board(self._resources):
return {}
return self.coordinator.resource(AIRFLOW_HREF) or {}
return self._rep(AIRFLOW_HREF)
def _legacy_preset(self) -> bool:
"""Whether presets come from the Comode_* token rather than a resource.
@@ -288,12 +295,25 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
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.
"""
return (not self.coordinator.resource(CONVENIENT_HREF)
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:
rep = self.coordinator.resource(href) or {}
"""`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
+18 -9
View File
@@ -194,7 +194,17 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
except OSError:
pass
return _order_candidates(live)
# 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:
@@ -238,14 +248,13 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
candidates = _find_live_ports(
host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S
)
if not candidates:
# Every port in the range actively refused: there is no DTLS/CoAP
# listener on this host, so a handshake can't succeed. Fail now with a
# clear message instead of retrying doomed ports.
raise CannotConnect(
f"no live DTLS port found on {host} "
f"in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}"
)
# 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
+344 -16
View File
@@ -30,10 +30,14 @@ from .registry.capabilities.common import (
remote_control_enabled,
remote_control_required_for_write,
)
from .registry.discovery import discover, BoundEntity
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 (
@@ -161,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)
@@ -197,6 +235,72 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
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
@@ -245,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
@@ -292,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."""
@@ -351,6 +532,72 @@ 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:
# Reported for diagnostics only -- it names the firmware generation
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
@@ -360,7 +607,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
info = resources.get('/information/vs/0', {})
reg = resolve_registry(resources)
unbound: list[str] = []
hot, warm = set(), set()
@@ -372,11 +618,54 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
model_num = info.get('x.com.samsung.da.modelNum', '')
description = info.get('x.com.samsung.da.description', '')
if reg is not None:
self._log.debug("device type: %s (modelNum=%r)", reg.name, model_num)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
log=unbound.append, tier_log=_tier_log)
self.device_type_name = reg.name
# 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:
# Both fields: detection reads each of them (board token, then
# consumer-model code), and this line is what a user pastes into
@@ -385,8 +674,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"unknown device type modelNum=%r description=%r; using common caps",
model_num, description,
)
bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log)
self.device_type_name = None
self.device_type_name = device_type_name
self.bound = bound
self._unbound_hrefs = unbound
@@ -396,7 +684,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.device_serial = serial
ident = self._identity
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
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'
@@ -407,15 +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)
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(
@@ -568,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
@@ -584,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()
@@ -671,7 +989,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# 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_href = '/' + '/'.join(path_segs)
#
# 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,
+105 -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(
@@ -36,9 +37,47 @@ async def async_get_config_entry_diagnostics(
# 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 unit exposes more than one logical Device. See
# 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,
@@ -49,7 +88,71 @@ async def async_get_config_entry_diagnostics(
"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": stl_version,
"observe_mode": coordinator.observe_mode,
+22 -3
View File
@@ -33,12 +33,19 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
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 or is_stub_rep(rep):
return True
@@ -122,9 +129,21 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
"""
tk = self._bound.desc.translation_key
if callable(tk):
return tk(self.coordinator.last_resources)
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)
+23 -9
View File
@@ -42,6 +42,7 @@ 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'
@@ -89,11 +90,13 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
"""
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
)
@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)
@@ -119,7 +122,18 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _all_speed_codes(self) -> list[str]:
rep = self._rep(self._bound.href)
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
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()
@@ -134,7 +148,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
resources = self.coordinator.last_resources
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
@@ -237,7 +251,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
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.coordinator.last_resources
resources = self._resources
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
return 'power', enabled, target
@@ -344,7 +358,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
resources -- disagreeing until the next poll refreshes the other
one (the same optimistic-apply lag coordinator.py's own comments
warn about)."""
resources = self.coordinator.last_resources
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.1"
],
"version": "0.15.0"
"version": "0.17.0"
}
@@ -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:
@@ -75,17 +75,24 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
# 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), and ARA-WW wall-mount (#115, #116, #117, #120).
# (#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',
@@ -129,8 +136,20 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]:
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.
"""
for token in _board_tokens(value, cut_at):
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
@@ -16,6 +16,11 @@ match_fn discriminators that keep them from colliding):
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).
@@ -7,6 +7,13 @@ 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
@@ -16,7 +23,9 @@ REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*[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,
@@ -25,6 +25,7 @@ REGISTRY = DeviceRegistry(
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
@@ -15,6 +15,11 @@ REGISTRY = DeviceRegistry(
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,
]),
)
@@ -14,10 +14,13 @@ None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`,
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
@@ -285,17 +288,64 @@ def _option_token(rep, prefix):
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 (Volume, 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. 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.
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)
@@ -445,14 +495,19 @@ CLIMATE = Capability(
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.
# Gated off the legacy ARTIK051 board generation (see is_legacy_board):
# that generation's own Volume_ token is already modeled as the
# buzzer_volume Number below, and both reading the same options[] slot
# into two entities would be redundant.
#
# 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: (
not is_legacy_board(resources)
and _option_token(rep, 'Volume') is not None),
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.
@@ -498,11 +553,6 @@ CLIMATE = Capability(
exists_fn=_has_option_token('AirMonitoring'),
write_fn=_option_switch_write('AirMonitoring'),
icon='mdi:air-filter', entity_category='config'),
NumberDesc(key='buzzer_volume', rep_fn=_option_token_num('Volume'),
exists_fn=_has_option_token('Volume'),
write_fn=_option_number_write('Volume'),
native_min=0, native_max=100, step=10,
icon='mdi:volume-high', 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.
@@ -889,24 +939,34 @@ _AC_IGNORED = [
'/stepcontrol/vs/0',
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/welcome/temperature/vs/0', # welcome-cooling plumbing
# System-AC-only (multi-indoor-unit commercial installs, e.g.
# 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-unit serials, MCU info.
# Commissioning-time plumbing, not user-actionable appliance state.
# 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 unit over one shared local IP, e.g.
# *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. This integration talks to whichever
# single local endpoint the config entry was set up against; the
# second indoor unit isn't independently reachable through this
# resource (or any other in the dump) -- it would need its own local
# DTLS session/IP, which SmartThings pairing doesn't expose here.
# /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).
@@ -259,6 +259,12 @@ def sensor_item_value(items, sensor_type, index=0):
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.
@@ -277,6 +283,7 @@ POWER_GENERIC = Capability(
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',
@@ -604,15 +611,22 @@ SELF_CHECK = Capability(
# level: 2 of 6 families confirmed, blanket-added everywhere else).
#
# POWER is kept separate -- airconditioner is the one family that opts out
# of it. 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/
# 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 = (
@@ -108,6 +108,37 @@ TEMP_SETPOINT = Capability(
),
)
# ---------------------------------------------------------------------------
# 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),
),
)
# ---------------------------------------------------------------------------
# Icemaker nighttime quiet mode
# ---------------------------------------------------------------------------
@@ -24,6 +24,7 @@ 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, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
@@ -130,6 +131,28 @@ def _option_value(options, prefix):
return None
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
@@ -199,44 +222,19 @@ def _oven_mode_write(p, rep, href=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
if not rep.get('x.com.samsung.da.options'):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write('UpperLamp', 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 _fastpreheat_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('fastpreheat', p),
}
def _naturalsteam_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('NaturalSteam', p),
}
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
# ---------------------------------------------------------------------------
@@ -377,19 +375,38 @@ OVEN_MODE = Capability(
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
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',
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',
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',
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')),
),
)
@@ -1,8 +1,10 @@
"""Capabilities for the Samsung water-purifier family (TP2X_WATERPURIFIER-class,
issue #90, model TP2X_WATERPURIFIER_20K).
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 diagnostics dump.
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
@@ -21,10 +23,23 @@ DISPENSE = Capability(
# 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})),
@@ -190,6 +205,86 @@ COFFEE = Capability(
),
)
# 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',
@@ -18,6 +18,7 @@ from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
from .subdevices import MAIN, Subdevice
@dataclass
@@ -28,6 +29,12 @@ class BoundEntity:
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:
@@ -57,13 +64,20 @@ def instance_suffix(href: str) -> str:
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None) -> list[BoundEntity]:
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."""
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=href, capability=cap, desc=desc, instance=inst,
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)
instance_name=inst_name, subdevice=subdevice)
for desc in cap.entities
]
@@ -74,6 +88,7 @@ def discover(
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
@@ -81,7 +96,19 @@ def discover(
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`."""
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():
@@ -97,10 +124,10 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
matched = True
if tier_log is not None:
tier_log(href, cap.poll_tier)
tier_log(subdevice.to_actual(href), cap.poll_tier)
if matched:
continue
@@ -117,13 +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']
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
subdevice=subdevice))
matched = True
if tier_log is not None:
tier_log(href, cap.poll_tier)
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
@@ -70,11 +70,14 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
# 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 unit -- one IP, one /oic/p -- exposing more than one logical
# Device, each as its own Collection resource, same rt shape as our own
# /device/0). Nothing consumes this yet; captured so a report from a
# multi-unit device shows us whether its firmware actually implements
# that model before any code assumes it does.
# 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',
@@ -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
+3 -1
View File
@@ -104,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 [])
@@ -73,6 +73,9 @@
"pouring": {
"name": "Pouring"
},
"alarm_in_mute": {
"name": "Alarm in mute"
},
"power_state": {
"name": "Power state"
},
@@ -158,9 +161,6 @@
}
},
"number": {
"buzzer_volume": {
"name": "Beep volume"
},
"cook_time": {
"name": "Cook time"
},
@@ -214,6 +214,9 @@
"100": "High"
}
},
"cooler_temperature_setpoint": {
"name": "Cooler temperature"
},
"day_brightness": {
"name": "Cabinet brightness",
"state": {
@@ -490,6 +493,14 @@
"buzzer": "Buzzer"
}
},
"water_purifier_sound_mode": {
"name": "Sound mode",
"state": {
"voice": "Voice",
"fixedtone": "Fixed tone",
"mute": "Mute"
}
},
"spin_speed": {
"name": "Spin speed",
"state": {
@@ -876,6 +887,15 @@
"waterpurifier_status": {
"name": "Status"
},
"cup_state": {
"name": "Cup state"
},
"last_pour_type": {
"name": "Last pour type"
},
"last_pour_capacity": {
"name": "Last pour capacity"
},
"machine_state": {
"name": "Machine state",
"state": {
@@ -1018,6 +1038,12 @@
"natural_steam": {
"name": "Natural steam"
},
"energy_saving": {
"name": "Energy saving"
},
"cooktop_on_alert": {
"name": "Cooktop-on alert"
},
"power_switch": {
"name": "Power"
},
@@ -73,6 +73,9 @@
"pouring": {
"name": "Schenken"
},
"alarm_in_mute": {
"name": "Alarm bij dempen"
},
"power_state": {
"name": "Voedingsstatus"
},
@@ -158,9 +161,6 @@
}
},
"number": {
"buzzer_volume": {
"name": "Zoemervolume"
},
"cook_time": {
"name": "Bereidingstijd"
},
@@ -214,6 +214,9 @@
"100": "Hoog"
}
},
"cooler_temperature_setpoint": {
"name": "Koeler temperatuur"
},
"day_brightness": {
"name": "Helderheid kast",
"state": {
@@ -490,6 +493,14 @@
"buzzer": "Zoemer"
}
},
"water_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"fixedtone": "Vaste toon",
"mute": "Dempen"
}
},
"spin_speed": {
"name": "Centrifugesnelheid",
"state": {
@@ -876,6 +887,15 @@
"waterpurifier_status": {
"name": "Status"
},
"cup_state": {
"name": "Kopstatus"
},
"last_pour_type": {
"name": "Laatste schenktype"
},
"last_pour_capacity": {
"name": "Laatste schenkhoeveelheid"
},
"machine_state": {
"name": "Machinestatus",
"state": {
@@ -1018,6 +1038,12 @@
"natural_steam": {
"name": "Natuurlijke stoom"
},
"energy_saving": {
"name": "Energiebesparing"
},
"cooktop_on_alert": {
"name": "Waarschuwing kookplaat aan"
},
"power_switch": {
"name": "Voeding"
},
+83
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 = {
+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"
}
}
]
}
File diff suppressed because it is too large Load Diff
@@ -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
}
}
}
]
}
@@ -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."
}
+19
View File
@@ -0,0 +1,19 @@
{
"state_keys": [
"alarm_code",
"clean_level",
"device_active",
"dust",
"energy_kwh",
"energy_saved_kwh",
"fine_dust",
"firmware_update",
"hepa_filter_status",
"hepa_filter_usage",
"mute_once",
"odor",
"power_switch",
"super_fine_dust",
"wind_strength_fan"
]
}
@@ -0,0 +1,27 @@
{
"state_keys": [
"alarm_code",
"auto_clean_legacy",
"beep",
"clean_level",
"climate",
"current_temperature_c",
"diagnosis_status",
"dust",
"energy_kwh",
"fine_dust",
"good_sleep",
"humidity",
"odor",
"power_energy_kwh",
"power_watts",
"super_fine_dust",
"subdevice1_alarm_code",
"subdevice1_auto_clean_legacy",
"subdevice1_beep",
"subdevice1_climate",
"subdevice1_current_temperature_c",
"subdevice1_good_sleep",
"subdevice1_humidity"
]
}
@@ -3,7 +3,7 @@
"air_monitoring",
"alarm_code",
"auto_clean_legacy",
"buzzer_volume",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
@@ -15,4 +15,4 @@
"outdoor_temperature",
"spi"
]
}
}
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_monitoring",
"alarm_code",
"auto_clean_legacy",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_time",
"good_sleep",
"humidity",
"outdoor_temperature",
"spi"
]
}
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_monitoring",
"alarm_code",
"auto_clean_legacy",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
"display_light",
"energy_kwh",
"filter_time",
"good_sleep",
"humidity",
"outdoor_temperature",
"spi"
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"state_keys": [
"absence_power_saving_active",
"absence_power_saving_mode",
"air_filter_status",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"clean_level",
"climate",
"current_temperature_c",
"dust",
"energy_kwh",
"energy_saved_kwh",
"fine_dust",
"firmware_update",
"humidity",
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"power_watts",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"super_fine_dust",
"tropical_night_mode"
]
}
@@ -0,0 +1,20 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
"energy_kwh",
"firmware_update",
"humidity",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
]
}
+22
View File
@@ -0,0 +1,22 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
"energy_kwh",
"firmware_update",
"humidity",
"power_energy_kwh",
"power_watts",
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
"subdevice_6c2dff6dee5cdad16a5e000000000001_current_temperature_c",
"tropical_night_mode"
]
}
+1 -3
View File
@@ -7,12 +7,10 @@
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
@@ -22,4 +20,4 @@
"remote_control",
"sound"
]
}
}
+2 -3
View File
@@ -27,12 +27,11 @@
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"energy_saving",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
@@ -42,4 +41,4 @@
"remote_control",
"sound"
]
}
}
+3 -3
View File
@@ -4,16 +4,16 @@
"child_lock",
"cloud_connected",
"cook_time",
"cooktop_on_alert",
"cooktop_running_state",
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"energy_saving",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
@@ -24,4 +24,4 @@
"sound",
"warming_center_state"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"state_keys": [
"alarm_code",
"cooktop_on_alert",
"child_lock",
"cloud_connected",
"cook_time",
"cooktop_running_state",
"current_temp_c",
"cycle_active",
"door_open",
"energy_saving",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
"oven_state",
"power_switch",
"progress_percentage",
"remote_control",
"sound",
"warming_center_state"
]
}
+1 -2
View File
@@ -8,12 +8,11 @@
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"energy_saving",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
+3 -3
View File
@@ -4,16 +4,16 @@
"child_lock",
"cloud_connected",
"cook_time",
"cooktop_on_alert",
"cooktop_running_state",
"current_temp_c",
"cycle_active",
"door_open",
"fast_preheat",
"energy_saving",
"finish_time",
"firmware_update",
"lamp",
"machine_state",
"natural_steam",
"operation_time_minutes",
"oven_mode",
"oven_setpoint",
@@ -24,4 +24,4 @@
"sound",
"warming_center_state"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"state_keys": [
"ai_energy_level",
"alarm_code",
"auto_door_opener",
"brightness_level",
"cabinet_light_dim",
"cabinet_light_switch",
"cooler_temperature_setpoint",
"day_brightness",
"door_alert",
"door_cooler_open",
"energy_kwh",
"energy_saved_kwh",
"firmware_update",
"fridge_sound",
"night_end",
"night_start",
"power_energy_kwh",
"power_watts",
"rapid_freezing",
"rapid_fridge",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
+36
View File
@@ -0,0 +1,36 @@
{
"state_keys": [
"alarm_code",
"alarm_in_mute",
"buzz_lock",
"coffee_brew_status",
"coldwater_lock",
"cup_state",
"dispense_capacity",
"dispense_type",
"favorite_capacity",
"favorite_capacity_enabled",
"favorite_coffee_enabled",
"favorite_hotwater_temperature",
"filter_clean_remain_time",
"filter_door_status",
"filter_status",
"filter_usage",
"firmware_update",
"hotwater_lock",
"last_pour_capacity",
"last_pour_type",
"pouring",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"sound_mode",
"sound_output",
"sound_volume",
"sterilize_last_time",
"sterilize_period",
"sterilize_plan_time",
"sterilize_run_time",
"waterpurifier_status"
]
}
-1
View File
@@ -15,7 +15,6 @@
"filter_status",
"filter_usage",
"firmware_update",
"hot_water_temperature",
"hotwater_lock",
"pouring",
"selfcheck_error",
File diff suppressed because one or more lines are too long
+401
View File
@@ -0,0 +1,401 @@
{
"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": "/alarms/vs/0",
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/cabinet/light/total/vs/0",
"rep": {
"x.com.samsung.da.lightLevel": "100",
"x.com.samsung.da.lightResolution": "3",
"x.com.samsung.da.lightControl.off.include": "Off",
"x.com.samsung.da.lightControl": "Off",
"x.com.samsung.da.lightControl.hide": "true",
"light.dimming.status": "On",
"href": "/cabinet/light/total/vs/0",
"rt": [
"x.com.samsung.da.cabinetlight"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/settings/sound/alert/door/vs/0",
"rep": {
"alert.door": "3",
"supportedAlert.door": [
"1",
"2",
"3",
"4"
],
"href": "/settings/sound/alert/door/vs/0",
"rt": [
"x.com.samsung.alert.door"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/cabinet/light/enhanced/vs/0",
"rep": {
"light.control.status": "Off",
"level.brightness.daytime": "100",
"level.brightness.nighttime": "100",
"night.starttime": "N/A",
"night.duration.minute": "N/A",
"timezone.offset": "-03:00",
"href": "/cabinet/light/enhanced/vs/0",
"rt": [
"x.com.samsung.da.light.enhanced"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "",
"x.com.samsung.da.countryCode": "",
"href": "/configuration/vs/0"
}
},
{
"href": "/temperature/definite/cooler/vs/0",
"rep": {
"x.com.samsung.da.definite.desired": "2",
"x.com.samsung.da.definite.supportedList": [
"1",
"2",
"3",
"4",
"7"
],
"x.com.samsung.da.unit": "Celsius",
"href": "/temperature/definite/cooler/vs/0"
}
},
{
"href": "/door/cooler/0",
"rep": {
"openState": "Close",
"href": "/door/cooler/0",
"rt": [
"oic.r.door"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/doors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Door"
},
{
"x.com.samsung.da.openState": "Close",
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Door"
}
],
"href": "/doors/vs/0"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.override": "Not_Supported",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.realSaving": "Off",
"href": "/drlc/vs/0"
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "9",
"x.com.samsung.da.instantaneousPower": "51",
"x.com.samsung.da.cumulativePower": "593883",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"href": "/energy/consumption/vs/0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "-03:00",
"x.com.samsung.supprtedtype": 1,
"href": "/file/information/vs/0"
}
},
{
"href": "/refrigeration/vs/0",
"rep": {
"x.com.samsung.da.rapidFridge": "Off",
"x.com.samsung.da.rapidFreezing": "Off",
"href": "/refrigeration/vs/0",
"rt": [
"x.com.samsung.da.fridge"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/energy/ailevel/vs/0",
"rep": {
"aiLevel": "1",
"supportedAiLevel": [
"1"
],
"href": "/energy/ailevel/vs/0"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00174941|0006012A001811304100000020010000",
"x.com.samsung.da.description": "TP1X_REF_21K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"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": "RT1",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"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": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "260617",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "24010202, FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"href": "/information/vs/0"
}
},
{
"href": "/status/lock/vs/0",
"rep": {
"x.com.samsung.da.device.sound": "On",
"x.com.samsung.da.childlock": "Locked",
"href": "/status/lock/vs/0",
"rt": [
"x.com.samsung.da.lockstatus"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2",
"ENERGY_REPORT_MODEL",
"18K_REF_OUTDOOR_CONTROL_V2"
],
"href": "/mode/vs/0",
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true",
"href": "/realtimenotiforclient/vs/0"
}
},
{
"href": "/runningmode/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0,
"href": "/runningmode/vs/0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.supportedActions": [
"Start"
],
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.result": "Success",
"x.com.samsung.da.error": [
"ErrorCode_None"
],
"href": "/selfcheck/vs/0"
}
},
{
"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 Refrigerator",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "A-RFWW-TP1-24-T4-COM",
"versions": [
"20260617"
],
"visVersion": "260617"
},
{
"type": "Micom",
"modelId": "035000174941FFFFFFFF",
"versions": [
"24010202",
"FFFFFFFF"
],
"visVersion": "240102"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "America/Sao_Paulo",
"offset": "-03: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": "Pumba_IoT"
}
},
{
"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
}
}
}
]
}
+590
View File
@@ -0,0 +1,590 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.supportedActions": [
"Start"
],
"x.com.samsung.da.result": "Success",
"x.com.samsung.da.error": [
"ErrorCode_None"
],
"x.com.samsung.da.status": "Ready"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/configuration/vs/0",
"rep": {}
},
{
"href": "/filter/waterfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "69",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterResetType": [
"replaceable"
],
"x.com.samsung.da.lastResetDate": "2025-11-19T05:39:17",
"x.com.samsung.da.filterStatus": "normal"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "AILITE_DA-REF-WATERPURIFIER-01011|70674641|900100000219130081088700001E0000",
"x.com.samsung.da.description": "AILITE_WATERPURIFIER_25K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"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": "RX0",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"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": "WiFi Module",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "251121",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Micom",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "25071603, 2510140E, 22092302, FFFFFFFF, 25061700, FFFFFFFF",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Bixby",
"x.com.samsung.da.type": "Bixby",
"x.com.samsung.da.number": "1.2.2-rc.3",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/runningmode/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/setting/waterpurifier/vs/0",
"rep": {
"x.com.samsung.da.supportedTypes": [
"ambientwater",
"coldwater",
"hotwater"
],
"x.com.samsung.da.desiredType": "coldwater",
"x.com.samsung.da.tempDesiredHotWater": "85",
"x.com.samsung.da.desiredCapacity": "120",
"x.com.samsung.da.hotwaterLevel": "5",
"x.com.samsung.da.hotwaterRange": [
"40",
"90"
],
"x.com.samsung.da.tempUnit": "C",
"x.com.samsung.da.pourStatus": "Off",
"x.com.samsung.da.capacityUnit": "C",
"x.com.samsung.da.capacityResolution": "10",
"x.com.samsung.da.desiredCapacityRange": [
"50",
"2000"
],
"x.com.samsung.da.triggerTime": "1785341896789"
}
},
{
"href": "/favorite/capacity/vs/0",
"rep": {
"x.com.samsung.da.switchCapacity": "On",
"x.com.samsung.da.defaultCapacity": "120",
"x.com.samsung.da.capacityList": [
"80",
"120",
"260",
"500",
"9999"
],
"x.com.samsung.da.triggerTime": "1785341867176"
}
},
{
"href": "/status/waterpurifier/vs/0",
"rep": {
"x.com.samsung.da.filterCleanMaxTime": "14",
"x.com.samsung.da.sterilizeMaxTime": "10",
"x.com.samsung.da.sterilizeRunTime": "0",
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.sterilizePlanTime": "2026-07-31T17:00:00",
"x.com.samsung.da.sterilizeLastTime": "2026-07-28T17:08:36",
"x.com.samsung.da.sterilizeStartTime": "2026-07-31T17:00:00",
"x.com.samsung.da.sterilizePeriod": "3"
}
},
{
"href": "/status/lock/vs/0",
"rep": {
"x.com.samsung.da.coldwaterLock": "Unlocked",
"x.com.samsung.da.device.microphone": "On"
}
},
{
"href": "/favorite/hotwater/vs/0",
"rep": {
"x.com.samsung.da.favorite.revision": "0",
"x.com.samsung.da.favorite.defaultTemperature": "85",
"x.com.samsung.da.favorite.showList": [
"40",
"50",
"75",
"85",
"90"
],
"x.com.samsung.da.favorite.supportedList": [
"40",
"75",
"85",
"90"
],
"x.com.samsung.da.switchHotwater": "Unlocked",
"x.com.samsung.da.triggerTime": "1785341867177"
}
},
{
"href": "/settings/sound/volume/vs/0",
"rep": {
"level": "5",
"minLevel": "0",
"maxLevel": "15",
"resolution": "5"
}
},
{
"href": "/settings/sound/mode/vs/0",
"rep": {
"mode": "voice",
"supportedModes": [
"voice",
"fixedTone",
"mute"
]
}
},
{
"href": "/settings/sound/output/vs/0",
"rep": {
"deviceType": "speaker",
"alarmInMute": "true"
}
},
{
"href": "/setting/vs/0",
"rep": {
"x.com.samsung.da.setLanguage": "ko_KR",
"x.com.samsung.da.supportedSetLanguage": [
"ko_KR"
]
}
},
{
"href": "/automation/waterpurifier/vs/0",
"rep": {
"automation.supported.modes": [
"0"
],
"automation.supported.options": [
"capacity"
]
}
},
{
"href": "/favorite/coffee/vs/0",
"rep": {
"favorite.activate": "On",
"brew.status": "Suspend",
"favorite.enable.list": [
"0",
"1",
"2",
"3"
]
}
},
{
"href": "/brand/recipe/info/vs/0",
"rep": {
"revision": "0",
"brand.num.max": "1"
}
},
{
"href": "/recipe/coffee/vs/0",
"rep": {}
},
{
"href": "/coffee/custom/recipe/vs/0",
"rep": {
"publisher": "0",
"cups": 1,
"favor": "223",
"numberOfStages": "2",
"stages": [
{
"temperature": 0,
"waterAmount": 30,
"timeSecond": 20,
"turnOver": 0
},
{
"temperature": 0,
"waterAmount": 30,
"timeSecond": 20,
"turnOver": 0
}
],
"publisher.support": [
"1",
"2",
"3",
"4",
"5"
]
}
},
{
"href": "/recipe/coffee/deletion/vs/0",
"rep": {}
},
{
"href": "/cup/state/vs/0",
"rep": {
"water.cup.state": "UnReady",
"water.cup.basis.revision": "0"
}
},
{
"href": "/statistic/pour/vs/0",
"rep": {
"last.type": "coldwater",
"last.capacity": "40"
}
},
{
"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 Water purifier",
"osVersion": "5.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "A-WPWW-AIL-25-COMMON",
"versions": [
"20251121"
],
"visVersion": "251121"
},
{
"type": "Micom",
"modelId": "838070674741FFFFFFFF",
"versions": [
"2510140E",
"FFFFFFFF"
],
"visVersion": "251014"
},
{
"type": "Micom",
"modelId": "838070674641FFFFFFFF",
"versions": [
"25071603",
"FFFFFFFF"
],
"visVersion": "250716"
},
{
"type": "Micom",
"modelId": "03807067464170674741",
"versions": [
"25071603",
"2510140E"
],
"visVersion": "251014"
},
{
"type": "Micom",
"modelId": "038000156141FFFFFFFF",
"versions": [
"22092302",
"FFFFFFFF"
],
"visVersion": "220923"
},
{
"type": "Micom",
"modelId": "038070673941FFFFFFFF",
"versions": [
"25061700",
"FFFFFFFF"
],
"visVersion": "250617"
}
]
}
},
{
"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",
"5G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/bixbyuservalidate/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.loginId": "**REDACTED**",
"x.com.samsung.da.isExpired": "false"
}
]
}
},
{
"href": "/bixby/status/vs/0",
"rep": {
"x.com.samsung.da.enable": "true"
}
},
{
"href": "/bixbyterms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.accountInfo": "**REDACTED**",
"x.com.samsung.da.termsName": "AudioReview",
"x.com.samsung.da.termsStatus": "Agreed",
"x.com.samsung.da.termsType": "Optional",
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.1/1773704680415/kor/kor_kor_AudioRecordingReview.html"
},
{
"x.com.samsung.da.accountInfo": "**REDACTED**",
"x.com.samsung.da.termsName": "Datasharing",
"x.com.samsung.da.termsStatus": "Agreed",
"x.com.samsung.da.termsType": "Mandatory",
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.1/1690269284216/kor/kor_kor_DataSharingAgreement_v3.html"
},
{
"x.com.samsung.da.accountInfo": "**REDACTED**",
"x.com.samsung.da.termsName": "Personalization",
"x.com.samsung.da.termsStatus": "Agreed",
"x.com.samsung.da.termsType": "Optional",
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.2/1773644167282/kor/kor_kor_PersonalizationAgreement.html"
},
{
"x.com.samsung.da.accountInfo": "**REDACTED**",
"x.com.samsung.da.termsName": "PrivacyNotice",
"x.com.samsung.da.termsStatus": "Agreed",
"x.com.samsung.da.termsType": "Mandatory",
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.2/1726806774110/kor/kor_kor_pn.html"
},
{
"x.com.samsung.da.accountInfo": "**REDACTED**",
"x.com.samsung.da.termsName": "PrivacyPolicy",
"x.com.samsung.da.termsStatus": "Agreed",
"x.com.samsung.da.termsType": "Mandatory",
"x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.7.8/1783668586319/kor/kor_kor_pp.html"
},
{
"x.com.samsung.da.accountInfo": "**REDACTED**",
"x.com.samsung.da.termsName": "SamsungLocation",
"x.com.samsung.da.termsStatus": "Agreed",
"x.com.samsung.da.termsType": "Mandatory",
"x.com.samsung.da.termsURL": "https://static.bada.com/contents/legal/kor/kor/locationinfortnc.html"
},
{
"x.com.samsung.da.accountInfo": "**REDACTED**",
"x.com.samsung.da.termsName": "FMMPrivacyPolicy",
"x.com.samsung.da.termsStatus": "Agreed",
"x.com.samsung.da.termsType": "Mandatory",
"x.com.samsung.da.termsURL": "https://account.samsung.com/membership/policy/privacy/service/o12ht525e6"
},
{
"x.com.samsung.da.accountInfo": "**REDACTED**",
"x.com.samsung.da.termsName": "BixbyPrivacy",
"x.com.samsung.da.termsStatus": "NotOnboarding",
"x.com.samsung.da.termsType": "Optional",
"x.com.samsung.da.termsURL": ""
}
],
"x.com.samsung.da.lastCheckedTime": "1785341867"
}
},
{
"href": "/bixby/vs/0",
"rep": {
"x.com.samsung.da.voiceWakeup": "Disable",
"x.com.samsung.da.multidevWakeup": "true"
}
},
{
"href": "/sec/networkaudio/audio/vs/0",
"rep": {
"volume": "4",
"mute": "false",
"maxVolume": "15",
"timestamp": "0"
}
},
{
"href": "/voice/feature/vs/0",
"rep": {
"voice.feature": {
"ContinuousConversation": {
"support": "true",
"value": "false",
"supportedValues": [
"true",
"false"
]
},
"DiscoverBixby": {
"support": "false"
},
"LLMBixby": {
"support": "true"
},
"SimpleCommand": {
"support": "false",
"value": "false",
"supportedValues": [
"true",
"false"
],
"description": [
"\ub0c9\uc7a5\uace0 \ubb38 \uc5f4\uc5b4\uc918"
]
}
}
}
},
{
"href": "/voice/provisioning/vs/0",
"rep": {
"voice.provisioning.headers": {
"login_id": "**REDACTED**",
"voice.provisioning.headers.bixbyLanguage": "ko_KR",
"voice.provisioning.headers.bixbyType": "BixbyAgent",
"voice.provisioning.headers.bixbyVersion": "1.2.2-rc.3",
"voice.provisioning.headers.companioncountry": "KR",
"voice.provisioning.headers.csc": "920",
"voice.provisioning.headers.csciso": "KR",
"voice.provisioning.headers.deviceLanguage": "ko-KR",
"voice.provisioning.headers.deviceModelNameAsh": "RWP70F15ANW",
"voice.provisioning.headers.deviceModelNamePro": "BIXBY-LITE",
"voice.provisioning.headers.deviceSubType": "screenless_waterpurifier_lite",
"voice.provisioning.headers.deviceTypeAsh": "appliance",
"voice.provisioning.headers.deviceTypePro": "appliance",
"voice.provisioning.headers.mcc": "450",
"voice.provisioning.headers.osType": "Tizen Lite",
"voice.provisioning.headers.osVersion": "Tizen Lite",
"voice.provisioning.headers.requestTrackId": "1785341867293",
"voice.provisioning.headers.sspServerUrl": "us-auth2.samsungosp.com",
"voice.provisioning.headers.storecountry": "KR",
"voice.provisioning.headers.userType": "COMMERCIAL"
},
"voice.provisioning.termsstatechange": {
"voice.provisioning.termsstatechange.refresh": "",
"voice.provisioning.termsstatechange.state": "Agreed",
"voice.provisioning.termsstatechange.terms": ""
},
"voice.provisioning.deviceinfo": {
"voice.provisioning.deviceinfo.accesstoken": "**REDACTED**",
"voice.provisioning.deviceinfo.bixbyserver": "bxb3PrdUS",
"voice.provisioning.deviceinfo.bixbyserviceid": "US1DAAPPFD31232A4E4B40D6B83630AF58385719",
"voice.provisioning.deviceinfo.deviceid": "**REDACTED**",
"voice.provisioning.deviceinfo.gdprcountry": "false",
"voice.provisioning.deviceinfo.isSupportedCountry": "true",
"voice.provisioning.deviceinfo.userid": "**REDACTED**"
}
}
}
]
}
+41
View File
@@ -105,6 +105,47 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
assert result == [live_port]
def test_find_live_ports_rescues_preferred_ports_the_sweep_missed(
socket_enabled, monkeypatch,
) -> None:
"""Issue #192: a segregated VLAN made the ICMP-based sweep call three
closed ports live while missing the one port (a historically confirmed
DTLS port) that nmap showed as genuinely open|filtered. The sweep's
verdict on a preferred port shouldn't be trusted blindly -- it must
always come back as a candidate even if the sweep marked it dead, so the
config flow gets a real handshake attempt against it.
Uses an OS-assigned port monkeypatched into PREFERRED_PROBE_PORTS rather
than the real 49154/49155, so this doesn't depend on those specific
system ports being free on whatever machine runs the suite.
"""
import socket
from custom_components.localthings import config_flow
from custom_components.localthings.config_flow import _find_live_ports
# Bind an OS-assigned port and immediately close it, same technique
# test_find_live_ports_detects_silent_port uses for its "closed" ports --
# once closed, loopback refuses datagrams to it, standing in for the
# sweep wrongly ruling out a port we have strong prior evidence for.
reserved = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
reserved.bind(('127.0.0.1', 0))
preferred_port = reserved.getsockname()[1]
reserved.close()
monkeypatch.setattr(config_flow, 'PREFERRED_PROBE_PORTS', [preferred_port])
live_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
live_sock.bind(('127.0.0.1', 0))
live_port = live_sock.getsockname()[1]
try:
result = _find_live_ports('127.0.0.1', [preferred_port, live_port], 0.8)
finally:
live_sock.close()
assert set(result) == {preferred_port, live_port}
async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None:
"""A device that only answers on 49153 — outside the historical
49154/49155 pair — is found by the liveness sweep and its port is stored
+86
View File
@@ -0,0 +1,86 @@
"""Tests for async_remove_config_entry_device (issue #214).
A subdevice's HA device outlives the discovery that created it: nothing
recreates it and nothing prunes it once discovery stops materializing that
subdevice, so a phantom created by an older release (issue #214's duplicate
air conditioner, and the duplicate the second reporter still sees on a
refrigerator whose diagnostics now report no subdevices at all) sticks
around forever. Defining this callback is what puts a working "Delete
device" button on it.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from custom_components.localthings import async_remove_config_entry_device
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.registry.subdevices import Subdevice
def _device(hass, entry, identifiers) -> dr.DeviceEntry:
return dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id, identifiers=identifiers,
)
async def test_stale_device_can_be_removed(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""The phantom case: a device entry left over from a subdevice this
entry no longer provides."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.subdevices == [] # this fixture is not a composite device
stale = _device(
hass, mock_entry, {(DOMAIN, f'{coordinator.device_serial}_1')},
)
assert await async_remove_config_entry_device(hass, mock_entry, stale) is True
async def test_master_device_cannot_be_removed(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""Refusing here is what stops the delete from *looking* like it worked
and then having HA recreate the device on the next entity add."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
master = _device(hass, mock_entry, set(coordinator.device_info['identifiers']))
assert await async_remove_config_entry_device(hass, mock_entry, master) is False
async def test_live_subdevice_device_cannot_be_removed(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""A subdevice that *did* materialize is as protected as the master --
the identifiers this checks against come from device_info_for, the same
call every one of that subdevice's entities reports."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
subdevice = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
coordinator.subdevices = [subdevice]
live = _device(
hass, mock_entry,
set(coordinator.device_info_for(subdevice)['identifiers']),
)
assert await async_remove_config_entry_device(hass, mock_entry, live) is False
async def test_removal_allowed_when_entry_is_not_loaded(
hass: HomeAssistant, mock_entry,
) -> None:
"""No coordinator means nothing is claiming the device -- don't strand
it behind a callback that can't answer."""
mock_entry.add_to_hass(hass)
orphan = _device(hass, mock_entry, {(DOMAIN, 'whatever')})
assert await async_remove_config_entry_device(hass, mock_entry, orphan) is True
+36
View File
@@ -122,6 +122,42 @@ async def test_diagnostics_include_oic_res_links(
assert links[1]['rt'] == ['x.com.samsung.devcol', 'oic.wk.col']
async def test_diagnostics_include_speculative_device_probes(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""/device/1 and /device/2 (issue #177's Composite Device follow-up) ride
along in identity.raw the same {href: rep} shape as the main /device/0
dump under "resources" -- ordinary redaction, no special-casing needed."""
from custom_components.localthings.registry.identity import DeviceIdentity
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics',
model='RF9000B',
name='Family Hub',
serial=None,
device_types=('oic.wk.d', 'oic.d.refrigerator'),
raw={
'/oic/p': {}, '/oic/d': {}, '/oic/res': [],
'/device/1': {
'/information/vs/0': {'x.com.samsung.da.serialNum': 'SECRET123'},
'/power/vs/0': {'x.com.samsung.da.power': 'On'},
},
'/device/2': {},
},
)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
device1 = diag['identity']['resources']['/device/1']
assert device1['/information/vs/0']['x.com.samsung.da.serialNum'] == REDACTED
assert device1['/power/vs/0']['x.com.samsung.da.power'] == 'On'
assert diag['identity']['resources']['/device/2'] == {}
async def test_diagnostics_identity_none_when_unavailable(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
+6
View File
@@ -20,6 +20,12 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
+62
View File
@@ -0,0 +1,62 @@
"""Tests for the AVT-WW-TP1-23-AXX500 air-purifier profile (issue #190).
Next-gen BESPOKE Cube Air board -- same resource surface as the
A-VTWW-TP2-21-COMMON family (issue #151), but the '-WW-' delimiter now falls
one letter to the left ('A-VTWW-' -> 'AVT-WW-'), splitting into an 'AVT'/'WW'
token pair the existing 'VTWW' whole-token entry can't see. The reporter's
diagnostics showed device_type 'unknown' with empty oneUiVersion and every
resource unbound -- once routed to the existing air_purifier registry, every
resource here binds with zero gaps.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _resources():
return _load_device('air_purifier_avt_ww')
def _reg(resources):
info = resources['/information/vs/0']
return for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
def test_resolves_to_air_purifier_registry():
assert _reg(_resources()).name == 'air_purifier'
def test_no_unbound_hrefs():
resources = _resources()
reg = _reg(resources)
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_wind_strength_fan_preset_names():
"""This dump's /wind/strength/vs/0 reports the same numeric-code +
modesName shape as the original VTWW fixture (SMART/MAX/WINDFREE/Sleep),
confirming the shared WIND_STRENGTH_FAN capability applies unchanged."""
resources = _resources()
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state['wind_strength_fan'] == '87'
wind = resources['/wind/strength/vs/0']
assert wind['x.com.samsung.da.modesName'] == ['SMART', 'MAX', 'WINDFREE', 'Sleep']
def test_air_quality_and_filter_sensors_present():
resources = _resources()
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state['dust'] == 10
assert state['fine_dust'] == 9
assert state['odor'] == 1
assert state['hepa_filter_usage'] == 0
assert state['hepa_filter_status'] == 'normal'
+6
View File
@@ -29,6 +29,12 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
+8 -2
View File
@@ -47,6 +47,12 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
@@ -102,7 +108,7 @@ def test_token_entities_present_with_calibrated_values():
assert state['filter_time'] == 171.5
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
assert state['outdoor_temperature'] == 19.0
assert state['buzzer_volume'] == 100.0
assert state['beep'] is True
assert state['good_sleep'] == 0.0
assert state['spi'] is False
assert state['auto_clean_legacy'] is False
@@ -115,7 +121,7 @@ def test_token_entities_stay_off_newer_boards():
token entities would duplicate them (auto clean) or apply a scale
calibrated on another board generation (outdoor temperature)."""
state = _state('airconditioner_tp1x_rac')
for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume',
for key in ('spi', 'auto_clean_legacy', 'air_monitoring',
'good_sleep', 'outdoor_temperature', 'filter_time'):
assert key not in state, key
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191).
0.16.0's device-type simplification dropped oneUiVersion detection on the
assumption every device it typed was already reachable via a modelNum board
token -- this board was the one exception (its oneUiVersion self-reports
"7.0 Air conditioner", but 'CAC' had never been added to the board-token
table), so it silently fell back to common caps and lost its climate entity.
This dump is NOT fully covered yet -- ten hrefs remain unbound (edge
lighting, PM1 filter, a second stateful light resource, absence-clean, four
`/settings/sound/*` resources, smart-sensing-cooling, UV LED), all genuinely
new to this board generation. That's a real device-support gap, left
documented here rather than guessed at, per the 'don't guess' rule -- fixing
the routing regression was the scope of #191.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
_STILL_UNBOUND = frozenset({
'/edgelighting/vs/0',
'/filter/airdustPM1filter/vs/0',
'/light/stateful/vs/0',
'/mds/absenceclean/vs/0',
'/settings/sound/mode/vs/0',
'/settings/sound/optimization/vs/0',
'/settings/sound/output/vs/0',
'/settings/sound/volume/vs/0',
'/smartsensingcooling/vs/0',
'/uvled/vs/0',
})
def _resources():
return _load_device('airconditioner_cac')
def _reg(resources):
info = resources['/information/vs/0']
return for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
def test_resolves_to_airconditioner_registry():
assert _reg(_resources()).name == 'airconditioner'
def test_documented_coverage_gap_is_exactly_this_set():
"""Locks in the current, known-incomplete coverage so a future fix to
any of these hrefs shows up as a golden-regression diff (extra keys) to
update here, rather than silently shrinking this list unnoticed."""
resources = _resources()
reg = _reg(resources)
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert set(unbound) == _STILL_UNBOUND
def test_non_legacy_board_uses_the_generic_energy_scale():
"""This board reports /wind/strength/vs/0 (not /airflow/vs/0), so
is_legacy_board() is False and it must use the plain wh_to_kwh scale,
not the /100000 correction added for the unrelated ARTIK051_KRAC-class
board in issue #193."""
resources = _resources()
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state['energy_kwh'] == round(84044 / 1000.0, 2)
+53 -5
View File
@@ -814,13 +814,61 @@ def test_sensor_item_value_picks_first_value():
def test_beep_and_tropical_night_stay_off_legacy_krac_board():
"""ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_
option token, but they're already modeled as buzzer_volume/good_sleep
(see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also
bind there, or the same options[] slot would surface as two entities."""
option token. Volume_ is modeled as the shared 'beep' switch (issue #136's
buzzer_volume Number never correctly modeled this board -- see
test_legacy_krac_board_beep_is_a_switch_not_a_volume_number below);
Sleep_ is still good_sleep, not tropical_night_mode, on this board (see
airconditioner.CLIMATE)."""
reg, resources = _resolve('airconditioner_artik051_krac_18k')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'beep' not in state
assert 'buzzer_volume' not in state
assert 'tropical_night_mode' not in state
assert state['buzzer_volume'] == 100.0
assert state['beep'] is True
assert state['good_sleep'] == 0.0
def test_legacy_krac_board_beep_is_a_switch_not_a_volume_number():
"""Issue #136: three real units across two reporters only ever reported
Volume_100 or Volume_Mute -- never an intermediate value -- and the old
buzzer_volume Number's write path (a plain integer string) could never
produce the literal 'Mute' token needed to turn it off. 'beep' now
applies uniformly across board generations instead of being gated off
the legacy board."""
reg, resources = _resolve('airconditioner_artik051_krac_18k')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['beep'] is True
resources['/mode/vs/0']['x.com.samsung.da.options'] = [
o.replace('Volume_100', 'Volume_Mute')
for o in resources['/mode/vs/0']['x.com.samsung.da.options']
]
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['beep'] is False
path, body = airconditioner._beep_write('Off', resources['/mode/vs/0'])
assert path == ['mode', 'vs', '0']
assert body == {'x.com.samsung.da.options': ['Volume_Mute']}
def test_legacy_krac_board_energy_kwh_uses_centiwatt_hour_scale():
"""Issue #193: this legacy ARTIK051 board reports cumulativePower in
centiwatt-hours (raw '117430000'), not the plain Wh common.wh_to_kwh
assumes -- confirmed against the reporter's own SmartThings-app reading
of 1,174.30 kWh. ENERGY_METER_LEGACY's /100000 scale must produce that
exact value, not the /1000-only 117430.0 the generic capability would."""
reg, resources = _resolve('airconditioner_artik051_krac_energy')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['energy_kwh'] == 1174.3
def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale():
"""The ENERGY_METER_GENERIC/LEGACY split must not change behavior for
every other AC board generation -- same value as plain wh_to_kwh."""
reg, resources = _ac()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['energy_kwh'] == round(1686632 / 1000.0, 2)
@@ -34,6 +34,12 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
+49 -1
View File
@@ -238,6 +238,19 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'airconditioner'
def test_airconditioner_via_cac_token(self):
"""Issue #191: a cassette AC (TP1X_DA-AC-CAC-01001_0000) regressed to
'unknown'/common-caps in 0.16.0 when oneUiVersion detection was
dropped -- 'CAC' had never been added to the modelNum board-token
table, only reachable before via oneUiVersion's '7.0 Air conditioner'."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00',
'TP1X_DA-AC-CAC-01001_0000',
)
assert reg is not None
assert reg.name == 'airconditioner'
def test_dehumidifier_via_dhm_token(self):
"""Issue #88: a dehumidifier (AY18CG7500GED) shares the DA_AC_ board
family with the room-AC models but reports no oneUiVersion and
@@ -264,6 +277,33 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'water_purifier'
def test_water_purifier_wins_over_ref_when_both_tokens_present(self):
"""Issue #196: an AILITE water purifier (RWP70F15ANW) spells its
modelNum '...-REF-WATERPURIFIER-...', which would otherwise match the
bare 'REF' board token (refrigerator) before ever reaching
'WATERPURIFIER' -- misrouting it to the refrigerator registry, whose
resource surface shares almost nothing with a water purifier."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'AILITE_DA-REF-WATERPURIFIER-01011|70674641|'
'900100000219130081088700001E0000',
'AILITE_WATERPURIFIER_25K',
)
assert reg is not None
assert reg.name == 'water_purifier'
def test_bare_ref_token_still_resolves_refrigerator(self):
"""Regression guard for the #196 carve-out above: a genuine
refrigerator modelNum with no 'WATERPURIFIER' token must still
resolve to 'refrigerator'."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'TP1X_REF_21K|00175941|00050126001811344100000020090000',
'TP1X_REF_21K',
)
assert reg is not None
assert reg.name == 'refrigerator'
def test_airconditioner_via_wac_token(self):
"""Issue #87: a Bespoke Window AC (AW06C7155EWAZ) reports no
oneUiVersion and a modelNum carrying the '_WAC_' (Window Air
@@ -474,6 +514,14 @@ class TestBoardTokenAmbiguity:
safe while no real model string contains two tokens naming different
device types. Guard that against every dump we have."""
# Issue #196: AILITE water-purifier boards spell their modelNum
# '...-REF-WATERPURIFIER-...' -- 'REF' names the shared cooling-subsystem
# board, not a refrigerator. `_board_family_key` carries an explicit
# carve-out resolving this one pair to 'water_purifier'; this is the one
# documented exception to the "no two board tokens ever co-occur"
# invariant the rest of this test enforces.
_ALLOWED_CONFLICTS = {frozenset({'refrigerator', 'water_purifier'})}
def test_no_fixture_model_string_yields_two_conflicting_keys(self, all_device_fixtures):
from custom_components.localthings.registry.by_type import (
_BOARD_TOKEN_TO_KEY, _board_tokens,
@@ -489,7 +537,7 @@ class TestBoardTokenAmbiguity:
for t in _board_tokens(field, cut)
if t in _BOARD_TOKEN_TO_KEY
}
assert len(keys) <= 1, (
assert len(keys) <= 1 or frozenset(keys) in self._ALLOWED_CONFLICTS, (
f"{name}: {field!r} matches conflicting board tokens {keys}"
)
+5
View File
@@ -106,6 +106,11 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# This test's entity uses the default MAIN subdevice, so the
# canonical view is just the raw snapshot (issue #177).
return self.last_resources
resources = _load_device('airconditioner_fac_bora')
info = resources['/information/vs/0']
reg = by_type.for_device_by_model(
+105
View File
@@ -0,0 +1,105 @@
"""Tests that a subdevice's LocalThingsClimate entity (issue #177) reads its
*own* power/mode/temperature -- not the master's, and not some mix of the
two -- and that the legacy-board test (is_legacy_board/_legacy_airflow) is
evaluated per subdevice rather than once globally.
Uses the issue #177 reporter's ARTIK051_DONGLE_FAC_18K fixture deliberately:
it's a legacy `/airflow/vs/<n>` board (no `/wind/*` at all) on *both* the
master and its materialized sibling, which is exactly the shape climate.py's
own comments warn is easy to get wrong if the canonical view leaks between
subdevices.
"""
from __future__ import annotations
import pytest
from homeassistant.components.climate import HVACMode
from homeassistant.core import HomeAssistant
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.registry.entities import ClimateDesc
from tests.test_subdevice_discovery import _coordinator, _discover
def _climate_entities(coordinator):
"""{subdevice_key_or_None: LocalThingsClimate}, None standing for MAIN."""
from custom_components.localthings.registry.subdevices import MAIN
out = {}
for b in coordinator.bound:
if isinstance(b.desc, ClimateDesc):
key = None if b.subdevice == MAIN else b.subdevice.key
out[key] = LocalThingsClimate(coordinator, b)
return out
@pytest.fixture
async def climates(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
return _climate_entities(coordinator)
async def test_subdevice_climate_reads_its_own_mode_and_power(climates):
"""Master reports 'Auto'; the bedroom subdevice (/device/1) reports 'Cool' --
confirmed distinct in the real captured fixture. If the subdevice
entity's _rep() weren't translating through its own subdevice, it would
read the master's /mode/vs/0 instead and report the master's mode."""
main, sub1 = climates[None], climates['1']
assert main.hvac_mode == HVACMode.AUTO
assert sub1.hvac_mode == HVACMode.COOL
async def test_subdevice_climate_reads_its_own_temperature(climates):
"""Master: current 25.0 / desired 26.0. Subdevice 1: current 27.0 / desired
28.0 -- distinct values in the real fixture, so a href mix-up here
would show up as a wrong number, not just a wrong mode string."""
main, sub1 = climates[None], climates['1']
assert main.current_temperature == 25.0
assert main.target_temperature == 26.0
assert sub1.current_temperature == 27.0
assert sub1.target_temperature == 28.0
async def test_subdevice_climate_reads_its_own_power_state(climates):
"""Both subdevices happen to report power On in this fixture -- this at
least confirms _is_on() reads the *subdevice's own* /power/vs/<n>, not a
hardcoded /power/vs/0, by checking the entity resolves without falling
back to OFF (which _is_on() would do if it silently read an absent
href instead of the subdevice's actual one)."""
main, sub1 = climates[None], climates['1']
assert main.hvac_mode != HVACMode.OFF
assert sub1.hvac_mode != HVACMode.OFF
async def test_legacy_board_test_is_evaluated_per_subdevice(climates):
"""The reporter's board has no /wind/* resources at all on *either*
subdevice -- is_legacy_board(self._resources) must independently
evaluate True for the master's own canonical view and for the
subdevice's own canonical view. If is_legacy_board were fed the raw,
unpartitioned snapshot (or if canonical_view leaked one subdevice's
hrefs into the other's), this wouldn't distinguish "this subdevice is
legacy" from "some subdevice on this connection is legacy" -- and a
future board with one legacy + one modern subdevice sharing a
connection would silently read the wrong fan/swing channel on one
side."""
main, sub1 = climates[None], climates['1']
assert main._legacy_airflow() != {}
assert sub1._legacy_airflow() != {}
# Both resolve to *some* fan mode via the legacy path rather than the
# /wind/strength/vs/0 channel (absent on this board) -- confirms
# is_legacy_board(self._resources) actually gated fan_mode's branch,
# not just that _legacy_airflow() itself returned something.
assert main.fan_mode is not None
assert sub1.fan_mode is not None
async def test_subdevice_climate_writes_are_scoped_to_its_own_bound_entity(climates):
"""A sanity check that the two entities are backed by genuinely
different BoundEntity objects (different hrefs), which is what makes
per-subdevice reads/writes possible at all -- see async_send_command's
translation test in test_coordinator_send_command.py for the write
side of this."""
main, sub1 = climates[None], climates['1']
assert main._bound.href == '/mode/vs/0'
assert sub1._bound.href == '/mode/vs/1'
assert main._bound is not sub1._bound
+8
View File
@@ -152,6 +152,14 @@ class TestPowerFallback:
assert switch.exists_fn(resources['/power/0'], resources) is True
assert sensor.exists_fn(resources['/power/0'], resources) is False
def test_polled_warm_so_power_state_reflects_quickly(self):
"""issue #56 follow-up: neither href had a poll_tier, so power state
only ever refreshed on the once-per-30s summary poll instead of the
subscribe/subpoll cadence 'warm'/'hot' hrefs get -- same reasoning
as REMOTE_CONTROL_GENERIC/VS_FALLBACK's own 'warm' tier."""
assert common.POWER_GENERIC.poll_tier == 'warm'
assert common.POWER_VS_FALLBACK.poll_tier == 'warm'
class TestWmSetinfoFlags:
def test_washer_fixture_carries_setinfo_from_device0(self):
+86
View File
@@ -25,7 +25,10 @@ from custom_components.localthings.const import (
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.capabilities import laundry
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.subdevices import Subdevice
ENTRY_DATA = {
CONF_HOST: '10.0.0.199',
@@ -92,3 +95,86 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator)
assert cached['x.com.samsung.da.options'] == [
'DeviceType_0167', 'Course_1D', 'GMT_04',
]
# ---------------------------------------------------------------------------
# Subdevice write translation (issue #177): a composite-entity write_fn (here
# airconditioner._climate_write) returns *canonical* path_segs
# (['power', 'vs', '0']) -- async_send_command must translate that through
# bound_entity.subdevice.to_actual before POSTing, applying the optimistic
# value, and starting the settle guard, or a subdevice's climate card would
# write to (and read confirmation from) the master's resource instead of
# its own.
# ---------------------------------------------------------------------------
def _climate_bound(href: str, subdevice: Subdevice) -> BoundEntity:
desc = ClimateDesc(key='climate', translation_key='airconditioner',
write_fn=_climate_write)
return BoundEntity(href=href, capability=None, desc=desc, subdevice=subdevice)
async def test_indexed_subdevice_write_posts_to_translated_path(coordinator) -> None:
"""A power write from the bedroom subdevice's (indexed '1') climate entity
must POST to /power/vs/1, not the master's /power/vs/0."""
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
bound = _climate_bound('/mode/vs/1', sub1)
await coordinator.async_send_command(bound, ('power', True))
posted_path, posted_bytes = coordinator._session.post_calls[0]
assert posted_path == ['power', 'vs', '1']
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
async def test_indexed_subdevice_write_applies_optimistic_value_to_translated_href(
coordinator,
) -> None:
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
bound = _climate_bound('/mode/vs/1', sub1)
await coordinator.async_send_command(bound, ('power', True))
# The optimistic apply + settle guard must land on /power/vs/1 -- the
# translated href a real device confirms this write against -- not on
# /mode/vs/1 (bound_entity.href) or /power/vs/0 (the master's resource).
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
assert coordinator._cache.get('/power/vs/0') is None
# The settle guard is armed on that same translated href: a stale poll
# reporting the pre-write value must be dropped, not allowed to revert
# the optimistic 'On' (issue #27's regression, translated to a
# subdevice's own resource).
applied = coordinator._observe.apply(
'/power/vs/1', {'x.com.samsung.da.power': 'Off'}, source='poll',
)
assert applied is False
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
async def test_prefixed_subdevice_write_posts_to_translated_path(coordinator) -> None:
"""A power write from a UUID-prefixed subdevice's climate entity must POST
to /<uuid>/power/vs/0, not the bare canonical href."""
sub_id = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
bound = _climate_bound(f'/{sub_id}/mode/vs/0', subdevice)
await coordinator.async_send_command(bound, ('power', True))
posted_path, posted_bytes = coordinator._session.post_calls[0]
assert posted_path == [sub_id, 'power', 'vs', '0']
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
assert coordinator._cache.get(f'/{sub_id}/power/vs/0') == {
'x.com.samsung.da.power': 'On',
}
async def test_main_climate_write_unaffected_by_subdevice_translation(coordinator) -> None:
"""MAIN's to_actual is the identity transform -- a device with no
subdevices must keep posting to the exact same path as before this
translation step existed."""
from custom_components.localthings.registry.subdevices import MAIN
bound = _climate_bound('/mode/vs/0', MAIN)
await coordinator.async_send_command(bound, ('power', True))
posted_path, _ = coordinator._session.post_calls[0]
assert posted_path == ['power', 'vs', '0']
+129
View File
@@ -0,0 +1,129 @@
"""Sanity check for diagnostics.py's issue #177 additions (subdevices,
subdevices_skipped, subdevice_probes) against a real composite-device
discovery run -- makes sure the new blocks are actually reachable/shaped
right, not just that the coordinator's own attributes look correct in
isolation (test_subdevice_discovery.py covers that)."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import (
async_get_config_entry_diagnostics,
)
from tests.test_subdevice_discovery import _coordinator, _discover
async def test_diagnostics_reports_materialized_and_skipped_subdevices(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
subdevice_keys = {su['key'] for su in diag['subdevices']}
skipped_keys = {su['key'] for su in diag['subdevices_skipped']}
assert subdevice_keys == {'1'}
assert skipped_keys == {'2'}
assert diag['subdevices'][0]['bound_entity_count'] > 0
assert diag['subdevices_skipped'][0]['hrefs']
assert '/multidevice/vs/0' in diag['subdevice_probes']
# The reporter's hand-read value, carried in the fixture's `probes` map
# (it belongs to no batch -- see that fixture's seeds_note). Two real
# subdevices, master + bedroom, independently corroborating the gate's
# decision to skip /device/2. Reported on its own, never as a resource.
assert diag['multidevice'] == {'x.com.samsung.da.numofsubdevice': '2'}
assert '/multidevice/vs/0' not in diag['resources']
async def test_top_level_resources_is_the_master_subdevice_only(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
"""`resources` reports this subdevice's own hrefs and nothing else.
It used to be `last_resources` raw -- the union across every live subdevice,
keyed by real hrefs -- so a sibling's /mode/vs/1 sat in it alongside the
master's /mode/vs/0 with no attribution, contradicting both the module
docstring and the adding-device-support skill ("the parsed /device/0
snapshot"). Each sibling carries its own canonicalized block instead.
"""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
assert not [h for h in diag['resources'] if h.endswith('/1')]
assert '/mode/vs/0' in diag['resources']
# The sibling's own block carries its state, canonicalized -- '/mode/vs/0',
# not the '/mode/vs/1' it actually answers on.
sub1 = diag["subdevices"][0]
assert '/mode/vs/0' in sub1["resources"]
assert not [h for h in sub1['resources'] if h.endswith('/1')]
assert sub1["resources"]["/power/vs/0"]['x.com.samsung.da.power'] == 'On'
async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
"""A gate-rejected slot is never polled again, so anything applied to the
state cache for it would sit frozen at its first-discovery value while
looking as live as every other href. It's kept out of the cache entirely
and reported only under its own subdevices_skipped entry -- which is also
the only place a reader can go to second-guess the gate."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
assert not [h for h in coordinator.last_resources if h.endswith('/2')]
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
skipped = diag['subdevices_skipped'][0]
# Present, canonicalized, and visibly the empty state the gate rejected.
assert skipped['resources']['/power/vs/0'] == {}
assert skipped['resources']['/mode/vs/0'] == {}
assert skipped['resources']['/information/vs/0']
async def test_diagnostics_reports_prefixed_subdevice(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
assert len(diag['subdevices']) == 1
assert diag['subdevices'][0]['kind'] == 'prefixed'
# diagnostics reports the raw modelNum field verbatim (board revision/
# capability-bitmap suffix included), unlike device_info_for's model
# (split at '|') -- confirms it's still the wall subdevice's own identity,
# not the master's TP2X_FAC_BORA_21K.
assert diag['subdevices'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K')
assert diag['subdevices_skipped'] == []
async def test_diagnostics_reports_flat_hrefs_for_skipped_prefixed_candidate(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
"""issue #205: a prefixed candidate found through the per-href flat
fallback (no working /<uuid>/device/0 Collection) has no meaningful
seed_path -- diagnostics reports None there instead of the misleading
bare "/" an empty tuple would otherwise join to, and lists the actual
hrefs the fallback confirmed instead."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_205_flat')
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
assert diag['subdevices'] == []
assert len(diag['subdevices_skipped']) == 1
skipped = diag['subdevices_skipped'][0]
assert skipped['kind'] == 'prefixed'
assert skipped['seed_path'] is None
assert skipped['flat_hrefs'] == ['/information/vs/0']
+8
View File
@@ -17,6 +17,14 @@ class _FakeCoordinator:
def __init__(self, last_resources):
self.last_resources = last_resources
def canonical_resources(self, subdevice):
# Every bound entity in this test file uses the default MAIN
# subdevice (identity transform), so the canonical view is just the
# raw snapshot -- same shape as the real
# LocalThingsCoordinator.canonical_resources for a device with no
# subdevices (issue #177).
return self.last_resources
def _bound(desc, href):
capability = Capability(href=href, entities=(desc,))
+38
View File
@@ -230,6 +230,44 @@ class TestPantryZone:
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
class TestDefiniteTemperatureCooler:
"""Discrete cooler setpoint (RT42DG6630B1FZ, issue #186) -- a single-door
fridge whose /temperature/definite/cooler/vs/0 falls outside both
TEMP_CURRENT_GENERIC and TEMP_SETPOINT's href prefixes, so temperature
control was entirely unbound before this capability was added."""
def test_href(self):
assert fridge.DEFINITE_TEMPERATURE_COOLER.href == '/temperature/definite/cooler/vs/0'
def test_write(self):
desc = fridge.DEFINITE_TEMPERATURE_COOLER.entities[0]
path, body = desc.write_fn('3', {})
assert path == ['temperature', 'definite', 'cooler', 'vs', '0']
assert body == {'x.com.samsung.da.definite.desired': '3'}
def test_no_unbound_hrefs_and_discrete_options(self):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import refrigerator
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
resources = _load_device('refrigerator_definite_cooler')
unbound = []
bound = discover(
resources,
refrigerator.REGISTRY.capabilities,
refrigerator.REGISTRY.pattern_capabilities,
log=unbound.append,
)
assert unbound == []
state = flatten(bound, resources)
assert state['cooler_temperature_setpoint'] == '2'
rep = resources['/temperature/definite/cooler/vs/0']
assert rep['x.com.samsung.da.definite.supportedList'] == ['1', '2', '3', '4', '7']
class TestKimchiZone:
"""Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) --
top/middle/bottom each report an identically-shaped currentMode/
+229 -1
View File
@@ -138,10 +138,37 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier():
)
def test_registry_reproduces_golden_state_keys_for_water_purifier_ailite_25k():
"""AILITE_WATERPURIFIER_25K (issue #196, RWP70F15ANW) -- a coffee-capable
water purifier on an AILITE_DA-REF-WATERPURIFIER board, whose modelNum's
'REF' token would otherwise misroute it to the refrigerator registry (see
TestBoardTokenAmbiguity's carve-out). Also the first dump to expose
/cup/state/vs/0, /statistic/pour/vs/0, and the settings/sound/* trio on
this device type, and a hot_water_temperature select that must gate off
(no supportedHotTemperatures reported) rather than surface as 'unknown'."""
from tests.conftest import _load_device
resources = _load_device('water_purifier_ailite_25k')
golden = json.loads((GOLDEN / 'water_purifier_ailite_25k.json').read_text())
state_keys = _new_state_keys('water_purifier_ailite_25k', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_water_purifier_coffee():
"""TP2X_WATERPURIFIER_20K coffee-capable variant (issue #107) adds
/favorite/coffee/vs/0, /favorite/hotwater/vs/0, and three static
coffee-recipe resources not present in issue #90's original dump."""
coffee-recipe resources not present in issue #90's original dump.
This fixture's /setting/waterpurifier/vs/0 turns out to report no
supportedHotTemperatures either (only hotwaterLevel/hotwaterRange, the
same shape issue #196 surfaced) -- so hot_water_temperature dropped out
of this golden when the #196 fix (see water_purifier.DISPENSE's
exists_fn) landed. This fixture was quietly hitting the same 'unknown'
bug all along; the registry-level golden just had no way to show it
since flatten()'s state dict doesn't model select option membership."""
from tests.conftest import _load_device
resources = _load_device('water_purifier_coffee')
golden = json.loads((GOLDEN / 'water_purifier_coffee.json').read_text())
@@ -859,6 +886,207 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora():
)
def _new_subdevice_aware_state_keys(name):
"""Like _new_state_keys, but runs the full subdevice-aware pipeline
(enumerate_subdevices + discover_partitioned, issue #177) instead of a
single discover() call, so the golden for a composite-device fixture
captures every materialized subdevice's keys
(subdevice1_-/subdevice_<uuid>_-prefixed), not just the master's."""
from custom_components.localthings.registry.adapter import flatten
from tests.conftest import _discover_full, _load_device_full
resources, oic_res, seeds = _load_device_full(name)
bound, _materialized, _skipped, full_resources, _device_type_name = _discover_full(
resources, oic_res, seeds,
)
state = flatten(bound, full_resources)
return sorted(state.keys())
def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_dongle_fac_18k():
"""The reporter's ARTIK051_DONGLE_FAC_18K (issue #177, Pattern A -- indexed
siblings): a real v0.16.0 dump with a genuine second indoor subdevice at
`/device/1` (subdevice1_-prefixed keys below) and an unused SmartThings
slot at `/device/2` that answers its seed but never produces a
materialized subdevice (see DESIGN-177.md section 4 and
test_subdevice_discovery.py's explicit "/device/2 produces no entities"
assertion) -- so this golden has no `subdevice2_`-prefixed keys at all,
which is the point."""
name = 'airconditioner_artik051_dongle_fac_18k'
golden = json.loads((GOLDEN / f'{name}.json').read_text())
state_keys = _new_subdevice_aware_state_keys(name)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_cac():
"""TP1X_DA-AC-CAC-01001_0000 (issue #191) -- fell back to 'unknown' in
0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never
been added to the modelNum board-token table. Resolved via the new 'CAC'
token onto the existing airconditioner registry. Not fully covered yet --
ten hrefs remain unbound (edge lighting, PM1 filter, stateful light,
absence-clean, four sound-settings resources, smart-sensing-cooling, UV
LED), all genuinely new to this board generation and out of scope for
the routing fix; see test_airconditioner_cac.py for the documented gap."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_cac')
golden = json.loads((GOLDEN / 'airconditioner_cac.json').read_text())
state_keys = _new_state_keys('airconditioner_cac', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1():
"""The reporter's TP2X_FAC_BORA_21K (issue #177, Pattern B -- UUID-prefixed
tree): device0/oic_res are real; the wall-mounted subdevice's own
/information/vs/0 is real (confirmed live by the reporter), the rest of
its seed tree is constructed (see the fixture's own seeds_note) -- just
enough to bind a real climate card under the
`subdevice_6c2dff6dee5cdad16a5e000000000001_` prefix below. Distinct from
tests/fixtures/airconditioner_fac_bora_device.json, which is
deliberately left unchanged as the redacted-subdeviceIdList regression
case (zero subdevices)."""
name = 'airconditioner_fac_bora_2in1'
golden = json.loads((GOLDEN / f'{name}.json').read_text())
state_keys = _new_subdevice_aware_state_keys(name)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_205_flat():
"""The same reporter's same physical TP2X_FAC_BORA_21K unit as the _2in1
fixture above, but a later capture (issue #205) where /<uuid>/device/0 doesn't
answer -- contrary to what that fixture's own seed batch assumed the
Collection endpoint would do. device0/oic_res are real; the only
UUID-prefixed data is the one href ever actually confirmed live
(/information/vs/0, same real capture the _2in1 fixture uses), fed
through registry.subdevices.enumerate_subdevices' per-href flat
fallback instead of a Collection batch. /information/vs/0 alone binds
no entity, so the candidate is found but never materializes -- this
golden has no `subdevice_...`-prefixed keys at all, same shape as
tests/fixtures/golden/airconditioner_fac_bora.json, which is the point:
a device whose sibling can't yet be confirmed live must regress to
exactly the master-only state, never a phantom or partial subdevice."""
name = 'airconditioner_fac_bora_205_flat'
golden = json.loads((GOLDEN / f'{name}.json').read_text())
state_keys = _new_subdevice_aware_state_keys(name)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k_slot():
"""The issue #214 reporter's single-split AR12NXWXCWKNEU: a
non-composite ARTIK051_KRAC_18K whose `/device/1` answers a full-shaped
batch with every operational rep empty {} and a populated
/energy/consumption/vs/1. The lifetime kWh counter used to be enough to
pass discover_partitioned's liveness gate, materializing a phantom
second air conditioner in HA; a meter is now excluded from that gate
(subdevices._has_live_primary_entity), so this golden must stay
byte-identical to airconditioner_artik051_krac_18k.json -- master keys
only, no `subdevice1_` prefix anywhere -- which is what
test_krac_18k_energy_only_slot_is_not_materialized asserts structurally."""
name = 'airconditioner_artik051_krac_18k_slot'
golden = json.loads((GOLDEN / f'{name}.json').read_text())
state_keys = _new_subdevice_aware_state_keys(name)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
master_golden = json.loads(
(GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text()
)
assert set(state_keys) == set(master_golden['state_keys'])
def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww():
"""AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board;
reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a
whole token doesn't match this board's 'AVT'/'WW' split. Resolved via the
new 'AVT' modelNum board-token fallback into the existing air_purifier
registry. Binds cleanly with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('air_purifier_avt_ww')
golden = json.loads((GOLDEN / 'air_purifier_avt_ww.json').read_text())
state_keys = _new_state_keys('air_purifier_avt_ww', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_krac_energy():
"""AR12NXWXCWKNEU/ARTIK051_KRAC_18K (issue #193) -- same legacy board
generation as the artik051_krac_18k fixture, but this dump has a nonzero
cumulativePower. Locks in state_keys; the actual /100000 scale fix is
asserted separately in test_airconditioner_capabilities.py since golden
only compares key sets, not values."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_artik051_krac_energy')
golden = json.loads(
(GOLDEN / 'airconditioner_artik051_krac_energy.json').read_text()
)
state_keys = _new_state_keys('airconditioner_artik051_krac_energy', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_refrigerator_definite_cooler():
"""RT42DG6630B1FZ (issue #186) -- a single-door "cooler only" fridge whose
/temperature/definite/cooler/vs/0 doesn't match either
TEMP_CURRENT_GENERIC's '/temperature/current/' or TEMP_SETPOINT's
'/temperature/desired/' href prefix, so temperature control was entirely
unbound. Resolved via the new DEFINITE_TEMPERATURE_COOLER capability
(a select over the device's own discrete supportedList, not a
continuous NumberDesc range -- 5 and 6 aren't valid setpoints on this
model). Binds cleanly with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('refrigerator_definite_cooler')
golden = json.loads(
(GOLDEN / 'refrigerator_definite_cooler.json').read_text()
)
state_keys = _new_state_keys('refrigerator_definite_cooler', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_range_ne6516a():
"""NE6516A-class range (issue #183) -- no /information/vs/0, resolved via
the same 'Bake'-in-supportedModes + /cooktopmonitoring/vs/0 signature as
the other no-info range fixtures. Its /mode/vs/0 options[] carries
EnergySaving_On and BurnerOnAlert_Off (previously unbound entirely) but
no fastpreheat_*/NaturalSteam_* tokens at all -- locks in that those two
switches now correctly stay unbound instead of binding as always-off,
does-nothing phantom controls."""
from tests.conftest import _load_device
resources = _load_device('range_ne6516a')
golden = json.loads((GOLDEN / 'range_ne6516a.json').read_text())
state_keys = _new_state_keys('range_ne6516a', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
dump = {
+19 -10
View File
@@ -72,22 +72,31 @@ def test_read_identity_keeps_raw_payloads_for_diagnostics():
def test_read_identity_captures_oic_res_links():
"""/oic/res is OCF's discovery endpoint: a baseline RETRIEVE returns an
array of Link objects, one per Resource/Collection the endpoint hosts --
including, per the OCF 'Composite Device' model, a second logical
Device's Collection on a multi-unit system (issue #177). Nothing routes
on this yet; captured so a report from such a device shows us whether
its firmware actually exposes more than the one /device/0 seed href."""
"""/oic/res is OCF's discovery endpoint. Real hardware (issue #177
follow-up, a TP1X_REF_21K fridge dump) groups the response by `di`: one
entry per logical Device, each carrying its own `links` array -- not a
flat array of individually-`di`-tagged links. Every entry that dump
returned had its discoverable policy bit set (`bm`'s bit 0); the whole
x.com.samsung.da.* tree (including /device/0 itself) did not appear at
all, meaning it's registered non-discoverable and simply invisible to
this endpoint -- see _SPECULATIVE_DEVICE_INDICES' docstring for why that
motivated probing /device/1 and /device/2 directly instead."""
sess = FakeSession({
('oic', 'res'): [
{'di': 'aaaa', 'href': '/device/0', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
{'di': 'bbbb', 'href': '/device/1', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
{'di': 'aaaa', 'links': [
{'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'],
'p': {'bm': 1}},
{'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}},
]},
],
})
ident = read_identity(sess, serial=None)
assert ident.raw['/oic/res'] == [
{'di': 'aaaa', 'href': '/device/0', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
{'di': 'bbbb', 'href': '/device/1', 'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
{'di': 'aaaa', 'links': [
{'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'],
'p': {'bm': 1}},
{'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}},
]},
]
+22
View File
@@ -19,6 +19,12 @@ class _FakeCoordinator:
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
@@ -218,3 +224,19 @@ async def test_microwave_vent_fan_set_percentage_zero_turns_off():
await entity.async_set_percentage(0)
assert coordinator.commands[-1][1] == ('speed', '0')
def test_microwave_vent_fan_missing_supported_fan_speed_falls_back_to_settable_min_max():
"""Hardware omitting supportedFanSpeed (e.g. ME8000T / issue #172) falls back
to building speed codes from settableMinFanSpeed ('0') and settableMaxFanSpeed ('3')."""
resources = _load_device('microwave_me7500d')
resources['/hood/fanspeed/vs/0'].pop('x.com.samsung.da.hood.supportedFanSpeed', None)
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.settableMinFanSpeed'] = '0'
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.settableMaxFanSpeed'] = '3'
entity = _microwave_entity(resources)
assert entity._all_speed_codes() == ['0', '1', '2', '3']
assert entity._active_speed_codes() == ['1', '2', '3']
assert entity.speed_count == 3
assert entity.supported_features & 1 # FanEntityFeature.SET_SPEED is present
+62
View File
@@ -0,0 +1,62 @@
"""Tests for the NE6516A-class range (issue #183).
Same no-/information/vs/0, no-burner-status shape as issues #74/#138. Its
/mode/vs/0 options[] carries EnergySaving_On and BurnerOnAlert_Off -- both
previously unbound entirely -- but no fastpreheat_*/NaturalSteam_* tokens at
all, which is what exposed the pre-existing fast_preheat/natural_steam
phantom-switch bug (both were shipped with no exists_fn, so they bound
unconditionally, always read off, and any write went to a token this
firmware never recognized).
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_resources
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _range():
resources = _load_device('range_ne6516a')
reg = for_device_by_resources(resources)
return reg, resources
def _state():
reg, resources = _range()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_range_registry():
reg, _ = _range()
assert reg is not None and reg.name == 'range'
def test_no_unbound_hrefs():
reg, resources = _range()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_fast_preheat_and_natural_steam_absent_without_their_tokens():
"""Neither token is in this dump's options[] -- both switches must stay
unbound rather than silently reading as an always-off phantom control."""
state = _state()
assert 'fast_preheat' not in state
assert 'natural_steam' not in state
def test_energy_saving_and_cooktop_alert_present():
state = _state()
assert state['energy_saving'] is True
assert state['cooktop_on_alert'] is False
def test_child_lock_already_reads_correctly():
"""issue #183 also reported child lock as unreadable -- but this dump's
/kidslock/vs/0 ('Run') and /doors/vs/0's door lock field ('Lock') already
agree the door is locked, and the existing common.KIDS_LOCK_VS_FALLBACK
already reads that correctly with no code change needed."""
state = _state()
assert state['child_lock'] is True
+6
View File
@@ -14,6 +14,12 @@ class _FakeCoordinator:
def __init__(self, last_resources):
self.last_resources = last_resources
def canonical_resources(self, subdevice):
# Every entity built by _make_select uses the default MAIN
# subdevice, so the canonical view is just the raw snapshot
# (issue #177 -- see LocalThingsEntity._resources).
return self.last_resources
def _make_select(desc, href, last_resources):
capability = Capability(href=href, entities=(desc,))
+508
View File
@@ -0,0 +1,508 @@
"""End-to-end discovery tests for issue #177's two composite-device
fixtures, against the real LocalThingsCoordinator (not the HA-free
registry-level helpers test_subdevices.py/test_unique_ids.py use) -- this is
what actually exercises _enumerate_subdevices_blocking + _run_discovery
together, including device_info_for/via_device and the "no phantom
/device/2 entities" guarantee.
"""
from __future__ import annotations
import pytest
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.identity import DeviceIdentity
from tests.conftest import FakeCoapSession, _load_device_full
ENTRY_DATA = {
CONF_HOST: '10.0.0.177',
CONF_PORT: 49154,
CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----',
CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----',
}
def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SUBDEVICE-TEST',
)
entry.add_to_hass(hass)
return LocalThingsCoordinator(hass, entry)
async def _discover_with(
coordinator: LocalThingsCoordinator, resources: dict, oic_res, seeds: dict,
) -> None:
"""Run the same two-step sequence _async_update_data's first cycle does
(enumerate, then discover) against arbitrary resources/oic_res/seeds,
without the polling/reconnect machinery around it -- see coordinator.py's
_enumerate_subdevices_blocking/_run_discovery. `_discover` below is the
fixture-file-backed convenience wrapper most tests want."""
coordinator._session = FakeCoapSession(seeds)
# _connect_session (skipped here -- the session is pre-set) is what
# normally populates _identity via read_identity; set it directly with
# the fixture's real /oic/res so enumeration sees the same links a live
# read_identity call would have captured.
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics', model='', name='', serial=None,
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': oic_res},
)
merged = await coordinator.hass.async_add_executor_job(
coordinator._enumerate_subdevices_blocking, resources,
)
# Mirror _async_update_data's first-cycle order exactly: discover, then
# drop the candidates the liveness gate rejected, then apply what's left
# to the observe/cache layer. The apply has to happen (canonical_resources
# -- device_info_for, is_legacy_board, ... -- reads the cache, not the
# dict passed to _run_discovery), but it has to happen *after* the gate,
# or a rejected slot's reps get frozen into the cache forever. Applying
# first here would leave this helper testing an ordering production no
# longer uses.
coordinator._run_discovery(merged)
for href, rep in coordinator._live_subdevice_resources(merged).items():
coordinator._observe.apply(href, rep, source='poll')
async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
"""Fixture-file-backed convenience wrapper around _discover_with."""
resources, oic_res, seeds = _load_device_full(name)
await _discover_with(coordinator, resources, oic_res, seeds)
def _climate_bound(coordinator, subdevice_key: str):
from custom_components.localthings.registry.subdevices import MAIN
for b in coordinator.bound:
if isinstance(b.desc, ClimateDesc):
if subdevice_key is None and b.subdevice == MAIN:
return b
if subdevice_key is not None and b.subdevice.key == subdevice_key:
return b
return None
# ---------------------------------------------------------------------------
# Pattern A reporter -- ARTIK051_DONGLE_FAC_18K, indexed siblings
# ---------------------------------------------------------------------------
async def test_pattern_a_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
assert [su.key for su in coordinator.subdevices] == ['1']
main_climate = _climate_bound(coordinator, None)
sub1_climate = _climate_bound(coordinator, '1')
assert main_climate is not None
assert sub1_climate is not None
assert main_climate.href == '/mode/vs/0'
assert sub1_climate.href == '/mode/vs/1'
async def test_pattern_a_device_2_produces_no_entities_at_all(hass: HomeAssistant):
"""The reporter's /device/2 is the unused SmartThings slot
(DESIGN-177.md section 4): it answers its seed with a full-shaped
batch, but every climate-state rep on it is empty. It must be recorded
as skipped, not materialized, and must contribute zero bound entities."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
assert '2' not in [su.key for su in coordinator.subdevices]
assert any(
skip.subdevice.kind == 'indexed' and skip.subdevice.key == '2'
for skip in coordinator._skipped_subdevices
)
assert not any(b.subdevice.key == '2' for b in coordinator.bound)
assert not any(href.endswith('/2') for href in coordinator._hot_hrefs)
assert not any(href.endswith('/2') for href in coordinator._warm_hrefs)
async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
sub1 = next(su for su in coordinator.subdevices if su.key == '1')
info = coordinator.device_info_for(sub1)
master_serial = coordinator.device_serial
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_1')}
assert info['via_device'] == (DOMAIN, master_serial)
# The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
# is what names/models this device, not the master's.
assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K'
# ---------------------------------------------------------------------------
# Issue #214 -- a *non-composite* board whose speculative /device/1 probe
# answers with an unused slot that reports the appliance's energy counter.
# ---------------------------------------------------------------------------
async def test_krac_18k_energy_only_slot_is_not_materialized(hass: HomeAssistant):
"""The issue #214 reporter's single-split AR12NXWXCWKNEU (ARTIK051_KRAC_18K,
one indoor unit) answers /device/1 with the Pattern A /device/2 shape --
every operational rep empty {} -- plus a populated
/energy/consumption/vs/1 carrying a lifetime cumulativePower. That one
counter was the only primary entity the candidate flattened to a value
for, and it was enough to materialize a phantom second air conditioner
device in HA (the duplicate the reporter saw). A cumulative meter is a
whole-appliance total, not evidence that hardware is installed at this
slot, so the candidate must now be recorded as skipped and contribute
nothing: no bound entities, no HA device, no hot/warm hrefs."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_krac_18k_slot')
assert coordinator.subdevices == []
assert [s.subdevice.key for s in coordinator._skipped_subdevices] == ['1']
# The gate ran against real bindings, not against nothing -- these are the
# six hrefs the reporter's own diagnostics reported for the (then
# materialized) subdevice, /energy/consumption/vs/1 among them.
assert coordinator._skipped_subdevices[0].hrefs == (
'/alarms/vs/1', '/diagnosis/vs/1', '/energy/consumption/vs/1',
'/humidity/vs/1', '/mode/vs/1', '/temperature/current/1',
)
assert coordinator._subdevice_probes['/device/1'] is True
assert not any(b.subdevice.key == '1' for b in coordinator.bound)
assert not any(href.endswith('/1') for href in coordinator._hot_hrefs)
assert not any(href.endswith('/1') for href in coordinator._warm_hrefs)
# The master is untouched -- one climate entity, on the master's own
# device, exactly as this board behaved before subdevice support existed.
assert _climate_bound(coordinator, None) is not None
assert _climate_bound(coordinator, '1') is None
async def test_krac_18k_slot_state_never_reaches_the_cache(hass: HomeAssistant):
"""A rejected candidate's reps must not be applied to the state cache
either (the same guarantee _live_subdevice_resources gives the Pattern A
/device/2 slot): the reporter's /energy/consumption/vs/1 would otherwise
sit frozen in `last_resources` -- and in every diagnostics dump built
from it -- at its first-discovery value forever, since nothing polls the
slot again."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_krac_18k_slot')
assert not any(href.endswith('/1') for href in coordinator.last_resources)
# It's kept aside for diagnostics only, which is where a reader can still
# check the gate's call for themselves.
assert '/energy/consumption/vs/1' in coordinator._skipped_subdevice_resources
# ---------------------------------------------------------------------------
# Issue #177's Pattern B reporter -- TP2X_FAC_BORA_21K, UUID-prefixed tree
# ---------------------------------------------------------------------------
_SUB_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
async def test_fac_bora_2in1_materializes_prefixed_wall_subdevice(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
assert coordinator.subdevices[0].kind == 'prefixed'
main_climate = _climate_bound(coordinator, None)
sub_climate = _climate_bound(coordinator, _SUB_UUID)
assert main_climate is not None
assert sub_climate is not None
assert main_climate.href == '/mode/vs/0'
assert sub_climate.href == f'/{_SUB_UUID}/mode/vs/0'
async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
subdevice = coordinator.subdevices[0]
info = coordinator.device_info_for(subdevice)
master_serial = coordinator.device_serial
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_{_SUB_UUID}')}
assert info['via_device'] == (DOMAIN, master_serial)
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
# subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K.
assert info['model'] == 'TP2X_FAC_BORA_RAC_21K'
async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant):
"""The prefixed subdevice's unique_id carries the full subdevice UUID
(non-alphanumerics stripped), not a truncation or an ordinal -- see
Subdevice.key_prefix."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
from custom_components.localthings.entity import LocalThingsEntity
sub_climate = _climate_bound(coordinator, _SUB_UUID)
entity = LocalThingsEntity(coordinator, sub_climate)
expected_slug = _SUB_UUID.replace('-', '')
assert entity._attr_unique_id == (
f"{DOMAIN}_{coordinator.device_serial}_subdevice_{expected_slug}_climate"
)
# ---------------------------------------------------------------------------
# Same reporter and physical unit/UUID again -- issue #205, but this
# time /<uuid>/device/0 doesn't answer. Exercises enumerate_subdevices'
# per-href flat-probe fallback (registry/subdevices.py) against a real
# capture instead of the synthetic sessions test_subdevices.py uses.
# ---------------------------------------------------------------------------
async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back(
hass: HomeAssistant,
):
"""The fixture's only seeded UUID-prefixed href is /information/vs/0 --
the one href ever actually confirmed live under this prefix (issue #177
comment 5113518087) -- since nothing else has been confirmed yet for
this unit. That's enough for the flat-probe fallback to find a
candidate, but /information/vs/0 binds no entity on its own (it's only
ever read for device-type resolution, never bound as a capability), so
discover_partitioned's liveness gate correctly holds it back rather than
materializing a phantom climate card from unconfirmed hrefs. This is the
honest current state of issue #205, not a guess at its resolution."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_205_flat')
assert coordinator.subdevices == []
assert [s.subdevice.key for s in coordinator._skipped_subdevices] == [_SUB_UUID]
skipped = coordinator._skipped_subdevices[0].subdevice
assert skipped.kind == 'prefixed'
assert skipped.seed_path == ()
assert skipped.flat_hrefs == ('/information/vs/0',)
assert coordinator._subdevice_probes[f'/{_SUB_UUID}/device/0'] is False
assert coordinator._subdevice_probes[f'/{_SUB_UUID}/information/vs/0'] is True
# Confirms the master itself is completely unaffected by its sibling's
# Collection endpoint not answering -- same guarantee every other
# subdevice test in this file relies on.
assert _climate_bound(coordinator, None) is not None
async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssistant):
"""Synthetic (not a real capture, unlike the fixture-driven test above) --
exercises the one path nothing else covers: a flat-mode prefixed
subdevice with *enough* confirmed hrefs to actually pass
discover_partitioned's liveness gate and materialize a real climate
entity, then a subsequent _poll_subdevice_seed re-poll refreshing its
state all the way through to canonical_resources -- the path a real
resolution of issue #205 (once more hrefs are confirmed live for some
unit) would actually need.
Also pins _poll_subdevice_flat_hrefs' hot/warm skip: climate-critical
hrefs (power/mode/temperature) land on the warm tier by discovery's own
rules, so they're already kept fresh every few seconds by _run_subpolls
-- re-fetching them again on this once-per-summary-poll pass would only
add GETs, not freshness, so they're the ones this method must skip.
/option/autoclean/vs/0 is cold-tier and is what actually needs this
path."""
resources, oic_res, _real_seeds = _load_device_full('airconditioner_fac_bora_2in1')
seeds = {
# No (_SUB_UUID, 'device', '0') entry -- forces the flat fallback,
# same as the real issue #205 capture above, but this time with
# enough hrefs answering to actually materialize. power/mode/
# temperature values copied verbatim from that fixture's own (real)
# Collection-batch seed, just served individually instead of
# batched, to isolate "does flat mode produce the same result as
# Collection mode" as the only variable.
f'/{_SUB_UUID}/power/vs/0': {'x.com.samsung.da.power': 'On'},
f'/{_SUB_UUID}/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['Cool', 'Dry', 'Wind', 'Auto'],
'x.com.samsung.da.modes': ['Cool'],
'x.com.samsung.da.options': [],
},
f'/{_SUB_UUID}/temperature/current/0': {
'range': [18.0, 30.0], 'units': 'C', 'temperature': 26.0,
},
f'/{_SUB_UUID}/temperature/desired/0': {
'range': [18.0, 30.0], 'units': 'C', 'temperature': 24.0,
},
# Cold-tier -- not covered by _run_subpolls, so this is the href
# that actually depends on _poll_subdevice_flat_hrefs to ever
# refresh at all.
f'/{_SUB_UUID}/option/autoclean/vs/0': {
'x.com.samsung.da.settingStatus': 'Off',
},
}
coordinator = _coordinator(hass)
await _discover_with(coordinator, resources, oic_res, seeds)
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
subdevice = coordinator.subdevices[0]
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs != ()
sub_climate = _climate_bound(coordinator, _SUB_UUID)
assert sub_climate is not None
# Re-poll: a fresh reading under the prefix should reach
# canonical_resources through _poll_subdevice_seed's flat-mode branch,
# not just sit frozen at the one-time enumeration snapshot.
coordinator._session.seeds[f'/{_SUB_UUID}/temperature/current/0'] = {
'range': [18.0, 30.0], 'units': 'C', 'temperature': 27.5,
}
coordinator._session.seeds[f'/{_SUB_UUID}/option/autoclean/vs/0'] = {
'x.com.samsung.da.settingStatus': 'On',
}
refreshed = coordinator._poll_subdevice_seed(subdevice)
# The warm-tier temperature href is skipped here -- already covered by
# _run_subpolls at a faster cadence -- so it does NOT show up refreshed
# through this path.
assert f'/{_SUB_UUID}/temperature/current/0' not in refreshed
assert refreshed == {
f'/{_SUB_UUID}/option/autoclean/vs/0': {'x.com.samsung.da.settingStatus': 'On'},
}
for href, rep in refreshed.items():
coordinator._observe.apply(href, rep, source='poll')
res = coordinator.canonical_resources(subdevice)
assert res['/option/autoclean/vs/0']['x.com.samsung.da.settingStatus'] == 'On'
# Confirms the skip is about redundant re-fetching, not stale data --
# the warm-tier value from initial discovery is still there, untouched.
assert res['/temperature/current/0']['temperature'] == 26.0
class _FakeCollectionSession:
"""Minimal session that only ever answers a Collection GET -- used to
prove the flat-mode re-poll path (issue #205) is only taken when
flat_hrefs is actually set, not whenever seed_path happens to be
unusual."""
def __init__(self, table):
self.table = table
self.calls: list[tuple[str, ...]] = []
def get(self, path, timeout=10.0):
self.calls.append(tuple(path))
body = self.table.get(tuple(path))
if body is None:
return 0x84, b''
import cbor2
return 0x45, cbor2.dumps(body)
def pace(self):
pass
def test_poll_subdevice_seed_collection_mode_unaffected_by_flat_fallback(
hass: HomeAssistant,
):
"""A subdevice with a working Collection endpoint (flat_hrefs empty)
keeps re-polling it with a single Collection GET, unchanged by issue
#205's fallback."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
devcol_rep = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']}
sess = _FakeCollectionSession({
(_SUB_UUID, 'device', '0'): [
devcol_rep, {'href': '/mode/vs/0', 'rep': {'mode': 'cool'}},
],
})
coordinator._session = sess
subdevice = Subdevice(kind='prefixed', key=_SUB_UUID, seed_path=(_SUB_UUID, 'device', '0'))
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}}
assert sess.calls == [(_SUB_UUID, 'device', '0')]
def test_poll_subdevice_seed_flat_mode_polls_each_href_individually(
hass: HomeAssistant,
):
"""A flat-mode subdevice (issue #205) has no Collection to batch-refresh
through, so each confirmed href is GET individually under the prefix on
every re-poll -- a href that stops answering just drops out, same
"never fail the master's poll over a sibling" posture as the Collection
path."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
sess = _FakeCollectionSession({
(_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
# (_SUB_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
})
coordinator._session = sess
subdevice = Subdevice(
kind='prefixed', key=_SUB_UUID, seed_path=(),
flat_hrefs=('/mode/vs/0', '/power/vs/0'),
)
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}}
assert sess.calls == [
(_SUB_UUID, 'mode', 'vs', '0'), (_SUB_UUID, 'power', 'vs', '0'),
]
def test_poll_subdevice_seed_flat_mode_skips_hrefs_covered_by_hot_warm_subpolls(
hass: HomeAssistant,
):
"""A flat href already in the hot/warm sub-poll tiers is refreshed every
few seconds by _run_subpolls -- re-fetching it again on this
once-per-summary-poll pass would only add GETs, not freshness, so
_poll_subdevice_flat_hrefs must not even attempt it."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
sess = _FakeCollectionSession({
(_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
(_SUB_UUID, 'power', 'vs', '0'): {'power': 'On'},
})
coordinator._session = sess
coordinator._warm_hrefs = [f'/{_SUB_UUID}/mode/vs/0']
subdevice = Subdevice(
kind='prefixed', key=_SUB_UUID, seed_path=(),
flat_hrefs=('/mode/vs/0', '/power/vs/0'),
)
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f'/{_SUB_UUID}/power/vs/0': {'power': 'On'}}
assert sess.calls == [(_SUB_UUID, 'power', 'vs', '0')]
async def test_multidevice_probe_never_reaches_discovery_or_the_cache(
hass: HomeAssistant,
):
"""/multidevice/vs/0 is probed on every device but is metadata, not state.
It has to stay out of the resources dict on both counts. Discovery would
otherwise report it as an unbound href on every family whose registry
doesn't ignore that path -- only the AC one does -- raising a spurious
"incomplete capability coverage" repair for, say, a washer whose
firmware happens to answer it. And nothing polls it after discovery, so
anything applied to the state cache would sit frozen there forever.
Driven with a washer fixture precisely because the AC registry's own
ignore entry would mask the coverage half of this on an AC.
"""
resources, _oic, _seeds = _load_device_full('washer_flexwash')
coordinator = _coordinator(hass)
coordinator._session = FakeCoapSession({
'/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'},
})
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics', model='', name='', serial=None,
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': []},
)
merged = await hass.async_add_executor_job(
coordinator._enumerate_subdevices_blocking, resources,
)
coordinator._run_discovery(merged)
for href, rep in coordinator._live_subdevice_resources(merged).items():
coordinator._observe.apply(href, rep, source='poll')
assert '/multidevice/vs/0' not in merged
assert '/multidevice/vs/0' not in coordinator._unbound_hrefs
assert '/multidevice/vs/0' not in coordinator.last_resources
# Still captured, just not as device state.
assert coordinator._multidevice == {'x.com.samsung.da.numofsubdevice': '2'}
+754
View File
@@ -0,0 +1,754 @@
"""Tests for registry/subdevices.py -- multi-indoor-subdevice ("composite
device") support, issue #177. See DESIGN-177.md for the two board patterns
(`ARTIK051_DONGLE_FAC_18K`'s indexed siblings, `TP2X_FAC_BORA_21K`'s
UUID-prefixed tree) this module unifies.
"""
from __future__ import annotations
import cbor2
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc
from custom_components.localthings.registry.subdevices import (
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
normalize_seed_batch,
)
_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
def _indexed(n: str) -> Subdevice:
return Subdevice(kind='indexed', key=n, seed_path=('device', n))
def _prefixed(sub_id: str) -> Subdevice:
return Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
# ---------------------------------------------------------------------------
# Subdevice.to_actual / to_canonical / owns / key_prefix
# ---------------------------------------------------------------------------
class TestMainIsIdentity:
def test_to_actual_unchanged(self):
assert MAIN.to_actual('/mode/vs/0') == '/mode/vs/0'
def test_to_canonical_unchanged(self):
assert MAIN.to_canonical('/mode/vs/0') == '/mode/vs/0'
def test_owns_is_always_false(self):
"""MAIN never "owns" a href by this definition -- it gets whatever's
left after every other subdevice's hrefs are excluded (canonical_view)."""
assert MAIN.owns('/mode/vs/0') is False
assert MAIN.owns('/mode/vs/1') is False
def test_key_prefix_is_empty(self):
"""The master's flattened state keys must stay byte-identical
to every device shipped before issue #177 -- see adapter._key."""
assert MAIN.key_prefix == ''
class TestIndexedTransform:
def test_to_actual_rewrites_trailing_zero(self):
subdevice = _indexed('1')
assert subdevice.to_actual('/mode/vs/0') == '/mode/vs/1'
assert subdevice.to_actual('/device/0') == '/device/1'
def test_to_actual_leaves_non_zero_trailing_segment_alone(self):
"""Deliberately not a "replace any trailing digit" rule -- a genuine
multi-instance resource (the fridge's pattern-cap hrefs, e.g.
'/door/vs/1') must not be misread as a subdevice's own href."""
subdevice = _indexed('1')
assert subdevice.to_actual('/door/vs/1') == '/door/vs/1'
def test_to_canonical_round_trips(self):
subdevice = _indexed('1')
assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0'
def test_to_canonical_rejects_wrong_index(self):
subdevice = _indexed('1')
assert subdevice.to_canonical('/mode/vs/2') is None
def test_to_canonical_rejects_index_zero(self):
"""Index 0 belongs to MAIN, never to an indexed subdevice."""
subdevice = _indexed('1')
assert subdevice.to_canonical('/mode/vs/0') is None
def test_owns(self):
subdevice = _indexed('1')
assert subdevice.owns('/mode/vs/1') is True
assert subdevice.owns('/mode/vs/0') is False
assert subdevice.owns('/mode/vs/2') is False
def test_key_prefix(self):
assert _indexed('1').key_prefix == 'subdevice1_'
assert _indexed('2').key_prefix == 'subdevice2_'
class TestPrefixedTransform:
def test_to_actual_prepends_id(self):
subdevice = _prefixed(_UUID)
assert subdevice.to_actual('/mode/vs/0') == f'/{_UUID}/mode/vs/0'
def test_to_canonical_round_trips(self):
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0'
def test_to_canonical_rejects_missing_prefix(self):
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical('/mode/vs/0') is None
def test_to_canonical_requires_path_boundary(self):
"""A different, longer id that merely starts with the same
characters must not be mistaken for this one's own href."""
subdevice = _prefixed(_UUID)
assert subdevice.to_canonical(f'/{_UUID}extra/mode/vs/0') is None
def test_owns(self):
subdevice = _prefixed(_UUID)
assert subdevice.owns(f'/{_UUID}/mode/vs/0') is True
assert subdevice.owns('/mode/vs/0') is False
def test_key_prefix_strips_non_alphanumerics(self):
assert _prefixed(_UUID).key_prefix == 'subdevice_6c2dff6dee5cdad16a5e000000000001_'
# ---------------------------------------------------------------------------
# canonical_view
# ---------------------------------------------------------------------------
class TestCanonicalView:
def test_main_with_no_subdevices_is_unchanged(self):
resources = {'/mode/vs/0': {'a': 1}, '/power/vs/0': {'b': 2}}
assert canonical_view(MAIN, resources, []) == resources
def test_main_excludes_subdevice_owned_hrefs(self):
"""A sibling's own /mode/vs/1 must not leak into the master's
canonical /mode/vs/0 view -- otherwise exists_fn/is_legacy_board
checks that scan the whole dict would see two subdevices' state mixed
together under one key."""
resources = {
'/mode/vs/0': {'unit': 'main'},
'/mode/vs/1': {'unit': 'sibling'},
}
sub1 = _indexed('1')
view = canonical_view(MAIN, resources, [sub1])
assert view == {'/mode/vs/0': {'unit': 'main'}}
def test_indexed_view_is_rewritten_to_canonical_hrefs(self):
resources = {
'/mode/vs/0': {'unit': 'main'},
'/mode/vs/1': {'unit': 'sibling'},
'/power/vs/1': {'p': 'On'},
}
sub1 = _indexed('1')
view = canonical_view(sub1, resources, [sub1])
assert view == {
'/mode/vs/0': {'unit': 'sibling'},
'/power/vs/0': {'p': 'On'},
}
def test_prefixed_view_is_rewritten_to_canonical_hrefs(self):
resources = {
f'/{_UUID}/mode/vs/0': {'unit': 'sub'},
'/mode/vs/0': {'unit': 'main'},
}
subdevice = _prefixed(_UUID)
view = canonical_view(subdevice, resources, [subdevice])
assert view == {'/mode/vs/0': {'unit': 'sub'}}
def test_including_main_in_subdevices_is_harmless(self):
"""MAIN.owns() is always False, so passing the full roster
(including MAIN itself) to canonical_view must not change anything."""
resources = {'/mode/vs/0': {'unit': 'main'}, '/mode/vs/1': {'unit': 'sib'}}
sub1 = _indexed('1')
assert (canonical_view(MAIN, resources, [MAIN, sub1])
== canonical_view(MAIN, resources, [sub1]))
# ---------------------------------------------------------------------------
# normalize_seed_batch
# ---------------------------------------------------------------------------
def test_normalize_seed_batch_indexed_is_a_no_op():
subdevice = _indexed('1')
batch = {'/mode/vs/1': {'a': 1}}
assert normalize_seed_batch(subdevice, batch) == batch
def test_normalize_seed_batch_prefixed_adds_missing_prefix():
subdevice = _prefixed(_UUID)
batch = {'/mode/vs/0': {'a': 1}}
assert normalize_seed_batch(subdevice, batch) == {f'/{_UUID}/mode/vs/0': {'a': 1}}
def test_normalize_seed_batch_prefixed_leaves_already_prefixed_alone():
subdevice = _prefixed(_UUID)
batch = {f'/{_UUID}/mode/vs/0': {'a': 1}}
assert normalize_seed_batch(subdevice, batch) == batch
# ---------------------------------------------------------------------------
# enumerate_subdevices
# ---------------------------------------------------------------------------
class _FakeSession:
"""path-tuple -> raw batch list, cbor-encoded on GET -- same shape as
test_identity.py's FakeSession, extended to serve Collection batches
(a CBOR list), not just a bare Property map."""
def __init__(self, table):
self.table = table
def get(self, path, timeout=10.0):
body = self.table.get(tuple(path))
if body is None:
return 0x84, b''
return 0x45, cbor2.dumps(body)
def pace(self):
pass
_DEVCOL_REP = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']}
def test_enumerate_indexed_from_oic_res_links():
oic_res = [{'di': 'aaaa', 'links': [
{'href': '/device/0'}, {'href': '/device/1'}, {'href': '/device/2'},
]}]
sess = _FakeSession({
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
('device', '2'): [_DEVCOL_REP, {'href': '/mode/vs/2', 'rep': {'m': 2}}],
})
subdevices, extra = enumerate_subdevices(sess, {}, oic_res)
assert sorted((u.kind, u.key) for u in subdevices) == [
('indexed', '1'), ('indexed', '2'),
]
assert extra == {'/mode/vs/1': {'m': 1}, '/mode/vs/2': {'m': 2}}
def test_enumerate_indexed_falls_back_to_speculative_probe_when_oic_res_hides_the_tree():
"""A board whose /oic/res doesn't reveal a second logical Device (e.g.
it hides the whole tree, TP2X_FAC_BORA_21K-style) falls back to probing
/device/1 and /device/2 directly -- the same bound the old
identity.py-level probe used."""
sess = _FakeSession({
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
# /device/2 not in the table -> 4.04 -> not materialized.
})
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert [(u.kind, u.key) for u in subdevices] == [('indexed', '1')]
assert extra == {'/mode/vs/1': {'m': 1}}
def test_enumerate_indexed_only_materializes_subdevices_with_a_non_empty_batch():
"""Per issue #177's design call: any seed that answers with a non-empty
batch is materialized, but an empty ({}) answer is not -- no separate
"is this subdevice real" gate."""
sess = _FakeSession({}) # neither /device/1 nor /device/2 answers
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_from_subdevice_id_list():
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
}
sess = _FakeSession({
(_UUID, 'device', '0'): [
_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'cool'}},
],
})
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
assert [(u.kind, u.key) for u in subdevices] == [('prefixed', _UUID)]
# Batch echoed the bare (unprefixed) href -- normalized to carry the id.
assert extra == {f'/{_UUID}/mode/vs/0': {'m': 'cool'}}
def test_enumerate_prefixed_falls_back_to_flat_hrefs_when_device0_collection_is_empty():
"""issue #205: not every prefixed subdevice exposes its own
/<uuid>/device/0 Collection -- not even TP2X_FAC_BORA_21K, the board
this pattern was built against, always does. When it doesn't,
enumeration probes every href the master itself answered this cycle,
individually, under the UUID prefix, and keeps whichever ones answer."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
'/mode/vs/0': {'m': 'cool'},
'/power/vs/0': {'p': 'On'},
}
sess = _FakeSession({
# (_UUID, 'device', '0') deliberately absent -> Collection probe fails.
(_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
# (_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
})
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
assert len(subdevices) == 1
subdevice = subdevices[0]
assert (subdevice.kind, subdevice.key) == ('prefixed', _UUID)
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs == ('/mode/vs/0',)
assert extra == {f'/{_UUID}/mode/vs/0': {'mode': 'cool'}}
def test_enumerate_prefixed_flat_fallback_materializes_nothing_when_no_href_answers():
"""Same posture as every other candidate check in this module: nothing
answering means no candidate, not a crash."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
'/mode/vs/0': {'m': 'cool'},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried():
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
'/mode/vs/0': {'m': 'cool'},
}
sess = _FakeSession({
(_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
})
probes: dict[str, bool] = {}
enumerate_subdevices(sess, resources, oic_res_links=[], probe_log=probes.__setitem__)
assert probes[f'/{_UUID}/device/0'] is False
assert probes[f'/{_UUID}/mode/vs/0'] is True
def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op():
"""The master itself having nothing but /subdevices/vs/0 in its own
resources this cycle (e.g. a very first, mostly-empty poll) must not
crash the fallback loop -- the only href in `resources` is
/subdevices/vs/0 itself, which the fake session doesn't answer under
the prefix either, so nothing materializes."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_flat_fallback_does_not_cross_contaminate_a_second_uuid():
"""Two prefixed candidates in the same subdeviceIdList, one whose
Collection endpoint works and one that needs the flat fallback -- each
must end up with only its own hrefs, no bleed between them."""
uuid_a, uuid_b = _UUID, '11111111-1111-1111-1111-111111111111'
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [uuid_a, uuid_b]},
'/mode/vs/0': {'m': 'cool'},
}
sess = _FakeSession({
(uuid_a, 'device', '0'): [
_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'a-collection'}},
],
# uuid_b's Collection deliberately absent -> falls back to the flat
# per-href probe.
(uuid_b, 'mode', 'vs', '0'): {'m': 'b-flat'},
})
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
by_key = {u.key: u for u in subdevices}
assert set(by_key) == {uuid_a, uuid_b}
assert by_key[uuid_a].seed_path == (uuid_a, 'device', '0')
assert by_key[uuid_a].flat_hrefs == ()
assert by_key[uuid_b].seed_path == ()
assert by_key[uuid_b].flat_hrefs == ('/mode/vs/0',)
assert extra == {
f'/{uuid_a}/mode/vs/0': {'m': 'a-collection'},
f'/{uuid_b}/mode/vs/0': {'m': 'b-flat'},
}
def test_enumerate_prefixed_tolerates_redacted_string_id_list():
"""subdeviceIdList matches redact.py's 'deviceid' substring rule, and the
real airconditioner_fac_bora_device.json fixture carries the literal
redacted string there -- this must yield zero subdevices, not crash."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': 'REDACTED'},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_no_subdevices_resource_at_all():
subdevices, extra = enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_indexed_materializes_candidate_regardless_of_content():
"""enumerate_subdevices itself has no way to tell a real sibling from an
unused slot that merely answers the same shape (the reporter's
/device/2) -- that's discover_partitioned's job (see its own tests
below). A same-shaped batch of otherwise-empty reps is still returned
as a candidate here."""
oic_res = [{'di': 'a', 'links': [{'href': '/device/2'}]}]
sess = _FakeSession({
('device', '2'): [_DEVCOL_REP, {'href': '/power/vs/2', 'rep': {}},
{'href': '/configuration/vs/2', 'rep': {'region': '123'}}],
})
subdevices, extra = enumerate_subdevices(sess, {}, oic_res)
assert [(u.kind, u.key) for u in subdevices] == [('indexed', '2')]
assert extra == {'/power/vs/2': {}, '/configuration/vs/2': {'region': '123'}}
def test_enumerate_probe_log_reports_every_attempt():
oic_res = [{'di': 'aaaa', 'links': [{'href': '/device/1'}]}]
sess = _FakeSession({
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}],
})
probes: dict[str, bool] = {}
enumerate_subdevices(sess, {}, oic_res, probe_log=probes.__setitem__)
# /multidevice/vs/0 is always probed too (issue #177 follow-up) -- not
# in this session's table, so it reports "checked, nothing there".
assert probes == {'/device/1': True, '/multidevice/vs/0': False}
def test_enumerate_probe_log_reports_empty_answers_too():
"""Distinguishes "checked, nothing there" from "never checked" (the
posture the speculative-probe code this replaced used to document
directly in identity.py)."""
probes: dict[str, bool] = {}
enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__)
assert probes == {
'/device/1': False, '/device/2': False, '/multidevice/vs/0': False,
}
def test_enumerate_multidevice_vs_0_is_captured_for_diagnostics_not_a_gate():
"""/multidevice/vs/0 (issue #177 follow-up) is corroborating evidence
only -- captured into the merged resources so diagnostics can report
it, never consulted by the liveness gate itself."""
sess = _FakeSession({
('multidevice', 'vs', '0'): {'x.com.samsung.da.numofsubdevice': '2'},
})
subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[])
assert subdevices == [] # no /device/<n> or subdevice id answered
assert extra == {
'/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'},
}
def test_enumerate_both_patterns_checked_independently():
"""No board needs disambiguation logic -- enumeration just checks both
signals and takes whatever answers."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
}
oic_res = [{'di': 'a', 'links': [{'href': '/device/1'}]}]
sess = _FakeSession({
(_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}],
('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 2}}],
})
subdevices, extra = enumerate_subdevices(sess, resources, oic_res)
assert sorted((u.kind, u.key) for u in subdevices) == [
('indexed', '1'), ('prefixed', _UUID),
]
# ---------------------------------------------------------------------------
# discover_partitioned
# ---------------------------------------------------------------------------
class _FakeRegistry:
def __init__(self, name, capabilities, pattern_capabilities=()):
self.name = name
self.capabilities = capabilities
self.pattern_capabilities = list(pattern_capabilities)
def test_discover_partitioned_binds_main_and_subdevice_separately():
mode_cap = Capability(
href='/mode/vs/0',
entities=(BinarySensorDesc(key='mode', field='m'),),
)
registry = {'/mode/vs/0': [mode_cap]}
reg = _FakeRegistry('airconditioner', registry)
sub1 = _indexed('1')
resources = {
'/mode/vs/0': {'m': 'main'},
'/mode/vs/1': {'m': 'sibling'},
}
def resolve(_resources):
return reg
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, [sub1], resolve, fallback_capabilities={},
)
assert device_type_name == 'airconditioner'
assert materialized == [sub1]
assert skipped == []
by_href = {b.href: b for b in bound}
assert set(by_href) == {'/mode/vs/0', '/mode/vs/1'}
assert by_href['/mode/vs/0'].subdevice == MAIN
assert by_href['/mode/vs/1'].subdevice == sub1
def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
"""A subdevice's own /mode/vs/1 must not land in the main pass's unbound
list -- otherwise it raises a spurious coverage-gap repair for a href
nothing in the main registry claims literally, even though the *same*
canonical /mode/vs/0 href is properly claimed for both subdevices.
A registry that binds /mode/vs/0 (so both the main pass and the
subdevice pass over their own canonical views have something to claim
it) makes this unambiguous: if partitioning were broken -- e.g. the
main pass iterating unfiltered `resources` instead of `main_view` --
/mode/vs/1 would show up in `unbound` because nothing in the registry
is keyed on the literal string '/mode/vs/1'. With correct partitioning
the main pass never sees that href at all (canonical_view excludes it),
and the subdevice pass resolves it via its own canonical '/mode/vs/0'."""
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='mode', field='m'),))
reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
sub1 = _indexed('1')
resources = {
'/mode/vs/0': {'m': 'main'},
'/mode/vs/1': {'m': 'sibling'},
}
unbound = []
discover_partitioned(
resources, [sub1], lambda r: reg, fallback_capabilities={},
log=unbound.append,
)
assert unbound == []
def test_discover_partitioned_subdevice_resolves_its_own_registry():
"""A subdevice reporting its own /information/vs/0 resolves its own
device type (issue #177's real wall subdevice: TP2X_FAC_BORA_RAC_21K ->
RAC -> airconditioner) independent of the master's."""
main_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
sub_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m2', field='x'),))
main_reg = _FakeRegistry('main_type', {'/mode/vs/0': [main_cap]})
sub_reg = _FakeRegistry('sub_type', {'/mode/vs/0': [sub_cap]})
sub1 = _indexed('1')
resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}}
def resolve(view):
# The subdevice's canonical view is exactly {'/mode/vs/0': {'x': 2}}.
return sub_reg if view.get('/mode/vs/0', {}).get('x') == 2 else main_reg
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, [sub1], resolve, fallback_capabilities={},
)
assert device_type_name == 'main_type'
assert materialized == [sub1]
assert skipped == []
keys = {(b.href, b.desc.key) for b in bound}
assert ('/mode/vs/0', 'm') in keys
assert ('/mode/vs/1', 'm2') in keys
def test_discover_partitioned_subdevice_falls_back_to_master_registry():
"""A subdevice that reports no identity of its own resolves through the
master's registry instead of the global fallback -- the master itself
must actually resolve here (it reports /information/vs/0), or there is
no master registry for the fallback to reach for."""
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
main_reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
sub1 = _indexed('1')
resources = {
'/information/vs/0': {'model': 'main'},
'/mode/vs/0': {'x': 1},
'/mode/vs/1': {'x': 2},
}
def resolve(view):
return main_reg if view.get('/information/vs/0') else None
bound, _, materialized, skipped = discover_partitioned(
resources, [sub1], resolve, fallback_capabilities={},
)
assert materialized == [sub1]
assert skipped == []
hrefs = {b.href for b in bound}
assert '/mode/vs/1' in hrefs
def test_discover_partitioned_no_subdevices_matches_plain_discover():
"""For a device with no subdevices this must be exactly the single
discover() call it replaces -- the hard regression guard the whole
design exists to protect."""
from custom_components.localthings.registry.discovery import discover
cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
resources = {'/mode/vs/0': {'x': 1}}
bound_via_helper, _, materialized, skipped = discover_partitioned(
resources, [], lambda r: reg, fallback_capabilities={},
)
bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities)
assert bound_via_helper == bound_direct
assert materialized == []
assert skipped == []
# ---------------------------------------------------------------------------
# discover_partitioned's materialization gate: a candidate is only kept if
# it produced at least one *primary* (no entity_category) bound entity whose
# flattened value isn't None. This is what tells the reporter's real
# /device/1 sibling apart from the unused /device/2 slot that answers the
# same shape.
# ---------------------------------------------------------------------------
def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
"""A candidate whose only populated entity is diagnostic-category
doesn't count -- exactly the reporter's /device/2 shape (an alarm_code
sensor reading something even though the subdevice itself is empty)."""
diag_cap = Capability(
href='/alarms/vs/0',
entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),),
)
reg = _FakeRegistry('airconditioner', {'/alarms/vs/0': [diag_cap]})
unit2 = _indexed('2')
resources = {
'/alarms/vs/0': {'code': 'main-alarm'},
'/alarms/vs/2': {'code': 'ErrorCode_OFF'}, # populated, but diagnostic-only
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit2], lambda r: reg, fallback_capabilities={},
)
assert materialized == []
assert len(skipped) == 1
assert skipped[0].subdevice == unit2
assert skipped[0].hrefs == ('/alarms/vs/2',)
# The candidate contributes nothing at all -- not even its diagnostic
# entity -- once skipped.
assert all(b.subdevice != unit2 for b in bound)
def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
"""A candidate with a populated *primary* (no entity_category) entity
is materialized, even alongside an all-empty diagnostic sibling href --
exactly the reporter's /device/1 shape."""
climate_cap = Capability(
href='/mode/vs/0',
entities=(BinarySensorDesc(key='mode', field='m'),), # no entity_category -> primary
)
diag_cap = Capability(
href='/alarms/vs/0',
entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),),
)
reg = _FakeRegistry(
'airconditioner', {'/mode/vs/0': [climate_cap], '/alarms/vs/0': [diag_cap]},
)
sub1 = _indexed('1')
resources = {
'/mode/vs/0': {'m': 'main'},
'/alarms/vs/0': {'code': 'main-alarm'},
'/mode/vs/1': {'m': 'Cool'},
'/alarms/vs/1': {}, # empty -- not what makes this subdevice live
}
bound, _, materialized, skipped = discover_partitioned(
resources, [sub1], lambda r: reg, fallback_capabilities={},
)
assert materialized == [sub1]
assert skipped == []
hrefs = {b.href for b in bound if b.subdevice == sub1}
assert hrefs == {'/mode/vs/1', '/alarms/vs/1'}
def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter():
"""A cumulative meter doesn't count as evidence either (issue #214) --
the reporter's non-composite ARTIK051_KRAC_18K answers /device/1 with
every operational rep empty {} plus a populated /energy/consumption/vs/1,
and that lifetime kWh total was enough to materialize a phantom second
air conditioner. It's the appliance's own counter, not proof a second
indoor unit is installed at that slot."""
energy_cap = Capability(
href='/energy/consumption/vs/0',
entities=(SensorDesc(key='energy_kwh', field='kwh',
device_class='energy',
state_class='total_increasing'),), # primary, but a meter
)
climate_cap = Capability(
href='/mode/vs/0',
entities=(BinarySensorDesc(key='mode', field='m'),),
)
reg = _FakeRegistry(
'airconditioner',
{'/energy/consumption/vs/0': [energy_cap], '/mode/vs/0': [climate_cap]},
)
unit1 = _indexed('1')
resources = {
'/energy/consumption/vs/0': {'kwh': 1175.2},
'/mode/vs/0': {'m': 'Cool'},
'/energy/consumption/vs/1': {'kwh': 1175.2}, # populated, but a meter
'/mode/vs/1': {}, # the slot's real state: empty
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r: reg, fallback_capabilities={},
)
assert materialized == []
assert [s.subdevice for s in skipped] == [unit1]
assert all(b.subdevice != unit1 for b in bound)
def test_discover_partitioned_meter_carve_out_does_not_gate_out_a_live_subdevice():
"""The carve-out removes one *kind* of evidence, not the subdevice: a
candidate reporting its own operational state alongside a meter still
materializes, and still gets its meter entity once it does."""
energy_cap = Capability(
href='/energy/consumption/vs/0',
entities=(SensorDesc(key='energy_kwh', field='kwh',
device_class='energy',
state_class='total_increasing'),),
)
climate_cap = Capability(
href='/mode/vs/0',
entities=(BinarySensorDesc(key='mode', field='m'),),
)
reg = _FakeRegistry(
'airconditioner',
{'/energy/consumption/vs/0': [energy_cap], '/mode/vs/0': [climate_cap]},
)
unit1 = _indexed('1')
resources = {
'/energy/consumption/vs/0': {'kwh': 1175.2},
'/mode/vs/0': {'m': 'Auto'},
'/energy/consumption/vs/1': {'kwh': 1175.2},
'/mode/vs/1': {'m': 'Cool'}, # its own state -- this is what counts
}
bound, _, materialized, skipped = discover_partitioned(
resources, [unit1], lambda r: reg, fallback_capabilities={},
)
assert materialized == [unit1]
assert skipped == []
assert {b.href for b in bound if b.subdevice == unit1} == {
'/mode/vs/1', '/energy/consumption/vs/1',
}
def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs():
"""A skipped candidate's hrefs must not appear via tier_log either --
'no entities, no hot/warm hrefs' (the skip is total, not just
entity-level)."""
cap = Capability(href='/mode/vs/0', poll_tier='warm',
entities=(SensorDesc(key='alarm_code', field='code',
entity_category='diagnostic'),))
reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]})
unit2 = _indexed('2')
resources = {'/mode/vs/0': {'code': 'x'}, '/mode/vs/2': {'code': 'y'}}
tiers = []
discover_partitioned(
resources, [unit2], lambda r: reg, fallback_capabilities={},
tier_log=lambda href, tier: tiers.append(href),
)
assert '/mode/vs/2' not in tiers
+82
View File
@@ -0,0 +1,82 @@
"""Corpus-wide invariant: adapter._key must be unique across every bound
entity that would actually be registered as an HA entity, for every fixture
in the corpus -- issue #177's whole Subdevice/key_prefix design exists to
protect this (see DESIGN-177.md section 3/6). Run over the entire fixture
set, not just the two new subdevice fixtures, so a future dump -- subdevice-
capable or not -- exercises it automatically.
"""
from collections import Counter
import pytest
from custom_components.localthings.entity import _is_included
from custom_components.localthings.registry.adapter import _key
from custom_components.localthings.registry.entities import PLATFORM_OF
from tests.conftest import FIXTURES, _discover_full, _load_device_full
_FIXTURE_NAMES = sorted(
p.name[:-len('_device.json')] for p in FIXTURES.glob('*_device.json')
)
class _FakeCoordinator:
"""Just enough of LocalThingsCoordinator's surface for entity.py's
_is_included -- the same one-time entity-creation gate every platform's
async_setup_entry runs (see entity.py's own module docstring)."""
def __init__(self, resources: dict[str, dict], subdevices):
self.last_resources = resources
self._subdevices = list(subdevices)
def canonical_resources(self, subdevice):
from custom_components.localthings.registry.subdevices import canonical_view
return canonical_view(subdevice, self.last_resources, self._subdevices)
@pytest.mark.parametrize('name', _FIXTURE_NAMES)
def test_key_is_unique_across_all_bound_entities(name):
"""`_key(b)` only has to be unique among entities `_is_included` would
actually register -- discover() alone can (deliberately) produce two
BoundEntity rows sharing a key on the same href when they're gated by
mutually-exclusive `exists_fn`s (see fridge.py's
REFRIGERATION_FALLBACK.defrost_active, which only exists when
DEFROST_BLOCK_STATUS's own `/defrost/block/vs/0` href is absent) --
only one of the two is ever actually included for a real device, so
checking the raw `bound` list would flag devices that have always
worked correctly. It also only has to be unique *within one HA
platform* -- unique_id collisions are scoped by (integration, platform)
in HA's entity registry (see entity.py's `_attr_unique_id`, which
doesn't itself encode the platform), and several devices in this corpus
deliberately bind the same key to the same href on two different
platforms discriminated the same way (e.g. a SwitchDesc and a
BinarySensorDesc both named 'power_switch' on /power/0).
"""
resources, oic_res, seeds = _load_device_full(name)
bound, materialized, skipped, full_resources, device_type_name = _discover_full(
resources, oic_res, seeds,
)
coordinator = _FakeCoordinator(full_resources, materialized)
included = [b for b in bound if _is_included(b, coordinator)]
keys = [(PLATFORM_OF[type(b.desc)], _key(b)) for b in included]
dupes = {k: n for k, n in Counter(keys).items() if n > 1}
assert not dupes, (
f"{name}: duplicate (platform, _key) values across {len(included)} "
f"included entities (materialized subdevices: "
f"{[su.key for su in materialized]}): {dupes}"
)
def test_subdevice_capable_fixtures_actually_exercise_a_subdevice():
"""A meta-check on the test above: if both subdevice fixtures somehow
stopped materializing any subdevice (a regression in enumeration or the
materialization gate), the corpus-wide uniqueness test above would keep
passing vacuously -- it never gets to check a single collision. Assert
the two fixtures this design added actually produce a materialized
subdevice, so that silent-vacuous-pass failure mode is caught here
instead."""
for name in ('airconditioner_artik051_dongle_fac_18k',
'airconditioner_fac_bora_2in1'):
resources, oic_res, seeds = _load_device_full(name)
_, materialized, _, _, _ = _discover_full(resources, oic_res, seeds)
assert materialized, f"{name}: expected at least one materialized subdevice"
+95
View File
@@ -295,3 +295,98 @@ def test_coffee_recipe_hrefs_are_ignored_not_guessed():
for href in ('/brand/recipe/info/vs/0', '/coffee/custom/recipe/vs/0',
'/recipe/coffee/vs/0', '/recipe/coffee/deletion/vs/0'):
assert href in ignored_hrefs, href
# ---------------------------------------------------------------------------
# AILITE_DA-REF-WATERPURIFIER board (issue #196, RWP70F15ANW) -- a
# coffee-capable variant on a different board family than issue #90/#107's
# TP2X_WATERPURIFIER_20K, whose modelNum's 'REF' token would otherwise
# misroute it to the refrigerator registry (see test_by_type.py's
# TestBoardTokenAmbiguity carve-out). Also the first dump to expose
# /cup/state/vs/0, /statistic/pour/vs/0, and the settings/sound/* trio on
# this device type.
# ---------------------------------------------------------------------------
def _water_purifier_ailite():
resources = _load_device('water_purifier_ailite_25k')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
)
return reg, resources
def _bound_ailite():
reg, resources = _water_purifier_ailite()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state_ailite():
bound, resources = _bound_ailite()
return flatten(bound, resources)
def _desc_ailite(key):
bound, _ = _bound_ailite()
return next(b.desc for b in bound if b.desc.key == key)
def test_ailite_model_resolves_to_water_purifier_not_refrigerator():
reg, _ = _water_purifier_ailite()
assert reg is not None and reg.name == 'water_purifier'
def test_ailite_no_unbound_hrefs():
reg, resources = _water_purifier_ailite()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_ailite_expected_state_keys_present():
state = _state_ailite()
for key in ('cup_state', 'sound_mode', 'sound_output', 'sound_volume',
'alarm_in_mute', 'last_pour_type', 'last_pour_capacity'):
assert key in state, key
def test_ailite_hot_water_temperature_gated_off_without_supported_list():
"""This board reports tempDesiredHotWater but no
supportedHotTemperatures (only a hotwaterRange/hotwaterLevel pair whose
write contract isn't confirmed) -- the exact shape that used to make
HA's select show 'unknown' (issue #196), since current_option isn't in
an empty options list. The descriptor must gate off entirely rather than
register with empty options."""
state = _state_ailite()
assert 'hot_water_temperature' not in state
def test_ailite_sound_mode_options_come_from_live_supported_modes():
"""This board's supportedModes (voice/fixedTone/mute) differs from both
laundry.SOUND_MODE's hardcoded voice/tone/mute and air_purifier.SOUND_MODE's
mute/buzzer -- reusing either would reject a value this device actually
supports, per the oven._OVEN_MODES lesson from issue #138."""
desc = _desc_ailite('sound_mode')
assert desc.options_field == 'supportedModes'
assert desc.translation_key == 'water_purifier_sound_mode'
def test_ailite_sound_volume_bounds_come_live_not_hardcoded():
desc = _desc_ailite('sound_volume')
assert isinstance(desc, NumberDesc)
assert desc.native_min is None and desc.native_max is None
rep = {'minLevel': '0', 'maxLevel': '15', 'resolution': '5'}
assert desc.native_min_fn(rep) == 0
assert desc.native_max_fn(rep) == 15
assert desc.step_fn(rep) == 5
def test_ailite_alarm_in_mute_is_read_only():
"""No sibling field advertises alarmInMute as user-settable -- surfaced
as a read-only diagnostic per the 'don't guess' rule rather than an
invented switch."""
from custom_components.localthings.registry.entities import BinarySensorDesc
desc = _desc_ailite('alarm_in_mute')
assert isinstance(desc, BinarySensorDesc)
assert desc.value_fn('true') is True
assert desc.value_fn('false') is False