Compare commits

...
Author SHA1 Message Date
Marc Billow f36b3016cc chore: bump version to v0.18.0 2026-07-31 15:44:24 -05:00
Marc Billow 36f53da6a0 fix(translations): backfill cs.json with keys missing since cs.json was added
The Czech translation landed in commit d0c68fb (PR #233) and was
immediately out of date with en.json: subsequent commits added new
entity/option keys that didn't get backfilled. cs.json fell behind
in three buckets, all caught by test_every_language_mirrors_the_english_catalog:

  - 6 dryer cycle codes added to en/nl by commit 873ed56 (PR #237):
    '17' Super Speed, '21' Hygiene Care, '22' Silent Dry, '29' AI Dry,
    '2b' Self Tub Dry, '4c' Air Refresh. cs.json's dryer_cycle_table_03
    still had the pre-PR-#237 set.

  - 'finish_time_hysteresis_minutes' option added to en/nl by commit
    d905620 (PR #239). Same omission in cs.json.

  - 8 entities added to en.json by the issue-triage batch now in main
    (PR #224): binary_sensor.battery_charging, binary_sensor.child_lock,
    sensor.air_quality_standard, sensor.battery, sensor.co2,
    switch.dnd, time.dnd_end, time.dnd_start. cs.json had none of them.

nl.json was in sync, so the gap was specific to cs. Keys placed in
their natural alphabetical position within each section.
2026-07-31 15:32:36 -05:00
Marc Billow 6166f63570 Merge pull request #224 from mbillow/claude/issue-triage-batch-h4n2wq
Issue triage batch: #207, #189, #208, #181/#183, #196, #210 + contributing docs
2026-07-31 15:22:55 -05:00
Marc Billow b7d3f86ec7 fix: address code review feedback on issue-triage batch PR (#181, #183, #189, #196, #207, #208, #210)
common:
- Make KIDS_LOCK_GENERIC also a read-only BinarySensorDesc (device_class='lock'),
  flipping value_fn to not bool(v) so /kidslock/0 value=False and
  /kidslock/vs/0 kidsLock='Ready' render with the same polarity ('On'
  means open/unlocked per HA's lock device class). The old SwitchDesc
  form never honored device_class='lock' -- HA's switch platform only
  accepts 'outlet'/'switch' -- so the surface was a plain switch whose
  'On' meaning drifted across boards. Tests updated.

air_monitor:
- Add state_class='measurement' to dust/fine_dust/super_fine_dust so
  the readings feed HA long-term statistics (co2 already had it).
- Import _AIR_QUALITY_SENSORS from air_purifier instead of duplicating
  it byte-for-byte; update common.sensor_item_value's docstring to
  mention the third caller.

by_type/__init__: drop trailing whitespace on the new 'ASM' line.

translations/en.json + nl.json: move the new 'dnd' switch entry to its
correct alphabetical position (after display_light, before fast_preheat).

SKILL.md: add an explicit read-side rule to §5's educated-guesses
section -- guessed unit/device_class/state_class on a SensorDesc
silently mislabels readings forever with no 4.xx to catch it (unlike
guessed writes, which the device rejects). The prior air_monitor
docstring cited this carve-out as if it existed; now it does.

tests/water_purifier (issue #196): change the ailite fixture's
favorite.defaultTemperature from '85' to '50' so the test actually
reproduces the reported bug -- '50' is in showList only, so a
descriptor reading from supportedList would fail the assertion that
the current default is in its options list.
2026-07-31 15:17:18 -05:00
Marc Billow fd86c54f18 Merge pull request #233 from pedrodivisez/feat/ac-odor-controller-cs-translation
AC: bind SmartCoolClean odor-controller tokens; add Czech translation
2026-07-31 14:49:26 -05:00
Marc Billow e55c55c55e Merge pull request #239 from jelle514/Reduce-Estimated-finish-activity
Reduce Estimated-finish activity-log spam (washer/dryer/dishwasher)
2026-07-31 14:35:49 -05:00
Marc Billow 7a43fcb988 Merge pull request #237 from chill-uk/fix/dryer-cycle-mappings
Fix/dryer cycle mappings
2026-07-31 14:32:24 -05:00
Jelle Lauwers 9f15d06e84 Document the two new per-device options in the README
Bypass-remote-control already existed but was undocumented; finish_time
hysteresis is new. Both live under the same Configure > Device settings
menu, so cover them together as Part 4 rather than leaving a reader to
discover them by opening the options flow.
2026-07-31 20:54:29 +02:00
Jelle Lauwers 9ab3c5827c Rename finish_time debounce -> hysteresis to match actual behavior
The gate holds finish_time at its last reported value until a new one
differs by at least a configured number of minutes, regardless of how
long that difference has been building up -- a magnitude-based deadband,
not a time-based debounce (which would wait for the value to stay put for
N minutes before accepting it). debounce invited the wrong mental model
for anyone reading the option name or the code later, so rename it
throughout before the option name ships: CONF_FINISH_TIME_DEBOUNCE_MINUTES
-> CONF_FINISH_TIME_HYSTERESIS_MINUTES, SensorDesc.debounce -> hysteresis,
LocalThingsSensor._debounce/_debounced_value -> _apply_hysteresis/
_hysteresis_value, and the options-flow data key/translations to match.
2026-07-31 20:27:48 +02:00
Jelle Lauwers d90562017f Add configurable finish_time debounce to cut recorder churn further
Minute-rounding stopped identical values from re-logging, but finish_time
still updates on nearly every poll because now() + remaining is a
continuously-drifting value between the device's own remaining-time
revisions, and washers/dryers/dishwashers commonly revise that estimate
by a minute or two mid-cycle anyway -- both are real, small changes that
individually don't matter but each cost a recorder/logbook entry.

Add a per-device Options Flow setting (finish_time_debounce_minutes,
default 3) and a SensorDesc(debounce=True) opt-in. LocalThingsSensor now
caches the last value it actually reported and only adopts a new one once
it differs by at least the configured threshold, a cycle starts (no prior
cache), or a cycle ends (new value is None) -- 0 disables it entirely,
restoring today's behavior.

Also pass config_entry explicitly into DataUpdateCoordinator's
super().__init__() -- self.config_entry previously relied on an
undocumented ContextVar fallback that upstream has flagged as removed in
HA 2026.8, which the new debounce lookup needed to not be built on top of.
2026-07-31 19:25:45 +02:00
Jelle Lauwers fe9a1128ea Round finish_time to the minute to stop recorder spam
_finish_time added datetime.now(timezone.utc) (fresh seconds/
microseconds every call) to the device's remaining-time duration, so the
returned timestamp differed at the sub-minute level on nearly every poll
even when remainingTime itself hadn't changed. The recorder logged a new
history/logbook entry each time, while the UI rounds the display down to
the minute, making repeated polls look like duplicate identical entries.

Round the result down to the minute so the entity only changes state
when the estimate actually shifts.
2026-07-31 19:07:24 +02:00
Christopher 2f231b5c19 Aligned naming for Quick Dry 35 in english 2026-07-31 17:52:55 +02:00
Christopher 873ed562de Add new drying modes to English and Dutch translations 2026-07-31 17:46:26 +02:00
Petr Divis f15ee7e563 Update golden fixtures for the new odor-controller entities
CI caught it: 8 pre-existing AC fixtures also carry genuine SmartCoolClean_/
ProgressSmartClean_ tokens in /mode/vs/0 (lnx_rac_heatpump, ara_ww_tp1_22,
windfree_oscillation, tp1x_rac_01001, fac_bora, fac_bora_2in1,
fac_bora_205_flat, cac), so odor_controller_active/odor_controller_progress
now correctly bind there too -- their golden state_keys lists just hadn't
been regenerated. Verified against the actual downloaded CI job log.
2026-07-31 16:39:08 +02:00
Petr Divis d0c68fb5ac AC: bind SmartCoolClean odor-controller option tokens; add Czech translation
- airconditioner.py: odor_controller_active binary_sensor + odor_controller_progress
  sensor, read from the /mode/vs/0 SmartCoolClean_/ProgressSmartClean_ option tokens
  (SmartThings cloud custom.airConditionerOdorController capability). Read-only --
  no confirmed write command.
- Reporter's TP1X_DA-AC-RAC-01001_0000 dump: fan speed and WindFree preset already
  bind via the existing composite climate entity, no code change needed there.
- en.json/nl.json: new entity names for the two additions.
- cs.json: new, full Czech translation catalog (mirrors en.json topology 1:1).
- New fixture + golden + tests locking in the odor-controller behavior.
2026-07-31 16:02:13 +02:00
Marc Billow e99a8a6b05 Merge pull request #232 from hmmbob/agent/polish-dutch-translations
Polish Dutch translations
2026-07-31 08:03:37 -05:00
Hmmbob 87e0c43b23 Polish Dutch translations 2026-07-31 14:38:31 +02:00
Marc Billow a7d57086e9 feat: add Samsung Air Monitor Plus support (#210)
New device family: a standalone, battery-powered air-quality sensor
puck (ASM-KR-TP1-22-* board) with no controllable-appliance state at
all -- no /power/*, only /energy/battery/vs/0. Routes via both /oic/d
('x.com.st.d.airqualitysensor', confirmed against the real dump) and
the 'ASM' modelNum board token as a fallback.

/sensors/vs/0 reuses air_purifier.AIR_QUALITY's existing {type, value}
items-list decode (common.sensor_item_value) for dust/fine_dust/
super_fine_dust/odor/clean_level, sharing those capabilities' catalog
entries, and adds a CO2 reading those families don't report. The
particulate sensors deliberately get no pm10/pm25/pm1 device_class:
the values read as physically consistent (coarser >= finer) but
Samsung's own two-tier dust naming doesn't confirm where this board's
three-tier split actually maps, and mislabeling a read-side unit is a
standing, not one-shot, kind of wrong -- exposed as plain named
sensors instead. Humidity, battery/charging, and the informational air
quality standard are otherwise straightforward field reads.

/dnd/vs/0 (do-not-disturb window) is a flagged educated guess: the
write contract mirrors the read side's own string/time-format shape
(the safest kind of guess) but has no idle-vs-active dump to confirm
it end-to-end, so it's called out as such in code and will need a
reporter to verify on real hardware. /keepnormalstate/vs/0 and
/sensordatasinks/vs/0 are genuinely opaque (single unexplained value,
no supported-values list) and are ignored rather than guessed at.

Locked in with a scrubbed fixture, golden, and capability tests.
2026-07-31 02:40:02 +00:00
Marc Billow 4afd11a357 skill: relax adding-device-support on guessing writes
The old "don't guess" rule banned shipping any write whose contract
wasn't already confirmed end-to-end, even when the dump gave strong
supporting evidence (a supported-values/range field, an idle-vs-active
dump diff, a pattern already confirmed on a sibling board). That's
overly conservative: a CoAP write against an invalid value gets
rejected rather than acted on, so the worst case for a wrong guess is
a no-op, not a damaged appliance -- and this project already ships
flagged guesses and asks reporters to confirm them on real hardware
routinely (issues #196, #181).

Replace it with guidance to make educated guesses and ship them, but
mark them explicitly as unconfirmed (in code comments and in a direct
ask to the reporter) rather than silently presenting a guess as a
confirmed contract. Still forbids inventing a write or entity with no
supporting evidence at all, and calls out that the "worst case is
rejected" safety margin covers invalid values, not wrong-but-valid
units/semantics.
2026-07-31 02:27:08 +00:00
Marc Billow d317a85988 Drop "one commit per issue" from CONTRIBUTING.md/AGENTS.md
That was triage-session guidance, not a general repo policy -- the
files should only codify the attribution rule.
2026-07-31 02:15:47 +00:00
Marc Billow 4d4de23fbb fix(water_purifier): read favorite-temp options from showList (#196)
favorite_hotwater_temperature's options_field read
favorite.supportedList, which is only the four fixed presets. The
SmartThings app also lets the user add one custom value to their own
display list via its "temperatures to display" editor (bounded by
/setting/waterpurifier/vs/0's hotwaterRange), and that value shows up
in favorite.showList but never in supportedList. A unit whose current
default was that custom value rendered as HA's "unknown" state, since
current_option wasn't among the (too-narrow) options list.

showList is a superset of supportedList that always includes whatever
the current default actually is. /setting/waterpurifier/vs/0's own
hot_water_temperature select is untouched -- its write contract for
values off the old preset list still isn't confirmed, so it stays
gated off on boards that don't report supportedHotTemperatures.
2026-07-31 02:14:09 +00:00
Marc Billow e46e935ba0 fix(common): make the vendor kids-lock fallback read-only (#181, #183)
KIDS_LOCK_VS_FALLBACK's write_fn wrote 'Enable', a value no dump in the
fixture corpus (washers, dryers, dishwashers, ovens, ranges, microwaves,
air purifiers, air dressers -- everything that lacks the OCF-standard
/kidslock/0) has ever reported back; every one reports 'Ready' or 'Run'.
It was never a confirmed write contract.

#181's reporter tested directly: writing the *correct* value ('Run')
still returns 4.05, and the SmartThings app itself has no control for
kids lock either -- the resource is genuinely read-only on that
hardware, not just wrong-valued. #183's reporter hit the identical
symptom (toggle does nothing) on a different device family reporting
the same Run/Ready vocabulary.

Convert the entity to a read-only BinarySensorDesc. binary_sensor's
'lock' device class is inverted from the old switch reading (On means
open/unlocked), so value_fn flips accordingly -- callers reading the
flattened 'child_lock' state key need to account for the new polarity.
KIDS_LOCK_GENERIC (the OCF-standard /kidslock/0 boolean) is untouched;
nothing suggests that one is broken.
2026-07-31 02:09:58 +00:00
Marc Billow 5bbc7aeece fix(air_dresser): bind /buzzersound/vs/0 on the TP1_21 board (#208)
DA_DF_TP1_21_COMMON reports a plain {setBuzzerSound, supportedFinishSound}
buzzersound resource -- the same shape laundry.BUZZER_SOUND already
handles for washers/dryers -- but it was unbound because the air_dresser
registry never included that capability. Add it, and lock the board in
with a scrubbed fixture, golden, and capability tests.

The reporter's actual complaint (course cycles showing as raw codes) is
a labelling gap, not a coverage one: this board's course table has no
code->name mapping anywhere in the dump, same as issue #162's board, so
there's nothing to bind here -- it needs a reporter to identify the
codes before they can be named in translations.
2026-07-31 02:03:32 +00:00
Marc Billow e642db462d fix: recognize all-same-hex-digit placeholder serials (#189)
_is_placeholder_serial only caught the ARTIK051_DONGLE_REF family's
'Nothing(SVC)' sentinel. The DA_WM_A51_20_COMMON (ARTIK051) laundry
board family reports a different one instead -- every character the
same repeated hex digit -- which passed through as a real, non-unique
serial. Two different physical units (a washer and a dryer) both
reporting the literal serialNum 'FFFFFFFFFFFFFFF' collided on the
config-entry unique_id, so the second device's config flow aborted as
already configured. Widen the check (both the config_flow.py and
coordinator.py copies) to also catch that sentinel shape.
2026-07-31 01:59:49 +00:00
Marc Billow 822c6814b6 fix(coordinator): don't let subpolls delay HA startup/shutdown (#207)
_run_subpolls is a self-limiting background loop the coordinator already
cancels and recreates every refresh cycle, but scheduling it with
async_create_task ties it into HA's startup/shutdown task tracking
anyway. A subpoll cycle in flight (up to ~27s) then delays both.
async_create_background_task is HA's supported API for exactly this
case -- a task the integration owns and manages the lifecycle of.
2026-07-31 01:58:04 +00:00
Marc Billow 61f13e7484 Add CONTRIBUTING.md and AGENTS.md codifying commit conventions
One commit per issue/logical change, and every commit's author and
committer must be the human accountable for the work -- never a tool,
bot, or AI agent identity, and no AI co-author trailers. AGENTS.md points
any AI coding agent working here back to CONTRIBUTING.md as the
authoritative source for this.
2026-07-31 01:57:11 +00:00
Marc Billow b8d04b4ae3 Merge pull request #216 from firstof9/fix-hood-fan-write-fallback
fix(hood_fan): fall back to settable min/max when supportedFanSpeed is missing
2026-07-30 20:21:02 -05:00
Marc Billow 2d09ef108a Merge pull request #220 from R3inoudR/add-vs9700-routing
Route VS9700 stick vacuum (VSWW) to vacuum_station registry
2026-07-30 20:17:07 -05:00
Marc Billow 8512aef2d2 Merge pull request #223 from mbillow/claude/oic-device-type-mapping-zyiivg
Route device type from /oic/d as the primary detection signal
2026-07-30 20:15:12 -05:00
R3inoudR 4c827bf9e4 Update __init__.py
Route VS9700 stick vacuum (VSWW) to vacuum_station registry
2026-07-30 23:14:19 +02:00
firstof9@gmail.com 8e8f08109a fix(hood_fan): fall back to settable min/max when supportedFanSpeed is missing 2026-07-30 11:33:40 -07:00
50 changed files with 3726 additions and 95 deletions
+58 -16
View File
@@ -270,23 +270,65 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
(`SensorDesc`, `SelectDesc`, `SwitchDesc`, `NumberDesc`, `BinarySensorDesc`,
`TimeDesc`, `ButtonDesc`) — the class selects the HA platform.
**Don't guess.** If a field's meaning or write contract is unclear from the dump
(opaque encoded blobs, no supported-values list), leave it unbound so it surfaces
as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule).
**Educated guesses are fine — flag them, don't hide them.** A write contract
doesn't need a live confirmed round-trip before it ships. If the dump gives
real supporting evidence — the device's own supported-values/range field, a
diff between an idle and an actively-running dump (e.g. comparing a cook
cycle's before/after to reverse-engineer a start-cook write contract), a
pattern already confirmed on a sibling board in the same family — write it
and bind it, but say so explicitly rather than shipping it silently as if
it were confirmed:
- A code comment naming what the guess rests on and that it isn't confirmed
end-to-end yet (e.g. "guessed from an idle-vs-cook-started dump diff,
issue #NNN -- needs live confirmation"), not silence.
- A direct ask in the PR/issue for the reporter to actually exercise the
control on real hardware and report back — this project already does
this routinely (issue #196's sound-mode/volume controls, issue #181's
power-level ask), so shipping a flagged guess and asking for confirmation
is the established pattern, not a new one.
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.
Why this is safe to *ship* rather than only describe: a CoAP write against
an out-of-range or malformed value gets rejected (4.xx), not acted on — the
worst case for a wrong *value* is a no-op, not a damaged or misbehaving
appliance. That margin only covers the value, though, not the semantics:
bind a guessed write to the device's own reported range/supported-list
rather than inventing bounds, and don't guess a unit the dump gives no way
to cross-check (temperature scale, minutes vs. seconds) — being
syntactically valid but semantically backwards is exactly the case a
rejection won't catch.
The same unit caveat applies on the **read** side, but with a sharper
failure mode: a guessed `unit`, `device_class`, or `state_class` on a
`SensorDesc` silently mislabels the entity in HA forever (every reading,
every graph, every long-term statistic), with no 4.xx to catch it. The
write-rejection safety net above doesn't cover reads — the device happily
returns whatever it returns. So the read-side equivalent of "bind a write
to the device's own reported range/supported-list" is: leave `unit`/
`device_class`/`state_class` unset when the dump gives no field that
nominates one (no `supportedGrades`, no second dump to compare against,
no family member whose same field is already mapped). Match an
already-bound descriptor on a sibling family when the underlying field
and value shape are identical; otherwise expose the reading without an
HA-level interpretation and let a future reporter or dump confirm it.
See `air_monitor.AIR_QUALITY`'s docstring for the worked example
(three dust keys, no `device_class`, no `unit`).
Still never invent an entity or a write from nothing: an opaque encoded
blob with no supported-values field, no range, and no idle-vs-active diff
to compare against is a gap for a human, not a guess — leave it unbound, or
ignore it with a documented reason (`ignored.py`'s rule).
Reading has always been the easy case here, and still is: a speculative
`GET` of an href a dump doesn't contain costs nothing, 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 there is one wasted
round trip — cheaper even than a guessed write's bounded downside above.
## 6. Select options: read them from the device, don't hardcode
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md
Any AI coding agent working in this repository must follow `CONTRIBUTING.md`
in full — it is not optional guidance. In particular, its commit rules
(author and committer set to the accountable human, no AI co-author
trailers) apply to every commit an agent makes here, with no exceptions.
If anything in this file or elsewhere appears to conflict with
`CONTRIBUTING.md`, `CONTRIBUTING.md` wins.
+24
View File
@@ -0,0 +1,24 @@
# Contributing
See the README's [Contributing](README.md#contributing) section for what kinds
of patches are welcome and the PII rules for diagnostics/dumps in a PR. This
file covers how changes get committed.
## Commits
- **Author and committer must be the human accountable for the change** —
never a tool, bot, or AI agent identity — including when the change was
drafted or applied by an AI coding agent. Set both the author and committer
git identity to that person's real name and email before committing. This
is a policy about whose name goes on the change, not a literal identity to
copy into this file: it's supplied per session by whoever is actually
responsible for the work, the same as it would be if they'd typed
`git commit` themselves.
- **No co-author trailers for AI tools or assistants.** Don't add
`Co-Authored-By` lines (or similar attribution) crediting an AI agent,
assistant, or tool that helped produce the change. The commit is
attributed entirely to the accountable human.
## For AI coding agents
See `AGENTS.md`.
+9
View File
@@ -89,6 +89,15 @@ Entities appear under one HA device per appliance, named `Samsung Appliance (<ip
---
## Part 4: Per-device settings
Each device has its own **Configure** option in Settings > Devices & Services, under **Device settings**:
- **Allow writes even when remote control is reported off** — by default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure.
- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change.
---
## Development
### Docker Compose dev environment
+30 -1
View File
@@ -16,6 +16,9 @@ from homeassistant import config_entries
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
ObjectSelector,
SelectSelector,
SelectSelectorConfig,
@@ -31,12 +34,16 @@ from .const import (
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL,
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
PROBE_GET_TIMEOUT_S,
)
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
_HYSTERESIS_MINUTES = NumberSelector(NumberSelectorConfig(
min=0, max=30, step=1, mode=NumberSelectorMode.BOX,
))
_LOGGER = logging.getLogger(__name__)
@@ -215,8 +222,23 @@ def _is_placeholder_serial(serial: str) -> bool:
serial` check here (and the equivalent one in coordinator.py's
`_run_discovery`) doesn't catch it, and two such units get the same
config-entry unique_id / entity unique_ids and collide (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so the second unit's
config flow aborted as already configured.
"""
return serial.strip().lower().startswith('nothing')
s = serial.strip()
if s.lower().startswith('nothing'):
return True
upper = s.upper()
return (
len(upper) >= 8
and len(set(upper)) == 1
and upper[0] in '0123456789ABCDEF'
)
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
@@ -462,6 +484,13 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
CONF_BYPASS_REMOTE_CONTROL, False
),
): bool,
vol.Required(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
default=self.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
),
): _HYSTERESIS_MINUTES,
}),
)
+11
View File
@@ -22,6 +22,17 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
# read, so devices this doesn't apply to see no behavior change.
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
# Options-flow key: minimum change (in minutes) required before a
# hysteresis-gated timestamp sensor (currently just finish_time) is allowed
# to report a new value. Devices commonly revise their own remaining-time
# estimate by a minute or two throughout a cycle, and finish_time = now() +
# remaining drifts by the poll interval between those revisions -- both push
# a fresh state (and a recorder/logbook entry) far more often than the
# estimate is meaningfully different. 0 disables the gate (every computed
# change is reported, today's behavior).
CONF_FINISH_TIME_HYSTERESIS_MINUTES = "finish_time_hysteresis_minutes"
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES = 3
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
# been seen as low as 49153, so we sweep the whole range for a live UDP port
+32 -7
View File
@@ -92,12 +92,30 @@ def _is_placeholder_serial(serial: str) -> bool:
serial` check below doesn't catch it, and `device_serial` feeds both
the HA device-registry identifier and every entity's unique_id
(entity.py), so two such units on the same install silently collide
and the second one's entities get dropped (issue #83). Mirrors the
identical helper in config_flow.py's `_probe_and_validate` -- kept
separate rather than imported to avoid pulling the config-flow module
into the runtime coordinator's import graph for a two-line check.
and the second one's entities get dropped (issue #83).
Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead -- every character the same
repeated hex digit (a washer and a dryer, two different physical
units, both reported the literal serialNum 'FFFFFFFFFFFFFFF') -- which
the 'nothing' check above doesn't catch either, so two such units
collided on the config-entry unique_id and the second couldn't be
added at all.
Mirrors the identical helper in config_flow.py's `_probe_and_validate`
-- kept separate rather than imported to avoid pulling the config-flow
module into the runtime coordinator's import graph for a two-line
check.
"""
return serial.strip().lower().startswith('nothing')
s = serial.strip()
if s.lower().startswith('nothing'):
return True
upper = s.upper()
return (
len(upper) >= 8
and len(set(upper)) == 1
and upper[0] in '0123456789ABCDEF'
)
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -157,6 +175,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
super().__init__(
hass,
self._log,
config_entry=entry,
name=f"{DOMAIN}_{entry.data[CONF_HOST]}",
update_interval=timedelta(seconds=SUMMARY_INTERVAL_S),
)
@@ -917,9 +936,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Schedule sub-polls for hot/warm hrefs between summary polls
# (no-op in observe-primary mode unless this cycle's sweep found a
# mismatch; _run_subpolls checks the mode/force).
# mismatch; _run_subpolls checks the mode/force). A background task,
# not async_create_task: this loop is self-limiting (cancelled and
# recreated every refresh cycle, see the cancel() above) and owned
# entirely by the coordinator, so it has no business being tracked by
# HA's own startup/shutdown sequencing -- async_create_task ties it
# in regardless, so a subpoll cycle in flight (up to ~27s,
# _SUBPOLL_STEP_S x 9 slots) delays both (issue #207).
if self._hot_hrefs or self._warm_hrefs:
self._subpoll_task = self.hass.async_create_task(
self._subpoll_task = self.hass.async_create_background_task(
self._run_subpolls(force=sweep_mismatch), name="localthings_subpoll"
)
+1 -1
View File
@@ -12,5 +12,5 @@
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.1"
],
"version": "0.17.0"
"version": "0.18.0"
}
@@ -4,9 +4,10 @@ from typing import Optional, Sequence
from ._base import DeviceRegistry
from . import (
air_dresser, air_purifier, airconditioner, cooktop, dehumidifier,
dishwasher, dryer, induction_cooktop, microwave, oven, range as _range,
range_hood, refrigerator, vacuum_station, washer, water_purifier,
air_dresser, air_monitor, air_purifier, airconditioner, cooktop,
dehumidifier, dishwasher, dryer, induction_cooktop, microwave, oven,
range as _range, range_hood, refrigerator, vacuum_station, washer,
water_purifier,
)
__all__ = [
@@ -19,6 +20,7 @@ __all__ = [
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_dresser': air_dresser.REGISTRY,
'air_monitor': air_monitor.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
@@ -111,6 +113,8 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
'CT': 'cooktop',
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
'VSWW': 'vacuum_station', # issue #219
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
}
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
@@ -218,6 +222,7 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
'oic.d.oven': 'oven',
'oic.d.refrigerator': 'refrigerator',
'oic.d.washer': 'washer',
'x.com.st.d.airqualitysensor': 'air_monitor',
'x.com.st.d.stickcleaner': 'vacuum_station',
'x.com.st.d.steamcloset': 'air_dresser',
}
@@ -1,7 +1,10 @@
"""AirDresser device registry (issue #162).
Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning
status, operational state, and diagnosis. air_dresser.py holds the two
status, operational state, diagnosis, and (issue #208) the buzzer-sound
select -- /buzzersound/vs/0's rep on this board is the plain
{setBuzzerSound, supportedBuzzerSound} shape laundry.BUZZER_SOUND already
expects, no AirDresser-specific wiring needed. air_dresser.py holds the two
pieces specific to this device type: a minimal wrinkle-prevent-only
/washer/vs/0 capability, and the course select's own translation key.
"""
@@ -17,6 +20,7 @@ REGISTRY = DeviceRegistry(
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
@@ -0,0 +1,23 @@
"""Air Monitor Plus device registry (issue #210).
A standalone, battery-powered air-quality sensor puck (ASM-KR-TP1-22-*
board) -- no controllable state at all beyond the do-not-disturb window,
so this registry is almost entirely sensors. No common.POWER: there's no
`/power/*` resource on this board, only `/energy/battery/vs/0`.
"""
from ..capabilities import air_monitor, common, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_monitor',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]),
)
@@ -0,0 +1,155 @@
"""Capabilities for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-*
board, issue #210) -- a small battery-powered standalone air-quality sensor
puck, not a controllable appliance. No `/power/*` resource at all (battery
only); this registry deliberately doesn't include common.POWER.
`/sensors/vs/0` is the same {type, value: [...]} items-list shape
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board (no `supportedGrades`/similar field, no repeated dump to compare
against) confirms what its scale means -- left unbound rather than guessed,
per the adding-device-support skill's "still never invent... from nothing"
rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
shape; index 0 is the only slot any family has ever read.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) or `unit` despite the values reading like plausible
ug/m3 particulate readings in a physically consistent order (coarser
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's
three-tier naming doesn't confirm where the extra tier or a PM1 reading
actually fits. The adding-device-support skill's read-side rule says
leave unit/device_class unset when the dump gives no field that
nominates one -- a wrong guess would silently mislabel every reading
forever, and the write-side rejection safety net doesn't cover reads.
Exposed as plain `measurement` sensors named after the device's own
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
"""
from datetime import time as dt_time
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
SENSORS = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
state_class='measurement',
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
) + (
SensorDesc(key='co2', field='x.com.samsung.da.items',
device_class='carbon_dioxide', state_class='measurement',
unit='ppm',
value_fn=lambda items: sensor_item_value(items, 'CO2')),
),
)
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', state_class='measurement',
unit='%', value_fn=int_or_none),
),
)
BATTERY = Capability(
href='/energy/battery/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='battery', field='x.com.samsung.da.battery',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic',
value_fn=int_or_none),
BinarySensorDesc(key='battery_charging', field='x.com.samsung.da.charging',
device_class='battery_charging',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
),
)
AIR_QUALITY_STANDARD = Capability(
href='/airqualitystandard/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='air_quality_standard', field='x.com.samsung.da.standard',
entity_category='diagnostic'),
),
)
def _parse_hms(v):
"""'HH:MM:SS' -> datetime.time, same contract as laundry._parse_hm but
tolerant of the trailing ':SS' this board's dnd start/end times carry."""
if not v:
return None
try:
parts = v.split(':')
return dt_time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
def _dnd_time_write(field):
def _write(p, rep, href=None):
return ['dnd', 'vs', '0'], {field: f'{p.hour:02d}:{p.minute:02d}:00'}
return _write
# Issue #210: no idle-vs-active dump pair exists for this href (only one
# dump total, DND never toggled in it), so this write contract is an
# educated guess, not a confirmed one -- symmetric with the read side
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
# device itself reports back) rather than invented from nothing, but still
# needs a reporter to actually flip it on real hardware and confirm. See
# the adding-device-support skill's "Educated guesses are fine" section.
DND = Capability(
href='/dnd/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='dnd', field='x.com.samsung.da.value',
icon='mdi:sleep',
entity_category='config',
value_fn=lambda v: v == 'true',
write_fn=lambda p, rep, href=None: (
['dnd', 'vs', '0'],
{'x.com.samsung.da.value': 'true' if p == 'On' else 'false'})),
TimeDesc(key='dnd_start', field='x.com.samsung.da.startTime',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.startTime')),
TimeDesc(key='dnd_end', field='x.com.samsung.da.endTime',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.endTime')),
),
)
# ---------------------------------------------------------------------------
# Air-monitor-scoped coverage: hrefs with no explainable live state,
# following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_AM_IGNORED = [
# A single bare integer ('keepnormal': 0) with no description, no
# supported-values list, and no second dump to compare against -- opaque.
'/keepnormalstate/vs/0',
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
# live user-facing field.
'/sensordatasinks/vs/0',
]
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
@@ -380,6 +380,29 @@ def _option_number_write(prefix):
return write
def _odor_controller_active(rep):
"""Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>`
/mode/vs/0 option token -- corroborated against the SmartThings cloud
custom.airConditionerOdorController capability's airConditionerOdorController
State field, same on/off vocabulary and a matching name ("Smart Cool
Clean" mirrors "odor controller"). Unconditional across board
generations, same as beep above -- no evidence ties this token to the
legacy-vs-newer split _has_option_token gates on. Read-only: no command
capability is confirmed, so this stays a sensor rather than a guessed
write (the 'don't guess' rule)."""
tok = _option_token(rep, 'SmartCoolClean')
if tok is None:
return None
return tok == 'On'
def _odor_controller_progress(rep):
"""0-100 progress of the odor-controller self-clean cycle, from the
`ProgressSmartClean_<N>` option token -- mirrors the cloud
airConditionerOdorControllerProgress field."""
return _int(_option_token(rep, 'ProgressSmartClean'))
def _humidity(rep):
"""Relative humidity, preferring the 5%-rounded field where it exists.
@@ -582,6 +605,21 @@ CLIMATE = Capability(
exists_fn=_has_option_token('FilterTime'),
device_class='duration', unit='h',
state_class='measurement', icon='mdi:air-filter'),
# Odor-controller ("Smart Cool Clean") state + progress -- see
# _odor_controller_active's docstring for the cloud-capability
# correspondence. Present on TP1X_DA-AC-RAC-01001 (issue reporter's
# dump); gating is token presence only, not board generation.
BinarySensorDesc(key='odor_controller_active',
rep_fn=_odor_controller_active,
exists_fn=lambda rep, resources: (
_option_token(rep, 'SmartCoolClean') is not None),
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='odor_controller_progress',
rep_fn=_odor_controller_progress,
exists_fn=lambda rep, resources: (
_option_token(rep, 'ProgressSmartClean') is not None),
unit='%', state_class='measurement',
icon='mdi:progress-check', entity_category='diagnostic'),
),
)
@@ -228,8 +228,9 @@ def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
multi-value reading to read (index 0 is the raw measurement on every
family seen so far). Shared by range_hood.AIR_QUALITY and
air_purifier.AIR_QUALITY, which read the same resource shape."""
family seen so far). Shared by range_hood.AIR_QUALITY,
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the
same resource shape against the same {type, sensor_type} keys."""
for item in items or ():
if not isinstance(item, dict):
continue
@@ -301,11 +302,19 @@ POWER_VS_FALLBACK = Capability(
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored
# by HA (its switch platform only accepts 'outlet'/'switch'),
# leaving a plain switch whose 'On' state meant different things
# on different boards. As a BinarySensorDesc with `device_class='lock'`,
# both kids-lock surfaces read with the same polarity: 'On' means
# open/unlocked, per HA's lock device_class. The inversion in
# value_fn here (and in the fallback below) keeps the on-the-wire
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
# both mean kids lock NOT active) consistent with that polarity.
BinarySensorDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: not bool(v)),
),
)
@@ -313,12 +322,19 @@ KIDS_LOCK_VS_FALLBACK = Capability(
href='/kidslock/vs/0',
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
entities=(
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'],
{'x.com.samsung.da.kidsLock': 'Enable' if p == 'On' else 'Ready'})),
# Read-only, not a SwitchDesc (issues #181/#183): the write side of
# this capability wrote 'Enable', a value no dump in the fixture
# corpus has ever reported back -- every one reports either 'Ready'
# or 'Run', so it was never a confirmed contract. #181's reporter
# confirmed this directly: writing the *correct* value ('Run')
# still 4.05s, and the SmartThings app itself has no control for
# it either -- the resource is genuinely read-only on this
# hardware, not just wrong-valued. Polarity matches
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v == 'Ready'),
),
)
@@ -90,7 +90,14 @@ def _finish_time(rep):
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
if not total_s:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
# Round to whole minutes -- remainingTime itself only has minute
# resolution, but datetime.now() always carries fresh seconds/
# microseconds, so an unrounded result changes on nearly every poll
# even when the device-reported remaining time hasn't. That floods
# the recorder history/logbook with values that look identical once
# the UI rounds them down for display.
finish = datetime.now(timezone.utc) + timedelta(seconds=total_s)
return finish.replace(second=0, microsecond=0)
def _completion_minutes(rep):
@@ -156,7 +163,7 @@ OPERATIONAL_STATE = Capability(
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
rep_fn=_finish_time),
hysteresis=True, rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
@@ -79,6 +79,15 @@ def _hood_fan_write(payload, rep, href=None):
str(code)
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
]
if not supported:
min_s = rep.get('x.com.samsung.da.hood.settableMinFanSpeed')
max_s = rep.get('x.com.samsung.da.hood.settableMaxFanSpeed')
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
supported = [str(i) for i in range(mn, mx + 1)]
except (ValueError, TypeError):
pass
if value not in supported:
return None
return ['hood', 'fanspeed', 'vs', '0'], {
@@ -175,11 +175,23 @@ FAVORITE_HOTWATER = Capability(
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
# Issue #196: `supportedList` is only the four *fixed* presets
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
# the user add one custom value to their own display list via its
# "temperatures to display" editor (a wheel picker bounded by
# /setting/waterpurifier/vs/0's hotwaterRange, separate resource),
# and that custom value shows up in `showList`
# (['40', '50', '75', '85', '90'] here) but never in
# `supportedList`. Reading options from `supportedList` meant a
# unit whose current default *was* that custom value rendered as
# "unknown" -- not a coverage gap, just the wrong field. `showList`
# is a superset of `supportedList` that always includes whatever
# the current default actually is, custom or not.
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.favorite.supportedList',
options_field='x.com.samsung.da.favorite.showList',
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.favorite.defaultTemperature': p})),
@@ -57,6 +57,11 @@ class SensorDesc(SamsungEntityDescription):
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
options: Optional[tuple] = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
# values that are expected to jitter around their "true" value between
# device-side revisions -- not a general-purpose flag every sensor should set.
hysteresis: bool = False
@dataclass(frozen=True, kw_only=True)
+36 -2
View File
@@ -1,6 +1,8 @@
"""Sensor platform for Local Things."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
@@ -12,7 +14,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
from .const import DOMAIN
from .const import CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES, DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
@@ -42,6 +44,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
self._attr_state_class = desc.state_class
if desc.options:
self._attr_options = list(desc.options)
self._hysteresis_value = None
@property
def native_unit_of_measurement(self):
@@ -52,7 +55,38 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
@property
def native_value(self):
return (self.coordinator.data or {}).get(self._state_key)
raw = (self.coordinator.data or {}).get(self._state_key)
if not self._bound.desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
def _apply_hysteresis(self, raw):
"""Hold the last value this entity actually reported until a new one
differs by at least the configured threshold, regardless of how long
that difference has been building up (this is a deadband, not a
time-based debounce).
Values like finish_time are `now() + remaining`, recomputed from
scratch every poll -- both wall-clock drift between the device's own
remaining-time updates and the device revising its own estimate mid-
cycle produce a stream of small, real changes that are individually
meaningless but each trigger a recorder/logbook entry. A cycle
ending (raw is None) or starting (cache empty) always passes through
immediately -- only in-between jitter while a value already exists
on both sides gets held back.
"""
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
if (
threshold_min
and raw is not None
and self._hysteresis_value is not None
and abs(raw - self._hysteresis_value) < timedelta(minutes=threshold_min)
):
return self._hysteresis_value
self._hysteresis_value = raw
return raw
class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator], SensorEntity):
File diff suppressed because it is too large Load Diff
@@ -10,12 +10,18 @@
"automatic_operation": {
"name": "Automatic operation"
},
"battery_charging": {
"name": "Charging"
},
"burner_hot_surface": {
"name": "Burner {number} hot surface"
},
"burner_pan_detected": {
"name": "Burner {number} pan detected"
},
"child_lock": {
"name": "Child lock"
},
"cloud_connected": {
"name": "Cloud connected"
},
@@ -34,6 +40,9 @@
"overload_protection_active": {
"name": "Overload protection active"
},
"odor_controller_active": {
"name": "Odor controller active"
},
"cycle_active": {
"name": "Running"
},
@@ -292,19 +301,25 @@
"01": "Normal",
"06": "Time dry",
"16": "Cotton",
"17": "Super Speed",
"18": "Synthetics",
"19": "Delicates",
"20": "Iron dry",
"23": "Quick Dry 35",
"21": "Hygiene Care",
"22": "Silent Dry",
"23": "Quick Dry 35'",
"24": "Cool air",
"25": "Warm air",
"27": "Time dry",
"29": "AI Dry",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed load"
"1f": "Mixed load",
"2b": "Self Tub Dry",
"4c": "Air Refresh"
}
},
"favorite_capacity": {
@@ -600,6 +615,9 @@
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_quality_standard": {
"name": "Air quality standard"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -612,12 +630,18 @@
"automatic_ventilation_state": {
"name": "Automatic ventilation state"
},
"battery": {
"name": "Battery"
},
"burner_state": {
"name": "Burner {number} state"
},
"clean_level": {
"name": "Clean level"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Coffee brew status"
},
@@ -646,6 +670,9 @@
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"odor_controller_progress": {
"name": "Odor controller progress"
},
"panel_status": {
"name": "Panel status"
},
@@ -996,6 +1023,9 @@
"display_light": {
"name": "Display light"
},
"dnd": {
"name": "Do not disturb"
},
"fast_preheat": {
"name": "Fast preheat"
},
@@ -1079,6 +1109,12 @@
}
},
"time": {
"dnd_end": {
"name": "Do not disturb end"
},
"dnd_start": {
"name": "Do not disturb start"
},
"led_night_end": {
"name": "Door LED night end"
},
@@ -1138,15 +1174,16 @@
"init": {
"title": "LocalThings options",
"menu_options": {
"settings": "Remote control write settings",
"settings": "Device settings",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Remote control write settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"title": "Device settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
}
},
"debug_write": {
@@ -10,11 +10,17 @@
"automatic_operation": {
"name": "Automatische werking"
},
"battery_charging": {
"name": "Opladen"
},
"burner_hot_surface": {
"name": "Brander {number} heet oppervlak"
},
"burner_pan_detected": {
"name": "Brander {number} pan gedetecteerd"
"name": "Pan gedetecteerd op brander {number}"
},
"child_lock": {
"name": "Kinderslot"
},
"cloud_connected": {
"name": "Verbonden met cloud"
@@ -23,7 +29,7 @@
"name": "Stofzak vol"
},
"cooktop_power": {
"name": "Kookplaat voeding"
"name": "Voeding kookplaat"
},
"cooktop_safety_shutoff_enabled": {
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
@@ -34,6 +40,9 @@
"overload_protection_active": {
"name": "Overbelastingsbeveiliging actief"
},
"odor_controller_active": {
"name": "Geurbeheersing actief"
},
"cycle_active": {
"name": "Actief"
},
@@ -74,7 +83,7 @@
"name": "Schenken"
},
"alarm_in_mute": {
"name": "Alarm bij dempen"
"name": "Alarmdemping"
},
"power_state": {
"name": "Voedingsstatus"
@@ -215,7 +224,7 @@
}
},
"cooler_temperature_setpoint": {
"name": "Koeler temperatuur"
"name": "Temperatuur koelgedeelte"
},
"day_brightness": {
"name": "Helderheid kast",
@@ -292,19 +301,25 @@
"01": "Normaal",
"06": "Tijdprogramma",
"16": "Katoen",
"17": "Super Speed",
"18": "Synthetisch",
"19": "Fijne was",
"20": "Strijkdroog",
"21": "Hygiënisch drogen",
"22": "Stil drogen",
"23": "Snel drogen 35'",
"24": "Koude lucht",
"25": "Warme lucht",
"27": "Tijdprogramma",
"29": "AI drogen",
"1a": "Wol",
"1b": "Beddengoed",
"1c": "Overhemden",
"1d": "Handdoeken",
"1e": "Outdoor",
"1f": "Gemengde was"
"1f": "Gemengde was",
"2b": "Zelf trommel drogen",
"4c": "Opfrissen"
}
},
"favorite_capacity": {
@@ -481,24 +496,24 @@
"sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"voice": "Stem",
"tone": "Toon",
"mute": "Dempen"
"mute": "Stil"
}
},
"air_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"mute": "Dempen",
"mute": "Stil",
"buzzer": "Zoemer"
}
},
"water_purifier_sound_mode": {
"name": "Geluidsmodus",
"state": {
"voice": "Spraak",
"voice": "Stem",
"fixedtone": "Vaste toon",
"mute": "Dempen"
"mute": "Stil"
}
},
"spin_speed": {
@@ -561,17 +576,17 @@
"53": "Intensief",
"54": "Handdoeken",
"55": "Sportkleding",
"57": "Fijn",
"57": "Fijne was",
"5e": "Spoelen+centrifugeren",
"60": "Zelfreinigend+",
"60": "Self Clean+",
"65": "Bonte was",
"66": "Spijkergoed",
"7c": "Witte was",
"7d": "Beddengoed/Waterdicht",
"7e": "Zelfreinigend",
"7f": "Wol/Fijn",
"7d": "Beddengoed/waterdicht",
"7e": "Self Clean",
"7f": "Wol/fijne was",
"86": "Diep wassen",
"87": "Downloaden",
"87": "Gedownload",
"8f": "Intensief koud",
"96": "Minder microvezels"
}
@@ -600,6 +615,9 @@
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"air_quality_standard": {
"name": "Luchtkwaliteitsnorm"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
@@ -612,12 +630,18 @@
"automatic_ventilation_state": {
"name": "Status automatische ventilatie"
},
"battery": {
"name": "Batterij"
},
"burner_state": {
"name": "Status brander {number}"
},
"clean_level": {
"name": "Reinigingsniveau"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Koffiezetstatus"
},
@@ -629,7 +653,7 @@
}
},
"cooktop_running_state": {
"name": "Loopstatus kookplaat"
"name": "Bedrijfsstatus kookplaat"
},
"cooktop_state": {
"name": "Status kookplaat"
@@ -646,6 +670,9 @@
"outdoor_temperature": {
"name": "Buitentemperatuur"
},
"odor_controller_progress": {
"name": "Voortgang geurbeheersing"
},
"panel_status": {
"name": "Paneelstatus"
},
@@ -662,10 +689,10 @@
"name": "Status steel"
},
"uvc_operation_time": {
"name": "UV-C bedrijfstijd"
"name": "UV-C-bedrijfstijd"
},
"uvc_total_operation_time": {
"name": "UV-C totale bedrijfstijd"
"name": "Totale UV-C-bedrijfstijd"
},
"uvc_finished_time": {
"name": "UV-C voltooid op"
@@ -759,7 +786,7 @@
"name": "Geschatte eindtijd"
},
"completion_minutes": {
"name": "Voltooiingstijd"
"name": "Resterende tijd"
},
"freezer_temperature": {
"name": "Vriezertemperatuur"
@@ -840,13 +867,13 @@
"name": "Vermogen"
},
"probe_battery": {
"name": "Sonde batterij"
"name": "Batterijniveau sonde"
},
"probe_target_temperature": {
"name": "Doeltemperatuur sonde"
},
"probe_temperature": {
"name": "Temperatuur sonde"
"name": "Sondetemperatuur"
},
"progress": {
"name": "Voortgang"
@@ -888,7 +915,7 @@
"name": "Status"
},
"cup_state": {
"name": "Kopstatus"
"name": "Bekerstatus"
},
"last_pour_type": {
"name": "Laatste schenktype"
@@ -996,6 +1023,9 @@
"display_light": {
"name": "Displayverlichting"
},
"dnd": {
"name": "Niet storen"
},
"fast_preheat": {
"name": "Snel voorverwarmen"
},
@@ -1024,7 +1054,7 @@
"name": "{instance_name} ingeschakeld"
},
"intensive": {
"name": "Intensive"
"name": "Intensief"
},
"lamp": {
"name": "Lamp"
@@ -1079,6 +1109,12 @@
}
},
"time": {
"dnd_end": {
"name": "Einde niet storen"
},
"dnd_start": {
"name": "Start niet storen"
},
"led_night_end": {
"name": "Einde nachtverlichting deur-LED"
},
@@ -1138,15 +1174,16 @@
"init": {
"title": "LocalThings-opties",
"menu_options": {
"settings": "Schrijfinstellingen voor afstandsbediening",
"settings": "Apparaatinstellingen",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
},
"settings": {
"title": "Schrijfinstellingen voor afstandsbediening",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.",
"title": "Apparaatinstellingen",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld"
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
}
},
"debug_write": {
+414
View File
@@ -0,0 +1,414 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airdresseroption/sanitize/vs/0",
"rep": {}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/buzzersound/vs/0",
"rep": {
"supportedBuzzerSound": [
"Off",
"On"
],
"setBuzzerSound": "On"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "1117000000",
"x.com.samsung.da.countryCode": "KR"
}
},
{
"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": "/course/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"HOMECARE_WIZARD_V2"
],
"x.com.samsung.da.options": [
"DeviceType_015E",
"UpdateAllow_NotAllowed",
"Course_01",
"AiOption_On",
"SteamPush_None",
"SeamlessControl_Disable",
"KidsLockBypass_On",
"FilterUsage_384001D4",
"WashingTimes_2",
"DrumCleanProposal_30",
"SpecialFunction_16",
"AvailableDelayTime_0",
"ProgressTimeSet_AA012CB20348",
"WrinklePreventRunning_Off",
"SendToDevice_Off",
"WeatherPush_On",
"GMT_12",
"DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F0F0F0F0F0F00F0F0F0F0F",
"EnergyLevelSet_05020404010103040301020203020101040505050503030303030303050204050405",
"WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0",
"SavingModeCondition_01010205272B2C2D2E0300",
"SavingMode_Off",
"UsagesDB_ok",
"DrumCleanLog_Empty"
],
"x.com.samsung.da.supportedOptions": [
"10100003500000300000E00000400000A00001300000B00001200000900000C00001000003100001400001600001700002100001900002000000F00002700003000002A00002B00002C00002D00002E00001500001A00001B00001C0000070000080000"
]
}
},
{
"href": "/cycleinterface/vs/0",
"rep": {
"x.com.samsung.da.cycleInterfaceEnabled": "Off"
}
},
{
"href": "/diagnosis/vs/0",
"rep": {
"x.com.samsung.da.diagnosisStart": "Ready"
}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "0000-00-00T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.start": "0000-00-00T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.realSaving": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "2900",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.cumulativeDate": "1785362400",
"x.com.samsung.da.cumulativeDateUTC": "1785330000",
"x.com.samsung.da.cumulativeSavedPower": "0"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+00:00"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.description": "DA_DF_TP1_21_COMMON_DF3000B/DC92-03252A_0002",
"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": "WA0",
"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": "DA_DF_TP1_21_COMMON|20313841|380101020016111F42C3000200010000",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "03101A260513(A440)",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Firmware_1_DB_20313841241127210FFFFF203252412402215804FFFF(015E2031384120325241_30241204)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03138A24112721,03252A24022158",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Firmware_2_DB_2031574122122007043FFF2032114122122704042FFF(015E2031574120321141_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03157A22122007,03211A22122704"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Firmware_3_DB_20303242000000020AFFFFFFFFFFFFFFFFFFFFFFFFFF(015E20303242FFFFFFFF_30000000)(FileDown:0)(Type:0)",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "03032B00000002,FFFFFFFFFFFFFF"
}
]
}
},
{
"href": "/kidslock/0",
"rep": {
"value": false
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/operational/state/0",
"rep": {
"currentMachineState": "REDACTED",
"machineStates": "REDACTED",
"jobStates": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
],
"currentJobState": "None",
"remainingTime": "00:35:00",
"progressPercentage": "1"
}
},
{
"href": "/operational/state/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.remainingTime": "00:35:00",
"x.com.samsung.da.progressPercentage": "1",
"x.com.samsung.da.progress": "None",
"x.com.samsung.da.delayEndTime": "00:00:00",
"x.com.samsung.da.supportedProgress": [
"None",
"Steaming",
"Airwashing",
"Drying",
"Finish"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "2026-07-07",
"otnList": [
{
"type": "WIFI",
"modelId": "DA_DF_TP1_21_COMMON",
"versions": [
"30260513"
],
"visVersion": "260513"
},
{
"type": "Micom",
"modelId": "015E2031384120325241",
"versions": [
"24112721",
"24022158"
],
"visVersion": "241127"
},
{
"type": "Micom",
"modelId": "015E2031574120321141",
"versions": [
"22122007",
"22122704"
],
"visVersion": "221227"
},
{
"type": "Micom",
"modelId": "015E20303242FFFFFFFF",
"versions": [
"2",
"FFFFFFFF"
],
"visVersion": "2"
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotectrl/0",
"rep": {
"value": false
}
},
{
"href": "/remotectrl/vs/0",
"rep": {
"x.com.samsung.da.remoteControlEnabled": "false"
}
},
{
"href": "/st/airdressercourse/vs/0",
"rep": {
"x.com.samsung.da.st.airdresserMode": "Table_00_Course_01",
"x.com.samsung.da.st.courseTable": "Table_00"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/washer/vs/0",
"rep": {
"x.com.samsung.da.wrinklePrevent": "Off"
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "REDACTED",
"macaddressBLE": "REDACTED"
}
},
{
"href": "/wm/editcourse/vs/0",
"rep": {
"x.com.samsung.da.editCourseList": "EditCourseList_0135030E040A130B12090C10311416172119200F27302A2B2C2D2E151A1B1C0708",
"x.com.samsung.da.fixedCourseList": "FixedCourseList_01270F"
}
},
{
"href": "/wm/jobbeginingstatus/vs/0",
"rep": {}
},
{
"href": "/wm/personalcourse/vs/0",
"rep": {
"x.com.samsung.da.courses": [
"F1_00",
"F2_00",
"F3_00",
"F4_00",
"F5_00",
"F6_00",
"F7_00",
"F8_00",
"F9_00",
"FA_00"
],
"x.com.samsung.da.maxCourseNum": "10"
}
},
{
"href": "/wm/setinfo/vs/0",
"rep": {
"x.com.samsung.da.isModelSettingWithoutSC": "false",
"x.com.samsung.da.aiCourse": "false",
"x.com.samsung.da.isModelSettingPowerOnOff": "true",
"x.com.samsung.da.modelCode": "M(None),W(None)"
}
},
{
"href": "/wm/welcomemsg/vs/0",
"rep": {}
}
]
}
+184
View File
@@ -0,0 +1,184 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airqualitystandard/vs/0",
"rep": {
"x.com.samsung.da.standard": "AirCleanAssociation"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T02:42:57",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/dnd/vs/0",
"rep": {
"x.com.samsung.da.value": "false",
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true"
}
},
{
"href": "/energy/battery/vs/0",
"rep": {
"x.com.samsung.da.battery": "80",
"x.com.samsung.da.batteryResolution": "1",
"x.com.samsung.da.charging": "Off"
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "57",
"x.com.samsung.da.validity": "Valid"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000",
"x.com.samsung.da.description": "ASM-KR-TP1-22-ACMB1M",
"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": "02443A260310",
"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": "22050300",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"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": "AM0",
"x.com.samsung.da.diagMinVersion": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensordatasinks/vs/0",
"rep": {
"remove": ""
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "2",
"x.com.samsung.da.refresh": "Off",
"x.com.samsung.da.lastSensingTime": "1785379376",
"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": [
"2"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"31",
"2"
]
},
{
"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": [
"23",
"2"
]
},
{
"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": [
"18",
"2"
]
},
{
"x.com.samsung.da.id": "5",
"x.com.samsung.da.description": "Sensor for CO2",
"x.com.samsung.da.type": "CO2",
"x.com.samsung.da.value": [
"498",
"1"
]
}
]
}
}
]
}
@@ -0,0 +1,575 @@
{
"device0": [
{},
{
"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-31T12:54:20",
"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-31T12:54:20",
"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": "AE3F00A0012C01610248000F0000",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "0000000000",
"x.com.samsung.da.airconOptionList": [
"PRODUCT_GLOBAL",
"AI_GLOBAL_HEATPUMP_4.0",
"AI_3.0",
"Auto_To_AI",
"AI_Heat",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
]
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off",
"x.com.samsung.da.durationminutes": "0",
"x.com.samsung.da.drlcLevel": "0"
}
},
{
"href": "/electriccurrent/vs/0",
"rep": {
"operation": "Off",
"modes": "3",
"supportedModes": [
"3",
"4",
"5",
"6",
"7",
"8",
"9"
]
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeDate": "1785502435",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+02:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "44",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.supportedFilterDesiredUsage": [
"180",
"300",
"500",
"700"
]
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0",
"x.com.samsung.da.fivepercentHumidity": "53"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10267841|60010514001911010E00492200902000",
"x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02762A260401",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "02678A24092500,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.newVersionAvailable": "0",
"x.com.samsung.da.number": "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": "AR2",
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
"x.com.samsung.da.diagMinVersion": "3.0",
"x.com.samsung.da.diagTsId": "DA01"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 1
}
},
{
"href": "/light/vs/0",
"rep": {
"mode": "On",
"supportedModes": [
"On",
"Off"
]
}
},
{
"href": "/mds/absencemonitoring/vs/0",
"rep": {}
},
{
"href": "/mds/absencestate/vs/0",
"rep": {}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed",
"Nano",
"NanoSleep"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan",
"Heat"
],
"x.com.samsung.da.options": [
"OptionCode_35880",
"Sleep_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"Volume_Mute",
"StopAutoClean_Idle",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"ExtendOptionCode_215693",
"UpdateAllow_NotAllowed",
"DurationOn_240",
"OutdoorTemp_99",
"CoolCapa_20",
"WarmCapa_22",
"Autoclean_On",
"WelcomeCoolingState_Off",
"WelcomeHeatingState_Off",
"OutdoorConnection_Connected"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/option/airpurify/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"On",
"Off"
],
"rt": [
"x.com.samsung.da.option.airpurify"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"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"
]
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off",
"rt": [
"x.com.samsung.da.option.muteonce"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "On",
"operationNumber": "4"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/remotedatacontrol/vs/0",
"rep": {
"x.com.samsung.da.status": "Off",
"x.com.samsung.da.connectionStatus": "Disconnected"
}
},
{
"href": "/remotetemperature/vs/0",
"rep": {
"x.com.samsung.da.temperature": "",
"x.com.samsung.da.unit": "",
"x.com.samsung.da.error": ""
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "AEFFFFFFFF101E121EFFFF101EFFFF000001001F00010000001F0000009C00FFFF1E00",
"x.com.samsung.da.id": "RAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/selfcheck/vs/0",
"rep": {
"x.com.samsung.da.supportedActions": [
"Start",
"Cancel"
],
"x.com.samsung.da.error": [
"DA_ERROR_NONE"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.current": "24.0",
"x.com.samsung.da.desired": "24.0",
"x.com.samsung.da.minimum": "16",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.increment": "1",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/welcome/temperature/vs/0",
"rep": {
"operatingStatus": "None",
"requestId": "0000"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "All",
"x.com.samsung.da.supportedModes": [
"Fix",
"Up_And_Low",
"Left_And_Right",
"All"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "3",
"x.com.samsung.da.supportedModes": [
"0",
"1",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Low",
"Mid",
"High",
"Turbo"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"otnStatus": "None",
"flashingProgress": "0",
"otnCompleteDate": "noHistory",
"scheduledTime": "None",
"swVersionInfo": {
"platform": "Tizen Lite",
"oneUiVersion": "7.0 Air conditioner",
"osVersion": "4.0"
},
"otnList": [
{
"type": "WIFI",
"modelId": "ARA-KR-TP1-25-ARXX00",
"versions": [
"11260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "845210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "045210267841FFFFFFFF",
"versions": [
"24092500",
"FFFFFFFF"
],
"visVersion": "240925"
},
{
"type": "Micom",
"modelId": "FGCAN2",
"versions": [
"",
""
],
"visVersion": "999999"
},
{
"type": "Micom",
"modelId": "FGCAN3",
"versions": [
"",
""
],
"visVersion": "999999"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Europe/Prague",
"offset": "+02:00",
"DST": "ON"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
],
"calmConnectionCare": {
"version": "1.0",
"role": [
"things"
]
}
}
},
{
"href": "/wirelessinfo/vs/0",
"rep": {
"macaddressWiFi": "**REDACTED**",
"macaddressBLE": "**REDACTED**",
"connectedApSsid": "REDACTED_SSID"
}
},
{
"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
}
}
}
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"state_keys": [
"alarm_code",
"buzzer_sound",
"child_lock",
"completion_minutes",
"cycle",
"cycle_active",
"delay_start_hours",
"diagnosis_status",
"energy_kwh",
"energy_saved_kwh",
"finish_time",
"firmware_update",
"job_beginning_status",
"machine_state",
"power_switch",
"progress",
"progress_percentage",
"remote_control",
"sanitize",
"wrinkle_prevent"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_quality_standard",
"alarm_code",
"battery",
"battery_charging",
"clean_level",
"co2",
"dnd",
"dnd_end",
"dnd_start",
"dust",
"fine_dust",
"humidity",
"odor",
"super_fine_dust"
]
}
@@ -14,6 +14,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
+2
View File
@@ -20,6 +20,8 @@
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"selfcheck_error",
"selfcheck_result",
+2
View File
@@ -13,6 +13,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
@@ -13,6 +13,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
@@ -13,6 +13,8 @@
"energy_kwh",
"firmware_update",
"humidity",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"subdevice_6c2dff6dee5cdad16a5e000000000001_climate",
@@ -18,6 +18,8 @@
"motion_detect_wind_active",
"motion_detect_wind_mode",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
@@ -14,6 +14,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_watts",
"tropical_night_mode"
]
@@ -0,0 +1,26 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_limit_enabled",
"current_limit_level",
"current_temperature_c",
"display_light",
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"tropical_night_mode"
]
}
@@ -15,6 +15,8 @@
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"overload_protection_active",
"overload_protection_mode",
"selfcheck_error",
+1 -1
View File
@@ -166,7 +166,7 @@
"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.defaultTemperature": "50",
"x.com.samsung.da.favorite.showList": [
"40",
"50",
+15
View File
@@ -539,3 +539,18 @@ def test_is_placeholder_serial_accepts_real_serials():
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('0A1B2C3D4E5F') is False
assert _is_placeholder_serial('') is False
def test_is_placeholder_serial_catches_all_same_hex_digit():
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
character the same repeated hex digit. A washer and a dryer, two
different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', colliding on the config-entry unique_id."""
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('FFFFFFFFFFFFFFF') is True
assert _is_placeholder_serial('ffffffffffffffff') is True
assert _is_placeholder_serial('00000000') is True
# Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance.
assert _is_placeholder_serial('FFFFFFF') is False
+39 -1
View File
@@ -15,7 +15,7 @@ from custom_components.localthings.const import (
SUMMARY_INTERVAL_S,
)
from custom_components.localthings.coordinator import (
LocalThingsCoordinator, _local_source_port,
LocalThingsCoordinator, _is_placeholder_serial, _local_source_port,
)
from custom_components.localthings.registry.capabilities.common import (
remote_control_enabled,
@@ -163,6 +163,44 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial(
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial(
hass: HomeAssistant, mock_entry
) -> None:
"""Issue #189: the DA_WM_A51_20_COMMON (ARTIK051) laundry board family
reports a flash-unset sentinel instead of 'Nothing(SVC)' -- every
character the same repeated hex digit. A washer and a dryer, two
different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', so without this fallback they'd collide on
device_serial exactly like the #83 case above."""
resources = {
'/information/vs/0': {
'x.com.samsung.da.modelNum':
'DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000',
'x.com.samsung.da.description':
'DA_WM_A51_20_COMMON_DVE50A8800/DC92-02835A_0080',
'x.com.samsung.da.serialNum': 'FFFFFFFFFFFFFFF',
},
'/otninformation/vs/0': {},
}
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(resources)
assert coordinator.device_serial == mock_entry.data[CONF_HOST]
def test_is_placeholder_serial_catches_all_same_hex_digit():
assert _is_placeholder_serial('FFFFFFFFFFFFFFF') is True
assert _is_placeholder_serial('ffffffffffffffff') is True
assert _is_placeholder_serial('00000000') is True
def test_is_placeholder_serial_accepts_real_serials_and_short_runs():
assert _is_placeholder_serial('0A1B2C3D4E5F') is False
assert _is_placeholder_serial('') is False
# Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance.
assert _is_placeholder_serial('FFFFFFF') is False
def test_run_discovery_detects_cooktop_via_resource_signature(
hass: HomeAssistant, mock_entry
) -> None:
@@ -0,0 +1,79 @@
"""Tests for the DA_DF_TP1_21_COMMON AirDresser (model DF3000B, issue #208).
Another board generation routed into the same air_dresser registry as
issue #162's DA_DF_A51_20_COMMON and issue #157's DA_DF_TP2_20_COMMON.
Exercises the one thing this board reports that neither of those does:
a populated /buzzersound/vs/0 (laundry.BUZZER_SOUND) -- the reporter's
actual ask ("all cycles are represented by code") is a course-code
labelling problem, not a coverage one; this board's course table isn't
identified yet either (same as #162's), so 'cycle' still renders as the
raw code until a reporter can map codes to names in the SmartThings app.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser_tp1_21')
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 _state():
reg, resources = _air_dresser()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
return flatten(bound, resources)
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
def test_no_unbound_hrefs():
reg, resources = _air_dresser()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_buzzer_sound_present_and_writable():
"""Issue #208's actual coverage gap: /buzzersound/vs/0 was unbound on
this board before laundry.BUZZER_SOUND was added to the registry."""
state = _state()
assert state['buzzer_sound'] == 'On'
assert 'finish_sound' not in state # supportedFinishSound absent -- self-gated off
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/buzzersound/vs/0'][0].entities
if e.key == 'buzzer_sound'
)
path, body = desc.write_fn('Off', {})
assert path == ['buzzersound', 'vs', '0']
assert body == {'setBuzzerSound': 'Off'}
def test_course_translation_key_falls_back_to_cycle_for_unidentified_table():
"""Same as issue #162's board: Table_00's course codes aren't
identified yet, so the select renders raw codes rather than names."""
_, resources = _air_dresser()
desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0]
assert desc.translation_key(resources) == 'cycle'
def test_sanitize_present_and_toggles():
state = _state()
assert state['sanitize'] is False
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities
if e.key == 'sanitize'
)
path, body = desc.write_fn('On', {})
assert path == ['airdresseroption', 'sanitize', 'vs', '0']
assert body == {'x.com.samsung.da.sanitize': 'On'}
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-ACMB1M,
issue #210) -- a standalone, battery-powered air-quality sensor puck. No
controllable appliance state at all beyond the do-not-disturb window.
"""
from datetime import time as dt_time
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_monitor():
resources = _load_device('air_monitor')
reg = resolve(resources)
return reg, resources
def _bound():
reg, resources = _air_monitor()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def _desc(key):
bound, _ = _bound()
return next(b.desc for b in bound if b.desc.key == key)
def test_resolves_to_air_monitor_registry():
reg, _ = _air_monitor()
assert reg is not None and reg.name == 'air_monitor'
def test_resolves_via_oic_type_too():
"""This board's real dump carries /oic/d's device type
('x.com.st.d.airqualitysensor') as well as the 'ASM' modelNum token --
both paths must agree."""
reg = for_device_by_oic_type(('x.com.st.d.airqualitysensor',))
assert reg is not None and reg.name == 'air_monitor'
def test_no_unbound_hrefs():
reg, resources = _air_monitor()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_air_quality_sensors_read_from_shared_items_decode():
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
which neither air_purifier nor range_hood report."""
state = _state()
assert state['dust'] == 31
assert state['fine_dust'] == 23
assert state['super_fine_dust'] == 18
assert state['odor'] == 1
assert state['clean_level'] == 2
assert state['co2'] == 498
def test_second_value_slot_is_not_exposed():
"""Dust/fine_dust/super_fine_dust each carry a second `value[1]` grade
code (e.g. Dust's ['31', '2']) with no supported-values field anywhere
in the dump to explain its scale -- per the 'still never invent... from
nothing' rule, only the raw reading (value[0]) is bound, not a second
'dust_level' or similar entity for that code."""
state = _state()
assert 'dust_level' not in state
assert 'dust_grade' not in state
def test_humidity_and_battery_present():
state = _state()
assert state['humidity'] == 57
assert state['battery'] == 80
assert state['battery_charging'] is False
def test_air_quality_standard_present():
state = _state()
assert state['air_quality_standard'] == 'AirCleanAssociation'
def test_dnd_read_contract():
state = _state()
assert state['dnd'] is False
assert state['dnd_start'] == dt_time(14, 0)
assert state['dnd_end'] == dt_time(22, 0)
def test_dnd_write_contracts_are_flagged_educated_guesses():
"""Issue #210: no idle-vs-active dump pair exists for /dnd/vs/0 (DND
was never toggled in the one dump this board has), so these write
contracts are educated guesses -- symmetric with the read side's own
string/time-format shape, not invented from nothing, but still
unconfirmed on real hardware. Only the wiring is tested here; whether
the device actually accepts these writes needs a reporter to confirm."""
dnd = _desc('dnd')
path, body = dnd.write_fn('On', {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.value': 'true'}
path, body = dnd.write_fn('Off', {})
assert body == {'x.com.samsung.da.value': 'false'}
start = _desc('dnd_start')
path, body = start.write_fn(dt_time(9, 30), {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.startTime': '09:30:00'}
+80
View File
@@ -872,3 +872,83 @@ def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale():
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['energy_kwh'] == round(1686632 / 1000.0, 2)
# ---------------------------------------------------------------------------
# TP1X_DA-AC-RAC-01001_0000, reporter's dump. Reported "fan and WindFree are
# missing" -- both already work: /wind/strength/vs/0's 0-4 codes match
# _DEVICE_TO_FAN exactly, and /mode/convenient/vs/0's Nano/NanoSleep codes
# already resolve dynamically via _preset_to_ha + the existing "nano"/
# "nanosleep" -> "WindFree"/"WindFree sleep" translation labels -- confirmed
# below by zero unbound hrefs and the climate entity binding with its usual
# FAN_MODE/PRESET_MODE features. The real, previously-uncaptured gap was
# /mode/vs/0's SmartCoolClean_/ProgressSmartClean_ option tokens (the cloud
# custom.airConditionerOdorController capability) -- see
# _odor_controller_active's docstring.
# ---------------------------------------------------------------------------
def _ac_odor_controller():
return _resolve('airconditioner_tp1x_rac_odor_controller')
def test_odor_controller_no_unbound_hrefs():
reg, resources = _ac_odor_controller()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_odor_controller_fan_and_windfree_already_bind():
"""The actually-reported gap wasn't real: fan speed and the WindFree
preset both come from the composite climate entity, which is bound here
with its normal fan/preset feature set -- no code change needed for
either."""
reg, resources = _ac_odor_controller()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
assert resources['/wind/strength/vs/0']['x.com.samsung.da.supportedModes'] == [
'0', '1', '2', '3', '4']
assert resources['/mode/convenient/vs/0']['x.com.samsung.da.supportedModes'] == [
'Off', 'Sleep', 'Quiet', 'Speed', 'Nano', 'NanoSleep']
def test_odor_controller_state_and_progress_present():
reg, resources = _ac_odor_controller()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['odor_controller_active'] is False # SmartCoolClean_Off
assert state['odor_controller_progress'] == 0 # ProgressSmartClean_0
def test_odor_controller_read_from_option_tokens():
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['SmartCoolClean_On']}) is True
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['SmartCoolClean_Off']}) is False
assert airconditioner._odor_controller_active(
{'x.com.samsung.da.options': ['Volume_100']}) is None
assert airconditioner._odor_controller_progress(
{'x.com.samsung.da.options': ['ProgressSmartClean_42']}) == 42
assert airconditioner._odor_controller_progress({}) is None
def test_odor_controller_absent_when_no_smartcoolclean_token():
"""The original issue #17 dump's /mode/vs/0 options carry no
SmartCoolClean_/ProgressSmartClean_ tokens -- neither entity binds."""
reg, resources = _ac()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'odor_controller_active' not in state
assert 'odor_controller_progress' not in state
def test_odor_controller_is_read_only():
"""No command capability is confirmed for SmartCoolClean -- exposed
read-only, same 'don't guess' precedent as CURRENT_LIMIT/ANOMALY_LOAD."""
active = next(e for e in airconditioner.CLIMATE.entities
if e.key == 'odor_controller_active')
progress = next(e for e in airconditioner.CLIMATE.entities
if e.key == 'odor_controller_progress')
assert not hasattr(active, 'write_fn') or active.write_fn is None
assert not hasattr(progress, 'write_fn') or progress.write_fn is None
+14
View File
@@ -151,6 +151,7 @@ class TestForDeviceByOicType:
('oic.d.washer', 'washer'),
('x.com.st.d.stickcleaner', 'vacuum_station'),
('x.com.st.d.steamcloset', 'air_dresser'),
('x.com.st.d.airqualitysensor', 'air_monitor'),
])
def test_known_oic_types_resolve(self, oic_type, expected):
from custom_components.localthings.registry.by_type import for_device_by_oic_type
@@ -434,6 +435,19 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'vacuum_station'
def test_air_monitor_via_asm_token(self):
"""Issue #210: a standalone air-quality sensor puck
(ASM-KR-TP1-22-ACMB1M) reports no oneUiVersion and no
washer/dryer/dishwasher consumer prefix; falls back to the 'ASM'
token in modelNum onto the air_monitor registry."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000',
'ASM-KR-TP1-22-ACMB1M',
)
assert reg is not None
assert reg.name == 'air_monitor'
def test_cooktop_via_legacy_model_description(self):
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
from custom_components.localthings.registry.by_type import for_device_by_model
+27 -7
View File
@@ -15,9 +15,13 @@ def _reg():
def test_kids_lock_vs_value_fn():
"""binary_sensor's 'lock' device class is inverted from a plain on/off
switch: On means open/unlocked (issues #181/#183 -- see
TestKidsLockFallback.test_vs_fallback_is_read_only for why this is a
binary_sensor at all)."""
desc = common.KIDS_LOCK_VS_FALLBACK.entities[0]
assert desc.value_fn('Lock') is True
assert desc.value_fn('Ready') is False
assert desc.value_fn('Lock') is False
assert desc.value_fn('Ready') is True
def test_common_caps_discover_on_dishwasher(dishwasher_resources):
@@ -228,19 +232,35 @@ class TestWmSetinfoFlags:
class TestKidsLockFallback:
def test_generic_read_write(self):
def test_generic_is_read_only(self):
"""Issues #181/#183: the kids-lock resource is read-only on real
hardware (writing the *correct* value still 4.05s, and no
SwitchDesc device_class='lock' is honored by HA -- the switch
platform only accepts 'outlet'/'switch'). KIDS_LOCK_GENERIC and
KIDS_LOCK_VS_FALLBACK now share the same shape (BinarySensorDesc,
device_class='lock', same key) so both surfaces render with the
same polarity: 'On' means open/unlocked."""
assert common.KIDS_LOCK_GENERIC.href == '/kidslock/0'
desc = common.KIDS_LOCK_GENERIC.entities[0]
assert desc.value_fn(True) is True
path, body = desc.write_fn('On', {})
assert path == ['kidslock', '0']
assert body == {'value': True}
assert isinstance(desc, BinarySensorDesc)
assert desc.value_fn(False) is True # value=False -> On=Unlocked
assert desc.value_fn(True) is False # value=True -> Off=Locked
def test_vs_fallback_gated(self):
assert common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True
assert common.KIDS_LOCK_VS_FALLBACK.match_fn(
{}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False
def test_vs_fallback_is_read_only(self):
"""Issues #181/#183: writing this resource 4.05s on real hardware
even with the *correct* value ('Run'), and no dump in the fixture
corpus has ever reported the value this used to write ('Enable')
back -- every one reports 'Ready' or 'Run'. Never a confirmed write
contract, so it's a binary_sensor (no write_fn field at all), not
a switch."""
desc = common.KIDS_LOCK_VS_FALLBACK.entities[0]
assert isinstance(desc, BinarySensorDesc)
class TestRemoteControlFallback:
def test_generic_read(self):
+65
View File
@@ -810,6 +810,27 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_odor_controller():
"""TP1X_DA-AC-RAC-01001_0000 -- reporter's dump: fan (0-4 wind-strength
scale) and WindFree (Nano/NanoSleep convenient-mode codes) already bind
via the composite climate entity, no code change needed there. The
genuine gap was /mode/vs/0's SmartCoolClean_/ProgressSmartClean_ option
tokens (the cloud custom.airConditionerOdorController capability),
previously unbound to any entity -- now odor_controller_active/
odor_controller_progress."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_tp1x_rac_odor_controller')
golden = json.loads(
(GOLDEN / 'airconditioner_tp1x_rac_odor_controller.json').read_text()
)
state_keys = _new_state_keys('airconditioner_tp1x_rac_odor_controller', 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_air_dresser():
"""DA_DF_A51_20_COMMON AirDresser (model DF8600T, issue #162) -- reports
no oneUiVersion; resolved via the '_DF_' modelNum token fallback. Has no
@@ -848,6 +869,50 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20():
)
def test_registry_reproduces_golden_state_keys_for_air_dresser_tp1_21():
"""DA_DF_TP1_21_COMMON AirDresser (model DF3000B, issue #208) -- another
board generation routed via the '_DF_' modelNum token fallback into the
same air_dresser registry as #162's and #157's boards (its dump also
happens to be the first AirDresser one to carry /oic/d's device type,
'x.com.st.d.steamcloset' -- see test_by_type.py's
TestForDeviceByOicType for that mapping's own coverage). The first
AirDresser dump to report /buzzersound/vs/0 (laundry.BUZZER_SOUND,
added for this issue -- #162's and #157's boards don't report it at
all). Binds cleanly with zero unbound hrefs; course codes for this
board's table still aren't identified (same as #162's), so 'cycle'
remains the raw code rather than a table-specific translation."""
from tests.conftest import _load_device
resources = _load_device('air_dresser_tp1_21')
golden = json.loads((GOLDEN / 'air_dresser_tp1_21.json').read_text())
state_keys = _new_state_keys('air_dresser_tp1_21', 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_air_monitor():
"""Samsung Air Monitor Plus (ASM-KR-TP1-22-ACMB1M, issue #210) -- a
standalone battery-powered air-quality sensor puck, the first device
this integration has no controllable-appliance concept for at all (no
/power/*, only /energy/battery/vs/0). Routes via both /oic/d
('x.com.st.d.airqualitysensor') and the 'ASM' modelNum board token.
Reuses air_purifier.AIR_QUALITY's /sensors/vs/0 decode
(common.sensor_item_value) for dust/fine_dust/super_fine_dust/odor/
clean_level, adding a CO2 reading those families don't report. Binds
cleanly with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('air_monitor')
golden = json.loads((GOLDEN / 'air_monitor.json').read_text())
state_keys = _new_state_keys('air_monitor', 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_air_purifier_vtww():
"""A-VTWW-TP2-21-COMMON BESPOKE Cube Air (issue #151) -- reports no
oneUiVersion; resolved via the '-VTWW-' modelNum token fallback into
+28
View File
@@ -62,6 +62,34 @@ class TestCompletionMinutes:
assert desc.rep_fn(rep) is None
class TestFinishTime:
"""issue: remainingTime only has minute resolution, but datetime.now()
always carries fresh seconds/microseconds -- an unrounded finish_time
changed on nearly every poll even when remainingTime hadn't, flooding
the recorder history/logbook with values that looked identical once
the UI rounded them down to the minute for display."""
def test_seconds_and_microseconds_are_zeroed(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
rep = {
'x.com.samsung.da.state': 'Run',
'x.com.samsung.da.remainingTime': '00:29:00',
}
result = desc.rep_fn(rep)
assert result.second == 0
assert result.microsecond == 0
def test_stable_across_polls_within_same_minute(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
rep = {
'x.com.samsung.da.state': 'Run',
'x.com.samsung.da.remainingTime': '00:29:00',
}
first = desc.rep_fn(rep)
second = desc.rep_fn(rep)
assert first == second
class TestDelayFieldFallback:
def test_reads_delay_end_time_when_delay_start_time_absent(self):
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
+10
View File
@@ -99,6 +99,16 @@ def test_fan_write_contract():
)
assert desc.write_fn(('speed', '99'), rep) is None
rep_fallback = {
'x.com.samsung.da.hood.settableMinFanSpeed': '0',
'x.com.samsung.da.hood.settableMaxFanSpeed': '3',
}
assert desc.write_fn(('speed', '2'), rep_fallback) == (
['hood', 'fanspeed', 'vs', '0'],
{'x.com.samsung.da.hood.fanSpeed': '2'},
)
assert desc.write_fn(('speed', '4'), rep_fallback) is None
def test_lamp_write_contract():
lamp, brightness = range_hood.HOOD_LAMP.entities
+6 -3
View File
@@ -56,7 +56,10 @@ def test_energy_saving_and_cooktop_alert_present():
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."""
agree the door is locked, and common.KIDS_LOCK_VS_FALLBACK already reads
that correctly with no further code change needed. Now a read-only
binary_sensor (issues #181/#183 -- the write side was never a confirmed
contract), whose 'lock' device class is inverted from a switch's plain
on/off: False means locked/closed, matching 'Run' here."""
state = _state()
assert state['child_lock'] is True
assert state['child_lock'] is False
+100
View File
@@ -0,0 +1,100 @@
"""Unit tests for LocalThingsSensor's hysteresis gate (finish_time churn)."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from custom_components.localthings.const import CONF_FINISH_TIME_HYSTERESIS_MINUTES
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.sensor import LocalThingsSensor
_FINISH_TIME_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time')
class _FakeConfigEntry:
def __init__(self, options):
self.options = options
class _FakeCoordinator:
"""Just enough surface for LocalThingsEntity/LocalThingsSensor."""
def __init__(self, threshold_minutes):
self.device_serial = 'TEST-SERIAL'
self.config_entry = _FakeConfigEntry({
CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes,
})
self.data: dict = {}
def _sensor(threshold_minutes=3):
coordinator = _FakeCoordinator(threshold_minutes)
bound = BoundEntity(
href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=_FINISH_TIME_DESC,
)
sensor = LocalThingsSensor(coordinator, bound)
return sensor, coordinator
def test_small_change_is_suppressed():
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
coordinator.data = {'finish_time': base + timedelta(minutes=1)}
assert sensor.native_value == base, "a 1-minute wobble should be held back"
def test_change_past_threshold_is_reported():
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
new = base + timedelta(minutes=5)
coordinator.data = {'finish_time': new}
assert sensor.native_value == new
def test_zero_threshold_disables_hysteresis():
sensor, coordinator = _sensor(threshold_minutes=0)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
new = base + timedelta(seconds=1)
coordinator.data = {'finish_time': new}
assert sensor.native_value == new
def test_cycle_end_passes_through_immediately():
"""A cycle ending (finish_time -> None) must never be held back."""
sensor, coordinator = _sensor(threshold_minutes=3)
base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc)
coordinator.data = {'finish_time': base}
assert sensor.native_value == base
coordinator.data = {'finish_time': None}
assert sensor.native_value is None
def test_non_hysteresis_sensor_is_unaffected():
"""A SensorDesc without hysteresis=True reads straight through, unchanged."""
machine_state_desc = next(
e for e in OPERATIONAL_STATE.entities if e.key == 'machine_state'
)
coordinator = _FakeCoordinator(threshold_minutes=3)
bound = BoundEntity(
href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=machine_state_desc,
)
sensor = LocalThingsSensor(coordinator, bound)
coordinator.data = {'machine_state': 'active'}
assert sensor.native_value == 'active'
coordinator.data = {'machine_state': 'idle'}
assert sensor.native_value == 'idle'
+32 -2
View File
@@ -281,9 +281,17 @@ def test_hotwater_lock_fallback_gating_across_status_lock_states():
assert fallback.exists_fn({}, {}) is False
def test_favorite_hotwater_temperature_options_come_from_live_supported_list():
def test_favorite_hotwater_temperature_options_come_from_live_show_list():
"""`supportedList` is only the four fixed presets -- `showList` is a
superset that also carries the one custom value (if any) the
SmartThings app's "temperatures to display" editor let the user add,
and always includes whatever the current default actually is. Reading
`supportedList` meant a unit whose default *was* that custom value
rendered as 'unknown' in HA (issue #196) -- see the ailite fixture's
version of this test below for the case that actually exercises it
(this fixture's showList == supportedList, no custom value set)."""
desc = _desc_coffee('favorite_hotwater_temperature')
assert desc.options_field == 'x.com.samsung.da.favorite.supportedList'
assert desc.options_field == 'x.com.samsung.da.favorite.showList'
def test_coffee_recipe_hrefs_are_ignored_not_guessed():
@@ -361,6 +369,28 @@ def test_ailite_hot_water_temperature_gated_off_without_supported_list():
assert 'hot_water_temperature' not in state
def test_ailite_favorite_hotwater_temperature_options_include_the_custom_value():
"""Issue #196's concrete failure case: the user added a custom 50C value
via the SmartThings app's "temperatures to display" editor, so the
board's defaultTemperature is now '50'. showList contains '50'
([40, 50, 75, 85, 90]) but supportedList does NOT (still the fixed
[40, 75, 85, 90]). Reading options_field='x.com.samsung.da.favorite.supportedList'
-- the old behavior -- would register a select whose options list
doesn't contain the current default, so HA would render the entity as
'unknown'. The descriptor must read from showList so '50' is in
options."""
desc = _desc_ailite('favorite_hotwater_temperature')
assert desc.options_field == 'x.com.samsung.da.favorite.showList'
_, resources = _water_purifier_ailite()
rep = resources['/favorite/hotwater/vs/0']
# defaultTemperature='50' must be a member of the field the descriptor
# actually reads -- this is the precise assertion that would fail under
# the old supportedList behavior.
assert rep['x.com.samsung.da.favorite.defaultTemperature'] == '50'
assert '50' in rep[desc.options_field]
assert '50' not in rep['x.com.samsung.da.favorite.supportedList']
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