Compare commits
107
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b85e20108 | ||
|
|
e5cd212a34 | ||
|
|
11c71a62e8 | ||
|
|
6d73ac8694 | ||
|
|
1cfe126313 | ||
|
|
8d1ecb4f2a | ||
|
|
0c1231794a | ||
|
|
67b28ed10f | ||
|
|
f12f67b2b3 | ||
|
|
2f7170c448 | ||
|
|
b8ef430ed4 | ||
|
|
cd3a47f9a4 | ||
|
|
ac995dcbd4 | ||
|
|
d80bd550fe | ||
|
|
cc16766b9d | ||
|
|
84465aee72 | ||
|
|
711a71876d | ||
|
|
9004125c97 | ||
|
|
0dd8bfb5fc | ||
|
|
9652a14f64 | ||
|
|
bb20eea191 | ||
|
|
0fbac7f14d | ||
|
|
78a545341b | ||
|
|
a4981f40a2 | ||
|
|
17cd78d975 | ||
|
|
863fe32526 | ||
|
|
13d2a38f5d | ||
|
|
d9e7daa203 | ||
|
|
d27bfc6405 | ||
|
|
bee4466b45 | ||
|
|
cef187b7af | ||
|
|
2265c52c77 | ||
|
|
b921bdbb28 | ||
|
|
9d28b088cb | ||
|
|
22b4508f95 | ||
|
|
f45bd6a72c | ||
|
|
1424c2222e | ||
|
|
ef66d1db71 | ||
|
|
16cb01ce5d | ||
|
|
676074b2f8 | ||
|
|
846aefbcba | ||
|
|
895fd87d2c | ||
|
|
248e473abe | ||
|
|
6828d0152b | ||
|
|
d6534c788a | ||
|
|
89fea83c80 | ||
|
|
d2787327fd | ||
|
|
1f7bdc9ac6 | ||
|
|
61a953d39a | ||
|
|
95ce358d55 | ||
|
|
5e2c23a62d | ||
|
|
b6f0bc22cb | ||
|
|
07c20e82aa | ||
|
|
efea9e9888 | ||
|
|
94798b5d9b | ||
|
|
f5e99d71e3 | ||
|
|
8ed3d1467f | ||
|
|
68dee12eb4 | ||
|
|
082a1b3cd4 | ||
|
|
00db7890f5 | ||
|
|
55765fdf9a | ||
|
|
3675d8087b | ||
|
|
4f3bdde6e5 | ||
|
|
d65735ac47 | ||
|
|
867f4b0ae8 | ||
|
|
9da775a8de | ||
|
|
6ee60beae9 | ||
|
|
fffe923afc | ||
|
|
a6d818dfc0 | ||
|
|
cec3dd4a68 | ||
|
|
5dbe990c1d | ||
|
|
9228da1d9c | ||
|
|
ce60b6967b | ||
|
|
a7dc1db8ff | ||
|
|
8551974719 | ||
|
|
e2dcc75ed5 | ||
|
|
c203bd42c5 | ||
|
|
42fd9c2aa4 | ||
|
|
df5b704f3e | ||
|
|
26c9168fb7 | ||
|
|
edf77309ba | ||
|
|
d24c94e303 | ||
|
|
f07ae4020e | ||
|
|
dd953b8150 | ||
|
|
b820a96277 | ||
|
|
eed04faaed | ||
|
|
960eca2d6a | ||
|
|
789aaf9849 | ||
|
|
e3e7f4f43c | ||
|
|
a3cc918343 | ||
|
|
69f93be4dc | ||
|
|
50bb893407 | ||
|
|
77c2d7831e | ||
|
|
252306838d | ||
|
|
455ed5b27c | ||
|
|
26e4c9c167 | ||
|
|
2d772f16a7 | ||
|
|
30bd0fd2af | ||
|
|
4e47a1c3d9 | ||
|
|
bea5206c06 | ||
|
|
75e985d392 | ||
|
|
93f45cb356 | ||
|
|
15279066b5 | ||
|
|
69844d9829 | ||
|
|
71f2101d11 | ||
|
|
ba5a3b529d | ||
|
|
f8849df8a6 |
@@ -95,6 +95,73 @@ Each device has its own **Configure** option in Settings > Devices & Services, u
|
||||
|
||||
- **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.
|
||||
- **Remember modes the device reports but doesn't advertise** — some firmware reports a current mode it never lists as supported. Issue #327's air conditioner sits in `Quiet` while offering only `Off/Sleep/Speed/Nano/NanoSleep`, so Home Assistant showed the preset as active but refused to select it. LocalThings remembers any such mode it sees and keeps offering it afterwards, stored on the config entry so it survives a restart — the device only names the mode while it is in it, and you shouldn't have to reach for the physical remote after every reboot. Defaults to on. Turning it off offers only what the device advertises, without discarding what was already learned.
|
||||
|
||||
The same **Configure** menu has a **Forget remembered modes** step, which clears what has been learned for that device. Use it if a mode was learned that turns out not to be selectable — otherwise, by design, it stays forever.
|
||||
|
||||
---
|
||||
|
||||
## Part 5: Reading and writing resources directly
|
||||
|
||||
Two HA actions, `localthings.write_resource` and `localthings.read_resource`, talk to a device's OCF resources directly instead of through this integration's entity model. They exist for two overlapping jobs: pinning down a device-specific write contract (the reverse-engineering work `docs/investigations/` and the provenance comments throughout `registry/capabilities/` are all about), and driving a resource this integration doesn't model as an entity yet, without waiting on a release.
|
||||
|
||||
Both take a `device_id` (a device picker filtered to this integration) and resolve to exactly one appliance — a target that expands to more than one LocalThings device is rejected rather than silently fanned out across all of them. `href` is always canonical (e.g. `/mode/vs/0`); if the device you targeted is a subdevice — an oven's second cavity, an AC's second indoor unit — it's translated to the real on-the-wire href for you (`/mode/vs/1`, say), and the response reports both forms so there's no ambiguity about what was actually sent.
|
||||
|
||||
`write_resource` exists because a single write, one at a time, isn't enough to probe some boards. Issue #300's Samsung wall oven answers `2.04 Changed` to a settings write while idle and then silently reverts it — the write only sticks once a cycle is already running. Finding what actually triggers a cycle needs an *ordered sequence* of writes to different resources, with real delays between them, and a way to check afterward whether anything actually held:
|
||||
|
||||
```yaml
|
||||
action: localthings.write_resource
|
||||
data:
|
||||
device_id: abc123...
|
||||
writes:
|
||||
- href: /mode/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.modes: ["Bake"]
|
||||
settle: 5
|
||||
- href: /operational/state/vs/0
|
||||
payload:
|
||||
x.com.samsung.da.state: "Run"
|
||||
verify_after: 30
|
||||
```
|
||||
|
||||
Mind the shapes: what you write is sent verbatim, so the field names and types have to be the ones that resource actually uses. `/mode/vs/0` takes `modes` as an *array* on this board; a bare string, or the singular `mode`, is a different field the device will simply ignore. `read_resource` (below) with no `href` is the quickest way to see the real shape of everything before you write to any of it.
|
||||
|
||||
Each write in `writes` (1-10 of them) needs `href` and a non-empty `payload`, sent verbatim as a partial-rep POST — this bypasses the remote-control-off block and every `write_fn`/`validate_fn` a normal entity write goes through, and sends exactly the fields you give it, so it can misconfigure your appliance if you get it wrong. `settle` (0-30s, default 0) is how long to wait *after* that write before starting the next one.
|
||||
|
||||
By default the whole sequence holds the device session from the first write to the last, settle delays included, so a routine poll or another entity's write can't land between two steps and blur which write the appliance was reacting to. The cost is that nothing else on that device updates until the sequence ends — up to 10 × 30s if you ask for the maximum of both. Set `hold_session_lock: false` to take the session per write and release it across the waits instead, trading that certainty for a device whose entities keep updating throughout.
|
||||
|
||||
The response has one `results` entry per write, with `before`/`after` reps and a `changed` flag (every key/value in `payload` present and equal in the immediate readback):
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "abc123...",
|
||||
"results": [
|
||||
{"href": "/mode/vs/0", "actual_href": "/mode/vs/0", "code": "2.04", "raw_code": 68,
|
||||
"accepted": true, "before": {...}, "after": {...}, "changed": true},
|
||||
...
|
||||
],
|
||||
"verified": {
|
||||
"/mode/vs/0": {"code": "2.05", "raw_code": 69, "rep": {...}, "held": false}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`verify_after` (0-60s, default 0, omit to skip) is what actually answers the "did it stick" question: after the sequence finishes, it waits that long and then re-reads every distinct href the sequence touched, reporting the result under `verified`, keyed by canonical href. `changed` tells you the write was accepted and reflected immediately; `held` tells you whether it was still there N seconds later, or whether the board quietly put it back — issue #300's exact symptom. Where an href was written more than once in a sequence, `held` compares against the *last* payload sent to it. A `held` of `null` means the re-read itself didn't come back (check `code` next to it) — unknown, deliberately not reported as a revert.
|
||||
|
||||
If the session drops partway through a sequence, the action raises rather than returning, and the error names how many writes completed and which — the appliance is left holding a partial sequence, so knowing where it stopped is the difference between a usable result and starting over blind.
|
||||
|
||||
`read_resource` is the read half, and it's deliberately not just a cache lookup:
|
||||
|
||||
```yaml
|
||||
action: localthings.read_resource
|
||||
data:
|
||||
device_id: abc123...
|
||||
href: /mode/vs/0
|
||||
```
|
||||
|
||||
returning `{"href", "actual_href", "code", "raw_code", "rep"}` off a **live GET straight from the device**, not the cache — which can be up to a poll interval stale, exactly the staleness that would make `held` above meaningless. A sixth key, `body`, appears only when the response isn't a Property map: a Collection (`/device/0`, and the `x.com.samsung.devcol` siblings some boards expose) answers a CBOR list, which `rep` can't carry, and which would otherwise read as an accepted-but-empty resource. Omit `href` and you get `{"resources": {href: rep, ...}}`, the cached snapshot of everything this integration currently tracks on that device, with no GET at all — useful for seeing what's there before you start writing to it, without hammering the appliance.
|
||||
|
||||
The **Debug write** panel under a device's Configure menu (Part 4) is the friendlier single-write path over this same machinery — pick an href, type a payload, see the result — for when you don't need a sequence.
|
||||
|
||||
---
|
||||
|
||||
@@ -135,6 +202,8 @@ custom_components/localthings/
|
||||
coordinator.py Polling + push update coordination, stale-state fallback, write dispatch
|
||||
observe.py CoAP OBSERVE (push-mode) support layered on the coordinator
|
||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||
services.py write_resource/read_resource actions (device resolution, href translation)
|
||||
services.yaml Selectors/descriptions for the two services above
|
||||
const.py Domain, config keys, probe ports
|
||||
entity.py Base entity wiring capability registry -> HA entity
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py / water_heater.py
|
||||
@@ -172,6 +241,11 @@ email, access tokens, device IDs, MAC addresses, serial numbers) before it's gen
|
||||
directly to a new issue using the linked device-support template. This is the fastest way to help add or expand
|
||||
support for hardware the maintainers don't have.
|
||||
|
||||
When a diagnostics dump alone isn't enough to pin down how a resource actually behaves — whether a write sticks,
|
||||
what order things need to happen in, whether the device reverts a change on its own — the `localthings.write_resource`
|
||||
and `localthings.read_resource` actions from Part 5 are the tool for probing it directly and reporting back what
|
||||
you found.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new appliance type
|
||||
|
||||
@@ -10,14 +10,23 @@ from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.identity import resolve_serial
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
# Services are process-global, registered once here rather than per
|
||||
# config entry (issue #300) -- see services.async_setup_services.
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
def _serial_from_unique_id(entry: ConfigEntry) -> str:
|
||||
"""The device identity a pre-v2 entry was created with.
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ temperature and wind resources alike.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -57,9 +58,6 @@ from .registry.capabilities.airconditioner import (
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_POWER_VS as POWER_VS_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
|
||||
)
|
||||
@@ -79,7 +77,12 @@ from .registry.capabilities.airconditioner import (
|
||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||
)
|
||||
from .registry.capabilities.airconditioner import (
|
||||
_temperature_step,
|
||||
extend_option_code_bit,
|
||||
has_extend_option_code,
|
||||
has_option_code,
|
||||
is_legacy_board,
|
||||
option_code_bit,
|
||||
)
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
from .registry.entities import ClimateDesc
|
||||
@@ -131,6 +134,10 @@ PRESET_AI_COMFORT = "ai_comfort"
|
||||
# not modeled as a preset either: nothing confirms it's user-selectable.
|
||||
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
|
||||
|
||||
# Seconds to let a legacy board settle into Cool before the WindFree token is
|
||||
# written after it -- see _legacy_preset_needs_cool for the measurement.
|
||||
_NANO_AFTER_MODE_DELAY = 3
|
||||
|
||||
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
|
||||
# a clean match exists so they auto-localize; "turbo" is custom (translated).
|
||||
_DEVICE_TO_FAN: dict[str, str] = {
|
||||
@@ -267,17 +274,125 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
|
||||
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed")
|
||||
|
||||
# Good Sleep occupies the same Comode_ slot as the presets above, so a unit
|
||||
# running it reports Comode_Sleep -- or Comode_NanoSleep, which the board
|
||||
# will produce by itself when nano wind is asked for while the timer runs.
|
||||
# Neither is in the list above, and a preset_mode outside preset_modes is
|
||||
# not a state HA allows, so they are added for boards that have the Sleep_
|
||||
# token these codes come with.
|
||||
_LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep")
|
||||
|
||||
# HVAC modes _legacy_preset_codes() has a rule for. Anything else is a mode
|
||||
# this transcription has never seen, which is the same "cannot judge" case as
|
||||
# a board that publishes no capability map -- and gets the same fallback.
|
||||
_LEGACY_KNOWN_HVAC = frozenset(
|
||||
{"Cool", "Heat", "HeatClean", "Dry", "Fan", "Wind", "Auto", _AI_COMFORT_MODE}
|
||||
)
|
||||
|
||||
# Which comfort modes a legacy board offers in which HVAC mode, and which of
|
||||
# them it has at all. Both come from the appliance rather than from a table
|
||||
# per model: the unit publishes its capabilities as two bit maps in
|
||||
# /mode/vs/0's options (OptionCode, ExtendOptionCode), and its own app gates
|
||||
# each item on a bit plus the current mode. _legacy_preset_codes() below is
|
||||
# that logic, transcribed from the app's updateOptionsList() so the two can be
|
||||
# compared line by line, with the bit each rule reads named in the comment.
|
||||
#
|
||||
# Confirmed against an ARTIK051_KRAC_18K whose owner read the same lists off
|
||||
# the remote and the app: WindFree in Cool/Dry/Fan and (being an 18K model)
|
||||
# Auto but never Heat, and Fast Turbo and Comfort in Heat because oc[12] is
|
||||
# set. d'light Cool is gated the same way on oc[2], which is zero here -- and
|
||||
# the appliance refuses the token locally too.
|
||||
#
|
||||
# Single User is deliberately not modelled, though the app does gate it on
|
||||
# oc[3] / oc[11]: it has no token of its own. The app's own Single User
|
||||
# command sends `Comode_Smart` -- the Smart Saver token -- with a hardcoded
|
||||
# 24 desired alongside it, so there is nothing to write that would be
|
||||
# distinguishable from the Smart preset below, and no name to give it that
|
||||
# the appliance would recognise.
|
||||
|
||||
def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]:
|
||||
"""Comfort-mode codes this unit offers in this HVAC mode.
|
||||
|
||||
Derived only for boards that publish *both* capability maps. One map on
|
||||
its own is not enough: every eoc-gated rule would then read None, and
|
||||
None means "this board does not publish the map", never "the feature is
|
||||
absent". The FAC/CAC boards on record carry only the older map, with
|
||||
values small enough that RAC bit positions all read as zeros, so
|
||||
requiring both also keeps these rules inside the family they were
|
||||
documented for.
|
||||
|
||||
Within the derived path, a bit that cannot be read (a malformed or
|
||||
over-wide token) is treated as permission rather than denial for the
|
||||
codes the unconditional list already carried -- losing a working preset
|
||||
to a parsing failure is worse than offering one too many. Codes that were
|
||||
never in that list (d'light) still need their bit to be explicitly set.
|
||||
"""
|
||||
rep = self._rep(MODE_HREF)
|
||||
if (
|
||||
not has_option_code(rep)
|
||||
or not has_extend_option_code(rep)
|
||||
or hvac not in self._LEGACY_KNOWN_HVAC
|
||||
):
|
||||
return list(self._LEGACY_PRESET_CODES)
|
||||
|
||||
cool = hvac in ("Cool", _AI_COMFORT_MODE)
|
||||
heat = hvac in ("Heat", "HeatClean")
|
||||
codes = ["Off"]
|
||||
# WindFree: shown on eoc[31]; disabled in Heat, in AIComfort, and in Auto
|
||||
# unless this is an 18K model (eoc[30]), where the app switches to Cool for
|
||||
# it instead -- which is what _legacy_preset_needs_cool does.
|
||||
nano_mode_ok = not heat and hvac != _AI_COMFORT_MODE
|
||||
if hvac == "Auto":
|
||||
nano_mode_ok = extend_option_code_bit(rep, 30) is not False
|
||||
if extend_option_code_bit(rep, 31) is not False and nano_mode_ok:
|
||||
codes.append("Nano")
|
||||
if cool or (heat and option_code_bit(rep, 12) is not False): # Fast Turbo
|
||||
codes.append("Speed")
|
||||
if cool:
|
||||
codes.append("2Step")
|
||||
if cool and option_code_bit(rep, 2): # d'light Cool -- needs the bit set
|
||||
codes.append("DlightCool")
|
||||
# Quiet reads oc[10] with no mode condition in the app, but the owner of
|
||||
# the unit above sees it in Cool and Heat only, on the remote as well as
|
||||
# in the app -- the observation wins over the reading.
|
||||
if option_code_bit(rep, 10) is not False and (cool or heat):
|
||||
codes.append("Quiet")
|
||||
if cool or (heat and option_code_bit(rep, 12) is not False): # Comfort
|
||||
codes.append("Comfort")
|
||||
# Smart Saver has no bit of its own and the app hides it from every single
|
||||
# RAC outright (showSaverOption = false), yet the appliance accepts it and
|
||||
# behaves as Samsung documents -- so absence from the app is not absence
|
||||
# from the hardware. Cool-only, per that documentation.
|
||||
if hvac == "Cool":
|
||||
codes.append("Smart")
|
||||
# Good Sleep, which shares this slot: the app enables it in Cool, Heat and
|
||||
# AIComfort only. Both codes, because the board turns Sleep into NanoSleep
|
||||
# by itself when WindFree is running.
|
||||
if (cool or heat) and any(
|
||||
isinstance(option, str) and option.startswith("Sleep_") for option in options
|
||||
):
|
||||
codes += self._LEGACY_SLEEP_PRESET_CODES
|
||||
return codes
|
||||
|
||||
def _legacy_convenient(self) -> dict:
|
||||
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
|
||||
/mode/vs/0's options, for boards that have no convenient resource."""
|
||||
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or []
|
||||
for option in options:
|
||||
if isinstance(option, str) and option.startswith("Comode_"):
|
||||
return {
|
||||
_MODES_FIELD: [option.split("_", 1)[1]],
|
||||
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES),
|
||||
}
|
||||
return {}
|
||||
active = next(
|
||||
(o.split("_", 1)[1] for o in options if isinstance(o, str) and o.startswith("Comode_")),
|
||||
None,
|
||||
)
|
||||
if active is None:
|
||||
return {}
|
||||
|
||||
codes = self._legacy_preset_codes(_first(self._rep(MODE_HREF).get(_MODES_FIELD)), options)
|
||||
# Whatever the unit is actually running has to be listed whether the
|
||||
# rules expect it there or not -- a preset_mode outside preset_modes is
|
||||
# not a state HA allows, and the appliance has the last word on what it
|
||||
# is doing (a remote can put it in a mode these rules would not offer).
|
||||
if active not in codes:
|
||||
codes.append(active)
|
||||
return {_MODES_FIELD: [active], _SUPPORTED_FIELD: codes}
|
||||
|
||||
def _legacy_airflow(self) -> dict:
|
||||
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel
|
||||
@@ -331,7 +446,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
return bool(self._rep(POWER_HREF).get("value"))
|
||||
|
||||
def _supported(self, href: str) -> list[str]:
|
||||
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
"""The resource's own supportedModes, plus any code this unit has
|
||||
been seen in but never advertised (issue #327, learned.py).
|
||||
|
||||
Both the option lists (preset_modes, fan_modes, ...) and the write
|
||||
paths resolve codes through here, so a learned code is selectable
|
||||
and writable by virtue of appearing in one list.
|
||||
"""
|
||||
supported = list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
learned = self.coordinator.learned_modes(self._bound.subdevice.to_actual(href))
|
||||
return supported + [code for code in learned if code not in supported]
|
||||
|
||||
def _warn_unmapped(self, href: str, code: str) -> None:
|
||||
"""Log once per (href, code) when a device-reported mode has no
|
||||
@@ -432,12 +556,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def target_temperature_step(self) -> float:
|
||||
return (
|
||||
_num(self._rep(TEMP_CONTROL_HREF).get("increment"))
|
||||
or _num(self._rep(TEMP_CONTROL_HREF).get("x.com.samsung.da.increment"))
|
||||
or _num(self._temps_vs().get("x.com.samsung.da.increment"))
|
||||
or 1.0
|
||||
)
|
||||
# Shared with the write path (airconditioner._climate_write) so a
|
||||
# step read here always matches the step a write is quantized to --
|
||||
# self._resources is this entity's own subdevice's canonical view
|
||||
# (issue #177), the same shape _temperature_step expects.
|
||||
return _temperature_step(self._resources) or 1.0
|
||||
|
||||
# -- hvac mode ----------------------------------------------------------
|
||||
|
||||
@@ -622,6 +745,38 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
if self._rep(WIND_OSCILLATION_HREF):
|
||||
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
|
||||
|
||||
async def _legacy_preset_needs_cool(self, code: str) -> None:
|
||||
"""Switch a legacy board to Cool first when the preset needs it.
|
||||
|
||||
WindFree does not exist in Auto: `["Comode_Nano"]` written while the unit
|
||||
is in Auto is answered 2.04 Changed and dropped (measured, still
|
||||
Comode_Off at +8s and +45s), and putting `modes: Cool` in the *same* POST
|
||||
does not help -- the mode moves and the token is still dropped, so the
|
||||
board judges the option against the mode it was in. Sent as its own write
|
||||
first, it holds. The appliance's own app pairs `modes: Cool` with its nano
|
||||
command for the same reason.
|
||||
|
||||
Auto only. The app's builder also covers AIComfort, but its
|
||||
`updateOptionsList()` disables the WindFree button there outright, so that
|
||||
pairing can never fire -- and `_legacy_preset_codes()` likewise does not
|
||||
offer `Nano` in AIComfort, which would leave such a branch unreachable.
|
||||
|
||||
The pause is measured, not padding: back to back (same session, no gap at
|
||||
all) the token was dropped again, two seconds apart it held. Three is that
|
||||
with a little margin, and it only ever runs for this one preset in this
|
||||
one HVAC mode.
|
||||
"""
|
||||
if not self._legacy_preset() or code != "Nano":
|
||||
return
|
||||
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) != "Auto":
|
||||
return
|
||||
# Same resolver the rest of the platform uses -- the device code for an HA
|
||||
# mode is read off the unit's own supportedModes rather than assumed.
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, ("mode", self._device_code_for_hvac(HVACMode.COOL))
|
||||
)
|
||||
await asyncio.sleep(_NANO_AFTER_MODE_DELAY)
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
if preset_mode == PRESET_AI_COMFORT:
|
||||
# Writes the primary mode resource, not the convenient one --
|
||||
@@ -633,6 +788,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
|
||||
for code in self._supported(CONVENIENT_HREF):
|
||||
if _preset_to_ha(code) == preset_mode:
|
||||
await self._legacy_preset_needs_cool(code)
|
||||
kind = "preset_legacy" if self._legacy_preset() else "preset"
|
||||
await self.coordinator.async_send_command(self._bound, (kind, code))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Cloud "Download" programs on a laundry device (issue #342).
|
||||
|
||||
Some course tables carry a course whose recipe isn't fixed in firmware --
|
||||
selecting it runs whichever program was last pushed down from the
|
||||
SmartThings cloud ("Download" / "Downloaded" in the course catalog). Three
|
||||
tokens on ``/course/vs/0``'s ``x.com.samsung.da.options`` array drive it:
|
||||
|
||||
``CloudExtraCourse_<slot><slot>...`` the device's own list of downloaded
|
||||
program slots, one byte each -- the cloud counterpart of
|
||||
``EditCourseList_`` for local courses.
|
||||
``CloudCourse_<blob>`` the persisted default program.
|
||||
``OneTimeCloudCourse_<blob>`` a this-run-only override.
|
||||
|
||||
A ``<blob>`` is an opaque fixed-width payload whose byte 2 is the slot id
|
||||
it belongs to. Confirmed on two independent DA_WM_TP1_21_COMMON washers:
|
||||
the issue #342 reporter's, whose ``CloudExtraCourse_0A5C286B2D0C55301A``
|
||||
enumerates nine slots matching byte 2 of all nine of its programs exactly,
|
||||
and the WA55A7700AV dump in ``tests/fixtures``, whose two-slot
|
||||
``CloudExtraCourse_5958`` likewise matches its ``CloudCourse`` blob's byte
|
||||
2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one
|
||||
reason nothing here ever synthesizes one.
|
||||
|
||||
``CloudExtraCourse_`` does not mean the same thing on every family, so
|
||||
nothing keys off it directly -- see ``cloud_slots``, which is what the rest
|
||||
of this module and its callers gate on. Even then, a device can advertise a
|
||||
slot whose payload has never been observed, so ``cloud_slots`` answers
|
||||
"which exist" while the store answers "which are usable"; the two are
|
||||
deliberately allowed to disagree.
|
||||
|
||||
What this module does and deliberately does not do
|
||||
--------------------------------------------------
|
||||
The device advertises *which* slots exist but never what any of them is
|
||||
called, and never the full blob for a slot other than the one currently
|
||||
loaded. A blob is only observable while the device happens to be sitting on
|
||||
that program, so the full payload is *learned by observation* and persisted
|
||||
(same rationale as learned.py's mode store), and the human-readable name is
|
||||
supplied by the user in the options flow. Nothing is hardcoded: no catalog
|
||||
of program ids, no table of blobs, no assumed Download course code. A
|
||||
hardcoded catalog was considered and rejected -- a blob is cloud-assigned
|
||||
per account/region, so one user's captured payload is not evidence about
|
||||
anyone else's device.
|
||||
|
||||
Blobs are replayed byte-for-byte, exactly as captured, and never
|
||||
decomposed or rebuilt. (Bytes 5/7/9 of the reporter's blobs do decode
|
||||
cleanly to that program's temperature/rinse/spin, but the same offsets
|
||||
produce nonsense against the WA55A7700AV blob, so that decode is recorded
|
||||
in docs/investigations/download-cycle.md rather than shipped.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from .const import CONF_CLOUD_COURSES
|
||||
from .registry.capabilities.common import hex_pairs, option_value
|
||||
|
||||
COURSE_HREF = "/course/vs/0"
|
||||
|
||||
EXTRA_PREFIX = "CloudExtraCourse"
|
||||
DEFAULT_PREFIX = "CloudCourse"
|
||||
ONESHOT_PREFIX = "OneTimeCloudCourse"
|
||||
COURSE_PREFIX = "Course"
|
||||
|
||||
# Synthetic, integration-owned field the coordinator merges onto
|
||||
# /course/vs/0's rep so the registry's exists_fn/rep_fn/options/write_fn all
|
||||
# reach this store through their existing signatures -- rep_fn in particular
|
||||
# receives only its own href's rep, never the resource snapshot, so a
|
||||
# sibling-resource lookup isn't available to it. Namespaced away from
|
||||
# Samsung's own 'x.com.samsung.da.' fields so it can never collide with one,
|
||||
# and merged at read time only: it is never written to the state cache, never
|
||||
# sent to the device, and never part of a diagnostics dump.
|
||||
FIELD = "x.localthings.cloudCourses"
|
||||
|
||||
# Raw-value namespace for a cloud program in the cycle select. A slot id is
|
||||
# itself two hex chars, exactly like a local course code, so the two would be
|
||||
# indistinguishable (and could collide outright) as bare select values.
|
||||
RAW_PREFIX = "cloud:"
|
||||
|
||||
# A blob whose first two bytes are FFFF means "no program loaded" rather than
|
||||
# naming one -- WA55A7700AV reports
|
||||
# OneTimeCloudCourse_FFFF010049004D004A804C0037F0AC00 while sitting on a
|
||||
# perfectly ordinary local course, and its byte 2 (01) is not one of the slots
|
||||
# its own CloudExtraCourse_ advertises.
|
||||
_SENTINEL_PREFIX = "FFFF"
|
||||
|
||||
# Distinct from None, which is a real observation that carried no payload.
|
||||
# Only "never observed" suppresses a candidate; absent-then-loaded is a
|
||||
# genuine transition and should count.
|
||||
_UNOBSERVED = object()
|
||||
|
||||
# Byte offset within a blob that carries its slot id.
|
||||
_SLOT_BYTE = 2
|
||||
_MIN_BLOB_BYTES = 4
|
||||
|
||||
|
||||
def _hex_bytes(blob):
|
||||
if not isinstance(blob, str) or len(blob) % 2 or len(blob) < _MIN_BLOB_BYTES * 2:
|
||||
return []
|
||||
try:
|
||||
int(blob, 16)
|
||||
except ValueError:
|
||||
return []
|
||||
return hex_pairs(blob.upper())
|
||||
|
||||
|
||||
def is_loaded(blob) -> bool:
|
||||
"""True when `blob` names an actual program rather than 'none'."""
|
||||
return _slot_and_loaded(blob)[1]
|
||||
|
||||
|
||||
def slot_of(blob) -> str | None:
|
||||
"""The slot id `blob` belongs to, or None if it names no program."""
|
||||
slot, loaded = _slot_and_loaded(blob)
|
||||
return slot if loaded else None
|
||||
|
||||
|
||||
def _slot_and_loaded(blob) -> tuple[str | None, bool]:
|
||||
"""Both answers off one parse -- the public pair above needs the same
|
||||
byte split, and observe() asks for both about the same payload."""
|
||||
parts = _hex_bytes(blob)
|
||||
if not parts:
|
||||
return None, False
|
||||
if "".join(parts[:2]) == _SENTINEL_PREFIX:
|
||||
return None, False
|
||||
return parts[_SLOT_BYTE], True
|
||||
|
||||
|
||||
def advertised_slots(rep) -> list[str]:
|
||||
"""Slot ids this device says it has downloaded programs in, from its own
|
||||
CloudExtraCourse_ token. The authority on *which* programs exist -- this
|
||||
is never inferred from what has been learned so far, so "3 of 9
|
||||
discovered" is answerable."""
|
||||
raw = option_value(rep.get("x.com.samsung.da.options"), EXTRA_PREFIX)
|
||||
if not isinstance(raw, str) or len(raw) % 2:
|
||||
return []
|
||||
# Preserve the device's own order (first-seen wins) while dropping any
|
||||
# repeat, so the flow lists slots the way the appliance does.
|
||||
return list(dict.fromkeys(hex_pairs(raw.upper())))
|
||||
|
||||
|
||||
def cloud_slots(rep, courses) -> list[str]:
|
||||
"""Advertised slots that are not already selectable courses.
|
||||
|
||||
`CloudExtraCourse_` does not mean the same thing on every family. On the
|
||||
washers its bytes are opaque payload slots sharing nothing with the
|
||||
device's own course list, and selecting one needs the full payload. On
|
||||
the DW5000C dishwasher all four of its bytes *are* course codes in that
|
||||
device's own list (8E/8D/8F/02 -- Plastic, Pots and pans, Baby Care, and
|
||||
one untranslated), so there it is tagging which of its ordinary courses
|
||||
came from the cloud. Those are already selectable as plain `Course_`
|
||||
writes and need nothing from this module.
|
||||
|
||||
Subtracting the course list tells the two apart without having to guess
|
||||
the family: what remains is slots that cannot be selected any other way,
|
||||
which is exactly the set this module exists for.
|
||||
"""
|
||||
known = {c.upper() for c in courses or ()}
|
||||
return [slot for slot in advertised_slots(rep) if slot not in known]
|
||||
|
||||
|
||||
def supports_cloud_courses(rep, courses) -> bool:
|
||||
"""True for a device with downloaded programs it cannot otherwise run."""
|
||||
return bool(cloud_slots(rep, courses))
|
||||
|
||||
|
||||
def loaded_slot(rep) -> str | None:
|
||||
"""The slot whose payload the appliance currently holds -- the one-time
|
||||
override when one is set, else the saved default.
|
||||
|
||||
Deliberately not gated on the course being Download: the guided setup
|
||||
flow watches this before the Download course has been confirmed, and
|
||||
during that walk a change here *is* the signal that the user selected a
|
||||
different program. A stale token can't produce a false positive because
|
||||
the flow waits for a change from its own baseline, not for a value.
|
||||
"""
|
||||
options = rep.get("x.com.samsung.da.options")
|
||||
return slot_of(option_value(options, ONESHOT_PREFIX)) or slot_of(
|
||||
option_value(options, DEFAULT_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]:
|
||||
"""Restore the persisted record, dropping anything not the shape this
|
||||
module writes -- it round-trips through the config entry as plain JSON
|
||||
and a hand-edited .storage file must not be able to crash setup (same
|
||||
posture as learned._coerce)."""
|
||||
if not isinstance(stored, dict):
|
||||
return None, {}
|
||||
download = stored.get("download_course")
|
||||
if not isinstance(download, str) or not download:
|
||||
download = None
|
||||
slots: dict[str, dict[str, str]] = {}
|
||||
raw_slots = stored.get("slots")
|
||||
if isinstance(raw_slots, dict):
|
||||
for slot, record in raw_slots.items():
|
||||
if not isinstance(slot, str) or not isinstance(record, dict):
|
||||
continue
|
||||
blob = record.get("blob")
|
||||
if not is_loaded(blob) or slot_of(blob) != slot.upper():
|
||||
continue
|
||||
name = record.get("name")
|
||||
slots[slot.upper()] = {
|
||||
"blob": blob.upper(),
|
||||
"name": name if isinstance(name, str) and name.strip() else "",
|
||||
}
|
||||
return download, slots
|
||||
|
||||
|
||||
def persist(hass, entry, record: dict) -> None:
|
||||
"""Write `record` onto the entry. Runs on the event loop, which
|
||||
async_update_entry requires."""
|
||||
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_CLOUD_COURSES: record})
|
||||
|
||||
|
||||
class CloudCourses:
|
||||
"""Per-device store of discovered cloud programs.
|
||||
|
||||
Mutated from whichever thread applied the update (the DTLS reader for an
|
||||
OBSERVE notify, an executor thread for a poll -- see ObserveManager.apply),
|
||||
so every access takes the lock; persistence is the caller's job, on the
|
||||
event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, stored_record=None) -> None:
|
||||
self._lock = threading.Lock()
|
||||
download, slots = _coerce(stored_record)
|
||||
self._download_course = download
|
||||
self._slots = slots
|
||||
# Course codes seen at the moment a one-time override was *loaded* --
|
||||
# candidates for "which course means Download on this board", pending
|
||||
# user confirmation (see download_candidates).
|
||||
self._candidates: dict[str, int] = {}
|
||||
# Last one-time payload seen, so a load can be told from a poll that
|
||||
# merely re-reports one. _UNOBSERVED until the first rep arrives.
|
||||
self._last_oneshot: object = _UNOBSERVED
|
||||
|
||||
# -- learning ---------------------------------------------------------
|
||||
|
||||
def observe(self, rep: dict) -> bool:
|
||||
"""Learn from one applied /course/vs/0 rep; True if anything changed.
|
||||
|
||||
Two facts are learnable here. A blob is recorded against the slot its
|
||||
own byte 2 names, so a program only has to be sitting loaded once --
|
||||
on either token -- to be replayable forever after.
|
||||
|
||||
The Download course code is only ever taken as a *candidate*, and
|
||||
only at the moment the one-time payload actually *changes* to a
|
||||
loaded value. That instant is the one the device is known to accept a
|
||||
program on, so the course selected then is real evidence. Counting
|
||||
every poll instead would rank by dwell time: tokens in this array are
|
||||
replaced by prefix and never evicted, so a stale OneTimeCloudCourse_
|
||||
outlives its run and sits there through however many polls the
|
||||
appliance spends on some ordinary course afterwards -- which is
|
||||
exactly the course that would then be suggested. A candidate is still
|
||||
never applied without confirmation in the options flow, but a
|
||||
confident wrong suggestion is most of the way to a wrong write, and a
|
||||
wrong write here starts a real wash cycle.
|
||||
"""
|
||||
options = rep.get("x.com.samsung.da.options")
|
||||
if not options:
|
||||
return False
|
||||
known_slots = advertised_slots(rep)
|
||||
oneshot = option_value(options, ONESHOT_PREFIX)
|
||||
with self._lock:
|
||||
# Compared once, at the end, against where this pass started --
|
||||
# not set per assignment. The two tokens can name the same slot
|
||||
# with different payloads (a downloaded program with its settings
|
||||
# tweaked for one run is exactly that shape), and a per-assignment
|
||||
# flag would then report a change on every single poll forever:
|
||||
# each pass writes the default's payload and then the one-shot's
|
||||
# over it, so neither is ever "already stored". Every one of those
|
||||
# reports rewrites the config entry, which on the SD-card installs
|
||||
# this integration runs on is the one cost here that really bites.
|
||||
# The end state is stable (the one-shot is written last and wins),
|
||||
# so comparing start to end settles after the first pass.
|
||||
before = {slot: record["blob"] for slot, record in self._slots.items()}
|
||||
for prefix in (DEFAULT_PREFIX, ONESHOT_PREFIX):
|
||||
blob = option_value(options, prefix)
|
||||
slot = slot_of(blob)
|
||||
# A blob whose slot the device doesn't advertise is not a
|
||||
# program this appliance offers -- don't record it.
|
||||
if slot is None or (known_slots and slot not in known_slots):
|
||||
continue
|
||||
record = self._slots.get(slot)
|
||||
if record is None:
|
||||
self._slots[slot] = {"blob": blob.upper(), "name": ""}
|
||||
else:
|
||||
record["blob"] = blob.upper()
|
||||
changed = before != {slot: rec["blob"] for slot, rec in self._slots.items()}
|
||||
|
||||
course = option_value(options, COURSE_PREFIX)
|
||||
# Only a transition we actually watched happen counts. On the
|
||||
# first observation there is nothing to compare against, so a
|
||||
# payload sitting there is equally consistent with "just loaded"
|
||||
# and "left over from last week" -- and on a board that doesn't
|
||||
# clear the token when leaving Download, believing the former
|
||||
# proposes whatever ordinary course the appliance happens to be
|
||||
# on. Accepting that prefill would start a real wash cycle.
|
||||
# (The two dumps in the corpus taken off the Download course both
|
||||
# show the appliance clearing it to the FFFF sentinel, so this
|
||||
# may never fire in practice -- which is not a reason to rely on
|
||||
# it.) Restores don't persist _last_oneshot, so every restart
|
||||
# re-enters this first-observation state deliberately.
|
||||
first_ever = self._last_oneshot is _UNOBSERVED
|
||||
if course and is_loaded(oneshot) and not first_ever and oneshot != self._last_oneshot:
|
||||
self._candidates[course] = self._candidates.get(course, 0) + 1
|
||||
self._last_oneshot = oneshot
|
||||
return changed
|
||||
|
||||
# -- reads ------------------------------------------------------------
|
||||
|
||||
def download_candidates(self) -> list[str]:
|
||||
"""Course codes seen at the moment a one-time program was loaded,
|
||||
most-observed first -- what the options flow offers as the likely
|
||||
Download course. Never used for a write on its own."""
|
||||
with self._lock:
|
||||
ranked = sorted(self._candidates.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
return [code for code, _ in ranked]
|
||||
|
||||
def named(self) -> dict[str, str]:
|
||||
"""Slots that are both learned and named -- the only ones offerable
|
||||
as a cycle option. An unnamed slot has no label that isn't either
|
||||
invented or an opaque hex id, so it stays out of the UI until the
|
||||
user supplies one."""
|
||||
with self._lock:
|
||||
return {slot: record["name"] for slot, record in self._slots.items() if record["name"]}
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
return {
|
||||
"download_course": self._download_course,
|
||||
"slots": {slot: dict(record) for slot, record in self._slots.items()},
|
||||
}
|
||||
|
||||
def view(self) -> dict:
|
||||
"""What the registry sees under FIELD: only what a write or a label
|
||||
can actually be built from, so a descriptor never has to re-apply
|
||||
this module's rules."""
|
||||
with self._lock:
|
||||
if not self._download_course:
|
||||
return {}
|
||||
return {
|
||||
"download_course": self._download_course,
|
||||
"programs": {
|
||||
slot: {"blob": record["blob"], "name": record["name"]}
|
||||
for slot, record in self._slots.items()
|
||||
if self._is_usable(record)
|
||||
},
|
||||
}
|
||||
|
||||
# -- writes -----------------------------------------------------------
|
||||
|
||||
def set_download_course(self, code: str | None) -> None:
|
||||
with self._lock:
|
||||
self._download_course = code or None
|
||||
|
||||
def set_name(self, slot: str, name: str) -> None:
|
||||
with self._lock:
|
||||
record = self._slots.get(slot.upper())
|
||||
if record is not None:
|
||||
record["name"] = name.strip()
|
||||
|
||||
@staticmethod
|
||||
def _is_usable(record) -> bool:
|
||||
"""A slot is offerable once it has a name. The device supplies the
|
||||
payload; only the user can supply the label, so this is the whole
|
||||
rule and it is stated once."""
|
||||
return bool(record["name"])
|
||||
|
||||
|
||||
def undiscovered(rep: dict, record: dict, courses) -> list[str]:
|
||||
"""Cloud slots that aren't yet usable -- unlearned or unnamed. What the
|
||||
Repairs issue counts, and what the options flow asks the user to walk the
|
||||
appliance through. Counts against cloud_slots, not every advertised byte:
|
||||
a slot that is already a selectable course is nothing to set up."""
|
||||
programs = record.get("slots") or {}
|
||||
return [s for s in cloud_slots(rep, courses) if not (programs.get(s) or {}).get("name")]
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import datetime
|
||||
import errno
|
||||
@@ -20,6 +21,7 @@ import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
@@ -33,6 +35,7 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorType,
|
||||
)
|
||||
|
||||
from . import cloudcourse
|
||||
from .const import (
|
||||
CLIENTHELLO_PROBE_RETRIES,
|
||||
CLIENTHELLO_PROBE_TIMEOUT_S,
|
||||
@@ -44,18 +47,25 @@ from .const import (
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_LEARN_MODES,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_MODEL,
|
||||
CONF_PORT,
|
||||
CONF_SERIAL,
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
DEFAULT_LEARN_MODES,
|
||||
DOMAIN,
|
||||
LIVENESS_PROBE_TIMEOUT_S,
|
||||
PREFERRED_PROBE_PORTS,
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
PROBE_MAX_WORKERS,
|
||||
PROBE_PORT_RANGE,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
)
|
||||
from .learned import persist as learned_persist
|
||||
from .learned import stored as learned_stored
|
||||
from .registry.capabilities.laundry import cycle_options, personal_course_labels
|
||||
from .registry.subdevices import MAIN
|
||||
|
||||
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
|
||||
@@ -68,6 +78,14 @@ _HYSTERESIS_MINUTES = NumberSelector(
|
||||
)
|
||||
)
|
||||
|
||||
# Guided download-cycle setup: how long a round waits for the user to
|
||||
# select a program, and how often it live-reads /course/vs/0 while doing
|
||||
# so. The read takes the session lock, so the interval is a few seconds
|
||||
# rather than sub-second -- fast enough to feel immediate to someone
|
||||
# standing at the appliance, slow enough not to starve polling.
|
||||
_CLOUD_WAIT_TIMEOUT_S = 180.0
|
||||
_CLOUD_PROBE_INTERVAL_S = 3.0
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SAMSUNG_CLOUD_HOST = "connect-v2.samsungiotcloud.com"
|
||||
@@ -163,6 +181,17 @@ def _fetch_samsung_uuid() -> str:
|
||||
raise RuntimeError(f"UUID not found in {_SAMSUNG_CLOUD_HOST} certificate subject")
|
||||
|
||||
|
||||
def _normalize_pem(text: str) -> str:
|
||||
"""Strip a pasted PEM's BOM, CRLF endings, and blank lines before
|
||||
`cryptography` sees it -- a text editor's copy carries all three and
|
||||
fails with an opaque InvalidHeader, while the same file dumped via
|
||||
`type` doesn't (issue #291)."""
|
||||
text = text.lstrip("\ufeff")
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [line for line in text.split("\n") if line.strip()]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str, str]:
|
||||
"""Mint a fresh RSA-2048 leaf cert signed by the CA.
|
||||
|
||||
@@ -722,8 +751,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if leaf_cert and leaf_key:
|
||||
existing_leaf = (leaf_cert, leaf_key)
|
||||
else:
|
||||
self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip()
|
||||
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
|
||||
# Normalized here, not just before minting: this is also
|
||||
# what gets stored and reused to re-mint the leaf later.
|
||||
self._ca_cert_pem = _normalize_pem(user_input[CONF_CA_CERT_PEM])
|
||||
self._ca_key_pem = _normalize_pem(user_input[CONF_CA_KEY_PEM])
|
||||
|
||||
try:
|
||||
info = await self.hass.async_add_executor_job(
|
||||
@@ -764,7 +795,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=step_id,
|
||||
data_schema=schema,
|
||||
data_schema=self.add_suggested_values_to_schema(schema, user_input),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -808,15 +839,27 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
def __init__(self) -> None:
|
||||
self._debug_href: str = ""
|
||||
self._debug_result: tuple[int, dict] | None = None
|
||||
# Guided download-cycle setup. `_cloud_task` is created once per
|
||||
# round and reused across re-entries (Home Assistant re-enters a
|
||||
# progress step while its spinner is up).
|
||||
self._cloud_task: asyncio.Task[str | None] | None = None
|
||||
self._cloud_slot: str | None = None
|
||||
self._cloud_baseline: str | None = None
|
||||
|
||||
def _coordinator(self):
|
||||
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||
|
||||
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
menu_options=["settings", "debug_write"],
|
||||
)
|
||||
menu = ["settings", "forget_learned_modes", "debug_write"]
|
||||
# Only offered on an appliance that actually advertises downloaded
|
||||
# programs (issue #342) -- every other device would get a menu entry
|
||||
# leading to an empty screen.
|
||||
coord = self._coordinator()
|
||||
if coord is not None and cloudcourse.supports_cloud_courses(
|
||||
coord.cloud_course_rep(), cycle_options(coord.canonical_resources(MAIN))
|
||||
):
|
||||
menu.insert(1, "cloud_courses")
|
||||
return self.async_show_menu(step_id="init", menu_options=menu)
|
||||
|
||||
async def async_step_settings(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -839,10 +882,428 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
),
|
||||
): _HYSTERESIS_MINUTES,
|
||||
vol.Required(
|
||||
CONF_LEARN_MODES,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_LEARN_MODES, DEFAULT_LEARN_MODES
|
||||
),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_step_forget_learned_modes(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm-and-clear for the learned-mode store (issue #327).
|
||||
|
||||
The point of learning is that it's permanent, so a code learned
|
||||
from a one-off firmware hiccup would otherwise sit in an option
|
||||
list forever. An empty schema renders as a plain confirmation
|
||||
form; the description lists what's about to be forgotten.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
# learned.py owns the entry key and the persisted shape, so this
|
||||
# step never parses or writes it itself -- including on an unloaded
|
||||
# entry, where a malformed record would otherwise abort the one
|
||||
# screen that can clear it.
|
||||
learned = (
|
||||
coord.learned_snapshot() if coord is not None else learned_stored(self.config_entry)
|
||||
)
|
||||
codes = sorted({code for codes in learned.values() for code in codes})
|
||||
|
||||
if user_input is not None:
|
||||
if coord is not None:
|
||||
coord.forget_learned_modes()
|
||||
else:
|
||||
learned_persist(self.hass, self.config_entry, {})
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="forget_learned_modes",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders={"codes": ", ".join(codes) if codes else "(none)"},
|
||||
)
|
||||
|
||||
async def async_step_cloud_courses(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Entry point for download-cycle setup (issue #342).
|
||||
|
||||
Guided setup is offered first because it is the only version of this
|
||||
that a first-time user can complete confidently: it asks about a
|
||||
program in the moment they select it, rather than about a list of hex
|
||||
ids some time later. The bulk form stays for renaming afterwards,
|
||||
which guided setup is bad at.
|
||||
"""
|
||||
return self.async_show_menu(
|
||||
step_id="cloud_courses",
|
||||
menu_options=["cloud_guided", "cloud_manual"],
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_remove(self) -> None:
|
||||
"""Stop probing when the flow goes away.
|
||||
|
||||
Closing the dialog is the documented way to leave guided setup, so it
|
||||
has to actually stop: an abandoned round would otherwise go on
|
||||
live-reading /course/vs/0 every few seconds until its timeout, taking
|
||||
the session lock each time, for a user who has walked away.
|
||||
"""
|
||||
if self._cloud_task is not None and not self._cloud_task.done():
|
||||
self._cloud_task.cancel()
|
||||
self._cloud_task = None
|
||||
|
||||
async def async_step_cloud_guided(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Start (or restart) a guided discovery round."""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
self._cloud_task = None
|
||||
self._cloud_slot = None
|
||||
# Baseline: whatever is loaded right now. The round completes when
|
||||
# the appliance moves off it, so the program the user has *already*
|
||||
# selected can't immediately re-trigger and loop the flow.
|
||||
self._cloud_baseline = cloudcourse.loaded_slot(coord.cloud_course_rep())
|
||||
return await self.async_step_cloud_wait()
|
||||
|
||||
async def async_step_cloud_wait(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Wait for the user to select a different downloaded program.
|
||||
|
||||
The task is created once and reused across re-entries -- Home
|
||||
Assistant polls this step while the spinner is up, and building a
|
||||
fresh task each time would restart the wait forever.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
if self._cloud_task is None:
|
||||
self._cloud_task = self.hass.async_create_task(
|
||||
self._await_cloud_selection(coord), eager_start=False
|
||||
)
|
||||
if not self._cloud_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="cloud_wait",
|
||||
progress_action="cloud_wait",
|
||||
progress_task=self._cloud_task,
|
||||
description_placeholders=self._cloud_progress_placeholders(coord),
|
||||
)
|
||||
|
||||
self._cloud_slot = self._cloud_task.result()
|
||||
self._cloud_task = None
|
||||
if self._cloud_slot is None:
|
||||
return self.async_show_progress_done(next_step_id="cloud_timeout")
|
||||
return self.async_show_progress_done(next_step_id="cloud_name")
|
||||
|
||||
async def _await_cloud_selection(self, coord) -> str | None:
|
||||
"""Poll until the loaded program changes; None on timeout."""
|
||||
deadline = time.monotonic() + _CLOUD_WAIT_TIMEOUT_S
|
||||
while time.monotonic() < deadline:
|
||||
slot = await coord.async_probe_cloud_courses()
|
||||
# Only a slot the store actually recorded. The probe reports
|
||||
# whatever payload is loaded, while observe() declines one whose
|
||||
# slot the device doesn't advertise -- offering to name that would
|
||||
# take a name and silently discard it, since there is no record to
|
||||
# hang it on and no payload to replay.
|
||||
if (
|
||||
slot is not None
|
||||
and slot != self._cloud_baseline
|
||||
and coord.cloud_courses.snapshot()["slots"].get(slot)
|
||||
):
|
||||
return slot
|
||||
await asyncio.sleep(_CLOUD_PROBE_INTERVAL_S)
|
||||
return None
|
||||
|
||||
def _cloud_progress_placeholders(self, coord) -> dict[str, str]:
|
||||
"""Counts plus the names assigned so far.
|
||||
|
||||
Listing them is what makes a nine-program walk followable -- it is
|
||||
the only orientation available, since the programs still to do are
|
||||
unnamed by definition. Shown while naming too, where it doubles as
|
||||
duplicate avoidance: the form rejects a repeated name, so seeing the
|
||||
others first beats being bounced.
|
||||
|
||||
In the appliance's own advertised order, which is at least a stable
|
||||
order, without numbering them -- whether that order matches the dial
|
||||
is plausible but unverified, and implying it would be worse than
|
||||
saying nothing.
|
||||
"""
|
||||
rep = coord.cloud_course_rep()
|
||||
courses = cycle_options(coord.canonical_resources(MAIN))
|
||||
record = coord.cloud_courses.snapshot()
|
||||
slots = cloudcourse.cloud_slots(rep, courses)
|
||||
names = [n for s in slots if (n := (record["slots"].get(s) or {}).get("name"))]
|
||||
return {
|
||||
"named": str(len(names)),
|
||||
"total": str(len(slots)),
|
||||
"named_list": ", ".join(names) if names else "none yet",
|
||||
}
|
||||
|
||||
async def async_step_cloud_name(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Name the program the user just selected.
|
||||
|
||||
Persists immediately rather than batching to the end of the flow, so
|
||||
closing the dialog at any point is a clean "save and exit" -- there is
|
||||
no pending work to lose, and reopening resumes from the store.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None or self._cloud_slot is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
slot = self._cloud_slot
|
||||
existing = (coord.cloud_courses.snapshot()["slots"].get(slot) or {}).get("name", "")
|
||||
|
||||
if user_input is not None:
|
||||
# Rebuilt into the shared validator's shape. `download_course`
|
||||
# is forwarded only when this form actually carried it, so its
|
||||
# absence still means "not asked about" rather than "clear it".
|
||||
payload: dict[str, Any] = {f"name_{slot}": str(user_input.get("name", "")).strip()}
|
||||
if "download_course" in user_input:
|
||||
payload["download_course"] = user_input["download_course"]
|
||||
errors = self._apply_cloud_course_names(coord, [slot], payload)
|
||||
if errors:
|
||||
return self._cloud_name_form(coord, slot, existing, errors=errors)
|
||||
# Straight back to waiting: the appliance is still sitting on this
|
||||
# program, and the next round baselines on it, so there is nothing
|
||||
# to click through.
|
||||
self._cloud_baseline = slot
|
||||
return await self.async_step_cloud_wait()
|
||||
|
||||
return self._cloud_name_form(coord, slot, existing)
|
||||
|
||||
def _cloud_name_form(
|
||||
self, coord, slot: str, existing: str, errors: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""One text field, with the copy switched on whether this program is
|
||||
already set up. Re-selecting one is not an error -- it is how someone
|
||||
checks their work -- so it gets an edit form rather than a rejection,
|
||||
and the counter deliberately does not move.
|
||||
|
||||
The Download course joins the form the first time round, and only
|
||||
until it is confirmed. Guided setup would otherwise finish having
|
||||
collected names but no course, and a program needs both before it can
|
||||
be offered -- so the whole walk would produce nothing selectable. It
|
||||
is asked here rather than up front because this is the first moment
|
||||
there is evidence to prefill: the user has just loaded a program, so
|
||||
the course showing alongside it is the Download one.
|
||||
"""
|
||||
placeholders = self._cloud_progress_placeholders(coord)
|
||||
placeholders["slot"] = slot
|
||||
placeholders["remaining"] = (
|
||||
coord.resource("/operational/state/vs/0").get("x.com.samsung.da.remainingTime") or "--"
|
||||
)
|
||||
fields: dict[Any, Any] = {vol.Optional("name", default=existing): _TEXT}
|
||||
if not coord.cloud_courses.snapshot()["download_course"]:
|
||||
fields[
|
||||
vol.Optional("download_course", description={"suggested_value": self._cloud_course})
|
||||
] = self._cloud_course_selector(coord)
|
||||
return self.async_show_form(
|
||||
step_id="cloud_name",
|
||||
data_schema=vol.Schema(fields),
|
||||
errors=errors or {},
|
||||
description_placeholders=placeholders,
|
||||
last_step=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def _cloud_course(self) -> str | None:
|
||||
"""The best observed candidate, narrowed to what the selector offers.
|
||||
|
||||
Unfiltered, a candidate the appliance's own course list no longer
|
||||
contains would prefill a dropdown that rejects it, and the form would
|
||||
fail validation on a value the user never chose.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return None
|
||||
available = cycle_options(coord.canonical_resources(MAIN))
|
||||
return next((c for c in coord.cloud_courses.download_candidates() if c in available), None)
|
||||
|
||||
def _cloud_course_selector(self, coord):
|
||||
"""The appliance's own course codes, observed candidates first.
|
||||
|
||||
custom_value stays off deliberately: whatever lands here becomes the
|
||||
Course_ token of a real write, and a typed-in code the appliance
|
||||
doesn't offer would start something nobody chose.
|
||||
"""
|
||||
available = cycle_options(coord.canonical_resources(MAIN))
|
||||
candidates = [c for c in coord.cloud_courses.download_candidates() if c in available]
|
||||
ordered = candidates + [c for c in available if c not in candidates]
|
||||
return SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=ordered,
|
||||
custom_value=False,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
|
||||
async def async_step_cloud_timeout(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Nothing was selected in time. Offer another round rather than
|
||||
dropping the user out of the flow -- and an explicit finish, for
|
||||
anyone who doesn't think to close the dialog."""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
return self.async_show_menu(
|
||||
step_id="cloud_timeout",
|
||||
menu_options=["cloud_guided", "cloud_finish"],
|
||||
description_placeholders=self._cloud_progress_placeholders(coord),
|
||||
)
|
||||
|
||||
async def async_step_cloud_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
|
||||
async def async_step_cloud_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Name the cloud "Download" programs this appliance has (issue #342).
|
||||
|
||||
The device advertises how many downloaded programs it holds but never
|
||||
what any of them is called, and only ever exposes the replay payload
|
||||
for the one currently loaded. So this screen can only offer the ones
|
||||
already seen loaded, and asks the user for the names -- the appliance
|
||||
has no name to give and inventing one is not an option (the same rule
|
||||
that governs unrecognized local course codes).
|
||||
|
||||
Also confirms which course code means Download. It is auto-detected
|
||||
by observation, but never used for a write until confirmed here: the
|
||||
options array replaces tokens by prefix and never evicts them, so a
|
||||
stale program token can be reported alongside an unrelated course and
|
||||
make an ordinary wash cycle look like the Download one. Writing the
|
||||
wrong code would start the wrong cycle.
|
||||
"""
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
store = coord.cloud_courses
|
||||
rep = coord.cloud_course_rep()
|
||||
record = store.snapshot()
|
||||
slots = record["slots"]
|
||||
advertised = cloudcourse.cloud_slots(rep, cycle_options(coord.canonical_resources(MAIN)))
|
||||
# Learned slots keep the appliance's own ordering; anything learned
|
||||
# but no longer advertised still gets a row so a name isn't stranded.
|
||||
known = [s for s in advertised if s in slots] + [s for s in slots if s not in advertised]
|
||||
|
||||
if user_input is not None:
|
||||
errors = self._apply_cloud_course_names(coord, known, user_input)
|
||||
if not errors:
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
return self._cloud_courses_form(coord, known, advertised, errors=errors)
|
||||
|
||||
return self._cloud_courses_form(coord, known, advertised)
|
||||
|
||||
def _apply_cloud_course_names(self, coord, known, user_input) -> dict[str, str]:
|
||||
"""Validate and store the submitted names + Download course code.
|
||||
|
||||
The select maps a chosen label back to a raw value by matching display
|
||||
text, so two options sharing a label resolve to whichever comes first.
|
||||
Two sources of collision are checkable here and both are rejected:
|
||||
the user's own names against each other, and against the appliance's
|
||||
personal-course labels, which the device reports verbatim and the
|
||||
select renders as-is.
|
||||
|
||||
A collision with a *translated* local course name is deliberately not
|
||||
checked. The catalog this process can read is English (catalog.py),
|
||||
while what the user actually sees is localized in the frontend -- so
|
||||
checking it would reject "Cotton" for a German user whose dropdown
|
||||
says "Baumwolle", and still miss the real collision when they type
|
||||
"Baumwolle". Wrong in both directions outside one locale, against an
|
||||
outcome the option ordering already makes deterministic (local
|
||||
courses come first, so a shared label resolves to the real cycle).
|
||||
"""
|
||||
names = {slot: str(user_input.get(f"name_{slot}", "")).strip() for slot in known}
|
||||
stored_slots = coord.cloud_courses.snapshot()["slots"]
|
||||
taken = {name.casefold() for name in self._device_course_names(coord)}
|
||||
# Programs this form isn't editing. The bulk form edits every slot at
|
||||
# once so this adds nothing there, but guided setup submits one at a
|
||||
# time -- without it, naming two programs the same was accepted, and
|
||||
# the select resolves a shared label to whichever option comes first,
|
||||
# so picking the second would run the first one's payload.
|
||||
taken |= {
|
||||
record["name"].casefold()
|
||||
for slot, record in stored_slots.items()
|
||||
if record["name"] and slot not in known
|
||||
}
|
||||
for name in names.values():
|
||||
if not name:
|
||||
continue
|
||||
if name.casefold() in taken:
|
||||
return {"base": "cloud_course_name_duplicate"}
|
||||
taken.add(name.casefold())
|
||||
|
||||
# Absent means "this form didn't ask" -- the guided name form drops
|
||||
# the field once the course is confirmed -- which must leave the
|
||||
# stored value alone rather than clearing it. A program is only
|
||||
# offerable when both a name and the course are set, so clearing it
|
||||
# here would make naming things remove them from the cycle list.
|
||||
if "download_course" not in user_input:
|
||||
coord.apply_cloud_courses(names)
|
||||
return {}
|
||||
|
||||
# Belt and braces over the selector's own custom_value=False: this
|
||||
# value becomes the Course_ token of a real write, so it is checked
|
||||
# against the appliance's own course list here too, where the store
|
||||
# is actually updated.
|
||||
course = user_input.get("download_course") or None
|
||||
if course is not None and course not in cycle_options(coord.canonical_resources(MAIN)):
|
||||
return {"base": "cloud_course_unknown_course"}
|
||||
|
||||
coord.apply_cloud_courses(names, course)
|
||||
return {}
|
||||
|
||||
def _device_course_names(self, coord) -> set[str]:
|
||||
"""Course names this appliance reports itself.
|
||||
|
||||
Only the personal-course labels: the device sends these as text and
|
||||
the select renders them unchanged, so they are the same string in
|
||||
every locale and can be compared against safely. See the caller for
|
||||
why translated course names are not included.
|
||||
"""
|
||||
resources = coord.canonical_resources(MAIN)
|
||||
personal = personal_course_labels(resources)
|
||||
return {name for code in cycle_options(resources) if (name := personal.get(code.upper()))}
|
||||
|
||||
def _cloud_courses_form(
|
||||
self, coord, known, advertised, errors: dict[str, str] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
store = coord.cloud_courses
|
||||
record = store.snapshot()
|
||||
slots = record["slots"]
|
||||
|
||||
fields: dict[Any, Any] = {}
|
||||
for slot in known:
|
||||
fields[vol.Optional(f"name_{slot}", default=slots.get(slot, {}).get("name", ""))] = (
|
||||
_TEXT
|
||||
)
|
||||
|
||||
suggested = record["download_course"] or self._cloud_course
|
||||
fields[vol.Optional("download_course", description={"suggested_value": suggested})] = (
|
||||
self._cloud_course_selector(coord)
|
||||
)
|
||||
|
||||
pending = [s for s in advertised if s not in slots]
|
||||
return self.async_show_form(
|
||||
step_id="cloud_manual",
|
||||
data_schema=vol.Schema(fields),
|
||||
errors=errors or {},
|
||||
description_placeholders={
|
||||
"found": str(len(known)),
|
||||
"total": str(len(advertised)) if advertised else str(len(known)),
|
||||
"pending": ", ".join(pending) if pending else "(none)",
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_debug_write(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -912,8 +1373,33 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"payload": "empty_payload"}, payload
|
||||
)
|
||||
# Goes through the write_resource service (issue #300), not
|
||||
# coord.async_raw_write directly, so there is exactly one code
|
||||
# path that performs a raw write. MAIN's own device -- the
|
||||
# panel's href dropdown already lists actual hrefs off
|
||||
# coord.last_resources, and MAIN.to_actual is identity, so
|
||||
# this preserves the panel's existing behavior byte for byte.
|
||||
dev = dr.async_get(self.hass).async_get_device(
|
||||
identifiers=coord.device_info["identifiers"]
|
||||
)
|
||||
if dev is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
try:
|
||||
code, new_rep = await coord.async_raw_write(href, payload)
|
||||
response = await self.hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": href, "payload": payload}]},
|
||||
target={"device_id": dev.id},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
results = (response or {}).get("results")
|
||||
first = results[0] if isinstance(results, list) and results else None
|
||||
raw_code = first.get("raw_code") if isinstance(first, dict) else None
|
||||
after = first.get("after") if isinstance(first, dict) else None
|
||||
if not isinstance(raw_code, int) or not isinstance(after, dict):
|
||||
raise RuntimeError("write_resource service returned an unexpected shape")
|
||||
code, new_rep = raw_code, after
|
||||
except Exception:
|
||||
_LOGGER.exception("debug raw write failed for %s", href)
|
||||
return self._show_debug_edit_form(href, current, {"base": "write_failed"}, payload)
|
||||
|
||||
@@ -34,6 +34,29 @@ CONF_MODEL = "model"
|
||||
CONF_MANUFACTURER = "manufacturer"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
|
||||
# entry.data key: modes this device reported itself in but never advertised
|
||||
# in the same resource's supportedModes (issue #327). Stored on the entry
|
||||
# rather than kept in memory so a mode the device only names while it is
|
||||
# active survives a restart -- see learned.py. Shape:
|
||||
# {actual_href: [code, ...]}.
|
||||
CONF_LEARNED_MODES = "learned_modes"
|
||||
|
||||
# Options-flow key: whether learned modes are remembered and offered.
|
||||
# Defaults to on; turning it off stops both halves at once (nothing new is
|
||||
# learned, nothing already learned is offered) without discarding what was
|
||||
# already remembered -- the options flow's reset step does that.
|
||||
CONF_LEARN_MODES = "learn_device_modes"
|
||||
DEFAULT_LEARN_MODES = True
|
||||
|
||||
# entry.data key: cloud "Download" programs discovered on a laundry device
|
||||
# (issue #342). Same rationale as CONF_LEARNED_MODES -- a program's full
|
||||
# replay payload is only ever visible while the device happens to be sitting
|
||||
# on it, so it has to survive a restart -- but a richer shape, because a
|
||||
# cloud program also needs a user-supplied name and the device's own
|
||||
# Download course code. See cloudcourse.py, which owns the shape. Shape:
|
||||
# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}}
|
||||
CONF_CLOUD_COURSES = "cloud_courses"
|
||||
|
||||
# Options-flow key (entry.options, not entry.data): lets a user override
|
||||
# the device-wide remote-control-off write block for a specific device
|
||||
# (issue #54). Some devices accept certain writes even while reporting
|
||||
@@ -96,3 +119,9 @@ SUMMARY_INTERVAL_S = 30.0
|
||||
DEVICE_SUPPORT_ISSUE_URL = (
|
||||
"https://github.com/mbillow/localthings/issues/new?template=device-support.yml"
|
||||
)
|
||||
|
||||
# Service names (services.py), shared with config_flow.py so the
|
||||
# options-flow debug panel calls the exact same service a user could call
|
||||
# from an automation (issue #300) -- one code path performs a raw write.
|
||||
SERVICE_WRITE_RESOURCE = "write_resource"
|
||||
SERVICE_READ_RESOURCE = "read_resource"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.loader import async_get_integration
|
||||
|
||||
from . import cloudcourse
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .registry.capabilities.laundry import cycle_options
|
||||
from .registry.redact import redact_resources
|
||||
from .registry.subdevices import MAIN
|
||||
|
||||
@@ -32,6 +34,7 @@ async def async_get_config_entry_diagnostics(
|
||||
# disk (listdir + open + read_text), which trips HA's event-loop blocking
|
||||
# detector when called inline here. Offload it to the executor.
|
||||
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
|
||||
cloud_courses = coordinator.cloud_courses.snapshot()
|
||||
|
||||
# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
|
||||
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
|
||||
@@ -54,7 +57,7 @@ async def async_get_config_entry_diagnostics(
|
||||
# than redacting /information/vs/0 again -- modelNum never matches
|
||||
# redact.py's substring rules, so the value is the same either way.
|
||||
matching = [b for b in coordinator.bound if b.subdevice == su]
|
||||
res = redact_resources(coordinator.canonical_resources(su))
|
||||
res = redact_resources(coordinator.device_resources(su))
|
||||
return {
|
||||
"kind": su.kind,
|
||||
"key": su.key,
|
||||
@@ -88,7 +91,7 @@ async def async_get_config_entry_diagnostics(
|
||||
# /mode/vs/0 under no attribution. Each sibling reports its own
|
||||
# resources in `subdevices` below instead. For a device with no
|
||||
# subdevices, this is byte-identical to `last_resources`.
|
||||
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
|
||||
"resources": redact_resources(coordinator.device_resources(MAIN)),
|
||||
# Sibling indoor subdevices discovered on this connection (issue
|
||||
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
|
||||
# from) is deliberately NOT redacted here, unlike elsewhere in
|
||||
@@ -128,6 +131,34 @@ async def async_get_config_entry_diagnostics(
|
||||
# about the connection rather than one subdevice's state, and
|
||||
# nothing polls it after discovery so it would go stale in there.
|
||||
"multidevice": redact_resources(coordinator._multidevice),
|
||||
# Modes this unit reported itself in but never advertised (issue
|
||||
# #327). Reported separately from `resources` on purpose: the dump
|
||||
# above stays exactly what the device said, so a triager can still
|
||||
# see the gap these codes were inferred from. Keyed by actual href,
|
||||
# like the store itself.
|
||||
"learned_modes": {
|
||||
"enabled": coordinator.learning_enabled,
|
||||
"codes": coordinator.learned_snapshot(),
|
||||
},
|
||||
# Cloud "Download" programs discovered on this device (issue #342),
|
||||
# reported separately from `resources` for the same reason as
|
||||
# learned_modes above -- the dump there stays exactly what the device
|
||||
# said, and this is what the integration made of it.
|
||||
#
|
||||
# Reported in full, names included. Half of what can go wrong with
|
||||
# this feature is a configuration question -- which programs got
|
||||
# named, which Download course was confirmed, whether a payload was
|
||||
# ever captured for a slot the device advertises -- and none of that
|
||||
# is answerable from the payloads alone. The names are the user's own
|
||||
# words, so this is the one place they appear; they reach a dump only
|
||||
# because its owner chose to download and share it.
|
||||
"cloud_courses": {
|
||||
"advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()),
|
||||
"cloud_slots": cloudcourse.cloud_slots(
|
||||
coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN))
|
||||
),
|
||||
**cloud_courses,
|
||||
},
|
||||
"integration_version": integration.version,
|
||||
"smartthings_local_version": stl_version,
|
||||
"observe_mode": coordinator.observe_mode,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Modes a device reports itself in but never advertises as supported.
|
||||
|
||||
Some firmwares report a current mode that is missing from the same
|
||||
resource's own supported list (issue #327: an ARTIK051 air conditioner
|
||||
sitting in 'Quiet' with supportedModes [Off, Sleep, Speed, Nano,
|
||||
NanoSleep]). The mode is real -- the remote and the SmartThings app select
|
||||
it, and the unit accepts it written back -- so once the device has been
|
||||
seen in it, it is remembered and offered alongside the advertised ones.
|
||||
|
||||
Learning is deliberately not global. A current value that isn't a
|
||||
selectable option is common across this corpus -- an oven idling in
|
||||
'NoOperation', a fridge's /mode/vs/0 carrying capability tokens like
|
||||
'WATERFILTER_DISABLE' -- and remembering one of those permanently would
|
||||
put an option in the UI that the device can only reject. LEARNABLE names
|
||||
the canonical hrefs where a reported mode is known to be genuinely
|
||||
selectable, and the coordinator narrows it further to the hrefs this
|
||||
device actually binds a climate entity to (see _refresh_learnable_hrefs):
|
||||
the same href is declared explicitly unmodeled on a dehumidifier and
|
||||
empty on an air purifier, and learning for those would persist a code
|
||||
nothing ever offers.
|
||||
|
||||
This module also owns the entry key the store persists under, so the
|
||||
shape lives in exactly one place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from .const import CONF_LEARNED_MODES
|
||||
from .registry.capabilities.airconditioner import HREF_CONVENIENT
|
||||
|
||||
MODES_FIELD = "x.com.samsung.da.modes"
|
||||
SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
|
||||
|
||||
# Convenient (preset) mode: firmware omits an active preset (e.g. Quiet)
|
||||
# from its own supportedModes -- a reporting gap, not a capability
|
||||
# difference (issue #327).
|
||||
LEARNABLE: frozenset[str] = frozenset({HREF_CONVENIENT})
|
||||
|
||||
|
||||
def _codes(value) -> list[str]:
|
||||
"""Mode codes from a `modes`-style field, which some firmwares send as
|
||||
a bare string rather than an array."""
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [v for v in value if isinstance(v, str)]
|
||||
return []
|
||||
|
||||
|
||||
def _coerce(stored) -> dict[str, list[str]]:
|
||||
"""Restore the persisted map, dropping anything that isn't the shape
|
||||
this module writes. It round-trips through the config entry as plain
|
||||
JSON, and a hand-edited .storage file shouldn't be able to crash
|
||||
setup."""
|
||||
if not isinstance(stored, dict):
|
||||
return {}
|
||||
restored = {}
|
||||
for href, codes in stored.items():
|
||||
if isinstance(href, str) and (valid := [c for c in _codes(codes) if c]):
|
||||
restored[href] = valid
|
||||
return restored
|
||||
|
||||
|
||||
def stored(entry) -> dict[str, list[str]]:
|
||||
"""What `entry` has persisted, coerced -- for a reader that can't go
|
||||
through a coordinator (the options flow, on an unloaded entry)."""
|
||||
return _coerce(entry.data.get(CONF_LEARNED_MODES))
|
||||
|
||||
|
||||
def persist(hass, entry, codes: dict[str, list[str]]) -> None:
|
||||
"""Write `codes` onto the entry. Runs on the event loop, which
|
||||
async_update_entry requires."""
|
||||
hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_LEARNED_MODES: codes})
|
||||
|
||||
|
||||
class LearnedModes:
|
||||
"""Per-device store of learned codes, keyed by actual (on-the-wire)
|
||||
href so two subdevices of one composite appliance learn separately.
|
||||
|
||||
Mutated from whichever thread applied the update (the DTLS reader for
|
||||
an OBSERVE notify, an executor thread for a poll -- see
|
||||
ObserveManager.apply), so every access takes the lock; persistence is
|
||||
the caller's job, on the event loop.
|
||||
"""
|
||||
|
||||
def __init__(self, stored=None) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._learned = _coerce(stored)
|
||||
|
||||
def observe(self, actual_href: str, rep: dict) -> list[str]:
|
||||
"""Learn from one applied rep; returns the codes newly learned, so
|
||||
an empty list means there is nothing to persist.
|
||||
|
||||
A rep that carries no supported list teaches nothing: "missing from
|
||||
the list" is only meaningful against a list that exists, and
|
||||
inventing one for a device that publishes none would offer options
|
||||
nothing ever said were selectable. `rep` is the merged rep
|
||||
ObserveManager.apply stores, so a partial notify carrying `modes`
|
||||
alone (issue #27) still sees the supported list from the last full
|
||||
poll.
|
||||
"""
|
||||
supported = _codes(rep.get(SUPPORTED_FIELD))
|
||||
if not supported:
|
||||
return []
|
||||
with self._lock:
|
||||
known = self._learned.get(actual_href, [])
|
||||
new = [
|
||||
code
|
||||
for code in _codes(rep.get(MODES_FIELD))
|
||||
if code and code not in supported and code not in known
|
||||
]
|
||||
if new:
|
||||
self._learned[actual_href] = [*known, *new]
|
||||
return new
|
||||
|
||||
def codes(self, actual_href: str) -> list[str]:
|
||||
with self._lock:
|
||||
return list(self._learned.get(actual_href, ()))
|
||||
|
||||
def snapshot(self) -> dict[str, list[str]]:
|
||||
with self._lock:
|
||||
return {href: list(codes) for href, codes in self._learned.items()}
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._learned = {}
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.2"
|
||||
],
|
||||
"version": "0.19.0"
|
||||
"version": "0.21.2"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import cbor2
|
||||
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||
@@ -55,6 +56,26 @@ SUCCESS_FRACTION = 0.8
|
||||
PUSH_HEALTH_WINDOW_S = 60.0
|
||||
|
||||
|
||||
def _is_alarms_href(href: str) -> bool:
|
||||
"""True for /alarms/vs/<index> in any subdevice-translated shape --
|
||||
the canonical MAIN form (/alarms/vs/0), an indexed subdevice's
|
||||
renumbered instance (/alarms/vs/<key>), or a prefixed subdevice's
|
||||
UUID-qualified form (/<uuid>/alarms/vs/0). `Subdevice.to_actual`
|
||||
(registry/subdevices.py) only ever rewrites the trailing index
|
||||
segment or prepends a prefix -- it never touches the 'alarms/vs'
|
||||
stem -- so matching that fixed segment plus a wildcard tail catches
|
||||
every shape without this module needing to be subdevice-aware.
|
||||
|
||||
See `ObserveManager.apply`'s use of this for why the href matters:
|
||||
unlike most resources, /alarms/vs/0's `x.com.samsung.da.items` array
|
||||
is a complete snapshot of every currently-active alarm, not a
|
||||
possibly-partial field update -- so it must never be merged onto a
|
||||
stale prior rep (issue #348).
|
||||
"""
|
||||
head, _, _ = href.rpartition("/")
|
||||
return head.endswith("/alarms/vs")
|
||||
|
||||
|
||||
class ObserveManager:
|
||||
"""Per-device observe-mode state: mode, write-settle guard, and (later)
|
||||
subscription/staleness tracking. Pure sync logic — safe to call from
|
||||
@@ -77,10 +98,21 @@ class ObserveManager:
|
||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
||||
self._notify_cond = threading.Condition()
|
||||
self.fallback_hrefs: set[str] = set()
|
||||
self._on_applied: Callable[[str, dict, str], None] | None = None
|
||||
self._refresh_task: ObserveRefreshTask | None = None
|
||||
self._refresh_stop: threading.Event | None = None
|
||||
self._refresh_thread: threading.Thread | None = None
|
||||
|
||||
def set_on_applied(self, callback: Callable[[str, dict, str], None]) -> None:
|
||||
"""Hook run after every accepted rep, on the applying thread.
|
||||
|
||||
Unlike StateCache.set_on_change it carries the href and rep, and
|
||||
fires even when the rep is unchanged -- which learned.py needs, a
|
||||
device sitting in an unadvertised mode re-sending the same rep
|
||||
every poll.
|
||||
"""
|
||||
self._on_applied = callback
|
||||
|
||||
def mark_write_pending(self, href: str, settle_s: float = DEFAULT_SETTLE_S) -> None:
|
||||
with self._settle_lock:
|
||||
self._settle_until[href] = time.monotonic() + settle_s
|
||||
@@ -110,6 +142,20 @@ class ObserveManager:
|
||||
comes through, even though nothing about the device's actual
|
||||
supported options changed.
|
||||
|
||||
`_is_alarms_href` is the one exception to that merge (issue #348):
|
||||
/alarms/vs/0's `items` array is always sent as a complete
|
||||
snapshot of every currently-active alarm, never a partial delta
|
||||
-- confirmed by a live `read_resource` GET returning `{}` (no
|
||||
`items` key at all) the moment a washer's board actually clears
|
||||
an alarm, which entity.py already documents as this resource's
|
||||
normal no-alarm shape. Merging that `{}` onto the prior rep the
|
||||
same way as everywhere else silently kept the stale `items`
|
||||
entry forever: an absent key merges as "unchanged" everywhere
|
||||
else, but on this href absent specifically means "cleared".
|
||||
Every family that exposes an alarm sensor shares this href
|
||||
(common.ALARMS, range_hood's own copy), so this is a full
|
||||
replace for all of them, not a washer-specific carve-out.
|
||||
|
||||
`apply()` is the sole path StateCache mutations flow through in
|
||||
this component (poll, sweep, and OBSERVE notify all funnel here),
|
||||
so `_cache_lock` serializes the read-then-write across those
|
||||
@@ -137,8 +183,15 @@ class ObserveManager:
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
with self._cache_lock:
|
||||
merged = {**(self.cache.get(href) or {}), **rep}
|
||||
return self.cache.apply_rep(href, merged, source=source)
|
||||
merged = dict(rep) if _is_alarms_href(href) else {**(self.cache.get(href) or {}), **rep}
|
||||
changed = self.cache.apply_rep(href, merged, source=source)
|
||||
# Outside the cache lock -- the hook takes locks of its own and
|
||||
# never reads the cache back. `source` is passed along rather than
|
||||
# filtered here: which sources are worth acting on is the hook's
|
||||
# policy, not this manager's.
|
||||
if self._on_applied is not None:
|
||||
self._on_applied(href, merged, source)
|
||||
return changed
|
||||
|
||||
def on_notification(self, href: str, payload: bytes) -> None:
|
||||
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
||||
@@ -171,17 +224,16 @@ class ObserveManager:
|
||||
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
|
||||
)
|
||||
|
||||
def try_enter_observe_mode(
|
||||
self,
|
||||
session,
|
||||
hrefs: list[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — subscribes to every href then waits up to
|
||||
`grace_period_s`, returning early once `success_fraction` of hrefs
|
||||
have notified. Caller must run this in an executor, never on the
|
||||
event loop."""
|
||||
def subscribe_hrefs(self, session, hrefs: list[str]) -> set[str]:
|
||||
"""Register OBSERVE on every href; returns the ones that took.
|
||||
Blocking — run in an executor.
|
||||
|
||||
Split from the grace wait below (issue #294) so the coordinator can
|
||||
hold its session lock for just these sends -- each is a fire-and-
|
||||
forget UDP datagram (DtlsCoapSession.subscribe doesn't wait for the
|
||||
device's ack), unlike the wait, which can block for the whole grace
|
||||
period and must not hold a lock a command write is also waiting on.
|
||||
"""
|
||||
with self._notify_cond:
|
||||
self._notified.clear()
|
||||
subscribed: set[str] = set()
|
||||
@@ -192,30 +244,65 @@ class ObserveManager:
|
||||
subscribed.add(href)
|
||||
except Exception as e:
|
||||
self.log.warning("subscribe %s failed: %s", href, e)
|
||||
return subscribed
|
||||
|
||||
def await_observe_notifies(
|
||||
self,
|
||||
subscribed: set[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — waits up to `grace_period_s`, returning early once
|
||||
`success_fraction` of `subscribed` have notified. Touches no
|
||||
session; safe to run without holding a session lock."""
|
||||
if not subscribed:
|
||||
self._stop_refresh_task()
|
||||
self._set_mode(MODE_POLL)
|
||||
self.subscribed_hrefs = set()
|
||||
return False
|
||||
|
||||
def _fraction_reached() -> bool:
|
||||
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
|
||||
|
||||
with self._notify_cond:
|
||||
reached = self._notify_cond.wait_for(
|
||||
_fraction_reached,
|
||||
timeout=grace_period_s,
|
||||
)
|
||||
return self._notify_cond.wait_for(_fraction_reached, timeout=grace_period_s)
|
||||
|
||||
if reached:
|
||||
self.subscribed_hrefs = subscribed
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
return True
|
||||
def enter_observe_mode(self, session, subscribed: set[str]) -> None:
|
||||
"""Commit a successful attempt. Caller must have re-confirmed
|
||||
`session` is still the live one under its session lock (issue
|
||||
#294) -- committing against a session a reconnect already replaced
|
||||
would claim observe mode with nothing left to notice it's dead."""
|
||||
self.subscribed_hrefs = set(subscribed)
|
||||
self._set_mode(MODE_OBSERVE)
|
||||
self.start_refresh_task(session)
|
||||
|
||||
def abandon_observe_attempt(self) -> None:
|
||||
"""Drop a failed or stale attempt: no subscriptions worth keeping."""
|
||||
self._stop_refresh_task()
|
||||
self.subscribed_hrefs = set()
|
||||
self._set_mode(MODE_POLL)
|
||||
|
||||
def try_enter_observe_mode(
|
||||
self,
|
||||
session,
|
||||
hrefs: list[str],
|
||||
grace_period_s: float = GRACE_PERIOD_S,
|
||||
success_fraction: float = SUCCESS_FRACTION,
|
||||
) -> bool:
|
||||
"""Blocking — subscribes to every href then waits up to
|
||||
`grace_period_s`, returning early once `success_fraction` of hrefs
|
||||
have notified. Caller must run this in an executor, never on the
|
||||
event loop.
|
||||
|
||||
Single-threaded convenience wrapper around the phase split above
|
||||
(subscribe_hrefs / await_observe_notifies / enter_observe_mode /
|
||||
abandon_observe_attempt) for callers -- direct and most existing
|
||||
tests -- that don't need the lock-scoping those phases exist for."""
|
||||
subscribed = self.subscribe_hrefs(session, hrefs)
|
||||
if not subscribed:
|
||||
self.abandon_observe_attempt()
|
||||
return False
|
||||
if self.await_observe_notifies(subscribed, grace_period_s, success_fraction):
|
||||
self.enter_observe_mode(session, subscribed)
|
||||
return True
|
||||
self.abandon_observe_attempt()
|
||||
return False
|
||||
|
||||
def _set_mode(self, mode: str) -> None:
|
||||
|
||||
@@ -225,13 +225,16 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
|
||||
"oic.d.dishwasher": "dishwasher",
|
||||
"oic.d.dryer": "dryer",
|
||||
"oic.d.oven": "oven",
|
||||
"oic.d.range": "range", # issue #324 -- oven+cooktop combo, no /information/vs/0
|
||||
"oic.d.refrigerator": "refrigerator",
|
||||
"oic.d.krefrigerator": "refrigerator", # issue #328 -- kimchi refrigerator
|
||||
"oic.d.washer": "washer",
|
||||
"x.com.st.d.airqualitysensor": "air_monitor",
|
||||
"x.com.st.d.dehumidifier": "dehumidifier",
|
||||
"x.com.st.d.hood": "range_hood", # AHD-WW-TP1-22-COMMON
|
||||
"x.com.st.d.stickcleaner": "vacuum_station",
|
||||
"x.com.st.d.steamcloset": "air_dresser",
|
||||
"x.com.st.d.winecellar": "refrigerator", # issue #328 -- same TP1X_REF_21K board
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ uses unconditionally.
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
|
||||
"""
|
||||
|
||||
from ..capabilities import airconditioner, common, dishwasher, ignored
|
||||
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
@@ -46,6 +46,28 @@ REGISTRY = DeviceRegistry(
|
||||
airconditioner.CURRENT_TEMPERATURE,
|
||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||
airconditioner.HUMIDITY,
|
||||
# TP1X_DA-AC-FAC-class (issue #319): shares its /display/vs/0,
|
||||
# /settings/sound/output/vs/0 and /settings/sound/volume/vs/0
|
||||
# shape with the sibling TP1X_DA-AC-AIR board in air_purifier.py.
|
||||
air_purifier.DISPLAY,
|
||||
air_purifier.SOUND_OUTPUT,
|
||||
air_purifier.SOUND_VOLUME,
|
||||
airconditioner.SOUND_MODE,
|
||||
airconditioner.ABSENCE_CLEAN,
|
||||
airconditioner.MDS_ABSENCE_CLEAN,
|
||||
airconditioner.ENERGY_SAVING,
|
||||
airconditioner.EDGE_LIGHTING,
|
||||
airconditioner.LIGHT_STATEFUL,
|
||||
# System Fresh Air Ventilator (PR #316, ACA-KR-TP2-21-AN9000):
|
||||
# WINDFREE/WINDSLEEP are this device's own hrefs; HEPA_FILTER/
|
||||
# DEVICE_ACTIVE reuse air_purifier.py's identical shapes.
|
||||
# AIR_LEVEL_CHECK is not this-device-specific -- see its
|
||||
# removal from _AC_IGNORED above.
|
||||
airconditioner.WINDFREE,
|
||||
airconditioner.WINDSLEEP,
|
||||
air_purifier.HEPA_FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIR_LEVEL_CHECK,
|
||||
*airconditioner.COVERAGE,
|
||||
]
|
||||
),
|
||||
|
||||
@@ -22,6 +22,12 @@ REGISTRY = DeviceRegistry(
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
# issue #314: /alarms/vs/0 and /kidslock/vs/0 are the same
|
||||
# generic shapes common.UNIVERSAL already models elsewhere --
|
||||
# picked individually rather than pulling in all of UNIVERSAL,
|
||||
# matching this registry's existing hand-picked-common style.
|
||||
common.ALARMS,
|
||||
common.KIDS_LOCK_VS_FALLBACK,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Oven device registry."""
|
||||
|
||||
from ..capabilities import common, ignored, oven
|
||||
from ..capabilities import common, dishwasher, ignored, oven
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
@@ -18,6 +18,9 @@ REGISTRY = DeviceRegistry(
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
# issue #300: /diagnosis/vs/0 is the same diagnosisStart shape
|
||||
# dishwasher.py and airconditioner.py already reuse.
|
||||
dishwasher.DIAGNOSIS,
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,11 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.STATUS_LOCK,
|
||||
fridge.DOOR_ALERT,
|
||||
common.WATER_FILTER,
|
||||
fridge.AIR_FILTER,
|
||||
fridge.DEODOR_FILTER,
|
||||
fridge.AUTO_DOOR_TIMER,
|
||||
fridge.WINECELLAR_PANTRY_ZONE,
|
||||
fridge.WINECELLAR_INFO,
|
||||
dishwasher.DIAGNOSIS,
|
||||
fridge.ICEMAKER_NIGHTTIME,
|
||||
fridge.FLEX_ZONE,
|
||||
@@ -43,5 +48,6 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.DOOR_GENERIC,
|
||||
fridge.KIMCHI_ZONE,
|
||||
fridge.KIMCHI_DOOR_GENERIC,
|
||||
fridge.AUTO_DOOR_VARIANT,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -31,6 +31,12 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
from .air_purifier import _AIR_QUALITY_SENSORS
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
# _AIR_QUALITY_SENSORS' fourth column (state_class) is deliberately discarded
|
||||
# here: air_purifier leaves Odor/CleanLevel unstamped because they read as
|
||||
# graded indices on that family, while this board has stamped all five as
|
||||
# `measurement` since it was added (issue #210). Consuming the column would
|
||||
# silently drop long-term statistics for two sensors on shipped devices, so
|
||||
# the shared rows supply only the key/icon/type here.
|
||||
SENSORS = Capability(
|
||||
href="/sensors/vs/0",
|
||||
poll_tier="warm",
|
||||
@@ -43,7 +49,7 @@ SENSORS = Capability(
|
||||
state_class="measurement",
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type, _ in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
SensorDesc(
|
||||
key="co2",
|
||||
|
||||
@@ -51,12 +51,31 @@ def _has_top_level_modes(rep, resources):
|
||||
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
|
||||
|
||||
|
||||
# The fourth column is state_class, which is what makes Home Assistant keep
|
||||
# long-term statistics for a sensor -- without one, a reading is only in the
|
||||
# short-term recorder history and disappears with the next purge (10 days by
|
||||
# default), so it can't back a long-range air-quality graph. The values are
|
||||
# already numeric (sensor_item_value returns int), so nothing else was in the
|
||||
# way; three sensors in this same module (filter_progress, fan_speed_level,
|
||||
# hepa_filter_usage) already declare one.
|
||||
#
|
||||
# Only the three particulate readings get it. They fall monotonically with
|
||||
# particle size on three independent board families -- 11/9/5 on ARTIK051_TVTL
|
||||
# (issue #56), 10/9/6 on AVT-WW-TP1 (issue #190), 18/14/9 on the range hood --
|
||||
# which is concentration behaviour, and an average over time is meaningful for
|
||||
# it. Odor and CleanLevel read 0-2 on every fixture and look like graded
|
||||
# indices instead, where the mean of a grade isn't obviously meaningful; left
|
||||
# without a state_class rather than guessing.
|
||||
#
|
||||
# Deliberately no device_class/unit here: pm1/pm25/pm10 would assert the
|
||||
# reading is a µg/m³ concentration, and the dumps never say so. That's a
|
||||
# separate call from making the series recordable at all.
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
("dust", "mdi:blur", "Dust"),
|
||||
("fine_dust", "mdi:blur", "FineDust"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust"),
|
||||
("odor", "mdi:scent", "Odor"),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel"),
|
||||
("dust", "mdi:blur", "Dust", "measurement"),
|
||||
("fine_dust", "mdi:blur", "FineDust", "measurement"),
|
||||
("super_fine_dust", "mdi:blur", "SuperFineDust", "measurement"),
|
||||
("odor", "mdi:scent", "Odor", None),
|
||||
("clean_level", "mdi:air-filter", "CleanLevel", None),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
@@ -67,9 +86,10 @@ AIR_QUALITY = Capability(
|
||||
key=key,
|
||||
field="x.com.samsung.da.items",
|
||||
icon=icon,
|
||||
state_class=state_class,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
|
||||
)
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
for key, icon, sensor_type, state_class in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
@@ -421,6 +441,10 @@ SOUND_VOLUME = Capability(
|
||||
field="level",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
# Some boards (issue #319's AC) report minLevel/resolution but no
|
||||
# maxLevel -- native_max_fn would silently collapse to 0, giving a
|
||||
# slider with no real range instead of no entity at all.
|
||||
exists_fn=lambda rep, resources: "maxLevel" in rep,
|
||||
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
|
||||
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
|
||||
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
|
||||
|
||||
@@ -9,6 +9,7 @@ here off the coordinator snapshot. These caps stay out of the global
|
||||
capabilities/__init__.py) -- AC-only, by_type registry only.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import replace
|
||||
|
||||
from ..capability import Capability
|
||||
@@ -22,7 +23,7 @@ from ..entities import (
|
||||
SwitchDesc,
|
||||
)
|
||||
from . import common
|
||||
from .common import filter_usage_percent, normalize_temp_unit
|
||||
from .common import filter_usage_hours, filter_usage_percent, normalize_temp_unit
|
||||
from .laundry import option_write
|
||||
|
||||
|
||||
@@ -206,6 +207,28 @@ def _mode_options(rep):
|
||||
return opts if isinstance(opts, (list, tuple)) else ()
|
||||
|
||||
|
||||
# Samsung's "System Fresh Air Ventilator" (PR #316, model
|
||||
# ACA-KR-TP2-21-AN9000, vid DA-AC-DIFFUSER-01001) self-reports oic.d.
|
||||
# airconditioner and routes through this same CLIMATE capability, but its
|
||||
# /mode/vs/0 supportedModes are Purification/Ventilation/SmartVentilation --
|
||||
# none of which climate.py's HVAC-mode table knows, so hvac_mode collapses
|
||||
# to a single stuck value with no way to tell the three apart. Gated to
|
||||
# devices whose *entire* supported-mode set is this vocabulary, so it can't
|
||||
# false-positive on a real AC's Cool/Heat/Dry list.
|
||||
_VENTILATION_MODE_VALUES = frozenset(("Purification", "Ventilation", "SmartVentilation"))
|
||||
|
||||
|
||||
def _is_ventilation_mode_device(rep, resources):
|
||||
supported = rep.get("x.com.samsung.da.supportedModes")
|
||||
if not isinstance(supported, (list, tuple)) or not supported:
|
||||
return False
|
||||
return set(supported) <= _VENTILATION_MODE_VALUES
|
||||
|
||||
|
||||
def _ventilation_mode_write(payload, rep, href=None):
|
||||
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [payload]}
|
||||
|
||||
|
||||
def _has_display_light_option(rep, resources):
|
||||
"""True when the panel light lives in /mode/vs/0's `Light_*` option
|
||||
token rather than a dedicated /light/vs/0 switch -- the two encodings
|
||||
@@ -245,6 +268,59 @@ def _option_token(rep, prefix):
|
||||
return None
|
||||
|
||||
|
||||
def option_bit(rep, prefix, index, width):
|
||||
"""One capability bit out of the `OptionCode_<n>` / `ExtendOptionCode_<n>` token.
|
||||
|
||||
The appliance packs which features it has into two integers. Its own app reads
|
||||
them by turning the number into binary, left-padding to a fixed width, and
|
||||
indexing that *string* -- so index 0 is the most significant bit, and the
|
||||
indices below are the app's own (`racOptionCodeValue[12]`, and so on), kept
|
||||
identical so the two can be compared line by line.
|
||||
|
||||
Returns None when the token is absent, which is not the same as a zero: a bit
|
||||
that is present and 0 says the feature is missing, while no token at all says
|
||||
only that this board does not publish the map.
|
||||
"""
|
||||
raw = _option_token(rep, prefix)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
bits = format(int(raw), f"0{width}b")
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if len(bits) != width or not 0 <= index < width:
|
||||
return None # a number too wide for the map is not one we can read
|
||||
return bits[index] == "1"
|
||||
|
||||
|
||||
def option_code_bit(rep, index):
|
||||
"""A bit of the 16-wide `OptionCode` map."""
|
||||
return option_bit(rep, "OptionCode", index, 16)
|
||||
|
||||
|
||||
def extend_option_code_bit(rep, index):
|
||||
"""A bit of the 32-wide `ExtendOptionCode` map."""
|
||||
return option_bit(rep, "ExtendOptionCode", index, 32)
|
||||
|
||||
|
||||
def has_option_code(rep):
|
||||
"""Whether this board publishes the 16-wide capability map at all."""
|
||||
return _option_token(rep, "OptionCode") is not None
|
||||
|
||||
|
||||
def has_extend_option_code(rep):
|
||||
"""Whether this board publishes the 32-wide capability map at all.
|
||||
|
||||
Its own name in the app is "Single RAC new option code, as old option code
|
||||
is full", and every RAC-class dump on record carries it while the FAC/CAC
|
||||
ones carry only the older map with values small enough that RAC bit
|
||||
positions read as zeros. So its presence is the closest thing available to
|
||||
"this is the family those bit positions were documented for" -- a proxy,
|
||||
not a proof, and used only to decide whether to read the map at all.
|
||||
"""
|
||||
return _option_token(rep, "ExtendOptionCode") is not None
|
||||
|
||||
|
||||
def is_legacy_board(resources):
|
||||
"""True for the board generation whose airflow lives in /airflow/vs/0
|
||||
rather than /wind/strength/vs/0 -- every AC dump on record has one shape
|
||||
@@ -311,16 +387,78 @@ def _option_switch_write(prefix):
|
||||
return write
|
||||
|
||||
|
||||
def _option_number_write(prefix):
|
||||
def _option_number_write(prefix, factor=1):
|
||||
"""Write a numeric options token. `factor` converts the entity's unit into the
|
||||
token's own: good_sleep is offered in hours while the token counts half hours."""
|
||||
|
||||
def write(payload, rep, href=None):
|
||||
return (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": option_write(prefix, str(round(float(payload))))},
|
||||
{"x.com.samsung.da.options": option_write(prefix, str(round(float(payload) * factor)))},
|
||||
)
|
||||
|
||||
return write
|
||||
|
||||
|
||||
def _good_sleep_write(payload, rep, href=None):
|
||||
"""Good Sleep needs its mode token in the same write as its duration.
|
||||
|
||||
`Sleep_<n>` on its own is answered 2.04 Changed and then thrown away:
|
||||
measured on an ARTIK051_KRAC_18K, writing `["Sleep_4"]` left the token at
|
||||
`Sleep_0` at both +8s and +45s, while the same value written together with
|
||||
`Comode_Sleep` held. So the number is a parameter of the mode, not a
|
||||
setting of its own, and the appliance's app never sends one without the
|
||||
other either.
|
||||
|
||||
Which mode token goes with it depends on nano wind, the way the app decides
|
||||
it: nano and Good Sleep share the single `Comode_` slot, so running both is
|
||||
`Comode_NanoSleep`, and switching the timer off while nano is on leaves nano
|
||||
running rather than turning everything off.
|
||||
"""
|
||||
half_hours = round(float(payload) * 2)
|
||||
nano = _option_token(rep, "Comode") in ("Nano", "NanoSleep")
|
||||
if half_hours:
|
||||
comode = "Comode_NanoSleep" if nano else "Comode_Sleep"
|
||||
else:
|
||||
comode = "Comode_Nano" if nano else "Comode_Off"
|
||||
return (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": [comode, f"Sleep_{half_hours}"]},
|
||||
)
|
||||
|
||||
|
||||
# What the appliance itself picks when a Good Sleep mode is asked for with no
|
||||
# duration to go with it: writing a bare `Comode_Nano` over a live
|
||||
# `Comode_Sleep`/`Sleep_4` came back as `Comode_NanoSleep`/`Sleep_16`. Used only
|
||||
# when a sleep preset is selected while the timer reads 0.
|
||||
_DEFAULT_SLEEP_HALF_HOURS = 16
|
||||
|
||||
|
||||
def _preset_options(code, rep):
|
||||
"""The options array for a legacy preset write.
|
||||
|
||||
One `Comode_` token has to express both nano wind and Good Sleep, so
|
||||
selecting nano while the timer is running means `Comode_NanoSleep` -- and it
|
||||
has to carry the duration, because the board otherwise supplies its own.
|
||||
Measured: `["Comode_Nano"]` written over `Comode_Sleep`/`Sleep_4` came back
|
||||
as `Comode_NanoSleep`/`Sleep_16`, silently turning the user's two hours into
|
||||
eight. Writing the pair keeps the two hours.
|
||||
|
||||
Leaving a sleep mode needs no such care: the board zeroes the duration by
|
||||
itself. Measured on the same unit -- a bare `["Comode_Off"]` written over
|
||||
`Comode_Sleep`/`Sleep_4` read back as `Comode_Off`/`Sleep_0` at +8s and +38s
|
||||
-- so the `none` preset cannot leave a stale token behind for the next nano
|
||||
selection to pick up as a running timer.
|
||||
"""
|
||||
sleep = _option_token(rep, "Sleep")
|
||||
running = sleep not in (None, "0")
|
||||
if code == "Nano" and running:
|
||||
code = "NanoSleep"
|
||||
if code in ("Sleep", "NanoSleep"):
|
||||
return [f"Comode_{code}", f"Sleep_{sleep if running else _DEFAULT_SLEEP_HALF_HOURS}"]
|
||||
return option_write("Comode", code)
|
||||
|
||||
|
||||
def _odor_controller_active(rep):
|
||||
"""Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>`
|
||||
option token (matches the SmartThings cloud's airConditionerOdorController
|
||||
@@ -354,7 +492,68 @@ def _humidity(rep):
|
||||
return None
|
||||
|
||||
|
||||
def _climate_write(payload, rep, href=None):
|
||||
def _quantize_temperature(value, step):
|
||||
"""Quantize a temperature to the device's advertised step size.
|
||||
|
||||
Mirrors climate.py's `target_temperature_step`, which defaults to `1.0`
|
||||
when no increment is advertised anywhere (`step=None` or `<=0` here) --
|
||||
so a board with no increment still gets whole-degree writes instead of
|
||||
the raw value passed through unrounded. Returns `None` for a payload
|
||||
that isn't numeric, so the caller can reject the write instead of
|
||||
building a body around a null (coordinator.py's async_send_command
|
||||
already logs and drops a write_fn result of None).
|
||||
|
||||
Normalizes an integral result to `int` here, once, so both write
|
||||
branches serialize `24` rather than `24.0` -- `round(..., 2)` guards
|
||||
against float noise in the division (e.g. `21.7 / 0.1`).
|
||||
"""
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not math.isfinite(numeric):
|
||||
# float('nan')/float('inf') pass the try/except above (they're
|
||||
# valid floats) but round()/division on them raises ValueError/
|
||||
# OverflowError instead of returning -- reject here so a bad
|
||||
# payload always comes back as a clean None, not an exception
|
||||
# out of write_fn.
|
||||
return None
|
||||
step_value = _num(step) or 1.0
|
||||
if step_value <= 0:
|
||||
step_value = 1.0
|
||||
quantized = round(round(numeric / step_value) * step_value, 2)
|
||||
return int(quantized) if quantized.is_integer() else quantized
|
||||
|
||||
|
||||
def _temperature_step(resources):
|
||||
"""Device-reported temperature increment, read the same way and in the
|
||||
same order as climate.py's `target_temperature_step`: OCF
|
||||
`/temperature/control/vs/0`'s `increment` (or its vendor-prefixed
|
||||
twin), falling back to vendor `/temperatures/vs/0`. The increment on
|
||||
that second resource lives inside its `items[]` array like every other
|
||||
per-item field, not at the resource's top level -- unwrapped via
|
||||
`_temps_vs_item()`, the same helper `_temps_vs_current`/`_temps_vs_unit`
|
||||
use above, rather than read off `resource` directly.
|
||||
|
||||
`rep` (the bound entity's own resource, `/mode/vs/0` for ClimateDesc --
|
||||
see rep_fn=_first_mode below) never carries an increment, so it isn't a
|
||||
candidate here; only the coordinator's `resources` snapshot is."""
|
||||
if not isinstance(resources, dict):
|
||||
return None
|
||||
control = resources.get(HREF_TEMP_CONTROL)
|
||||
if isinstance(control, dict):
|
||||
step = _num(control.get("increment")) or _num(control.get("x.com.samsung.da.increment"))
|
||||
if step is not None:
|
||||
return step
|
||||
temps_vs = resources.get(HREF_TEMPS_VS)
|
||||
if isinstance(temps_vs, dict):
|
||||
step = _num(_temps_vs_item(temps_vs).get("x.com.samsung.da.increment"))
|
||||
if step is not None:
|
||||
return step
|
||||
return None
|
||||
|
||||
|
||||
def _climate_write(payload, rep, href=None, resources=None):
|
||||
"""Maps a (kind, value) command from the climate platform to the
|
||||
(path_segs, body) for that one sub-write; `value` is already the raw
|
||||
device code. Power always goes to vendor `/power/vs/0` (OCF `/power/0`
|
||||
@@ -367,9 +566,12 @@ def _climate_write(payload, rep, href=None):
|
||||
return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
|
||||
if kind == "mode":
|
||||
return (["mode", "vs", "0"], {"x.com.samsung.da.modes": [value]})
|
||||
if kind == "temperature_ocf":
|
||||
return (["temperature", "desired", "0"], {"temperature": round(float(value))})
|
||||
if kind == "temperature":
|
||||
if kind in ("temperature_ocf", "temperature"):
|
||||
quantized = _quantize_temperature(value, _temperature_step(resources))
|
||||
if quantized is None:
|
||||
return None
|
||||
if kind == "temperature_ocf":
|
||||
return (["temperature", "desired", "0"], {"temperature": quantized})
|
||||
# Vendor items[] array; only one item observed on every AC dump, id '0'.
|
||||
return (
|
||||
["temperatures", "vs", "0"],
|
||||
@@ -377,7 +579,7 @@ def _climate_write(payload, rep, href=None):
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.desired": str(round(float(value))),
|
||||
"x.com.samsung.da.desired": str(quantized),
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -401,7 +603,7 @@ def _climate_write(payload, rep, href=None):
|
||||
if kind == "swing_legacy":
|
||||
return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value})
|
||||
if kind == "preset_legacy":
|
||||
return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Comode", value)})
|
||||
return (["mode", "vs", "0"], {"x.com.samsung.da.options": _preset_options(value, rep)})
|
||||
if kind == "preset":
|
||||
return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value})
|
||||
return None
|
||||
@@ -417,6 +619,17 @@ CLIMATE = Capability(
|
||||
rep_fn=_first_mode,
|
||||
write_fn=_climate_write,
|
||||
),
|
||||
# Purification/Ventilation/SmartVentilation mode select (PR #316) --
|
||||
# _is_ventilation_mode_device gates this to devices using that
|
||||
# vocabulary exclusively, so a real AC's climate card is unaffected.
|
||||
SelectDesc(
|
||||
key="ventilation_mode",
|
||||
rep_fn=_first_mode,
|
||||
exists_fn=_is_ventilation_mode_device,
|
||||
options_field="x.com.samsung.da.supportedModes",
|
||||
icon="mdi:air-filter",
|
||||
write_fn=_ventilation_mode_write,
|
||||
),
|
||||
# Panel light switch for boards that encode it in /mode/vs/0's options
|
||||
# instead of a dedicated /light/vs/0 (see _has_display_light_option).
|
||||
# Shares the switch.display_light translation key with DISPLAY_LIGHT
|
||||
@@ -528,16 +741,34 @@ CLIMATE = Capability(
|
||||
icon="mdi:air-filter",
|
||||
entity_category="config",
|
||||
),
|
||||
# "Good Sleep" timer. 0 = off; the upper bound is a guess (only 0 has
|
||||
# been observed on hardware), so a write above 0 is unverified.
|
||||
# "Good Sleep" timer, offered in hours. 0 = off.
|
||||
#
|
||||
# The token counts *half* hours, so it is halved on the way in and doubled
|
||||
# on the way out. The appliance's own app pairs a picker of durations with
|
||||
# the values it puts on the wire, one to one:
|
||||
#
|
||||
# 0:00 0:30 1:00 1:30 2:00 2:30 3:00 4:00 5:00 ... 12:00
|
||||
# 0 1 2 3 4 5 6 8 10 ... 24
|
||||
#
|
||||
# which also pins the maximum at 12 hours (its own help text says so:
|
||||
# "will be turned off after a selected period of time (Max. 12 hours)").
|
||||
# Before this the token was published as if it were hours: the entity
|
||||
# capped at 12, which set six, and twelve hours could not be asked for at
|
||||
# all.
|
||||
#
|
||||
# Half-hour steps are what the app offers below three hours; above that it
|
||||
# offers whole hours only, and a half hour up there is untested rather than
|
||||
# known-bad. A Number cannot change step part-way, and turning this into a
|
||||
# Select of the app's sixteen values would change the entity's domain on
|
||||
# every unit that already has it, so the step stays 0.5 throughout.
|
||||
NumberDesc(
|
||||
key="good_sleep",
|
||||
rep_fn=_option_token_num("Sleep"),
|
||||
rep_fn=_option_token_num("Sleep", divisor=2),
|
||||
exists_fn=_has_option_token("Sleep"),
|
||||
write_fn=_option_number_write("Sleep"),
|
||||
write_fn=_good_sleep_write,
|
||||
native_min=0,
|
||||
native_max=12,
|
||||
step=1,
|
||||
step=0.5,
|
||||
unit="h",
|
||||
icon="mdi:sleep",
|
||||
entity_category="config",
|
||||
@@ -688,16 +919,17 @@ AIR_FILTER = Capability(
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
# Lifetime hour counter, resets only on filter replacement.
|
||||
# Lifetime hour counter, resets only on filter replacement. Derived
|
||||
# from the percentage and filterCapacity (issue #330) -- filterUsage
|
||||
# itself is not an hour count.
|
||||
SensorDesc(
|
||||
key="air_filter_usage_hours",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
rep_fn=filter_usage_hours,
|
||||
device_class="duration",
|
||||
state_class="total_increasing",
|
||||
unit_fn=_filter_unit,
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_int,
|
||||
),
|
||||
# Locally writable alarm threshold (see _threshold_write); only
|
||||
# surfaces where supportedFilterDesiredUsage is advertised.
|
||||
@@ -759,13 +991,12 @@ AIR_FILTER_PM1 = Capability(
|
||||
),
|
||||
SensorDesc(
|
||||
key="air_filter_pm1_usage_hours",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
rep_fn=filter_usage_hours,
|
||||
device_class="duration",
|
||||
state_class="total_increasing",
|
||||
unit_fn=_filter_unit,
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=_int,
|
||||
exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"),
|
||||
),
|
||||
SelectDesc(
|
||||
@@ -1034,6 +1265,253 @@ HUMIDITY = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# TP1X_DA-AC-FAC-class additions (issue #319): most of these hrefs are the
|
||||
# same shapes air_purifier.py already models on the sibling TP1X_DA-AC-AIR
|
||||
# board (DISPLAY/SOUND_OUTPUT/SOUND_VOLUME, reused directly in the
|
||||
# registry); SOUND_MODE and the two below are genuinely new.
|
||||
|
||||
SOUND_MODE = Capability(
|
||||
href="/settings/sound/mode/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
# Values seen (mute/tone/voice) are exactly laundry.SOUND_MODE's
|
||||
# vocabulary, so this shares that catalog entry -- but reads the
|
||||
# live supportedModes field rather than laundry's static tuple,
|
||||
# since this resource carries one (issue #319).
|
||||
#
|
||||
# exists_fn is required, not optional here: this board's rep never
|
||||
# reports a live 'mode' value ({"supportedModes": [...]} only), and
|
||||
# entity.py's default field-presence gate would otherwise keep the
|
||||
# select from ever registering -- adapter.flatten() (what the
|
||||
# golden/tests read) has no such gate, so it would look bound while
|
||||
# silently absent from HA. Register on supportedModes' presence
|
||||
# instead; current_option reads unknown until the device reports
|
||||
# 'mode' live.
|
||||
SelectDesc(
|
||||
key="sound_mode",
|
||||
field="mode",
|
||||
icon="mdi:volume-high",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
exists_fn=lambda rep, resources: bool(rep.get("supportedModes")),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["settings", "sound", "mode", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Absence-detection auto air clean (issue #319) -- a plain On/Off toggle,
|
||||
# sibling feature to ABSENCE_POWER_SAVING above but on its own href.
|
||||
ABSENCE_CLEAN = Capability(
|
||||
href="/csi/absenceclean/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="absence_clean",
|
||||
field="mode",
|
||||
icon="mdi:broom",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["csi", "absenceclean", "vs", "0"],
|
||||
{"mode": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# The CAC-class board (issue #191) reports the identical {mode,
|
||||
# supportedModes: [On, Off]} shape under /mds/absenceclean/vs/0 instead --
|
||||
# confirmed against that board's own fixture, not guessed. Shares
|
||||
# ABSENCE_CLEAN's key/translation: no dump has ever reported both hrefs
|
||||
# together, so there's nothing for the two to collide over in
|
||||
# adapter.flatten().
|
||||
MDS_ABSENCE_CLEAN = Capability(
|
||||
href="/mds/absenceclean/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="absence_clean",
|
||||
field="mode",
|
||||
icon="mdi:broom",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["mds", "absenceclean", "vs", "0"],
|
||||
{"mode": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Energy-saving schedule (issue #319). `mode` is a device-chosen preset
|
||||
# (e.g. Cooling_60/Off_180) with no confirmed unit for the trailing number
|
||||
# (minutes seen elsewhere on this board are unprefixed ints, not
|
||||
# underscore-suffixed) -- exposed as a select over the live options rather
|
||||
# than translating labels we can't confirm. `state`/`operatingStatus` stay
|
||||
# bare diagnostic passthroughs for the same reason.
|
||||
ENERGY_SAVING = Capability(
|
||||
href="/csi/energysaving/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SelectDesc(
|
||||
key="energy_saving_mode",
|
||||
field="mode",
|
||||
icon="mdi:leaf",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["csi", "energysaving", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key="energy_saving_state",
|
||||
field="state",
|
||||
icon="mdi:leaf",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
SensorDesc(
|
||||
key="energy_saving_operating_status",
|
||||
field="operatingStatus",
|
||||
icon="mdi:leaf",
|
||||
entity_category="diagnostic",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# TP1X_DA-AC-CAC-01001-class additions (issue #288, six System A/C cassette
|
||||
# units on the same board test_airconditioner_cac.py's coverage-gap test
|
||||
# documents). `convenientMode`/`operatingOption` stay unexposed -- present
|
||||
# on every dump seen but no evidence of what either actually controls.
|
||||
EDGE_LIGHTING = Capability(
|
||||
href="/edgelighting/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="edge_lighting",
|
||||
field="status",
|
||||
icon="mdi:led-strip",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["edgelighting", "vs", "0"],
|
||||
{"status": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="edge_lighting_mode",
|
||||
field="mode",
|
||||
icon="mdi:led-strip-variant",
|
||||
entity_category="config",
|
||||
options_field="modeSupportedList",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["edgelighting", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
# Color temperature in Kelvin (3000K/4000K/6500K), not a hue -- a
|
||||
# select over the live-reported codes rather than a light color_temp
|
||||
# entity, consistent with this project's other Kelvin-coded selects.
|
||||
SelectDesc(
|
||||
key="edge_lighting_color",
|
||||
field="colorOption",
|
||||
icon="mdi:palette",
|
||||
entity_category="config",
|
||||
options_field="colorSupportedList",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["edgelighting", "vs", "0"],
|
||||
{"colorOption": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Second, distinct light resource on this board generation -- an
|
||||
# always-on-style indicator light with its own status/mode, not to be
|
||||
# confused with EDGE_LIGHTING (a different href/rep entirely) or
|
||||
# DISPLAY_LIGHT (/light/vs/0's ambient mood light).
|
||||
LIGHT_STATEFUL = Capability(
|
||||
href="/light/stateful/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="indicator_light",
|
||||
field="status",
|
||||
icon="mdi:led-on",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["light", "stateful", "vs", "0"],
|
||||
{"status": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key="indicator_light_mode",
|
||||
field="mode",
|
||||
icon="mdi:led-variant-on",
|
||||
entity_category="config",
|
||||
options_field="supportedModes",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["light", "stateful", "vs", "0"],
|
||||
{"mode": p},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Wind-Free / Wind-Sleep mode toggles (PR #316, ACA-KR-TP2-21-AN9000). Each
|
||||
# on its own dedicated href, so unlike ventilation_mode above these need no
|
||||
# device gating -- absent on every other family's dump. Write contract
|
||||
# extrapolated from this file's other plain On/Off options-array fields
|
||||
# (AIR_PURIFY, AUTO_CLEAN); not confirmed live.
|
||||
#
|
||||
# NOT the same WindFree already modeled elsewhere: on regular AC boards,
|
||||
# WindFree is a `Comode_Nano` token inside /mode/vs/0's options[], surfaced
|
||||
# as a climate preset (climate.py's _LEGACY_PRESET_CODES/preset_mode) with
|
||||
# real coupling to hvac_mode (disabled in Heat/AIComfort/Auto, timing rules
|
||||
# on legacy boards). This device's windfree/windsleep are bare booleans on
|
||||
# their own hrefs with no such coupling evidenced -- same feature name,
|
||||
# different wire mechanism, so plain switches rather than folding into
|
||||
# climate.py's preset machinery.
|
||||
WINDFREE = Capability(
|
||||
href="/modeoption/windfree/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="windfree",
|
||||
field="x.com.samsung.da.windfree",
|
||||
icon="mdi:leaf",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["modeoption", "windfree", "vs", "0"],
|
||||
{"x.com.samsung.da.windfree": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
WINDSLEEP = Capability(
|
||||
href="/modeoption/windsleep/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key="windsleep",
|
||||
field="x.com.samsung.da.windsleep",
|
||||
icon="mdi:sleep",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
["modeoption", "windsleep", "vs", "0"],
|
||||
{"x.com.samsung.da.windsleep": "On" if p == "On" else "Off"},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is
|
||||
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar,
|
||||
# so it's a measurement; the others stay string diagnostics (see
|
||||
@@ -1070,6 +1548,27 @@ AIR_QUALITY = Capability(
|
||||
("super_fine_dust", "mdi:weather-fog", "SuperFineDust"),
|
||||
)
|
||||
),
|
||||
# CO2 (PR #316, ACA-KR-TP2-21-AN9000) -- a type this file's other AC
|
||||
# families don't report. Same field/shape air_monitor.SENSORS
|
||||
# already models with device_class='carbon_dioxide'/unit='ppm', so
|
||||
# this matches that descriptor rather than guessing fresh -- unlike
|
||||
# the pm10/pm25/pm1 mapping air_monitor.py's own docstring
|
||||
# deliberately rejects for the three dust-type keys above (Samsung's
|
||||
# two-tier PM10/PM2.5 convention doesn't confirm where a third tier
|
||||
# or PM1 fits), ppm for a field literally named CO2 isn't a guess of
|
||||
# that kind.
|
||||
SensorDesc(
|
||||
key="co2",
|
||||
field="x.com.samsung.da.items",
|
||||
icon="mdi:molecule-co2",
|
||||
entity_category="diagnostic",
|
||||
device_class="carbon_dioxide",
|
||||
state_class="measurement",
|
||||
unit="ppm",
|
||||
exists_fn=_has_sensor_type("CO2"),
|
||||
enabled_default=False,
|
||||
value_fn=lambda items: _int(_sensor_item_value(items, "CO2")),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1089,7 +1588,12 @@ _AC_IGNORED = [
|
||||
# state or documented write contract. /option/muteonce/vs/0 and
|
||||
# /selfcheck/vs/0 are deliberately NOT here -- see MUTE_ONCE above and
|
||||
# common.SELF_CHECK, both of which have a confirmed, modelable contract.
|
||||
"/airlevelcheck/vs/0", # periodic air-quality sensing scheduler plumbing
|
||||
# /airlevelcheck/vs/0 is deliberately NOT here either (PR #316):
|
||||
# despite this list's old description of it as "scheduler plumbing",
|
||||
# both the CAC and TP1X_DA_AC_RAC_01011 fixtures already carry real,
|
||||
# populated periodicSensingActivationState/autoExeState values here --
|
||||
# the AI-Purify feature air_purifier.AIR_LEVEL_CHECK already models,
|
||||
# reused below rather than reinvented.
|
||||
"/aisleep/vs/0", # AI-sleep feedback state (no actionable control)
|
||||
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap
|
||||
"/da/softreset/vs/0", # soft-reset trigger plumbing
|
||||
@@ -1117,6 +1621,24 @@ _AC_IGNORED = [
|
||||
# registry.subdevices.enumerate_subdevices, hence the entry here rather
|
||||
# than a coverage gap.
|
||||
"/multidevice/vs/0",
|
||||
# TP1X_DA-AC-FAC-class-only (issue #319) -- scoped here rather than
|
||||
# promoted to the global ignore list since it'd collide with families
|
||||
# that do bind some of these. Only /dnd/autosleep/vs/0 has a precedent
|
||||
# (air_purifier.COVERAGE ignores the same href for the same reason);
|
||||
# the rest are new, each with its own reason below.
|
||||
"/dnd/autosleep/vs/0", # every field its inert default; needs a schedule editor
|
||||
"/outdoorsharing/vs/0", # empty on this dump -- outdoor-unit sharing plumbing
|
||||
"/lifestyle/survey/vs/0", # {list: [""]} placeholder, nothing to expose
|
||||
# supportedVoices carries opaque numeric voice-pack IDs ("100"/"101")
|
||||
# with no live current-selection field and, unlike SOUND_MODE's
|
||||
# self-descriptive mute/tone/voice codes, no confirmed human-readable
|
||||
# meaning to expose them under -- don't guess.
|
||||
"/settings/sound/voice/vs/0",
|
||||
# rssi/wifiFrequency (network housekeeping); lastEnergySavingTime and
|
||||
# cleaningStartTime are inert '1900-01-00' placeholders on this dump;
|
||||
# absenceInfo is an unconfirmed 48-slot P/A history blob with no
|
||||
# documented meaning -- don't guess what it encodes.
|
||||
"/csi/information/vs/0",
|
||||
]
|
||||
|
||||
# Built as bare no-entity caps; folded into the AC registry (not global).
|
||||
|
||||
@@ -72,15 +72,25 @@ def epoch_to_utc(value):
|
||||
|
||||
|
||||
def filter_usage_percent(rep):
|
||||
"""Filter usage as a percentage of rated capacity. Several families
|
||||
report `filterUsage` as a raw count in `filterCapacityUnit` (e.g. 100 of
|
||||
a 500-hour capacity), so a plain value with a '%' unit would be wrong.
|
||||
Returns None when capacity is missing/zero."""
|
||||
used = _num(rep.get("x.com.samsung.da.filterUsage"))
|
||||
"""Filter usage as a percentage. `filterUsage` is already 0-100 on every
|
||||
family confirmed so far, including ARTIK051_PRAC (issue #330): its own
|
||||
fixture and three live heads all show `filterStatus == 'wash'` at
|
||||
`filterUsage == '100'` regardless of `filterCapacity` (60/224/500 across
|
||||
other families' fixtures), which only holds if `filterUsage` is already
|
||||
a percent -- dividing by capacity again would read that filter as fresh
|
||||
at 20%."""
|
||||
return int_or_none(rep.get("x.com.samsung.da.filterUsage"))
|
||||
|
||||
|
||||
def filter_usage_hours(rep):
|
||||
"""Elapsed filter hours, derived from the percentage and capacity rather
|
||||
than read off `filterUsage` directly -- `filterUsage` is a percent, not
|
||||
an hour count (issue #330). Returns None when capacity is missing/zero."""
|
||||
pct = filter_usage_percent(rep)
|
||||
cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
|
||||
if used is None or not cap:
|
||||
if pct is None or not cap:
|
||||
return None
|
||||
return round(used / cap * 100)
|
||||
return round(pct / 100 * cap, 1)
|
||||
|
||||
|
||||
def normalize_temp_unit(raw, default="°F"):
|
||||
@@ -126,6 +136,28 @@ def _active_alarm_codes(items):
|
||||
return ", ".join(codes) if codes else "none"
|
||||
|
||||
|
||||
def hex_pairs(codes):
|
||||
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in an options[] array and return <value>.
|
||||
|
||||
Lives here rather than in laundry.py, which is where it grew, because
|
||||
cloudcourse.py needs it too and laundry.py imports *that* -- so the
|
||||
reverse import would be a module cycle. The coordinator already imports
|
||||
from this module, so nothing about the dependency direction is unusual;
|
||||
it is specifically the laundry/cloudcourse pair that can't reach each
|
||||
other. Anchored at position 0 so 'Course_' never matches
|
||||
'CloudCourse_'/'OneTimeCloudCourse_'.
|
||||
"""
|
||||
for o in options or []:
|
||||
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||
return o.split("_", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def merge_options_field(cached, new_tokens):
|
||||
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached
|
||||
x.com.samsung.da.options[]-style array the same way the device itself
|
||||
|
||||
@@ -46,6 +46,13 @@ DRYER_SETTINGS = Capability(
|
||||
# (issue #244). /st/dryercourse/vs/0 re-encodes the same selected course
|
||||
# and is ignored (ignored.py), mirroring /st/washercourse/vs/0 for washers.
|
||||
#
|
||||
# dryer_cycle_table_00 is a separate, older course-code family reported by
|
||||
# a DVE45R6300W/A3 (issue #357), confirmed the same way: the reporter
|
||||
# selected each cycle on the appliance and read back the resulting raw
|
||||
# code. It shares no codes with Table_03 above -- 'a5' Bedding here and
|
||||
# '01' Normal are both table-scoped, so a Table_03 dryer never picks up a
|
||||
# Table_00 label or vice versa (see laundry.cycle_select's table_href).
|
||||
#
|
||||
# Drum Clean+ maintenance tracking (issue #258) reuses washer.py's
|
||||
# DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens on this same
|
||||
# options[] array -- see laundry.drum_clean_cycles_remaining/
|
||||
|
||||
@@ -25,7 +25,7 @@ from ..entities import (
|
||||
SwitchDesc,
|
||||
TimeDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .common import int_or_none, normalize_temp_unit
|
||||
|
||||
# Display names for the beverage zone, flex zone, ice type, and
|
||||
# ice-making-status enums below live in translations/en.json, keyed by the
|
||||
@@ -266,6 +266,39 @@ DOOR_ALERT = Capability(
|
||||
)
|
||||
|
||||
|
||||
# Internal deodorizing filter (issue #318, TP1X_REF_21K). Same
|
||||
# filterUsage/filterStatus field pair as common.WATER_FILTER, but
|
||||
# filterUsage here is already a 0-100 percentage with no filterCapacity to
|
||||
# divide by (confirmed by filterStatus=="wash" at filterUsage=="100") --
|
||||
# 'air_'-prefixed keys so a fridge with both a water and an air filter gets
|
||||
# two distinct entities rather than a unique_id collision.
|
||||
AIR_FILTER = Capability(
|
||||
href="/filter/airdustfilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="air_filter_usage",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="air_filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
translation_key="filter_status",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _status_lock_write(field):
|
||||
return lambda p, rep, href=None: (
|
||||
["status", "lock", "vs", "0"],
|
||||
@@ -293,9 +326,154 @@ STATUS_LOCK = Capability(
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_status_lock_write("x.com.samsung.da.device.sound"),
|
||||
),
|
||||
# Auto Door Open's own voice/sound feedback toggles (issue #328,
|
||||
# TP1X_REF_21K family) -- siblings of auto_door_opener above, not
|
||||
# duplicates of fridge_sound (device.sound is the general appliance
|
||||
# beep, these two gate ado's own prompts). Only seen on the
|
||||
# auto-door-equipped variants (single/kimchi/winecellar), not the
|
||||
# earlier TP1X_REF_21K dumps that predate that feature -- gated on
|
||||
# each field's own presence rather than assumed universal.
|
||||
SwitchDesc(
|
||||
key="auto_door_voice_control",
|
||||
field="x.com.samsung.da.ado.voicecontrol",
|
||||
icon="mdi:microphone",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_status_lock_write("x.com.samsung.da.ado.voicecontrol"),
|
||||
exists_fn=lambda rep, resources: "x.com.samsung.da.ado.voicecontrol" in rep,
|
||||
),
|
||||
SwitchDesc(
|
||||
key="auto_door_sound_control",
|
||||
field="x.com.samsung.da.ado.soundcontrol",
|
||||
icon="mdi:volume-medium",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v == "On",
|
||||
write_fn=_status_lock_write("x.com.samsung.da.ado.soundcontrol"),
|
||||
exists_fn=lambda rep, resources: "x.com.samsung.da.ado.soundcontrol" in rep,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Auto Door Open's paired delay setting (issue #328, TP1X_REF_21K family):
|
||||
# how long the door stays held open before it re-closes, on/off itself is
|
||||
# STATUS_LOCK.auto_door_opener above. Same discrete-options-select shape as
|
||||
# DEFINITE_TEMPERATURE_COOLER/FREEZER. Unit unconfirmed -- no supportedList
|
||||
# field states it and the report carried no app screenshot -- so this is a
|
||||
# raw-code select rather than a guessed seconds/minutes NumberDesc.
|
||||
|
||||
|
||||
def _auto_door_timer_write(p, rep, href=None):
|
||||
return ["autodoor", "timer", "vs", "0"], {"x.com.samsung.da.time.desired": p}
|
||||
|
||||
|
||||
AUTO_DOOR_TIMER = Capability(
|
||||
href="/autodoor/timer/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(
|
||||
key="auto_door_timer",
|
||||
field="x.com.samsung.da.time.desired",
|
||||
icon="mdi:timer-outline",
|
||||
translation_key="auto_door_timer",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.time.supportedOptions",
|
||||
write_fn=_auto_door_timer_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# /autodoor/<variant>/vs/0 -- one per fridge sub-type sharing the Auto Door
|
||||
# Open feature (single-door, kimchi, winecellar seen so far; issue #328).
|
||||
# Each reports only x.com.samsung.da.ado.openOptions, declaring which open
|
||||
# styles that variant supports -- every dump seen so far carries exactly
|
||||
# one option ('Single') with no paired desired/current field to make a
|
||||
# choice against, the same "no real choice to expose yet" shape as
|
||||
# ignored.py's /mode/0. Bound with no entities to record coverage; revisit
|
||||
# if a device ever reports more than one option.
|
||||
#
|
||||
# A pattern cap rather than one entry per variant: match_fn (not just the
|
||||
# prefix) is what actually gates this, so a future variant href needs no
|
||||
# code change to stay covered, and /autodoor/timer/vs/0's own exact-href
|
||||
# AUTO_DOOR_TIMER above always wins for that href regardless (discover()
|
||||
# only falls through to pattern caps when no exact cap matched). This is
|
||||
# registry-scoped, not global -- unlike ignored.IGNORED, the unknown-
|
||||
# device-type fallback never reaches it, so the prefix caveat in
|
||||
# ignored.py's own docstring doesn't apply here.
|
||||
AUTO_DOOR_VARIANT = Capability(
|
||||
href=None,
|
||||
href_prefix="/autodoor/",
|
||||
match_fn=lambda rep, resources: "x.com.samsung.da.ado.openOptions" in rep,
|
||||
)
|
||||
|
||||
# Wine-cellar variant (x.com.st.d.winecellar, issue #328) of the same
|
||||
# deodorizing filter AIR_FILTER models -- filterUsage/filterStatus at a
|
||||
# different href, same 0-100-percentage-already shape (see AIR_FILTER's own
|
||||
# comment). filterUsage reads '-1' on the only dump seen (filterStatus
|
||||
# 'normal'), relayed as-is rather than special-cased -- no second dump to
|
||||
# confirm whether that's a real sentinel or this unit just not tracking it.
|
||||
# Own 'deodor_'-prefixed keys rather than reusing AIR_FILTER.entities
|
||||
# verbatim -- same collision AIR_FILTER's own 'air_' prefix was chosen to
|
||||
# avoid against WATER_FILTER, and both filters are plausible on one unit
|
||||
# (this device's own board reports an internal air filter on other
|
||||
# TP1X_REF_21K variants).
|
||||
DEODOR_FILTER = Capability(
|
||||
href="/filter/deodorfilter/vs/0",
|
||||
poll_tier="cold",
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key="deodor_filter_usage",
|
||||
field="x.com.samsung.da.filterUsage",
|
||||
unit="%",
|
||||
state_class="measurement",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key="deodor_filter_status",
|
||||
field="x.com.samsung.da.filterStatus",
|
||||
device_class="enum",
|
||||
options=("normal", "wash", "replace"),
|
||||
translation_key="filter_status",
|
||||
icon="mdi:air-filter",
|
||||
entity_category="diagnostic",
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Wine-cellar multi-compartment pantry select (issue #328): same
|
||||
# mode/supportedOptions shape as PANTRY_ZONE, at its own href with a 5-way
|
||||
# option set (Processed_Meat/Cheese/Nuts/Fruit/Wine) instead of PANTRY_ZONE's.
|
||||
# Only a "one" instance seen -- not generalized to a pattern cap, same
|
||||
# discipline as PANTRY_ZONE itself.
|
||||
|
||||
|
||||
def _winecellar_pantry_write(p, rep, href=None):
|
||||
return ["status", "winecellar", "pantry", "one", "vs", "0"], {"x.com.samsung.da.mode": p}
|
||||
|
||||
|
||||
WINECELLAR_PANTRY_ZONE = Capability(
|
||||
href="/status/winecellar/pantry/one/vs/0",
|
||||
poll_tier="warm",
|
||||
entities=(
|
||||
SelectDesc(
|
||||
key="winecellar_pantry_zone_mode",
|
||||
field="x.com.samsung.da.mode",
|
||||
icon="mdi:glass-wine",
|
||||
translation_key="winecellar_pantry_zone_mode",
|
||||
entity_category="config",
|
||||
options_field="x.com.samsung.da.supportedOptions",
|
||||
write_fn=_winecellar_pantry_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
# Wine-cellar internal table-revision marker (issue #328) -- versioning
|
||||
# metadata, not appliance state. Same treatment as ignored.py's
|
||||
# /wm/setinfo/vs/0.
|
||||
WINECELLAR_INFO = Capability(href="/information/winecellar/vs/0")
|
||||
|
||||
# /defrost/delay/vs/0 is the writable toggle to postpone a scheduled
|
||||
# defrost. /defrost/block/vs/0 is unrelated: despite the "block" naming,
|
||||
# live dumps confirm DEFROST_BLOCK_ON means the defrost cycle is actively
|
||||
|
||||
@@ -23,9 +23,11 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
from datetime import UTC, datetime
|
||||
from datetime import time as dt_time
|
||||
|
||||
from ... import cloudcourse
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
from .common import hex_pairs, option_value
|
||||
|
||||
_LED_LEVELS = ("Low", "High")
|
||||
_SOUND_MODES = ("voice", "tone", "mute")
|
||||
@@ -198,11 +200,6 @@ BUZZER_SOUND = Capability(
|
||||
# boards expose the same /course/vs/0 options contract.
|
||||
|
||||
|
||||
def hex_pairs(codes):
|
||||
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
|
||||
|
||||
|
||||
def parse_edit_course_list(raw):
|
||||
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
|
||||
if not isinstance(raw, str) or "_" not in raw:
|
||||
@@ -218,14 +215,6 @@ def cycle_options(resources):
|
||||
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})
|
||||
|
||||
|
||||
def option_value(options, prefix):
|
||||
"""Find `<prefix>_<value>` in the options array and return <value>."""
|
||||
for o in options or []:
|
||||
if isinstance(o, str) and o.startswith(prefix + "_"):
|
||||
return o.split("_", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
# Drum Clean+ maintenance tracking, from the same options[] array as the
|
||||
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
|
||||
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
|
||||
@@ -309,26 +298,175 @@ def _course_codes_from_supported_options(course_rep):
|
||||
return []
|
||||
|
||||
|
||||
def option_tokens(*pairs):
|
||||
"""[(prefix, value), ...] -> ['<prefix>_<value>', ...] -- the general
|
||||
form of option_write, for the one write that needs two tokens to land in
|
||||
the same options[] array together (see cycle_write's cloud branch)."""
|
||||
return [f"{prefix}_{value}" for prefix, value in pairs]
|
||||
|
||||
|
||||
def option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write -- see the module comment
|
||||
above for why this doesn't read/rewrite the whole array."""
|
||||
return [f"{prefix}_{new_value}"]
|
||||
return option_tokens((prefix, new_value))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cloud "Download" programs, folded into this same cycle select (issue #342).
|
||||
#
|
||||
# A device that has downloaded programs advertises them on the same
|
||||
# /course/vs/0 options array; cloudcourse.py owns the token shapes, the
|
||||
# learned store, and the reasoning for all of it. Everything below is just
|
||||
# how that store reaches the select: the coordinator merges it onto this
|
||||
# href's rep under cloudcourse.FIELD, so the option list, current value,
|
||||
# label, and write path each read it from the rep or snapshot they already
|
||||
# receive.
|
||||
#
|
||||
# They ride in the cycle select rather than a select of their own because
|
||||
# that is what they are to a user -- on the appliance's own controls,
|
||||
# "Download" occupies one position among the ordinary courses, and picking a
|
||||
# downloaded program is picking a cycle. Their raw values are namespaced
|
||||
# ('cloud:<slot>') so they can never be confused with, or collide with, a
|
||||
# two-hex-char local course code.
|
||||
#
|
||||
# Bound by whichever families declare it. Washers are where this was worked
|
||||
# out, but a DW5000C dishwasher advertises the same token (see
|
||||
# cloudcourse.py), so nothing below is washer-specific.
|
||||
#
|
||||
# Confirmed on hardware before any of this was written (issue #342): writing
|
||||
# the program token alone, while some other course is selected, is silently
|
||||
# ignored -- the course token has to switch to Download in the *same* write.
|
||||
# Hence the two-token write, the only one in this module.
|
||||
|
||||
|
||||
def _cloud_state(rep):
|
||||
return rep.get(cloudcourse.FIELD) or {}
|
||||
|
||||
|
||||
def cloud_options(rep):
|
||||
"""Namespaced raw values for every named, learned cloud program."""
|
||||
return [
|
||||
f"{cloudcourse.RAW_PREFIX}{slot}" for slot in sorted(_cloud_state(rep).get("programs", {}))
|
||||
]
|
||||
|
||||
|
||||
def cloud_label(value, resources):
|
||||
"""The user's own name for a 'cloud:<slot>' value.
|
||||
|
||||
Cloud program names are user-supplied, never translated: the appliance
|
||||
reports only an opaque slot id, and inventing an English label for one
|
||||
is exactly what this module refuses to do for unrecognized local course
|
||||
codes (see washer_cycle_fallback).
|
||||
"""
|
||||
if not isinstance(value, str) or not value.startswith(cloudcourse.RAW_PREFIX):
|
||||
return None
|
||||
slot = value[len(cloudcourse.RAW_PREFIX) :]
|
||||
rep = resources.get(cloudcourse.COURSE_HREF) or {}
|
||||
program = _cloud_state(rep).get("programs", {}).get(slot)
|
||||
return program["name"] if program else None
|
||||
|
||||
|
||||
def cloud_current(rep):
|
||||
"""'cloud:<slot>' when a named cloud program is the live selection.
|
||||
|
||||
Gated on the course actually being this device's confirmed Download
|
||||
course: tokens in this array are replaced by prefix and never evicted, so
|
||||
a one-time program token outlives the run it belonged to and would
|
||||
otherwise report "Jeans" while an ordinary cotton cycle runs.
|
||||
"""
|
||||
state = _cloud_state(rep)
|
||||
download = state.get("download_course")
|
||||
options = rep.get("x.com.samsung.da.options")
|
||||
if not download or option_value(options, "Course") != download:
|
||||
return None
|
||||
blob = option_value(options, cloudcourse.ONESHOT_PREFIX)
|
||||
slot = cloudcourse.slot_of(blob)
|
||||
if slot is None:
|
||||
# No one-time override loaded: the appliance falls back to whatever
|
||||
# the persisted default holds (confirmed with the issue #342
|
||||
# reporter -- leaving Download and returning to it re-selects the
|
||||
# saved program, not the last one-time one).
|
||||
slot = cloudcourse.slot_of(option_value(options, cloudcourse.DEFAULT_PREFIX))
|
||||
if slot is None or slot not in state.get("programs", {}):
|
||||
return None
|
||||
return f"{cloudcourse.RAW_PREFIX}{slot}"
|
||||
|
||||
|
||||
def cycle_write(p, rep, href=None):
|
||||
if not rep.get("x.com.samsung.da.options"):
|
||||
return None
|
||||
if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX):
|
||||
return _cloud_cycle_write(p, rep)
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_write("Course", p),
|
||||
}
|
||||
|
||||
|
||||
def _cloud_cycle_write(p, rep):
|
||||
state = _cloud_state(rep)
|
||||
download = state.get("download_course")
|
||||
program = state.get("programs", {}).get(p[len(cloudcourse.RAW_PREFIX) :])
|
||||
if not download or program is None:
|
||||
return None
|
||||
# Order matches what the appliance was confirmed to accept.
|
||||
return ["course", "vs", "0"], {
|
||||
"x.com.samsung.da.options": option_tokens(
|
||||
("Course", download), (cloudcourse.ONESHOT_PREFIX, program["blob"])
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def personal_course_labels(resources, href="/wm/personalcourse/vs/0"):
|
||||
"""Return device-provided personal course names keyed by course code.
|
||||
|
||||
Populated entries use a small TLV payload. The leading field is
|
||||
``01 <UTF-8-byte-length> <name>``; later fields contain a description and
|
||||
settings and are intentionally left uninterpreted. Empty slots are
|
||||
encoded as ``<code>_00``. Malformed or undecodable entries are ignored so
|
||||
opaque device data can never become a misleading label.
|
||||
"""
|
||||
rep = resources.get(href) or {}
|
||||
labels = {}
|
||||
for entry in rep.get("x.com.samsung.da.courses") or []:
|
||||
if not isinstance(entry, str) or "_" not in entry:
|
||||
continue
|
||||
code, encoded = entry.split("_", 1)
|
||||
try:
|
||||
payload = bytes.fromhex(encoded)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(payload) < 3 or payload[0] != 0x01:
|
||||
continue
|
||||
name_length = payload[1]
|
||||
if name_length == 0 or len(payload) < 2 + name_length:
|
||||
continue
|
||||
try:
|
||||
name = payload[2 : 2 + name_length].decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if name.strip() and name.isprintable():
|
||||
labels[code.upper()] = name
|
||||
return labels
|
||||
|
||||
|
||||
def washer_cycle_fallback(value, resources):
|
||||
"""Label a personal washer course from its device-provided name.
|
||||
|
||||
No fallback for an unrecognized standard code -- an invented English
|
||||
label would defeat translation (PR #251 review); the raw code displays
|
||||
instead, same as before this function existed.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
return personal_course_labels(resources).get(value.upper())
|
||||
|
||||
|
||||
def _table_id(resources, table_href):
|
||||
rep = resources.get(table_href) or {}
|
||||
return rep.get("x.com.samsung.da.st.courseTable")
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=None):
|
||||
def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
The option list, current value, and write path are shared across
|
||||
@@ -346,9 +484,19 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
instead of borrowing a label from another board generation --
|
||||
translating a new table is a translations-only change.
|
||||
|
||||
The raw course code remains writable regardless; its display uses
|
||||
display_fn when supplied, otherwise it remains raw. display_fn is an
|
||||
optional family-specific fallback for untranslated raw values --
|
||||
select.py applies it after catalog lookup to both state and options.
|
||||
|
||||
Left at its default for dishwasher, which has no equivalent table-id
|
||||
resource and no evidence its codes vary by table the way washer/
|
||||
dryer's do.
|
||||
|
||||
Any cloud "Download" programs the user has discovered and named join the
|
||||
same option list, after the local courses -- see the cloud section above.
|
||||
A device with none (or one whose owner hasn't named any yet) gets exactly
|
||||
the list it got before they existed.
|
||||
"""
|
||||
key = translation_key
|
||||
if table_href is not None:
|
||||
@@ -360,13 +508,31 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
candidate = f"{translation_key}_{table.lower()}"
|
||||
return candidate if has_entity_translation("select", candidate) else "cycle"
|
||||
|
||||
def options(resources):
|
||||
rep = resources.get(cloudcourse.COURSE_HREF) or {}
|
||||
# Local courses first: a user-supplied cloud name that happens to
|
||||
# match a translated course name resolves back to the real local
|
||||
# course on write, which is the safer of the two. The options flow
|
||||
# rejects such a name outright, so this is a backstop, not the fix.
|
||||
return [*cycle_options(resources), *cloud_options(rep)]
|
||||
|
||||
def current(rep):
|
||||
return cloud_current(rep) or option_value(rep.get("x.com.samsung.da.options"), "Course")
|
||||
|
||||
def label(value, resources):
|
||||
cloud = cloud_label(value, resources)
|
||||
if cloud is not None:
|
||||
return cloud
|
||||
return display_fn(value, resources) if display_fn is not None else None
|
||||
|
||||
return SelectDesc(
|
||||
key="cycle",
|
||||
icon=icon,
|
||||
translation_key=key,
|
||||
options=cycle_options,
|
||||
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
|
||||
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
|
||||
options=options,
|
||||
exists_fn=lambda rep, resources: bool(options(resources)),
|
||||
rep_fn=current,
|
||||
display_fn=label,
|
||||
write_fn=cycle_write,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,12 +35,48 @@ def _int(v):
|
||||
return None
|
||||
|
||||
|
||||
def _state_is_active(rep):
|
||||
return _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||
|
||||
|
||||
def _is_active(rep):
|
||||
"""Check if appliance is actively running and cycle is not finished."""
|
||||
return (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||
and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||
)
|
||||
return _state_is_active(rep) and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||
|
||||
|
||||
def _just_finished(rep):
|
||||
"""progress/progress_percentage's sticky_fn (issue #345, see sensor.py's
|
||||
_apply_sticky): arms their grace window the moment `progress` reads
|
||||
'Finish'.
|
||||
|
||||
Deliberately not also requiring `state == 'active'` in the same rep,
|
||||
unlike _is_active above: #345 reports a washer whose `state` can
|
||||
already read idle by the time `progress` is observed at 'Finish' --
|
||||
the same-family dryer's firmware apparently keeps `state` at 'active'
|
||||
longer, per the report -- so requiring both together risked the arm
|
||||
condition never actually firing on the one device this fixes.
|
||||
_apply_sticky's edge-triggering (only a fresh False->True transition
|
||||
(re)arms) is what keeps a `progress` stuck at 'Finish' indefinitely
|
||||
(the same class of quirk `_completion_minutes` below already works
|
||||
around) from holding this open forever instead."""
|
||||
return rep.get("x.com.samsung.da.progress") == "Finish"
|
||||
|
||||
|
||||
def _new_cycle_running(rep):
|
||||
"""progress/progress_percentage's sticky_bypass_fn: drop the #345 hold
|
||||
early once a new cycle is genuinely running.
|
||||
|
||||
Gated on `state == 'active'`, unlike _just_finished's arm condition
|
||||
above: issue #358's dryer resets `progress` to its course's first
|
||||
stage ('Drying') in the same moment `state` goes idle, ~4s before
|
||||
settling to 'None' -- confirmed by the reporter's machine_state
|
||||
history, which flips to idle on the exact second progress reads
|
||||
'Drying', in both captured cycles. A bypass keyed on the progress
|
||||
code alone read that as a new cycle and republished it. Releasing
|
||||
late costs nothing -- an unreleased hold still expires on its own --
|
||||
so this side takes the stronger signal."""
|
||||
v = rep.get("x.com.samsung.da.progress")
|
||||
return _state_is_active(rep) and v is not None and v not in ("None", "Finish")
|
||||
|
||||
|
||||
def _remaining_seconds(raw):
|
||||
@@ -75,7 +111,7 @@ def _delay_hours(v):
|
||||
def _format_delay(hours):
|
||||
total_minutes = round(max(float(hours), 0) * 60)
|
||||
h, m = divmod(total_minutes, 60)
|
||||
return f"{h}:{m:02d}:00"
|
||||
return f"{h:02d}:{m:02d}:00"
|
||||
|
||||
|
||||
def _delay_field(rep):
|
||||
@@ -153,19 +189,26 @@ OPERATIONAL_STATE = Capability(
|
||||
BinarySensorDesc(
|
||||
key="cycle_active",
|
||||
device_class="running",
|
||||
rep_fn=lambda rep: (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
|
||||
and rep.get("x.com.samsung.da.progress") != "Finish"
|
||||
),
|
||||
rep_fn=_is_active,
|
||||
),
|
||||
# sticky_* (issue #345): once progress reads 'Finish', keep
|
||||
# showing Finish/100 for a grace window even after machine_state
|
||||
# reverts, rather than falling to Idle/0 the instant it does --
|
||||
# see sensor.py's _apply_sticky. rep_fn below is unchanged and
|
||||
# stays the only definition of a live value -- the hold decides
|
||||
# only *whether* to freeze. A second, ungated one here is what
|
||||
# let issue #358's post-Finish tail reach the entity.
|
||||
SensorDesc(
|
||||
key="progress",
|
||||
icon="mdi:progress-wrench",
|
||||
rep_fn=lambda rep: (
|
||||
"Idle"
|
||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
||||
if not _state_is_active(rep)
|
||||
else _progress(rep.get("x.com.samsung.da.progress"))
|
||||
),
|
||||
sticky_fn=_just_finished,
|
||||
sticky_value_fn=lambda rep: "Finish",
|
||||
sticky_bypass_fn=_new_cycle_running,
|
||||
),
|
||||
SensorDesc(
|
||||
key="progress_percentage",
|
||||
@@ -173,9 +216,12 @@ OPERATIONAL_STATE = Capability(
|
||||
state_class="measurement",
|
||||
rep_fn=lambda rep: (
|
||||
0
|
||||
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
|
||||
if not _state_is_active(rep)
|
||||
else _int(rep.get("x.com.samsung.da.progressPercentage"))
|
||||
),
|
||||
sticky_fn=_just_finished,
|
||||
sticky_value_fn=lambda rep: 100,
|
||||
sticky_bypass_fn=_new_cycle_running,
|
||||
),
|
||||
# Only show finish time while actively running -- firmware leaves a
|
||||
# stale remainingTime after a cycle ends, frozen at '00:01:00'.
|
||||
|
||||
@@ -401,10 +401,17 @@ OVEN_MODE = Capability(
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write,
|
||||
),
|
||||
# No exists_fn on the NV7000BS-class board this was proven against
|
||||
# (UpperLamp_ is always in its options[]) -- but issue #300's
|
||||
# steam-oven-class WALLOVEN board's options[] has no UpperLamp_
|
||||
# token at all, so this was a phantom, always-off, write-does-
|
||||
# nothing switch there. Same fastpreheat/NaturalSteam-class gap
|
||||
# issue #183 already fixed on the other switches below.
|
||||
SwitchDesc(
|
||||
key="lamp",
|
||||
field="x.com.samsung.da.options",
|
||||
icon="mdi:track-light",
|
||||
exists_fn=_has_option("UpperLamp"),
|
||||
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
|
||||
write_fn=_option_switch_write("UpperLamp"),
|
||||
),
|
||||
|
||||
@@ -148,11 +148,13 @@ COOKTOP_STATUS = Capability(
|
||||
value_fn=lambda v: str(v).lower() == "on",
|
||||
),
|
||||
# Safe to write -- a lock toggle, not a heat control -- via a
|
||||
# direct single-field PUT, no RMW needed.
|
||||
# direct single-field PUT, no RMW needed. No device_class:
|
||||
# SwitchDeviceClass only has 'outlet'/'switch', not 'lock' --
|
||||
# passing it crashed switch platform setup for the whole device
|
||||
# (issue #349, same bug as water_purifier.py's lock switches).
|
||||
SwitchDesc(
|
||||
key="cooktop_child_lock",
|
||||
field="childLock",
|
||||
device_class="lock",
|
||||
entity_category="config",
|
||||
icon="mdi:lock",
|
||||
value_fn=lambda v: str(v).lower() == "on",
|
||||
|
||||
@@ -27,6 +27,7 @@ from .laundry import (
|
||||
hex_pairs,
|
||||
option_value,
|
||||
option_write,
|
||||
washer_cycle_fallback,
|
||||
)
|
||||
|
||||
# Course_XX hex code labels (translations/en.json,
|
||||
@@ -50,6 +51,23 @@ from .laundry import (
|
||||
# options' MostUsed_* entry was considered as a fallback source (its first
|
||||
# byte matches the selected Course_XX on both dumps), but the remaining
|
||||
# bytes don't decode to any confirmed course code, so it isn't used.
|
||||
#
|
||||
# The owner of a Korean Table_02 washer confirmed the names for its newer
|
||||
# 69/6A-79/88 course-code family, including Course_69 as AI Wash. Those names
|
||||
# live only in the table-scoped translation catalog; a code not confirmed by
|
||||
# the owner or device metadata falls back to washer_cycle_fallback, which
|
||||
# surfaces a personal-course name only -- no invented English label for an
|
||||
# unrecognized standard code (PR #251 review).
|
||||
#
|
||||
# washer_cycle_table_00 (issue #357) is a separate, older course-code family
|
||||
# reported by a WF45R6300AW/US -- confirmed by the reporter selecting each
|
||||
# cycle on the appliance and reading back the raw code, the same method used
|
||||
# for Table_02's WF50A8600AV/US codes above. A device reporting Table_00 with
|
||||
# an unconfirmed code (FlexWash's washer_flexwash_device fixture, for
|
||||
# instance) still renders that code raw rather than borrowing a Table_02
|
||||
# label -- the two tables are unrelated code spaces despite a handful of
|
||||
# overlapping hex values.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# /washer/vs/0 -- wash temperature, spin speed, rinse cycle count.
|
||||
# Despite the shared href, this is unrelated to dryer.DRYER_SETTINGS (also
|
||||
@@ -250,6 +268,7 @@ WASHER_COURSE = Capability(
|
||||
translation_key="washer_cycle",
|
||||
icon="mdi:washing-machine",
|
||||
table_href="/st/washercourse/vs/0",
|
||||
display_fn=washer_cycle_fallback,
|
||||
),
|
||||
SensorDesc(
|
||||
key="drum_clean_cycles_remaining",
|
||||
|
||||
@@ -182,10 +182,15 @@ FAVORITE_HOTWATER = Capability(
|
||||
# adapter.flatten() only ever honors exists_fn, not entity.py's
|
||||
# implicit field-presence default -- without it, whichever
|
||||
# same-keyed descriptor is processed last would silently win.
|
||||
# No device_class: SwitchDeviceClass only has 'outlet'/'switch',
|
||||
# not 'lock' -- passing it crashed switch platform setup entirely
|
||||
# for the whole device (issue #349), same bug KIDS_LOCK_GENERIC
|
||||
# dodged by switching to BinarySensorDesc (issues #181/#183). This
|
||||
# entity stays a SwitchDesc since it's genuinely writable.
|
||||
SwitchDesc(
|
||||
key="hotwater_lock",
|
||||
field="x.com.samsung.da.switchHotwater",
|
||||
device_class="lock",
|
||||
icon="mdi:lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
exists_fn=lambda rep, resources: (
|
||||
@@ -354,11 +359,13 @@ LOCK = Capability(
|
||||
entities=(
|
||||
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
|
||||
# above (issue #144); see the comment there. A stub rep ({}) still
|
||||
# counts as "present" here, matching entity.py's own default.
|
||||
# counts as "present" here, matching entity.py's own default. No
|
||||
# device_class on any of the three locks below -- see the
|
||||
# device_class note on FAVORITE_HOTWATER's hotwater_lock (issue #349).
|
||||
SwitchDesc(
|
||||
key="hotwater_lock",
|
||||
field="x.com.samsung.da.hotwaterLock",
|
||||
device_class="lock",
|
||||
icon="mdi:lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep,
|
||||
@@ -370,7 +377,7 @@ LOCK = Capability(
|
||||
SwitchDesc(
|
||||
key="coldwater_lock",
|
||||
field="x.com.samsung.da.coldwaterLock",
|
||||
device_class="lock",
|
||||
icon="mdi:lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -381,7 +388,7 @@ LOCK = Capability(
|
||||
SwitchDesc(
|
||||
key="buzz_lock",
|
||||
field="x.com.samsung.da.buzzLock",
|
||||
device_class="lock",
|
||||
icon="mdi:lock",
|
||||
entity_category="config",
|
||||
value_fn=lambda v: v != "Unlocked",
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
|
||||
@@ -19,6 +19,7 @@ WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
|
||||
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
|
||||
# option list).
|
||||
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
|
||||
DisplayFn = Callable[[Any, dict], Any] | None
|
||||
|
||||
|
||||
def _identity(v: Any) -> Any:
|
||||
@@ -63,6 +64,18 @@ class SensorDesc(SamsungEntityDescription):
|
||||
# (see sensor.py). Only for values expected to jitter between
|
||||
# device-side revisions -- not a general-purpose flag.
|
||||
hysteresis: bool = False
|
||||
# Opt-in, entity-instance-only hold -- see sensor.py's _apply_sticky
|
||||
# for the full contract (arm/value/bypass semantics, one window per
|
||||
# bypass, why this never touches the coordinator cache).
|
||||
# sticky_fn arms it; sticky_value_fn picks what to freeze at that
|
||||
# moment (defaults to rep_fn's own result); sticky_bypass_fn drops the
|
||||
# hold and lets rep_fn's own live result through; sticky_seconds
|
||||
# bounds how long it can hold. There is deliberately no hook for
|
||||
# computing a live value differently from rep_fn -- see issue #358.
|
||||
sticky_fn: Callable[[dict], bool] | None = None
|
||||
sticky_value_fn: Callable[[dict], Any] | None = None
|
||||
sticky_bypass_fn: Callable[[dict], bool] | None = None
|
||||
sticky_seconds: float = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -77,6 +90,10 @@ class SelectDesc(SamsungEntityDescription):
|
||||
# snapshot (not just this entity's own href) and returns raw device
|
||||
# option values; see select.py's LocalThingsSelect._raw_options().
|
||||
options_field: str | None = None # resource field that contains the live options list
|
||||
# Optional device-specific fallback for values absent from the translation
|
||||
# catalog. Receives (raw_value, canonical_resources); select.py applies it
|
||||
# identically to the current state and every option.
|
||||
display_fn: DisplayFn = None
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ appliance's own bookkeeping, not evidence of a second indoor unit -- see
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -221,12 +222,12 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
|
||||
return "/" + "/".join(path_segs)
|
||||
|
||||
|
||||
def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
def _get_raw(sess, path_segs: tuple[str, ...], timeout: float = 10.0):
|
||||
"""GET `path_segs` and CBOR-decode the payload, or None on any
|
||||
missing/malformed response (a 4.04, a timeout, an empty payload) --
|
||||
shared tolerated-absence posture for both callers below."""
|
||||
try:
|
||||
code, pl = sess.get(list(path_segs), timeout=10.0)
|
||||
code, pl = sess.get(list(path_segs), timeout=timeout)
|
||||
if code == 0x45 and pl:
|
||||
return cbor2.loads(pl)
|
||||
except Exception:
|
||||
@@ -234,19 +235,29 @@ def _get_raw(sess, path_segs: tuple[str, ...]):
|
||||
return None
|
||||
|
||||
|
||||
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
|
||||
def _get_batch(
|
||||
sess,
|
||||
path_segs: tuple[str, ...],
|
||||
timeout: float = 10.0,
|
||||
) -> dict[str, dict]:
|
||||
"""GET a Samsung Collection resource and parse it the same way
|
||||
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
|
||||
{href, rep}, ...] CBOR list, not a bare Property map."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
body = _get_raw(sess, path_segs, timeout)
|
||||
return parse_device0_batch(body) if isinstance(body, list) else {}
|
||||
|
||||
|
||||
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
||||
def _get_property(
|
||||
sess,
|
||||
path_segs: tuple[str, ...],
|
||||
timeout: float = 10.0,
|
||||
) -> dict:
|
||||
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
|
||||
batch). Used for `/multidevice/vs/0`: listed in `/oic/res` but absent
|
||||
from `/device/0`'s batch, so it needs its own RETRIEVE."""
|
||||
body = _get_raw(sess, path_segs)
|
||||
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
|
||||
`/oic/res` on the Pattern A reporter's board but absent from
|
||||
`/device/0`'s batch, so it needs its own RETRIEVE, and it answers a
|
||||
single Property map, not a [devcol-rep, ...] list."""
|
||||
body = _get_raw(sess, path_segs, timeout)
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
@@ -255,6 +266,11 @@ def enumerate_subdevices(
|
||||
resources: dict[str, dict],
|
||||
oic_res_links,
|
||||
probe_log: Callable[[str, bool], None] | None = None,
|
||||
*,
|
||||
preferred_hrefs: Sequence[str] = (),
|
||||
time_budget: float | None = None,
|
||||
collection_timeout: float = 10.0,
|
||||
property_timeout: float = 10.0,
|
||||
) -> tuple[list[Subdevice], dict[str, dict]]:
|
||||
"""Discover every sibling indoor subdevice reachable over `sess`'s
|
||||
connection.
|
||||
@@ -269,6 +285,13 @@ def enumerate_subdevices(
|
||||
or not it answered, so diagnostics can tell "checked, nothing there"
|
||||
apart from "never checked".
|
||||
|
||||
`preferred_hrefs` only changes the order of the flat Property fallback;
|
||||
it never filters the device's resource surface. When `time_budget` is
|
||||
supplied, probes are bounded by one shared monotonic deadline and this
|
||||
returns every candidate/resource confirmed before it. This makes first
|
||||
setup finite even when firmware silently drops unknown paths instead of
|
||||
returning 4.04.
|
||||
|
||||
Every candidate whose seed answers with a non-empty batch is returned
|
||||
here -- this function can't tell a real sibling from an unused
|
||||
SmartThings slot that answers the same shape; that requires
|
||||
@@ -282,6 +305,30 @@ def enumerate_subdevices(
|
||||
# casing, and probing it twice would materialize the same physical
|
||||
# subdevice as two Subdevice candidates.
|
||||
probed_ids: set[str] = set()
|
||||
deadline = time.monotonic() + max(0.0, time_budget) if time_budget is not None else None
|
||||
budget_exhausted = False
|
||||
|
||||
def _next_timeout(maximum: float) -> float | None:
|
||||
"""Clamp one probe to the remaining enumeration wall-clock budget."""
|
||||
nonlocal budget_exhausted
|
||||
if deadline is None:
|
||||
return maximum
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
budget_exhausted = True
|
||||
return None
|
||||
return min(maximum, remaining)
|
||||
|
||||
def _flat_probe_hrefs():
|
||||
"""Preferred live-state hrefs first, then every remaining master href."""
|
||||
seen = set()
|
||||
for href in preferred_hrefs:
|
||||
if href in resources and href not in seen:
|
||||
seen.add(href)
|
||||
yield href
|
||||
for href in sorted(resources):
|
||||
if href not in seen:
|
||||
yield href
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
if probe_log is not None:
|
||||
@@ -296,7 +343,10 @@ def enumerate_subdevices(
|
||||
return
|
||||
probed_ids.add(sub_id.lower())
|
||||
seed = (sub_id, "device", "0")
|
||||
batch = _get_batch(sess, seed)
|
||||
timeout = _next_timeout(collection_timeout)
|
||||
if timeout is None:
|
||||
return
|
||||
batch = _get_batch(sess, seed, timeout)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if batch:
|
||||
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
|
||||
@@ -307,10 +357,12 @@ def enumerate_subdevices(
|
||||
# doesn't always expose its own `/<uuid>/device/0` Collection. With
|
||||
# no Collection to seed from and no per-UUID entry in /oic/res to
|
||||
# enumerate hrefs from, the only signal left is that a composite
|
||||
# device's siblings share the master's own resource surface -- so
|
||||
# probe every href the master answered this cycle, individually,
|
||||
# under this UUID's prefix, and keep whichever answer. Each is a
|
||||
# plain tolerated-404 RETRIEVE.
|
||||
# device's siblings are the same physical board family as the
|
||||
# subdevice this config entry already talks to -- so probe every
|
||||
# href the master itself answered this cycle, individually, under
|
||||
# this UUID's prefix, and keep whichever ones answer before the
|
||||
# optional enumeration deadline. Each is a plain tolerated-404
|
||||
# RETRIEVE, same posture as every other probe in this function.
|
||||
#
|
||||
# Known gap: a firmware that echoes the master's own state back
|
||||
# under an unrecognized prefix, rather than 4.04ing, would pass
|
||||
@@ -321,12 +373,15 @@ def enumerate_subdevices(
|
||||
# reps against the master's own values for the same hrefs.
|
||||
flat_hrefs = []
|
||||
first = True
|
||||
for href in sorted(resources):
|
||||
for href in _flat_probe_hrefs():
|
||||
if not first:
|
||||
sess.pace()
|
||||
first = False
|
||||
timeout = _next_timeout(property_timeout)
|
||||
if timeout is None:
|
||||
break
|
||||
actual = f"/{sub_id}{href}"
|
||||
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
|
||||
rep = _get_property(sess, tuple(actual.strip("/").split("/")), timeout)
|
||||
_probed(actual, rep)
|
||||
if rep:
|
||||
flat_hrefs.append(href)
|
||||
@@ -353,14 +408,20 @@ def enumerate_subdevices(
|
||||
listed = sorted(i for i in ids if isinstance(i, str) and i)
|
||||
for sub_id in listed:
|
||||
_probe_prefixed(sub_id)
|
||||
if budget_exhausted:
|
||||
break
|
||||
|
||||
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
|
||||
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) No
|
||||
# /subdevices/vs/0 and /device/<n> 404s; the only trace of the sibling
|
||||
# is a UUID-prefixed link in /oic/res (the x.com.samsung.da.multidevice
|
||||
# link). Its own tree answers a full Collection at /<uuid>/device/0,
|
||||
# exactly Pattern B's transform, so every UUID path prefix seen in
|
||||
# /oic/res is treated as a candidate. _probe_prefixed's probed_ids
|
||||
# exactly Pattern B's transform, so a UUID prefix attached to that
|
||||
# resource type is treated as a candidate. Other UUID-prefixed links are
|
||||
# not evidence of a sibling: some single-unit AC boards advertise only
|
||||
# per-prefix file-transfer resources, and probing those prefixes against
|
||||
# every master href needlessly burns the setup timeout budget.
|
||||
# _probe_prefixed's probed_ids
|
||||
# guard (not a set difference against `listed`) is what keeps an id
|
||||
# already named by subdeviceIdList from being probed twice, since the
|
||||
# two sources can disagree on case.
|
||||
@@ -369,11 +430,13 @@ def enumerate_subdevices(
|
||||
m.group(1)
|
||||
for link in _iter_oic_res_hrefs(oic_res_links)
|
||||
for m in [_UUID_PREFIX_RE.match(link.get("href", ""))]
|
||||
if m
|
||||
if m and "x.com.samsung.da.multidevice" in (link.get("rt") or ())
|
||||
}
|
||||
)
|
||||
for sub_id in linked:
|
||||
_probe_prefixed(sub_id)
|
||||
if budget_exhausted:
|
||||
break
|
||||
|
||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||
indices = sorted(
|
||||
@@ -390,8 +453,11 @@ def enumerate_subdevices(
|
||||
# this replaces from identity.py.
|
||||
indices = list(_SPECULATIVE_DEVICE_INDICES)
|
||||
for n in indices:
|
||||
timeout = _next_timeout(collection_timeout)
|
||||
if timeout is None:
|
||||
break
|
||||
seed = ("device", str(n))
|
||||
batch = _get_batch(sess, seed)
|
||||
batch = _get_batch(sess, seed, timeout)
|
||||
_probed(_seed_href(seed), batch)
|
||||
if not batch:
|
||||
continue
|
||||
@@ -399,18 +465,26 @@ def enumerate_subdevices(
|
||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||
subdevices.append(subdevice)
|
||||
|
||||
# /multidevice/vs/0: listed in /oic/res on some boards but never in
|
||||
# /device/0's batch, so it needs its own RETRIEVE. A plain corroborating
|
||||
# count (numofsubdevice), confirmed read-only -- captured for
|
||||
# diagnostics only, folded into the merged resources dict like any
|
||||
# other href (see airconditioner._AC_IGNORED). Not a gate:
|
||||
# discover_partitioned's entity-level liveness check decides
|
||||
# materialization without it.
|
||||
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter's
|
||||
# board lists it in /oic/res but it never appears in /device/0's batch,
|
||||
# so it needs its own RETRIEVE. It's a plain corroborating count
|
||||
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
|
||||
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
|
||||
# into the merged resources dict like any other href (see
|
||||
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
|
||||
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
|
||||
# liveness check decides materialization correctly without it, and only
|
||||
# this one board family is known to expose it at all. Whether it agrees
|
||||
# with the number of subdevices actually materialized is the
|
||||
# coordinator's call to log (it owns the logger; this module doesn't),
|
||||
# not this function's.
|
||||
multidevice_seed = ("multidevice", "vs", "0")
|
||||
multidevice = _get_property(sess, multidevice_seed)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched["/multidevice/vs/0"] = multidevice
|
||||
timeout = _next_timeout(property_timeout)
|
||||
if timeout is not None:
|
||||
multidevice = _get_property(sess, multidevice_seed, timeout)
|
||||
_probed(_seed_href(multidevice_seed), multidevice)
|
||||
if multidevice:
|
||||
fetched["/multidevice/vs/0"] = multidevice
|
||||
|
||||
return subdevices, fetched
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
return snake if snake in known else None
|
||||
|
||||
|
||||
def _display(value, translation_key: str | None):
|
||||
def _display(value, translation_key: str | None, fallback_fn=None):
|
||||
"""Turn a raw device option/state value into what's shown in the UI.
|
||||
|
||||
`translation_key` is the entity's already-resolved key (it can itself
|
||||
@@ -67,14 +67,21 @@ def _display(value, translation_key: str | None):
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if translation_key:
|
||||
known = translated_states("select", translation_key)
|
||||
if not known:
|
||||
# No state table for this key: either untranslated, or its
|
||||
# options deliberately aren't (an unrecognized course table).
|
||||
return value
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
known = translated_states("select", translation_key) if translation_key else frozenset()
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
if fallback_fn is not None and (fallback := fallback_fn(value)) is not None:
|
||||
return fallback
|
||||
if translation_key and not known:
|
||||
# No state table for this key: either the entity isn't translated at
|
||||
# all, or its name is translated but its options deliberately aren't
|
||||
# (an unrecognized course table, say). Nothing named this value, so
|
||||
# the raw device value is the best choice -- the cosmetic reshaping
|
||||
# below would only mangle an opaque code, turning a course '0E' into
|
||||
# '0 E'. Reached only when the fallback *declined* the value, not
|
||||
# merely when none was supplied: cycle_select always supplies one now
|
||||
# (it labels cloud programs) and returns None for everything else.
|
||||
return value
|
||||
if value.islower():
|
||||
return value.replace("_", " ").title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(" ", value)
|
||||
@@ -85,7 +92,15 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
super().__init__(coordinator, bound)
|
||||
desc = cast(SelectDesc, bound.desc)
|
||||
if not desc.options_field and not callable(desc.options):
|
||||
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
|
||||
self._attr_options = [self._display_option(o) for o in desc.options]
|
||||
|
||||
def _display_option(self, value):
|
||||
"""Normalize both current state and options through one path."""
|
||||
display_fn = cast(SelectDesc, self._bound.desc).display_fn
|
||||
fallback_fn = (
|
||||
(lambda raw: display_fn(raw, self._resources)) if display_fn is not None else None
|
||||
)
|
||||
return _display(value, self.translation_key, fallback_fn)
|
||||
|
||||
def _raw_options(self) -> list[str]:
|
||||
desc = cast(SelectDesc, self._bound.desc)
|
||||
@@ -106,17 +121,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
def options(self) -> list[str]:
|
||||
desc = cast(SelectDesc, self._bound.desc)
|
||||
if desc.options_field or callable(desc.options):
|
||||
return [_display(o, self.translation_key) for o in self._raw_options()]
|
||||
return [self._display_option(o) for o in self._raw_options()]
|
||||
return self._attr_options
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
return _display(raw, self.translation_key)
|
||||
return self._display_option(raw)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
raw = next(
|
||||
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
|
||||
(o for o in self._raw_options() if self._display_option(o) == option),
|
||||
option,
|
||||
)
|
||||
await self.coordinator.async_send_command(self._bound, raw)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from typing import cast
|
||||
|
||||
@@ -51,6 +52,9 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
if desc.options:
|
||||
self._attr_options = list(desc.options)
|
||||
self._hysteresis_value = None
|
||||
self._sticky_value = None
|
||||
self._sticky_until: float | None = None
|
||||
self._sticky_spent = False
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
@@ -63,10 +67,65 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
|
||||
def native_value(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
desc = cast(SensorDesc, self._bound.desc)
|
||||
if desc.sticky_fn is not None:
|
||||
raw = self._apply_sticky(raw, desc)
|
||||
if not desc.hysteresis:
|
||||
return raw
|
||||
return self._apply_hysteresis(raw)
|
||||
|
||||
def _apply_sticky(self, raw, desc: SensorDesc):
|
||||
"""Freeze this entity at a value for up to `desc.sticky_seconds`
|
||||
after `desc.sticky_fn` next stops matching this href's live rep
|
||||
(issue #345 -- see operational.py's `_just_finished` for the
|
||||
motivating case). Entity-instance state only, exactly like
|
||||
`_hysteresis_value` above -- never written back to the coordinator
|
||||
cache, so write_fn, diagnostics, and the observe-mode sweep
|
||||
comparison keep seeing real device data throughout.
|
||||
|
||||
`sticky_fn`/`sticky_bypass_fn` read this href's live rep rather
|
||||
than the already-computed `raw`, so they can key on fields rep_fn
|
||||
has collapsed away -- but they never compute a value. `raw` and
|
||||
the frozen `sticky_value` are the only things returned here, so a
|
||||
held entity and a free-running one agree on what "live" means; a
|
||||
hook that broke that rule caused issue #358.
|
||||
|
||||
At most one window per `sticky_bypass_fn` cycle: arming marks the
|
||||
hold spent, and only the bypass clears that. So a `sticky_fn` that
|
||||
keeps matching (firmware leaving the field stuck -- the quirk
|
||||
`_completion_minutes` works around) can't extend the window, and
|
||||
one flapping in and out can't restart it either, before or after
|
||||
expiry. Expiry alone doesn't re-open the door: without something
|
||||
the calibre of "a new cycle is actually running" in between, a
|
||||
second Finish is the same Finish, and re-arming on it would strobe
|
||||
the entity between held and live once per window -- exactly the
|
||||
repeated announcements #345 and #358 are about.
|
||||
|
||||
`sticky_bypass_fn` drops the hold and returns `raw`, for when "not
|
||||
sticky right now" is ambiguous between "went idle, honor the hold"
|
||||
and "genuinely moved on to new data". It is both the early release
|
||||
and the only re-arm, so it should demand positive evidence of that
|
||||
move; when unsure, letting the window run out is the cheaper
|
||||
mistake.
|
||||
"""
|
||||
assert desc.sticky_fn is not None # native_value only calls this when set
|
||||
rep = self.coordinator.resource(self._bound.href)
|
||||
now = time.monotonic()
|
||||
|
||||
if desc.sticky_fn(rep):
|
||||
if not self._sticky_spent:
|
||||
self._sticky_value = (
|
||||
desc.sticky_value_fn(rep) if desc.sticky_value_fn is not None else raw
|
||||
)
|
||||
self._sticky_until = now + desc.sticky_seconds
|
||||
self._sticky_spent = True
|
||||
elif desc.sticky_bypass_fn is not None and desc.sticky_bypass_fn(rep):
|
||||
self._sticky_until = None
|
||||
self._sticky_spent = False
|
||||
return raw
|
||||
|
||||
holding = self._sticky_until is not None and now < self._sticky_until
|
||||
return self._sticky_value if holding else 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
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Home Assistant services for direct OCF resource read/write access
|
||||
(issue #300): a raw-transport escape hatch for reverse-engineering a
|
||||
device's write contract -- an ordered multi-write sequence with settle
|
||||
delays and a delayed re-read, which the single-write options-flow debug
|
||||
panel can't express. Both sit on the same coordinator primitives the panel
|
||||
now calls too (config_flow.py), so there is exactly one code path that
|
||||
performs a raw write.
|
||||
|
||||
Kept thin on purpose: session/lock ownership lives on the coordinator
|
||||
(coordinator.py). This module only resolves the service call's device
|
||||
target to a `(coordinator, subdevice)` pair, translates canonical hrefs
|
||||
through that subdevice, and shapes the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse, SupportsResponse
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import DOMAIN, SERVICE_READ_RESOURCE, SERVICE_WRITE_RESOURCE
|
||||
from .coordinator import LocalThingsCoordinator, normalize_href
|
||||
from .registry.subdevices import MAIN, Subdevice
|
||||
|
||||
ATTR_HREF = "href"
|
||||
ATTR_PAYLOAD = "payload"
|
||||
ATTR_SETTLE = "settle"
|
||||
ATTR_WRITES = "writes"
|
||||
ATTR_VERIFY_AFTER = "verify_after"
|
||||
ATTR_HOLD_SESSION_LOCK = "hold_session_lock"
|
||||
ATTR_DEVICE_ID = "device_id"
|
||||
|
||||
_WRITE_ITEM_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_HREF): str,
|
||||
# Not `dict` here: a non-dict payload must fail the same way an
|
||||
# empty one does -- coordinator.async_raw_write_sequence's
|
||||
# ServiceValidationError -- not a raw schema vol.Invalid, so every
|
||||
# caller sees one consistent error shape regardless of which rule
|
||||
# a bad payload tripped.
|
||||
vol.Required(ATTR_PAYLOAD): object,
|
||||
vol.Optional(ATTR_SETTLE): vol.Coerce(float),
|
||||
}
|
||||
)
|
||||
|
||||
# Structural validation only (types, and unwrapping a bare dict into a
|
||||
# one-item list) -- the semantic checks (non-empty payload, non-root href,
|
||||
# the 1..10/settle/verify_after ranges) live on
|
||||
# LocalThingsCoordinator.async_raw_write_sequence, so every caller gets the
|
||||
# same ServiceValidationError + translation key regardless of whether it
|
||||
# reached the primitive through this service, the options-flow panel, or a
|
||||
# future caller.
|
||||
_WRITE_RESOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**cv.TARGET_SERVICE_FIELDS,
|
||||
vol.Required(ATTR_WRITES): vol.All(cv.ensure_list, [_WRITE_ITEM_SCHEMA]),
|
||||
vol.Optional(ATTR_VERIFY_AFTER): vol.Coerce(float),
|
||||
vol.Optional(ATTR_HOLD_SESSION_LOCK): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
_READ_RESOURCE_SCHEMA = vol.Schema(
|
||||
{
|
||||
**cv.TARGET_SERVICE_FIELDS,
|
||||
vol.Optional(ATTR_HREF): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolve_target(
|
||||
hass: HomeAssistant, call: ServiceCall
|
||||
) -> tuple[LocalThingsCoordinator, Subdevice, str]:
|
||||
"""The one `(coordinator, subdevice, device_id)` a service call's
|
||||
device target names.
|
||||
|
||||
Deliberately strict about count, not just presence: the `target:
|
||||
device:` selector in services.yaml still lets a user pick an area or
|
||||
label in the picker, and the frontend expands that into a `device_id`
|
||||
list before the call reaches here -- more than one entry means an
|
||||
area/label fanned this out across several appliances, which a raw
|
||||
debug write must never do silently (issue #300).
|
||||
"""
|
||||
device_ids = cv.ensure_list(call.data.get(ATTR_DEVICE_ID) or [])
|
||||
if len(device_ids) != 1:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_target_invalid",
|
||||
)
|
||||
device_id = device_ids[0]
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get(device_id)
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_not_found",
|
||||
)
|
||||
|
||||
for coordinator in hass.data.get(DOMAIN, {}).values():
|
||||
if device.identifiers & coordinator.device_info.get("identifiers", set()):
|
||||
return coordinator, MAIN, device_id
|
||||
for sub in coordinator.subdevices:
|
||||
if device.identifiers & coordinator.device_info_for(sub).get("identifiers", set()):
|
||||
return coordinator, sub, device_id
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="service_device_not_loaded",
|
||||
)
|
||||
|
||||
|
||||
async def _async_write_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||
coordinator, subdevice, device_id = _resolve_target(hass, call)
|
||||
writes_in: list[dict[str, Any]] = call.data[ATTR_WRITES]
|
||||
|
||||
# Canonical -> actual translation happens here, not in the coordinator
|
||||
# (issue #177), whose raw-write primitive has no notion of subdevices --
|
||||
# identity transform for MAIN. Normalized first, or a trailing slash
|
||||
# slips past to_actual onto the master (see coordinator.normalize_href).
|
||||
canonicals = [normalize_href(w[ATTR_HREF]) for w in writes_in]
|
||||
raw_writes = [
|
||||
{
|
||||
"href": subdevice.to_actual(canonical),
|
||||
"payload": w.get(ATTR_PAYLOAD),
|
||||
"settle": w.get(ATTR_SETTLE, 0.0),
|
||||
}
|
||||
for canonical, w in zip(canonicals, writes_in, strict=True)
|
||||
]
|
||||
sequence = await coordinator.async_raw_write_sequence(
|
||||
raw_writes,
|
||||
verify_after=call.data.get(ATTR_VERIFY_AFTER, 0.0),
|
||||
hold_session_lock=call.data.get(ATTR_HOLD_SESSION_LOCK, True),
|
||||
)
|
||||
|
||||
results = [
|
||||
{
|
||||
"href": canonical,
|
||||
"actual_href": result["href"],
|
||||
"code": result["code"],
|
||||
"raw_code": result["raw_code"],
|
||||
"accepted": result["accepted"],
|
||||
"before": result["before"],
|
||||
"after": result["after"],
|
||||
"changed": result["changed"],
|
||||
}
|
||||
for canonical, result in zip(canonicals, sequence["results"], strict=True)
|
||||
]
|
||||
response: dict[str, Any] = {"device_id": device_id, "results": results}
|
||||
if "verified" in sequence:
|
||||
# Keyed off the same normalized canonicals the sequence was built
|
||||
# from, so the lookup can't miss and return an actual href where the
|
||||
# contract promises a canonical one.
|
||||
canonical_by_actual = {subdevice.to_actual(c): c for c in canonicals}
|
||||
response["verified"] = {
|
||||
canonical_by_actual.get(actual_href, actual_href): verified
|
||||
for actual_href, verified in sequence["verified"].items()
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse:
|
||||
coordinator, subdevice, _device_id = _resolve_target(hass, call)
|
||||
href = call.data.get(ATTR_HREF)
|
||||
if not href:
|
||||
# No href -> the cached snapshot, not a live sweep of every known
|
||||
# href: lets a user enumerate what exists without hammering the
|
||||
# device (see this module's docstring and the coordinator's
|
||||
# canonical_resources).
|
||||
# device_resources, not canonical_resources: this response is what
|
||||
# the appliance reported, without the fields this integration merges
|
||||
# on for its own use (see coordinator.entity_resources).
|
||||
snapshot: dict[str, Any] = {"resources": coordinator.device_resources(subdevice)}
|
||||
return cast(ServiceResponse, snapshot)
|
||||
|
||||
# Same normalize-before-translate order as the write path above.
|
||||
canonical = normalize_href(href)
|
||||
actual_href = subdevice.to_actual(canonical)
|
||||
code, rep, body = await coordinator.async_raw_read(actual_href)
|
||||
read_result: dict[str, Any] = {
|
||||
"href": canonical,
|
||||
"actual_href": actual_href,
|
||||
"code": f"{code >> 5}.{code & 0x1F:02d}",
|
||||
"raw_code": code,
|
||||
"rep": rep,
|
||||
}
|
||||
# `body` only when it isn't the Property map already in `rep` -- a
|
||||
# Collection (`/device/0`, `/sec/devices`) answers a CBOR list, which
|
||||
# `rep` can't carry and which used to vanish into an empty-looking
|
||||
# 2.05 (issue #335). Omitted for the ordinary map case rather than
|
||||
# duplicating every rep in every response.
|
||||
if body is not None and not isinstance(body, dict):
|
||||
read_result["body"] = body
|
||||
return cast(ServiceResponse, read_result)
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the write_resource/read_resource services (issue #300).
|
||||
|
||||
Called once from `async_setup`, not per config entry: services are
|
||||
process-global, and `hass.services.async_register` on an
|
||||
already-registered name just replaces the handler, so re-registering
|
||||
on every entry setup would silently rebind to whichever entry loaded
|
||||
last. `async_unload_entry` must never call the inverse of this.
|
||||
"""
|
||||
|
||||
async def _handle_write(call: ServiceCall) -> ServiceResponse:
|
||||
return await _async_write_resource(hass, call)
|
||||
|
||||
async def _handle_read(call: ServiceCall) -> ServiceResponse:
|
||||
return await _async_read_resource(hass, call)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
_handle_write,
|
||||
schema=_WRITE_RESOURCE_SCHEMA,
|
||||
supports_response=SupportsResponse.OPTIONAL,
|
||||
)
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
_handle_read,
|
||||
schema=_READ_RESOURCE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
write_resource:
|
||||
name: Write resource
|
||||
description: >-
|
||||
Send one or more raw partial-rep writes straight to a device's OCF
|
||||
resources, in order, with an optional settle delay between steps and a
|
||||
delayed re-read at the end -- for reverse-engineering a device's write
|
||||
contract (issue #300), not day-to-day control. This bypasses the
|
||||
remote-control-off block and every write_fn/validate_fn a normal entity
|
||||
write goes through, and sends exactly the fields you give it verbatim:
|
||||
it can misconfigure your appliance. Prefer a real entity, or the Debug
|
||||
write panel in the integration's Configure menu, for anything this
|
||||
integration already models.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The appliance to write to, or one of its subdevices.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: localthings
|
||||
writes:
|
||||
name: Writes
|
||||
description: >-
|
||||
1-10 writes to perform in order. Each item needs href (the
|
||||
canonical resource, e.g. /mode/vs/0) and payload (a non-empty
|
||||
object sent verbatim as a partial-rep POST); settle is how many
|
||||
seconds to wait after that write before starting the next one
|
||||
(0-30, default 0).
|
||||
required: true
|
||||
example: >-
|
||||
[{"href": "/mode/vs/0", "payload": {"x.com.samsung.da.modes":
|
||||
["Bake"]}, "settle": 3}]
|
||||
selector:
|
||||
object:
|
||||
hold_session_lock:
|
||||
name: Hold the session for the whole sequence
|
||||
description: >-
|
||||
Keep the device session for the entire sequence, settle delays
|
||||
included, so nothing else -- a routine poll, another entity's write
|
||||
-- can land between two steps and blur which write the appliance
|
||||
was reacting to. On by default. Turning it off takes the session
|
||||
per write and frees it across the waits, which lets entities keep
|
||||
updating during a long sequence at the cost of that certainty.
|
||||
required: false
|
||||
default: true
|
||||
selector:
|
||||
boolean:
|
||||
verify_after:
|
||||
name: Verify after
|
||||
description: >-
|
||||
Seconds to wait after the whole sequence finishes before
|
||||
re-reading every href touched, to see whether the values held or
|
||||
were reverted by the board. 0 (default) skips verification.
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 60
|
||||
step: 0.5
|
||||
unit_of_measurement: seconds
|
||||
mode: box
|
||||
|
||||
read_resource:
|
||||
name: Read resource
|
||||
description: >-
|
||||
Read a device's OCF resources directly, bypassing this integration's
|
||||
entity model. Give an href for a live GET straight from the device --
|
||||
deliberately not the cache, which can be up to a poll interval stale --
|
||||
or omit it to get the cached snapshot of every resource this
|
||||
integration currently tracks on that device.
|
||||
fields:
|
||||
device_id:
|
||||
name: Device
|
||||
description: The appliance to read from, or one of its subdevices.
|
||||
required: true
|
||||
selector:
|
||||
device:
|
||||
integration: localthings
|
||||
href:
|
||||
name: Resource href
|
||||
description: >-
|
||||
Canonical resource href to read (e.g. /mode/vs/0). Omit to get the
|
||||
cached snapshot of every tracked resource instead of a live GET.
|
||||
required: false
|
||||
example: /mode/vs/0
|
||||
selector:
|
||||
text:
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "Chytrý",
|
||||
"speed": "Rychlý",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Spánek",
|
||||
"nanosleep": "WindFree spánek",
|
||||
"longwind": "Dlouhý vánek",
|
||||
"motionindirect": "Nepřímý vzduch při pohybu",
|
||||
"motiondirect": "Přímý vzduch při pohybu",
|
||||
"drycomfort": "Komfortní sušení",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2stupňový"
|
||||
}
|
||||
}
|
||||
@@ -223,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Práh upozornění na filtr"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Časovač automatického otevírání dvířek"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Režim zóny nápojů",
|
||||
"state": {
|
||||
@@ -295,7 +300,16 @@
|
||||
"07": "Předoplach",
|
||||
"8d": "Hrnce a pánve",
|
||||
"8e": "Plast",
|
||||
"8f": "Dětské potřeby"
|
||||
"8f": "Dětské potřeby",
|
||||
"82": "Automatický",
|
||||
"8a": "Normální",
|
||||
"a7": "Intenzivní",
|
||||
"a8": "Expresní",
|
||||
"8c": "Extra tichý",
|
||||
"88": "Samočištění",
|
||||
"85": "Jemné",
|
||||
"0c": "Express",
|
||||
"0d": "Samočištění"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -310,6 +324,22 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"9c": "Intenzivní",
|
||||
"a5": "Ložní prádlo",
|
||||
"9e": "Nežehlivé prádlo",
|
||||
"9b": "Parní dezinfekce+",
|
||||
"27": "Osvěžení",
|
||||
"a0": "Provětrání",
|
||||
"a4": "Časové sušení",
|
||||
"a6": "Rychlé sušení",
|
||||
"a3": "Sportovní oblečení",
|
||||
"a2": "Jemné prádlo"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
@@ -337,7 +367,9 @@
|
||||
"4c": "Osvěžení vzduchem",
|
||||
"51": "Eko bavlna",
|
||||
"53": "AI sušení+",
|
||||
"4e": "Automatické sušení"
|
||||
"4e": "Automatické sušení",
|
||||
"26": "Provětrání",
|
||||
"2a": "Hygienické sušení+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -459,9 +491,11 @@
|
||||
"kimchi_storage_crunfch": "Křupavé kimči",
|
||||
"kimchi_storage_buy": "Kupované kimči",
|
||||
"storage_fridge_normal": "Chlazení",
|
||||
"storage_fridge": "Chlazení",
|
||||
"storage_fridge_cold": "Chlazení, silné",
|
||||
"storage_fridge_warm": "Chlazení, slabé",
|
||||
"storage_freezer_normal": "Mražení",
|
||||
"storage_freezer": "Mražení",
|
||||
"storage_freezer_cold": "Mražení, silné",
|
||||
"storage_freezer_warm": "Mražení, slabé",
|
||||
"kimchi_ripe_low_temp": "Zrání kimči, nízká teplota",
|
||||
@@ -474,7 +508,8 @@
|
||||
"storage_fresh_cereal": "Obiloviny",
|
||||
"storage_fridge_drink": "Nápoje",
|
||||
"storage_fresh_wine": "Víno",
|
||||
"storage_fresh_potato_banana": "Brambory a banány"
|
||||
"storage_fresh_potato_banana": "Brambory a banány",
|
||||
"newmode_kimchi_0000": "Nový režim"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -485,6 +520,16 @@
|
||||
"fdr_drinks": "Nápoje"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Režim spíže",
|
||||
"state": {
|
||||
"processed_meat": "Zpracované maso",
|
||||
"cheese": "Sýr",
|
||||
"nuts": "Ořechy",
|
||||
"fruit": "Ovoce",
|
||||
"wine": "Víno"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Výkon hořáku {number}",
|
||||
"state": {
|
||||
@@ -536,6 +581,9 @@
|
||||
"mute": "Ztlumeno"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Režim úspory energie"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Zvukový režim",
|
||||
"state": {
|
||||
@@ -572,11 +620,29 @@
|
||||
"extra_hot": "Extra horká"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"70": "Intenzivní",
|
||||
"55": "Bílé prádlo",
|
||||
"71": "Ložní prádlo",
|
||||
"72": "Dezinfekce",
|
||||
"77": "Nežehlivé prádlo",
|
||||
"57": "Samočištění+",
|
||||
"73": "Máchání + odstřeďování",
|
||||
"74": "Sportovní oblečení",
|
||||
"75": "Jemné prádlo",
|
||||
"78": "Rychlé praní"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Cyklus",
|
||||
"state": {
|
||||
"01": "Normální",
|
||||
"04": "Rychlé praní",
|
||||
"06": "XXL prádlo",
|
||||
"08": "Máchání+odstřeďování",
|
||||
"17": "Stažený program",
|
||||
"1b": "Bavlna",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -587,7 +653,7 @@
|
||||
"21": "Barevné prádlo",
|
||||
"22": "Vlna",
|
||||
"23": "Outdoor",
|
||||
"24": "Ručníky",
|
||||
"24": "Ložní prádlo",
|
||||
"25": "Syntetika",
|
||||
"26": "Jemné prádlo",
|
||||
"27": "Máchání+odstřeďování",
|
||||
@@ -600,7 +666,7 @@
|
||||
"2f": "Sportovní oblečení",
|
||||
"30": "Zataženo",
|
||||
"32": "Košile",
|
||||
"33": "Ložní prádlo",
|
||||
"33": "Ručníky",
|
||||
"34": "Smíšené",
|
||||
"36": "Praní+sušení",
|
||||
"37": "Sušení vzduchem",
|
||||
@@ -616,14 +682,34 @@
|
||||
"60": "Samočištění+",
|
||||
"65": "Barevné prádlo",
|
||||
"66": "Džíny",
|
||||
"69": "AI praní",
|
||||
"6a": "Vlna",
|
||||
"6b": "Džíny",
|
||||
"6c": "Halenky",
|
||||
"6d": "Jemné prádlo",
|
||||
"6e": "Sportovní oblečení",
|
||||
"6f": "Ložní prádlo",
|
||||
"70": "Ručníky",
|
||||
"71": "Rychlé praní",
|
||||
"72": "Košile",
|
||||
"73": "Dezinfekce",
|
||||
"74": "Čištění bubnu",
|
||||
"75": "Outdoor",
|
||||
"76": "Dětské potřeby",
|
||||
"77": "Bavlna",
|
||||
"78": "Máchání + odstřeďování",
|
||||
"79": "Pouze odstřeďování",
|
||||
"7c": "Bílé prádlo",
|
||||
"7d": "Ložní prádlo/nepromokavé",
|
||||
"7e": "Samočištění",
|
||||
"7f": "Vlna/jemné",
|
||||
"86": "Hloubkové praní",
|
||||
"87": "Stažený program",
|
||||
"88": "Péče o domácí mazlíčky",
|
||||
"8f": "Intenzivní studená",
|
||||
"96": "Méně mikrovláken"
|
||||
"96": "Méně mikrovláken",
|
||||
"a0": "15min rychlé praní",
|
||||
"35": "Eko bavlna"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -663,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Teplota mrazicí zóny"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Režim okrajového osvětlení",
|
||||
"state": {
|
||||
"smart": "Chytrý",
|
||||
"high": "Vysoký",
|
||||
"low": "Nízký"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Barva okrajového osvětlení",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Režim kontrolky",
|
||||
"state": {
|
||||
"smart": "Chytrý",
|
||||
"high": "Vysoký",
|
||||
"low": "Nízký"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Režim",
|
||||
"state": {
|
||||
"purification": "Čištění",
|
||||
"ventilation": "Větrání",
|
||||
"smartventilation": "Chytré větrání"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -678,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Hodiny využití filtru"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Využití filtru"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Norma kvality vzduchu"
|
||||
},
|
||||
@@ -785,6 +906,12 @@
|
||||
"indirect": "Nepřímý"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Stav úspory energie"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Provozní stav úspory energie"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Teplota"
|
||||
},
|
||||
@@ -1038,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Úspora energie při nepřítomnosti aktivní"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Čištění při nepřítomnosti"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Vyhýbání se proudu vzduchu při pohybu aktivní"
|
||||
},
|
||||
@@ -1071,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Automatické otevírání dvířek"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Zvuk automatického otevírání dvířek"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Hlasové ovládání automatického otevírání dvířek"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Automatické uvolnění při sušení"
|
||||
},
|
||||
@@ -1202,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Alarm větrání"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Okrajové osvětlení"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Kontrolka"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Režim Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Noční režim"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1290,17 +1438,24 @@
|
||||
"title": "Možnosti LocalThings",
|
||||
"menu_options": {
|
||||
"settings": "Nastavení zápisu pro dálkové ovládání",
|
||||
"cloud_courses": "Stažené cykly",
|
||||
"forget_learned_modes": "Zapomenout zapamatované režimy",
|
||||
"debug_write": "Ladění: zápis do prostředku"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Nastavení zápisu pro dálkové ovládání",
|
||||
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.",
|
||||
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté",
|
||||
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)"
|
||||
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)",
|
||||
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Zapomenout zapamatované režimy",
|
||||
"description": "Aktuálně zapamatováno: {codes}\n\nJde o režimy, ve kterých se toto zařízení samo hlásilo, aniž by je uvádělo mezi podporovanými; jsou uchovány, aby zůstaly volitelné. Zapomenutí je řešením, pokud se některý ukázal jako chybný – cokoli, co zařízení skutečně znovu nahlásí, si systém opět zapamatuje, pokud zároveň nevypnete volbu „Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované“ v nastavení."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Ladění: zápis do prostředku",
|
||||
"description": "Nástroj pro pokročilé uživatele k odhalení chování zápisu specifického pro dané zařízení. Vyberte prostředek (href), do kterého chcete zapisovat, nebo zadejte vlastní, který není uveden v seznamu. Toto obchází blokování při vypnutém dálkovém ovládání a odesílá přesně ta pole, která zadáte -- může to špatně nakonfigurovat váš spotřebič, používejte tedy záměrně.",
|
||||
@@ -1322,20 +1477,63 @@
|
||||
"debug_write": "Zapsat do dalšího prostředku",
|
||||
"finish": "Dokončit"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
|
||||
"data": {
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Stažené cykly",
|
||||
"description": "Průvodce nastavením vás provede zařízením: na zařízení vyberte stažený cyklus a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Průvodce nastavením",
|
||||
"cloud_manual": "Upravit názvy"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Stažené cykly"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Pojmenujte tento cyklus",
|
||||
"description": "Zařízení přepnulo na stažený cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na zařízení vyberte další cyklus. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
|
||||
"data": {
|
||||
"name": "Název",
|
||||
"download_course": "Kód programu „Stažený program“"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Nebyl vybrán žádný cyklus",
|
||||
"description": "Na zařízení nebylo nic vybráno. Ujistěte se, že je nastaveno na Stažený program, a poté postupně procházejte stažené programy.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Počkat znovu",
|
||||
"cloud_finish": "Dokončit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Zadejte alespoň jedno pole k zápisu.",
|
||||
"write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant."
|
||||
"write_failed": "Zápis se nezdařil. Podrobnosti najdete v protokolech Home Assistant.",
|
||||
"cloud_course_name_duplicate": "Dva cykly mají stejný název. Názvy musí být jedinečné.",
|
||||
"cloud_course_unknown_course": "Tento program zařízení nenabízí. Vyberte jej ze seznamu."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Nyní na zařízení vyberte stažený cyklus.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Neúplné pokrytí funkcí pro {device_name}",
|
||||
"description": "Toto zařízení nemá úplné pokrytí funkcí. Buď nebyl rozpoznán jeho typ spotřebiče, nebo některé jím poskytované prostředky ještě nejsou namodelovány. Bude i nadále fungovat se vším, co je již podporováno. Podporu můžete pomoci rozšířit tak, že přejdete do Nastavení > Zařízení a služby > {device_name} > nabídka (vpravo nahoře) > Stáhnout diagnostiku a poté ji vložíte do odkazované šablony issue."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Stažené cykly nejsou nastaveny pro {device_name}",
|
||||
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1356,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intenzivní není u vybraného cyklu k dispozici."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Příkaz pro {href} selhal i po opětovném připojení: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Zadejte 1 až 10 zápisů."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "Hodnota settle musí být mezi 0 a 30 sekundami."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "Hodnota verify_after musí být mezi 0 a 60 sekundami."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Tato služba vyžaduje přesně jedno cílové zařízení."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Pro tento cíl nebylo nalezeno žádné odpovídající zařízení."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -152,11 +152,13 @@
|
||||
"smart": "Smart",
|
||||
"speed": "Speed",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sleep",
|
||||
"nanosleep": "WindFree sleep",
|
||||
"longwind": "Long wind",
|
||||
"motionindirect": "Motion indirect",
|
||||
"motiondirect": "Motion direct",
|
||||
"drycomfort": "Dry comfort",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2-Step"
|
||||
}
|
||||
}
|
||||
@@ -223,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Filter alarm threshold"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Auto door open timer"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Beverage zone mode",
|
||||
"state": {
|
||||
@@ -294,8 +299,17 @@
|
||||
"0e": "AI Wash",
|
||||
"07": "Pre blast",
|
||||
"8d": "Pots and pans",
|
||||
"85": "Delicate",
|
||||
"0c": "Express",
|
||||
"0d": "Self clean",
|
||||
"8e": "Plastic",
|
||||
"8f": "Baby Care"
|
||||
"8f": "Baby Care",
|
||||
"82": "Auto",
|
||||
"8a": "Normal",
|
||||
"a7": "Heavy",
|
||||
"a8": "Express",
|
||||
"8c": "Extra Silence",
|
||||
"88": "Self Clean"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -310,6 +324,22 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"9c": "Heavy Duty",
|
||||
"a5": "Bedding",
|
||||
"9e": "Perm Press",
|
||||
"9b": "Steam Sanitize+",
|
||||
"27": "Refresh",
|
||||
"a0": "Air Fluff",
|
||||
"a4": "Time Dry",
|
||||
"a6": "Quick Dry",
|
||||
"a3": "Active Wear",
|
||||
"a2": "Delicates"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
@@ -325,8 +355,10 @@
|
||||
"23": "Quick Dry 35'",
|
||||
"24": "Cool air",
|
||||
"25": "Warm air",
|
||||
"26": "Air wash",
|
||||
"27": "Time dry",
|
||||
"29": "AI Dry",
|
||||
"2a": "Hygiene Care+",
|
||||
"1a": "Wool",
|
||||
"1b": "Bedding",
|
||||
"1c": "Shirts",
|
||||
@@ -461,9 +493,11 @@
|
||||
"storage_fridge_normal": "Fridge",
|
||||
"storage_fridge_cold": "Fridge, strong",
|
||||
"storage_fridge_warm": "Fridge, weak",
|
||||
"storage_fridge": "Fridge",
|
||||
"storage_freezer_normal": "Freezer",
|
||||
"storage_freezer_cold": "Freezer, strong",
|
||||
"storage_freezer_warm": "Freezer, weak",
|
||||
"storage_freezer": "Freezer",
|
||||
"kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
|
||||
"kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
|
||||
"kimchi_ripe_kkakdugi": "Kkakdugi ripening",
|
||||
@@ -474,7 +508,8 @@
|
||||
"storage_fresh_cereal": "Grains",
|
||||
"storage_fridge_drink": "Beverages",
|
||||
"storage_fresh_wine": "Wine",
|
||||
"storage_fresh_potato_banana": "Potato & banana"
|
||||
"storage_fresh_potato_banana": "Potato & banana",
|
||||
"newmode_kimchi_0000": "New mode"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -485,6 +520,16 @@
|
||||
"fdr_drinks": "Drinks"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Pantry zone mode",
|
||||
"state": {
|
||||
"processed_meat": "Processed meat",
|
||||
"cheese": "Cheese",
|
||||
"nuts": "Nuts",
|
||||
"fruit": "Fruit",
|
||||
"wine": "Wine"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Burner {number} power level",
|
||||
"state": {
|
||||
@@ -536,6 +581,9 @@
|
||||
"mute": "Mute"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Energy saving mode"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Sound mode",
|
||||
"state": {
|
||||
@@ -572,11 +620,29 @@
|
||||
"extra_hot": "Extra hot"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"70": "Heavy Duty",
|
||||
"55": "Whites",
|
||||
"71": "Bedding",
|
||||
"72": "Sanitize",
|
||||
"77": "Perm Press",
|
||||
"57": "Self Clean+",
|
||||
"73": "Rinse + Spin",
|
||||
"74": "Active Wear",
|
||||
"75": "Delicates",
|
||||
"78": "Quick Wash"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Cycle",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"04": "Quick Wash",
|
||||
"06": "XXL Laundry",
|
||||
"08": "Rinse+Spin",
|
||||
"17": "Downloaded",
|
||||
"1b": "Cotton",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -587,7 +653,7 @@
|
||||
"21": "Colors",
|
||||
"22": "Wool",
|
||||
"23": "Outdoor",
|
||||
"24": "Towels",
|
||||
"24": "Bedding",
|
||||
"25": "Synthetics",
|
||||
"26": "Delicates",
|
||||
"27": "Rinse+Spin",
|
||||
@@ -600,8 +666,9 @@
|
||||
"2f": "Activewear",
|
||||
"30": "Cloudy Day",
|
||||
"32": "Shirts",
|
||||
"33": "Bedding",
|
||||
"33": "Towels",
|
||||
"34": "Mixed",
|
||||
"35": "E Cotton",
|
||||
"36": "Wash+Dry",
|
||||
"37": "Air Wash",
|
||||
"38": "Cotton Dry",
|
||||
@@ -616,14 +683,33 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Colors",
|
||||
"66": "Denim",
|
||||
"69": "AI Wash",
|
||||
"6a": "Wool",
|
||||
"6b": "Denim",
|
||||
"6c": "Blouses",
|
||||
"6d": "Delicates",
|
||||
"6e": "Active Wear",
|
||||
"6f": "Bedding",
|
||||
"70": "Towels",
|
||||
"71": "Quick Wash",
|
||||
"72": "Shirts",
|
||||
"73": "Sanitize",
|
||||
"74": "Drum Clean",
|
||||
"75": "Outdoor",
|
||||
"76": "Baby Care",
|
||||
"77": "Cottons",
|
||||
"78": "Rinse + Spin",
|
||||
"79": "Spin Only",
|
||||
"7c": "Whites",
|
||||
"7d": "Bedding/Waterproof",
|
||||
"7e": "Self-Clean",
|
||||
"7f": "Wool/Delicate",
|
||||
"86": "Deep Wash",
|
||||
"87": "Download",
|
||||
"88": "Pet Care",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber"
|
||||
"96": "Less Microfiber",
|
||||
"a0": "15' Quick Wash"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -663,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Freezer temperature"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Edge lighting mode",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "High",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Edge lighting color",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Indicator light mode",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "High",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"purification": "Purification",
|
||||
"ventilation": "Ventilation",
|
||||
"smartventilation": "Smart Ventilation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -678,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filter usage hours"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Filter usage"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Air quality standard"
|
||||
},
|
||||
@@ -785,6 +906,12 @@
|
||||
"indirect": "Indirect"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Energy saving state"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Energy saving operating status"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
@@ -1038,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Absence power saving active"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Absence clean"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Motion-detect wind avoidance active"
|
||||
},
|
||||
@@ -1071,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Auto door opener"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Auto door sound"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Auto door voice control"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Auto release dry"
|
||||
},
|
||||
@@ -1202,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilation alarm"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Edge lighting"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Indicator light"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Wind-Free mode"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Sleep mode"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1290,17 +1438,24 @@
|
||||
"title": "LocalThings options",
|
||||
"menu_options": {
|
||||
"settings": "Device settings",
|
||||
"cloud_courses": "Download cycles",
|
||||
"forget_learned_modes": "Forget remembered modes",
|
||||
"debug_write": "Debug: write to a resource"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"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.",
|
||||
"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.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
|
||||
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
|
||||
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)",
|
||||
"learn_device_modes": "Remember modes the device reports but doesn't advertise"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Forget remembered modes",
|
||||
"description": "Currently remembered: {codes}\n\nThese are modes this device reported itself in without listing them as supported, kept so they stay selectable. Forgetting them is the fix if one turned out to be bogus -- anything the device genuinely reports again will simply be remembered again, unless you also turn off \"Remember modes the device reports but doesn't advertise\" in Device settings."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Debug: write to a resource",
|
||||
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
|
||||
@@ -1322,20 +1477,63 @@
|
||||
"debug_write": "Write another resource",
|
||||
"finish": "Finish"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Download cycles",
|
||||
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
|
||||
"data": {
|
||||
"download_course": "Download cycle course code"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Download cycles",
|
||||
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Guided setup",
|
||||
"cloud_manual": "Edit names"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Download cycles"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Name this cycle",
|
||||
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
|
||||
"data": {
|
||||
"name": "Name",
|
||||
"download_course": "Download cycle course code"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No cycle selected",
|
||||
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Wait again",
|
||||
"cloud_finish": "Finish"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Enter at least one field to write.",
|
||||
"write_failed": "The write failed. Check the Home Assistant logs for details."
|
||||
"write_failed": "The write failed. Check the Home Assistant logs for details.",
|
||||
"cloud_course_name_duplicate": "Two cycles have the same name. Names must be unique.",
|
||||
"cloud_course_unknown_course": "That course isn't one this appliance offers. Pick one from the list."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Incomplete capability coverage for {device_name}",
|
||||
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Downloaded cycles not set up for {device_name}",
|
||||
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1356,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensive isn't available on the selected cycle."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "The command to {href} failed even after reconnecting: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Provide between 1 and 10 writes."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle must be between 0 and 30 seconds."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after must be between 0 and 60 seconds."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "This service requires exactly one target device."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "No matching device was found for that target."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "This device isn't connected yet. Try again once it has loaded."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,17 +53,24 @@
|
||||
"title": "Opciones de LocalThings",
|
||||
"menu_options": {
|
||||
"settings": "Ajustes del dispositivo",
|
||||
"cloud_courses": "Ciclos descargados",
|
||||
"forget_learned_modes": "Olvidar los modos recordados",
|
||||
"debug_write": "Depuración: escribir en un recurso"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes del dispositivo",
|
||||
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.",
|
||||
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.\n\nAlgunos modelos notifican un modo que nunca incluyen entre los admitidos: por ejemplo, un aire acondicionado en modo Quiet que solo ofrece Off/Sleep/Speed. LocalThings recuerda cualquier modo así que detecte y lo sigue ofreciendo, de modo que quede seleccionable en cuanto el dispositivo haya estado en él al menos una vez. Desactívalo para ofrecer solo lo que el dispositivo anuncia; usa «Olvidar los modos recordados» en la pantalla anterior para borrar lo ya recordado.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Permitir escrituras incluso cuando el control remoto se notifica como desactivado",
|
||||
"finish_time_hysteresis_minutes": "Finalización estimada -- cambio mínimo (minutos)"
|
||||
"finish_time_hysteresis_minutes": "Finalización estimada -- cambio mínimo (minutos)",
|
||||
"learn_device_modes": "Recordar los modos que el dispositivo notifica pero no anuncia"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Olvidar los modos recordados",
|
||||
"description": "Recordados actualmente: {codes}\n\nSon modos en los que este dispositivo se notificó a sí mismo sin incluirlos entre los admitidos; se conservan para que sigan siendo seleccionables. Olvidarlos es la solución si alguno resultó ser erróneo: cualquier modo que el dispositivo vuelva a notificar de verdad se recordará otra vez, salvo que además desactives «Recordar los modos que el dispositivo notifica pero no anuncia» en los ajustes del dispositivo."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Depuración: escribir en un recurso",
|
||||
"description": "Herramienta para usuarios avanzados para precisar el comportamiento de escritura específico del dispositivo. Elige el recurso (href) sobre el que quieres escribir, o escribe uno personalizado que no esté en la lista. Esto omite el bloqueo de control remoto desactivado y envía exactamente los campos que proporciones; puede desconfigurar tu electrodoméstico, así que úsalo deliberadamente.",
|
||||
@@ -85,20 +92,63 @@
|
||||
"debug_write": "Escribir otro recurso",
|
||||
"finish": "Finalizar"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Ciclos descargados",
|
||||
"description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en el dispositivo, selecciona Descarga de Programas y ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Código de programa de Descarga de Programas"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Ciclos descargados",
|
||||
"description": "La configuración guiada te acompaña con el dispositivo: selecciona un ciclo descargado en el dispositivo y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Configuración guiada",
|
||||
"cloud_manual": "Editar nombres"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Ciclos descargados"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Nombra este ciclo",
|
||||
"description": "El dispositivo cambió a un ciclo descargado (ranura {slot}) e informa de {remaining} restantes.\n\nDale el nombre que quieras ver en Home Assistant y luego selecciona el siguiente en el dispositivo. Déjalo en blanco para mantener este ciclo fuera de la lista.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nLos nombres deben ser únicos. Puedes cerrar este cuadro de diálogo cuando quieras — los nombres se guardan sobre la marcha.",
|
||||
"data": {
|
||||
"name": "Nombre",
|
||||
"download_course": "Código de programa de Descarga de Programas"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "No se seleccionó ningún ciclo",
|
||||
"description": "No se seleccionó nada en el dispositivo. Asegúrate de que esté puesto en Descarga de Programas y luego ve pasando por los programas descargados.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nTodo lo nombrado hasta ahora ya está guardado.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Esperar de nuevo",
|
||||
"cloud_finish": "Finalizar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Introduce al menos un campo para escribir.",
|
||||
"write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles."
|
||||
"write_failed": "La escritura falló. Consulta los registros de Home Assistant para más detalles.",
|
||||
"cloud_course_name_duplicate": "Dos ciclos tienen el mismo nombre. Los nombres deben ser únicos.",
|
||||
"cloud_course_unknown_course": "Ese programa no es uno que ofrezca este dispositivo. Elige uno de la lista."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selecciona ahora un ciclo descargado en el dispositivo.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nPuedes cerrar este cuadro de diálogo en cualquier momento — los nombres se guardan sobre la marcha."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Cobertura de capacidades incompleta para {device_name}",
|
||||
"description": "A este dispositivo le falta cobertura completa de capacidades. O su tipo de electrodoméstico no fue reconocido, o algunos de los recursos que expone aún no están modelados. Seguirá funcionando con lo que ya está soportado. Puedes ayudar a ampliar el soporte yendo a Ajustes > Dispositivos y servicios > {device_name} > el menú (arriba a la derecha) > Descargar diagnósticos, y reportándolo con la plantilla de incidencia enlazada."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Ciclos descargados sin configurar para {device_name}",
|
||||
"description": "{device_name} tiene {pending} de {total} ciclos descargados que Home Assistant todavía no puede ofrecer. Un ciclo descargado solo se puede usar una vez que el dispositivo se ha visto con él cargado y le has dado un nombre.\n\nPara configurarlos, ve a Ajustes > Dispositivos y servicios > LocalThings > {device_name} > Configurar > Ciclos descargados y sigue las instrucciones que aparecen allí."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -119,6 +169,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "El modo intensivo no está disponible en el ciclo seleccionado."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "El comando para {href} falló incluso después de reconectar: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Proporciona entre 1 y 10 escrituras."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle debe estar entre 0 y 30 segundos."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after debe estar entre 0 y 60 segundos."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Este servicio requiere exactamente un dispositivo de destino."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "No se encontró ningún dispositivo coincidente para ese destino."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Este dispositivo aún no está conectado. Vuelve a intentarlo cuando se haya cargado."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -274,11 +345,13 @@
|
||||
"smart": "Inteligente",
|
||||
"speed": "Rápido",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sueño",
|
||||
"nanosleep": "WindFree sueño",
|
||||
"longwind": "Viento prolongado",
|
||||
"motionindirect": "Indirecto al movimiento",
|
||||
"motiondirect": "Directo al movimiento",
|
||||
"drycomfort": "Confort seco",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2 pasos"
|
||||
}
|
||||
}
|
||||
@@ -345,6 +418,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Umbral de alarma del filtro"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Temporizador de apertura automática de puerta"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Modo de zona de bebidas",
|
||||
"state": {
|
||||
@@ -417,7 +493,16 @@
|
||||
"07": "Prelavado intenso",
|
||||
"8d": "Ollas y sartenes",
|
||||
"8e": "Plástico",
|
||||
"8f": "Cuidado del bebé"
|
||||
"8f": "Cuidado del bebé",
|
||||
"82": "Automático",
|
||||
"8a": "Normal",
|
||||
"a7": "Intenso",
|
||||
"a8": "Express",
|
||||
"8c": "Extra silencioso",
|
||||
"88": "Autolimpieza",
|
||||
"85": "Delicado",
|
||||
"0c": "Express",
|
||||
"0d": "Autolimpieza"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -432,6 +517,22 @@
|
||||
"4": "Alarma 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"9c": "Servicio intensivo",
|
||||
"a5": "Ropa de cama",
|
||||
"9e": "Planchado fácil",
|
||||
"9b": "Desinfección por vapor+",
|
||||
"27": "Renovar",
|
||||
"a0": "Aireación",
|
||||
"a4": "Secado por tiempo",
|
||||
"a6": "Secado rápido",
|
||||
"a3": "Ropa deportiva",
|
||||
"a2": "Delicados"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
@@ -459,7 +560,9 @@
|
||||
"4c": "Renovación de aire",
|
||||
"51": "Eco algodón",
|
||||
"53": "Secado IA+",
|
||||
"4e": "Autosecado"
|
||||
"4e": "Autosecado",
|
||||
"26": "Aireación",
|
||||
"2a": "Cuidado higiénico+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -581,9 +684,11 @@
|
||||
"kimchi_storage_crunfch": "Kimchi crujiente",
|
||||
"kimchi_storage_buy": "Kimchi comprado",
|
||||
"storage_fridge_normal": "Frigorífico",
|
||||
"storage_fridge": "Frigorífico",
|
||||
"storage_fridge_cold": "Frigorífico, fuerte",
|
||||
"storage_fridge_warm": "Frigorífico, suave",
|
||||
"storage_freezer_normal": "Congelador",
|
||||
"storage_freezer": "Congelador",
|
||||
"storage_freezer_cold": "Congelador, fuerte",
|
||||
"storage_freezer_warm": "Congelador, suave",
|
||||
"kimchi_ripe_low_temp": "Maduración kimchi, baja temperatura",
|
||||
@@ -596,7 +701,8 @@
|
||||
"storage_fresh_cereal": "Cereales",
|
||||
"storage_fridge_drink": "Bebidas",
|
||||
"storage_fresh_wine": "Vino",
|
||||
"storage_fresh_potato_banana": "Patata y plátano"
|
||||
"storage_fresh_potato_banana": "Patata y plátano",
|
||||
"newmode_kimchi_0000": "Modo nuevo"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -607,6 +713,16 @@
|
||||
"fdr_drinks": "Bebidas"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Modo de zona de despensa",
|
||||
"state": {
|
||||
"processed_meat": "Embutidos",
|
||||
"cheese": "Queso",
|
||||
"nuts": "Frutos secos",
|
||||
"fruit": "Fruta",
|
||||
"wine": "Vino"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Nivel de potencia del fuego {number}",
|
||||
"state": {
|
||||
@@ -658,6 +774,9 @@
|
||||
"mute": "Silencio"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Modo de ahorro de energía"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Modo de sonido",
|
||||
"state": {
|
||||
@@ -694,11 +813,29 @@
|
||||
"extra_hot": "Muy caliente"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"70": "Servicio intensivo",
|
||||
"55": "Blancos",
|
||||
"71": "Ropa de cama",
|
||||
"72": "Desinfección",
|
||||
"77": "Planchado fácil",
|
||||
"57": "Autolimpieza+",
|
||||
"73": "Aclarar + Centrifugar",
|
||||
"74": "Ropa deportiva",
|
||||
"75": "Delicados",
|
||||
"78": "Lavado rápido"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normal",
|
||||
"04": "Lavado rápido",
|
||||
"06": "Colada XXL",
|
||||
"08": "Aclarar + Centrifugar",
|
||||
"17": "Descargado",
|
||||
"1b": "Algodón",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -709,7 +846,7 @@
|
||||
"21": "Color",
|
||||
"22": "Lana",
|
||||
"23": "Exterior",
|
||||
"24": "Toallas",
|
||||
"24": "Ropa de cama",
|
||||
"25": "Sintéticos",
|
||||
"26": "Delicados",
|
||||
"27": "Aclarar + Centrifugar",
|
||||
@@ -722,7 +859,7 @@
|
||||
"2f": "Ropa deportiva",
|
||||
"30": "Día nublado",
|
||||
"32": "Camisas",
|
||||
"33": "Ropa de cama",
|
||||
"33": "Toallas",
|
||||
"34": "Mezcla",
|
||||
"36": "Lavado + Secado",
|
||||
"37": "Lavado con aire",
|
||||
@@ -745,7 +882,27 @@
|
||||
"86": "Lavado profundo",
|
||||
"87": "Descarga de Programas",
|
||||
"8f": "Lavado en frío",
|
||||
"96": "Menos microfibras"
|
||||
"96": "Menos microfibras",
|
||||
"a0": "Lavado rápido 15'",
|
||||
"69": "Lavado IA",
|
||||
"6a": "Lana",
|
||||
"6b": "Denim",
|
||||
"6c": "Blusas",
|
||||
"6d": "Delicados",
|
||||
"6e": "Ropa deportiva",
|
||||
"6f": "Ropa de cama",
|
||||
"70": "Toallas",
|
||||
"71": "Lavado rápido",
|
||||
"72": "Camisas",
|
||||
"73": "Desinfección",
|
||||
"74": "Limpieza de Tambor",
|
||||
"75": "Exterior",
|
||||
"76": "Cuidado del bebé",
|
||||
"77": "Algodón",
|
||||
"78": "Aclarar + Centrifugar",
|
||||
"79": "Solo centrifugar",
|
||||
"88": "Cuidado de mascotas",
|
||||
"35": "Algodón E"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -785,6 +942,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatura del congelador"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Modo de iluminación perimetral",
|
||||
"state": {
|
||||
"smart": "Inteligente",
|
||||
"high": "Alto",
|
||||
"low": "Bajo"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Color de iluminación perimetral",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Modo de luz indicadora",
|
||||
"state": {
|
||||
"smart": "Inteligente",
|
||||
"high": "Alto",
|
||||
"low": "Bajo"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modo",
|
||||
"state": {
|
||||
"purification": "Purificación",
|
||||
"ventilation": "Ventilación",
|
||||
"smartventilation": "Ventilación inteligente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -797,6 +986,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Horas de uso del filtro"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Uso del filtro"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Estándar de calidad del aire"
|
||||
},
|
||||
@@ -904,6 +1096,12 @@
|
||||
"indirect": "Indirecto"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Estado de ahorro de energía"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Estado de funcionamiento del ahorro de energía"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatura"
|
||||
},
|
||||
@@ -1160,6 +1358,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Ahorro por ausencia activo"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Limpieza por ausencia"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Evitación de viento por detección de movimiento activa"
|
||||
},
|
||||
@@ -1193,6 +1394,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Apertura automática de puerta"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Sonido de apertura automática de puerta"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Control por voz de apertura automática de puerta"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Liberación automática de secado"
|
||||
},
|
||||
@@ -1324,6 +1531,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Alarma de ventilación"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Iluminación perimetral"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Luz indicadora"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Modo Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Modo nocturno"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "Smart",
|
||||
"speed": "Veloce",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Sonno",
|
||||
"nanosleep": "WindFree sonno",
|
||||
"longwind": "Vento prolungato",
|
||||
"motionindirect": "Indiretto al movimento",
|
||||
"motiondirect": "Diretto al movimento",
|
||||
"drycomfort": "Comfort asciugatura",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2 fasi"
|
||||
}
|
||||
}
|
||||
@@ -223,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Soglia allarme filtro"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Timer apertura automatica sportello"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Modalità zona bevande",
|
||||
"state": {
|
||||
@@ -295,7 +300,16 @@
|
||||
"07": "Prelavaggio intenso",
|
||||
"8d": "Pentole e padelle",
|
||||
"8e": "Plastica",
|
||||
"8f": "Bambini"
|
||||
"8f": "Bambini",
|
||||
"82": "Automatico",
|
||||
"8a": "Normale",
|
||||
"a7": "Intenso",
|
||||
"a8": "Express",
|
||||
"8c": "Extra silenzioso",
|
||||
"88": "Autopulizia",
|
||||
"85": "Delicato",
|
||||
"0c": "Express",
|
||||
"0d": "Autopulizia"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -310,6 +324,22 @@
|
||||
"4": "Allarme 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"9c": "Intenso",
|
||||
"a5": "Biancheria da letto",
|
||||
"9e": "Pronto da stirare",
|
||||
"9b": "Igienizzante a vapore+",
|
||||
"27": "Rinfresca",
|
||||
"a0": "Arieggiatura",
|
||||
"a4": "Asciugatura a tempo",
|
||||
"a6": "Asciugatura rapida",
|
||||
"a3": "Abbigliamento sportivo",
|
||||
"a2": "Delicati"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
@@ -337,7 +367,9 @@
|
||||
"4c": "Rinfresca",
|
||||
"51": "Eco cotone",
|
||||
"53": "Asciugatura AI+",
|
||||
"4e": "Autoasciugatura"
|
||||
"4e": "Autoasciugatura",
|
||||
"26": "Arieggiatura",
|
||||
"2a": "Cura igienica+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -459,9 +491,11 @@
|
||||
"kimchi_storage_crunfch": "Kimchi croccante",
|
||||
"kimchi_storage_buy": "Kimchi acquistato",
|
||||
"storage_fridge_normal": "Frigorifero",
|
||||
"storage_fridge": "Frigorifero",
|
||||
"storage_fridge_cold": "Frigorifero, forte",
|
||||
"storage_fridge_warm": "Frigorifero, debole",
|
||||
"storage_freezer_normal": "Congelatore",
|
||||
"storage_freezer": "Congelatore",
|
||||
"storage_freezer_cold": "Congelatore, forte",
|
||||
"storage_freezer_warm": "Congelatore, debole",
|
||||
"kimchi_ripe_low_temp": "Maturazione kimchi, bassa temperatura",
|
||||
@@ -474,7 +508,8 @@
|
||||
"storage_fresh_cereal": "Cereali",
|
||||
"storage_fridge_drink": "Bevande",
|
||||
"storage_fresh_wine": "Vino",
|
||||
"storage_fresh_potato_banana": "Patate e banane"
|
||||
"storage_fresh_potato_banana": "Patate e banane",
|
||||
"newmode_kimchi_0000": "Nuova modalità"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -485,6 +520,16 @@
|
||||
"fdr_drinks": "Bevande"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Modalità zona dispensa",
|
||||
"state": {
|
||||
"processed_meat": "Salumi",
|
||||
"cheese": "Formaggio",
|
||||
"nuts": "Frutta secca",
|
||||
"fruit": "Frutta",
|
||||
"wine": "Vino"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Livello potenza fornello {number}",
|
||||
"state": {
|
||||
@@ -536,6 +581,9 @@
|
||||
"mute": "Muto"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Modalità risparmio energetico"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Modalità audio",
|
||||
"state": {
|
||||
@@ -572,11 +620,29 @@
|
||||
"extra_hot": "Extra caldo"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"70": "Intenso",
|
||||
"55": "Bianchi",
|
||||
"71": "Biancheria da letto",
|
||||
"72": "Igienizzante",
|
||||
"77": "Pronto da stirare",
|
||||
"57": "Self Clean+",
|
||||
"73": "Risciacquo+Centrifuga",
|
||||
"74": "Abbigliamento sportivo",
|
||||
"75": "Delicati",
|
||||
"78": "Lavaggio rapido"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Ciclo",
|
||||
"state": {
|
||||
"01": "Normale",
|
||||
"04": "Lavaggio rapido",
|
||||
"06": "Bucato XXL",
|
||||
"08": "Risciacquo+Centrifuga",
|
||||
"17": "Scaricato",
|
||||
"1b": "Cotone",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -587,7 +653,7 @@
|
||||
"21": "Colorati",
|
||||
"22": "Lana",
|
||||
"23": "Capi outdoor",
|
||||
"24": "Asciugamani",
|
||||
"24": "Biancheria da letto",
|
||||
"25": "Sintetici",
|
||||
"26": "Delicati",
|
||||
"27": "Risciacquo+Centrifuga",
|
||||
@@ -600,7 +666,7 @@
|
||||
"2f": "Abbigliamento sportivo",
|
||||
"30": "Giornata nuvolosa",
|
||||
"32": "Camicie",
|
||||
"33": "Biancheria da letto",
|
||||
"33": "Asciugamani",
|
||||
"34": "Misti",
|
||||
"36": "Lavaggio+Asciugatura",
|
||||
"37": "Lavaggio ad aria",
|
||||
@@ -623,7 +689,27 @@
|
||||
"86": "Lavaggio profondo",
|
||||
"87": "Scaricato",
|
||||
"8f": "Intenso a freddo",
|
||||
"96": "Riduci microfibre"
|
||||
"96": "Riduci microfibre",
|
||||
"69": "Lavaggio AI",
|
||||
"6a": "Lana",
|
||||
"6b": "Jeans",
|
||||
"6c": "Camicette",
|
||||
"6d": "Delicati",
|
||||
"6e": "Abbigliamento sportivo",
|
||||
"6f": "Biancheria da letto",
|
||||
"70": "Asciugamani",
|
||||
"71": "Lavaggio rapido",
|
||||
"72": "Camicie",
|
||||
"73": "Igienizzante",
|
||||
"74": "Pulizia cestello",
|
||||
"75": "Capi outdoor",
|
||||
"76": "Bambini",
|
||||
"77": "Cotone",
|
||||
"78": "Risciacquo+Centrifuga",
|
||||
"79": "Solo centrifuga",
|
||||
"88": "Cura animali",
|
||||
"35": "Cotone E",
|
||||
"a0": "Rapido 15'"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -663,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatura freezer"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Modalità illuminazione perimetrale",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "Alta",
|
||||
"low": "Bassa"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Colore illuminazione perimetrale",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Modalità spia luminosa",
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"high": "Alta",
|
||||
"low": "Bassa"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modalità",
|
||||
"state": {
|
||||
"purification": "Purificazione",
|
||||
"ventilation": "Ventilazione",
|
||||
"smartventilation": "Ventilazione intelligente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -678,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Ore di utilizzo filtro"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Utilizzo filtro"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Standard qualità aria"
|
||||
},
|
||||
@@ -785,6 +906,12 @@
|
||||
"indirect": "Indiretto"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Stato risparmio energetico"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Stato di funzionamento del risparmio energetico"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatura"
|
||||
},
|
||||
@@ -1038,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Risparmio energetico in assenza attivo"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Pulizia in assenza"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Elusione flusso d'aria a rilevamento movimento attiva"
|
||||
},
|
||||
@@ -1071,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Apertura automatica sportello"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Suono apertura automatica sportello"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Controllo vocale apertura automatica sportello"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Apertura automatica per asciugatura"
|
||||
},
|
||||
@@ -1202,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Allarme ventilazione"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Illuminazione perimetrale"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Spia luminosa"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Modalità Wind-Free"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Modalità notte"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1290,17 +1438,24 @@
|
||||
"title": "Opzioni LocalThings",
|
||||
"menu_options": {
|
||||
"settings": "Impostazioni dispositivo",
|
||||
"cloud_courses": "Cicli scaricati",
|
||||
"forget_learned_modes": "Dimentica le modalità memorizzate",
|
||||
"debug_write": "Debug: scrivi su una risorsa"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni dispositivo",
|
||||
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.",
|
||||
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Consenti la scrittura anche quando il controllo remoto risulta disattivato.",
|
||||
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)"
|
||||
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)",
|
||||
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Dimentica le modalità memorizzate",
|
||||
"description": "Attualmente memorizzate: {codes}\n\nSono modalità in cui questo dispositivo si è dichiarato senza elencarle tra quelle supportate; vengono conservate perché restino selezionabili. Dimenticarle è la soluzione se una si è rivelata errata: qualsiasi modalità che il dispositivo segnali davvero di nuovo verrà memorizzata un'altra volta, a meno che non si disattivi anche «Memorizza le modalità che il dispositivo segnala ma non dichiara supportate» nelle impostazioni del dispositivo."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Debug: scrivi su una risorsa",
|
||||
"description": "Strumento avanzato per definire con precisione il comportamento di scrittura specifico del dispositivo. Seleziona la risorsa (href) su cui desideri scrivere oppure digitane una personalizzata non presente nell'elenco. Questo bypassa il blocco di disattivazione del controllo remoto e invia esattamente i campi specificati; tuttavia, potrebbe causare una configurazione errata del dispositivo, quindi utilizzalo con cautela.",
|
||||
@@ -1322,20 +1477,63 @@
|
||||
"debug_write": "Scrivi un'altra risorsa",
|
||||
"finish": "Fine"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
|
||||
"data": {
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Cicli scaricati",
|
||||
"description": "La configurazione guidata accompagna l'utente al dispositivo: selezionare un ciclo scaricato sul dispositivo e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Configurazione guidata",
|
||||
"cloud_manual": "Modifica nomi"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Cicli scaricati"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Assegna un nome a questo ciclo",
|
||||
"description": "Il dispositivo è passato a un ciclo scaricato (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi selezionare il successivo sul dispositivo. Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
|
||||
"data": {
|
||||
"name": "Nome",
|
||||
"download_course": "Codice programma del ciclo Scaricato"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Nessun ciclo selezionato",
|
||||
"description": "Non è stato selezionato nulla sul dispositivo. Assicurarsi che sia impostato sul ciclo Scaricato, quindi scorrere i programmi scaricati.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Attendi di nuovo",
|
||||
"cloud_finish": "Fine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Inserisci almeno un campo da scrivere.",
|
||||
"write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli."
|
||||
"write_failed": "Operazione di scrittura non riuscita. Controlla i registri di Home Assistant per i dettagli.",
|
||||
"cloud_course_name_duplicate": "Due cicli hanno lo stesso nome. I nomi devono essere univoci.",
|
||||
"cloud_course_unknown_course": "Quel programma non è tra quelli offerti da questo dispositivo. Selezionarne uno dall'elenco."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selezionare ora un ciclo scaricato sul dispositivo.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Copertura delle funzionalità incompleta per {device_name}",
|
||||
"description": "Questo dispositivo non è completamente supportato. Il tipo di dispositivo non è stato riconosciuto oppure alcune delle risorse che espone non sono ancora state modellate. Continuerò a funzionare con le funzionalità già supportate. Puoi contribuire ad ampliare il supporto andando su Impostazioni > Dispositivi e servizi > {device_name} > il menu (in alto a destra) > Scarica diagnostica, quindi inviando una segnalazione tramite il modulo di segnalazione collegato."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Cicli scaricati non configurati per {device_name}",
|
||||
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1356,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensivo non è disponibile per il ciclo selezionato."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Il comando per {href} è fallito anche dopo la riconnessione: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Specificare da 1 a 10 scritture."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle deve essere compreso tra 0 e 30 secondi."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after deve essere compreso tra 0 e 60 secondi."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Questo servizio richiede esattamente un dispositivo di destinazione."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Nessun dispositivo corrispondente trovato per quella destinazione."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Questo dispositivo non è ancora connesso. Riprova una volta caricato."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "스마트",
|
||||
"speed": "스피드",
|
||||
"nano": "무풍",
|
||||
"sleep": "숙면",
|
||||
"nanosleep": "무풍 수면",
|
||||
"longwind": "롱바람",
|
||||
"motionindirect": "간접풍",
|
||||
"motiondirect": "직접풍",
|
||||
"drycomfort": "쾌적 제습",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2단계"
|
||||
}
|
||||
}
|
||||
@@ -223,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "필터 알림 기준"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "오토 오픈 도어 타이머"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "음료 보관 모드",
|
||||
"state": {
|
||||
@@ -295,7 +300,16 @@
|
||||
"07": "애벌 세척",
|
||||
"8d": "냄비 및 팬",
|
||||
"8e": "플라스틱",
|
||||
"8f": "젖병 살균"
|
||||
"8f": "젖병 살균",
|
||||
"82": "자동",
|
||||
"8a": "표준",
|
||||
"a7": "강력",
|
||||
"a8": "급속",
|
||||
"8c": "저소음",
|
||||
"88": "내부 세척",
|
||||
"85": "섬세",
|
||||
"0c": "급속",
|
||||
"0d": "내부 세척"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -310,6 +324,22 @@
|
||||
"4": "알림음 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "코스",
|
||||
"state": {
|
||||
"01": "표준건조",
|
||||
"9c": "강력건조",
|
||||
"a5": "이불",
|
||||
"9e": "구김방지",
|
||||
"9b": "스팀살균+",
|
||||
"27": "리프레시",
|
||||
"a0": "송풍",
|
||||
"a4": "시간건조",
|
||||
"a6": "쾌속건조",
|
||||
"a3": "운동복",
|
||||
"a2": "섬세의류"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "코스",
|
||||
"state": {
|
||||
@@ -337,7 +367,9 @@
|
||||
"4c": "에어 리프레시",
|
||||
"51": "에코 면",
|
||||
"53": "AI 맞춤건조+",
|
||||
"4e": "자체 건조"
|
||||
"4e": "자체 건조",
|
||||
"26": "송풍",
|
||||
"2a": "살균건조+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -459,9 +491,11 @@
|
||||
"kimchi_storage_crunfch": "아삭 김치",
|
||||
"kimchi_storage_buy": "구입 김치",
|
||||
"storage_fridge_normal": "냉장",
|
||||
"storage_fridge": "냉장",
|
||||
"storage_fridge_cold": "냉장 강",
|
||||
"storage_fridge_warm": "냉장 약",
|
||||
"storage_freezer_normal": "냉동",
|
||||
"storage_freezer": "냉동",
|
||||
"storage_freezer_cold": "냉동 강",
|
||||
"storage_freezer_warm": "냉동 약",
|
||||
"kimchi_ripe_low_temp": "김치 저온 숙성",
|
||||
@@ -474,7 +508,8 @@
|
||||
"storage_fresh_cereal": "곡류",
|
||||
"storage_fridge_drink": "음료",
|
||||
"storage_fresh_wine": "와인",
|
||||
"storage_fresh_potato_banana": "감자 및 바나나"
|
||||
"storage_fresh_potato_banana": "감자 및 바나나",
|
||||
"newmode_kimchi_0000": "새 모드"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -485,6 +520,16 @@
|
||||
"fdr_drinks": "음료"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "팬트리실 모드",
|
||||
"state": {
|
||||
"processed_meat": "가공육",
|
||||
"cheese": "치즈",
|
||||
"nuts": "견과류",
|
||||
"fruit": "과일",
|
||||
"wine": "와인"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "{number}번 화구 화력",
|
||||
"state": {
|
||||
@@ -536,6 +581,9 @@
|
||||
"mute": "음소거"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "절전 모드"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "소리 모드",
|
||||
"state": {
|
||||
@@ -572,11 +620,29 @@
|
||||
"extra_hot": "고온수"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "코스",
|
||||
"state": {
|
||||
"01": "표준세탁",
|
||||
"70": "강력세탁",
|
||||
"55": "흰옷",
|
||||
"71": "이불",
|
||||
"72": "살균",
|
||||
"77": "구김방지",
|
||||
"57": "통세척+",
|
||||
"73": "헹굼+탈수",
|
||||
"74": "운동복",
|
||||
"75": "섬세의류",
|
||||
"78": "쾌속세탁"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "코스",
|
||||
"state": {
|
||||
"01": "표준세탁",
|
||||
"04": "쾌속세탁",
|
||||
"06": "XXL 세탁",
|
||||
"08": "헹굼+탈수",
|
||||
"17": "다운로드 코스",
|
||||
"1b": "면",
|
||||
"1c": "에코 40-60",
|
||||
@@ -587,7 +653,7 @@
|
||||
"21": "컬러 의류",
|
||||
"22": "울",
|
||||
"23": "아웃도어",
|
||||
"24": "타월",
|
||||
"24": "이불",
|
||||
"25": "합성섬유",
|
||||
"26": "섬세의류",
|
||||
"27": "헹굼+탈수",
|
||||
@@ -600,7 +666,7 @@
|
||||
"2f": "피트니스",
|
||||
"30": "흐린 날",
|
||||
"32": "셔츠",
|
||||
"33": "이불",
|
||||
"33": "타월",
|
||||
"34": "혼합",
|
||||
"36": "세탁+건조",
|
||||
"37": "에어워시",
|
||||
@@ -623,7 +689,27 @@
|
||||
"86": "찌든 때 세탁",
|
||||
"87": "다운로드",
|
||||
"8f": "강력 냉수 세탁",
|
||||
"96": "미세플라스틱저감"
|
||||
"96": "미세플라스틱저감",
|
||||
"a0": "15분 쾌속세탁",
|
||||
"69": "AI 맞춤세탁",
|
||||
"6a": "울",
|
||||
"6b": "데님",
|
||||
"6c": "블라우스",
|
||||
"6d": "섬세의류",
|
||||
"6e": "운동복",
|
||||
"6f": "이불",
|
||||
"70": "타월",
|
||||
"71": "쾌속세탁",
|
||||
"72": "셔츠",
|
||||
"73": "살균",
|
||||
"74": "무세제통세척",
|
||||
"75": "아웃도어",
|
||||
"76": "아기옷",
|
||||
"77": "면",
|
||||
"78": "헹굼+탈수",
|
||||
"79": "탈수만",
|
||||
"88": "펫케어",
|
||||
"35": "에코 면"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -663,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "냉동실 온도"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "엣지 라이팅 모드",
|
||||
"state": {
|
||||
"smart": "스마트",
|
||||
"high": "높음",
|
||||
"low": "낮음"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "엣지 라이팅 색상",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "표시등 모드",
|
||||
"state": {
|
||||
"smart": "스마트",
|
||||
"high": "높음",
|
||||
"low": "낮음"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "모드",
|
||||
"state": {
|
||||
"purification": "청정",
|
||||
"ventilation": "환기",
|
||||
"smartventilation": "스마트환기"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -678,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "필터 사용 시간"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "필터 사용량"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "공기질 기준"
|
||||
},
|
||||
@@ -785,6 +906,12 @@
|
||||
"indirect": "간접풍"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "절전 상태"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "절전 작동 상태"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "온도"
|
||||
},
|
||||
@@ -1038,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "부재 절전 작동"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "부재 청소"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "모션 감지 바람 회피 작동"
|
||||
},
|
||||
@@ -1071,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "오토 오픈 도어"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "오토 오픈 도어 소리"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "오토 오픈 도어 음성 안내"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "자동 문 열림 건조"
|
||||
},
|
||||
@@ -1202,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "환기 알림"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "엣지 라이팅"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "표시등"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "무풍 모드"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "취침 모드"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1290,17 +1438,24 @@
|
||||
"title": "LocalThings 옵션",
|
||||
"menu_options": {
|
||||
"settings": "기기 설정",
|
||||
"cloud_courses": "다운로드 코스",
|
||||
"forget_learned_modes": "기억된 모드 지우기",
|
||||
"debug_write": "디버그: 리소스에 쓰기"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "기기 설정",
|
||||
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.",
|
||||
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "스마트 컨트롤이 꺼진 것으로 보고되어도 쓰기 허용",
|
||||
"finish_time_hysteresis_minutes": "예상 완료 시각 - 최소 변경량(분)"
|
||||
"finish_time_hysteresis_minutes": "예상 완료 시각 - 최소 변경량(분)",
|
||||
"learn_device_modes": "기기가 보고하지만 지원 목록에 없는 모드 기억하기"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "기억된 모드 지우기",
|
||||
"description": "현재 기억된 항목: {codes}\n\n이 기기가 지원 목록에 넣지 않은 채 자신의 현재 모드로 보고했던 모드들이며, 계속 선택할 수 있도록 보관되어 있습니다. 잘못된 항목이 기억되었다면 지우면 됩니다. 다만 기기가 실제로 다시 보고하면 다시 기억되므로, 그러길 원하지 않는다면 기기 설정에서 '기기가 보고하지만 지원 목록에 없는 모드 기억하기'도 함께 꺼 주세요."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "디버그: 리소스에 쓰기",
|
||||
"description": "기기별 쓰기 동작을 파악하기 위한 고급 사용자용 도구입니다. 쓰려는 리소스(href)를 선택하거나 목록에 없는 값을 직접 입력하세요. 이 기능은 스마트 컨트롤 꺼짐 차단을 우회하고 입력한 필드를 그대로 전송합니다. 가전제품이 잘못 설정될 수 있으므로 신중하게 사용하세요.",
|
||||
@@ -1322,20 +1477,63 @@
|
||||
"debug_write": "다른 리소스에 쓰기",
|
||||
"finish": "완료"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "다운로드 코스",
|
||||
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
|
||||
"data": {
|
||||
"download_course": "다운로드 코스 코드"
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "다운로드 코스",
|
||||
"description": "안내 설정은 기기와 함께 진행됩니다. 기기에서 다운로드한 코스를 선택하고, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "안내 설정",
|
||||
"cloud_manual": "이름 편집"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "다운로드 코스"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "이 코스 이름 지정",
|
||||
"description": "기기가 다운로드한 코스(슬롯 {slot})로 전환되었으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, 기기에서 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.",
|
||||
"data": {
|
||||
"name": "이름",
|
||||
"download_course": "다운로드 코스 코드"
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "선택된 코스 없음",
|
||||
"description": "기기에서 아무것도 선택되지 않았습니다. 다운로드 코스로 설정되어 있는지 확인한 다음, 다운로드된 프로그램을 하나씩 실행해 보세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "다시 대기",
|
||||
"cloud_finish": "완료"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "쓸 필드를 하나 이상 입력하세요.",
|
||||
"write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요."
|
||||
"write_failed": "쓰기에 실패했습니다. 자세한 내용은 Home Assistant 로그에서 확인하세요.",
|
||||
"cloud_course_name_duplicate": "두 코스의 이름이 같습니다. 이름은 서로 달라야 합니다.",
|
||||
"cloud_course_unknown_course": "이 기기가 제공하지 않는 코스입니다. 목록에서 선택하세요."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "지금 기기에서 다운로드한 코스를 선택하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "{device_name}의 기능 지원이 완전하지 않음",
|
||||
"description": "이 기기의 일부 기능이 아직 완전히 지원되지 않습니다. 가전제품 유형이 인식되지 않았거나, 기기가 제공하는 일부 리소스가 아직 구현되지 않았습니다. 현재 지원되는 기능은 계속 사용할 수 있습니다. 설정 > 기기 및 서비스 > {device_name} > 오른쪽 위 메뉴 > 진단 정보 다운로드로 이동하여 진단 정보를 내려받은 뒤, 연결된 이슈 양식에 첨부하면 지원 범위를 넓히는 데 도움을 줄 수 있습니다."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "{device_name}의 다운로드 코스가 설정되지 않음",
|
||||
"description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 기기에서 해당 코스가 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1356,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "선택한 코스에서는 강력 세탁을 사용할 수 없습니다."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "재연결 후에도 {href} 명령이 실패했습니다: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "1~10개의 쓰기를 지정하세요."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle 값은 0에서 30초 사이여야 합니다."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after 값은 0에서 60초 사이여야 합니다."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "이 서비스는 정확히 하나의 대상 기기가 필요합니다."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "해당 대상에 일치하는 기기를 찾을 수 없습니다."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "이 기기는 아직 연결되지 않았습니다. 로드된 후 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "Slim",
|
||||
"speed": "Snel",
|
||||
"nano": "WindFree",
|
||||
"sleep": "Slaap",
|
||||
"nanosleep": "WindFree-slaap",
|
||||
"longwind": "Lange wind",
|
||||
"motionindirect": "Beweging indirect",
|
||||
"motiondirect": "Beweging direct",
|
||||
"drycomfort": "Droog comfort",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2-Step"
|
||||
}
|
||||
}
|
||||
@@ -223,6 +225,9 @@
|
||||
"air_filter_threshold": {
|
||||
"name": "Filteralarmdrempel"
|
||||
},
|
||||
"auto_door_timer": {
|
||||
"name": "Timer automatische deuropener"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Modus drankenzone",
|
||||
"state": {
|
||||
@@ -295,7 +300,16 @@
|
||||
"07": "Voorspoelen",
|
||||
"8d": "Potten en pannen",
|
||||
"8e": "Kunststof",
|
||||
"8f": "Babyverzorging"
|
||||
"8f": "Babyverzorging",
|
||||
"82": "Auto",
|
||||
"8a": "Normaal",
|
||||
"a7": "Intensief",
|
||||
"a8": "Express",
|
||||
"8c": "Extra stil",
|
||||
"88": "Zelfreiniging",
|
||||
"85": "Delicaat",
|
||||
"0c": "Express",
|
||||
"0d": "Zelfreiniging"
|
||||
}
|
||||
},
|
||||
"dispense_type": {
|
||||
@@ -310,6 +324,22 @@
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_00": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"9c": "Intensief",
|
||||
"a5": "Beddengoed",
|
||||
"9e": "Strijkvrij",
|
||||
"9b": "Stoomhygiëne+",
|
||||
"27": "Opfrissen",
|
||||
"a0": "Luchtdrogen",
|
||||
"a4": "Tijdprogramma",
|
||||
"a6": "Snel drogen",
|
||||
"a3": "Sportkleding",
|
||||
"a2": "Fijne was"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
@@ -337,7 +367,9 @@
|
||||
"4c": "Opfrissen",
|
||||
"51": "Eco katoen",
|
||||
"53": "AI drogen+",
|
||||
"4e": "Zelf drogen"
|
||||
"4e": "Zelf drogen",
|
||||
"26": "Luchtverfrissing",
|
||||
"2a": "Hygiënische verzorging+"
|
||||
}
|
||||
},
|
||||
"favorite_capacity": {
|
||||
@@ -459,9 +491,11 @@
|
||||
"kimchi_storage_crunfch": "Knapperige kimchi",
|
||||
"kimchi_storage_buy": "Gekochte kimchi",
|
||||
"storage_fridge_normal": "Koelkast",
|
||||
"storage_fridge": "Koelkast",
|
||||
"storage_fridge_cold": "Koelkast, sterk",
|
||||
"storage_fridge_warm": "Koelkast, zwak",
|
||||
"storage_freezer_normal": "Vriezer",
|
||||
"storage_freezer": "Vriezer",
|
||||
"storage_freezer_cold": "Vriezer, sterk",
|
||||
"storage_freezer_warm": "Vriezer, zwak",
|
||||
"kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur",
|
||||
@@ -474,7 +508,8 @@
|
||||
"storage_fresh_cereal": "Granen",
|
||||
"storage_fridge_drink": "Dranken",
|
||||
"storage_fresh_wine": "Wijn",
|
||||
"storage_fresh_potato_banana": "Aardappelen en bananen"
|
||||
"storage_fresh_potato_banana": "Aardappelen en bananen",
|
||||
"newmode_kimchi_0000": "Nieuwe modus"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
@@ -485,6 +520,16 @@
|
||||
"fdr_drinks": "Dranken"
|
||||
}
|
||||
},
|
||||
"winecellar_pantry_zone_mode": {
|
||||
"name": "Modus voorraadzone",
|
||||
"state": {
|
||||
"processed_meat": "Vleeswaren",
|
||||
"cheese": "Kaas",
|
||||
"nuts": "Noten",
|
||||
"fruit": "Fruit",
|
||||
"wine": "Wijn"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Vermogensniveau brander {number}",
|
||||
"state": {
|
||||
@@ -536,6 +581,9 @@
|
||||
"mute": "Stil"
|
||||
}
|
||||
},
|
||||
"energy_saving_mode": {
|
||||
"name": "Energiebesparingsmodus"
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
@@ -572,11 +620,29 @@
|
||||
"extra_hot": "Extra heet"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_00": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"70": "Intensief",
|
||||
"55": "Witte was",
|
||||
"71": "Beddengoed",
|
||||
"72": "Hygiëne",
|
||||
"77": "Strijkvrij",
|
||||
"57": "Self Clean+",
|
||||
"73": "Spoelen + centrifugeren",
|
||||
"74": "Sportkleding",
|
||||
"75": "Fijne was",
|
||||
"78": "Snelle was"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"01": "Normaal",
|
||||
"04": "Snelle was",
|
||||
"06": "XXL was",
|
||||
"08": "Spoelen+centrifugeren",
|
||||
"17": "Gedownload",
|
||||
"1b": "Katoen",
|
||||
"1c": "Eco 40-60",
|
||||
@@ -587,7 +653,7 @@
|
||||
"21": "Bonte was",
|
||||
"22": "Wol",
|
||||
"23": "Outdoor",
|
||||
"24": "Handdoeken",
|
||||
"24": "Beddengoed",
|
||||
"25": "Synthetisch",
|
||||
"26": "Fijne was",
|
||||
"27": "Spoelen+centrifugeren",
|
||||
@@ -600,7 +666,7 @@
|
||||
"2f": "Sportkleding",
|
||||
"30": "Bewolkte dag",
|
||||
"32": "Overhemden",
|
||||
"33": "Beddengoed",
|
||||
"33": "Handdoeken",
|
||||
"34": "Gemengd",
|
||||
"36": "Wassen+drogen",
|
||||
"37": "Air Wash",
|
||||
@@ -616,14 +682,34 @@
|
||||
"60": "Self Clean+",
|
||||
"65": "Bonte was",
|
||||
"66": "Spijkergoed",
|
||||
"69": "AI wassen",
|
||||
"6a": "Wol",
|
||||
"6b": "Spijkergoed",
|
||||
"6c": "Blouses",
|
||||
"6d": "Fijne was",
|
||||
"6e": "Sportkleding",
|
||||
"6f": "Beddengoed",
|
||||
"70": "Handdoeken",
|
||||
"71": "Snelle was",
|
||||
"72": "Overhemden",
|
||||
"73": "Hygiëne",
|
||||
"74": "Trommel reinigen",
|
||||
"75": "Outdoor",
|
||||
"76": "Babyverzorging",
|
||||
"77": "Katoen",
|
||||
"78": "Spoelen + centrifugeren",
|
||||
"79": "Alleen centrifugeren",
|
||||
"7c": "Witte was",
|
||||
"7d": "Beddengoed/waterdicht",
|
||||
"7e": "Self Clean",
|
||||
"7f": "Wol/fijne was",
|
||||
"86": "Diep wassen",
|
||||
"87": "Gedownload",
|
||||
"88": "Huisdierverzorging",
|
||||
"8f": "Intensief koud",
|
||||
"96": "Minder microvezels"
|
||||
"96": "Minder microvezels",
|
||||
"a0": "15' Snelle was",
|
||||
"35": "Eco katoen"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
@@ -663,6 +749,38 @@
|
||||
},
|
||||
"freezer_temperature_setpoint": {
|
||||
"name": "Temperatuur vriesgedeelte"
|
||||
},
|
||||
"edge_lighting_mode": {
|
||||
"name": "Randverlichtingsmodus",
|
||||
"state": {
|
||||
"smart": "Slim",
|
||||
"high": "Hoog",
|
||||
"low": "Laag"
|
||||
}
|
||||
},
|
||||
"edge_lighting_color": {
|
||||
"name": "Randverlichtingskleur",
|
||||
"state": {
|
||||
"3000k": "3000 K",
|
||||
"4000k": "4000 K",
|
||||
"6500k": "6500 K"
|
||||
}
|
||||
},
|
||||
"indicator_light_mode": {
|
||||
"name": "Indicatorlampjemodus",
|
||||
"state": {
|
||||
"smart": "Slim",
|
||||
"high": "Hoog",
|
||||
"low": "Laag"
|
||||
}
|
||||
},
|
||||
"ventilation_mode": {
|
||||
"name": "Modus",
|
||||
"state": {
|
||||
"purification": "Zuivering",
|
||||
"ventilation": "Ventilatie",
|
||||
"smartventilation": "Slimme ventilatie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -678,6 +796,9 @@
|
||||
"air_filter_usage_hours": {
|
||||
"name": "Filterverbruik (uren)"
|
||||
},
|
||||
"deodor_filter_usage": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
"air_quality_standard": {
|
||||
"name": "Luchtkwaliteitsnorm"
|
||||
},
|
||||
@@ -785,6 +906,12 @@
|
||||
"indirect": "Indirect"
|
||||
}
|
||||
},
|
||||
"energy_saving_state": {
|
||||
"name": "Energiebesparing status"
|
||||
},
|
||||
"energy_saving_operating_status": {
|
||||
"name": "Bedrijfsstatus energiebesparing"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatuur"
|
||||
},
|
||||
@@ -1038,6 +1165,9 @@
|
||||
"absence_power_saving_active": {
|
||||
"name": "Energiebesparing bij afwezigheid actief"
|
||||
},
|
||||
"absence_clean": {
|
||||
"name": "Reiniging bij afwezigheid"
|
||||
},
|
||||
"motion_detect_wind_active": {
|
||||
"name": "Bewegingsdetectie luchtstroomvermijding actief"
|
||||
},
|
||||
@@ -1071,6 +1201,12 @@
|
||||
"auto_door_opener": {
|
||||
"name": "Automatische deuropener"
|
||||
},
|
||||
"auto_door_sound_control": {
|
||||
"name": "Geluid automatische deuropener"
|
||||
},
|
||||
"auto_door_voice_control": {
|
||||
"name": "Spraakbediening automatische deuropener"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Auto Release Dry"
|
||||
},
|
||||
@@ -1202,6 +1338,18 @@
|
||||
},
|
||||
"ventilation_alarm": {
|
||||
"name": "Ventilatiealarm"
|
||||
},
|
||||
"edge_lighting": {
|
||||
"name": "Randverlichting"
|
||||
},
|
||||
"indicator_light": {
|
||||
"name": "Indicatorlampje"
|
||||
},
|
||||
"windfree": {
|
||||
"name": "Wind-Free modus"
|
||||
},
|
||||
"windsleep": {
|
||||
"name": "Slaapmodus"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
@@ -1290,17 +1438,24 @@
|
||||
"title": "LocalThings-opties",
|
||||
"menu_options": {
|
||||
"settings": "Apparaatinstellingen",
|
||||
"cloud_courses": "Gedownloade programma's",
|
||||
"forget_learned_modes": "Onthouden modi vergeten",
|
||||
"debug_write": "Foutopsporing: naar een resource schrijven"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"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.",
|
||||
"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.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
|
||||
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
|
||||
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)",
|
||||
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft"
|
||||
}
|
||||
},
|
||||
"forget_learned_modes": {
|
||||
"title": "Onthouden modi vergeten",
|
||||
"description": "Nu onthouden: {codes}\n\nDit zijn modi waarin dit apparaat zichzelf meldde zonder ze als ondersteund op te geven; ze worden bewaard zodat ze selecteerbaar blijven. Vergeten is de oplossing als er een onterecht tussen staat: alles wat het apparaat echt opnieuw meldt, wordt gewoon opnieuw onthouden, tenzij je ook \"Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft\" bij Apparaatinstellingen uitzet."
|
||||
},
|
||||
"debug_write": {
|
||||
"title": "Foutopsporing: naar een resource schrijven",
|
||||
"description": "Geavanceerd hulpmiddel om apparaatspecifiek schrijfgedrag te onderzoeken. Kies de resource (href) waarnaar je wilt schrijven, of voer een aangepaste resource in die niet in de lijst staat. Hiermee wordt de blokkering bij een uitgeschakelde afstandsbediening omzeild en worden precies de opgegeven velden verzonden. Dit kan de configuratie van je apparaat verstoren, dus gebruik het bewust.",
|
||||
@@ -1322,20 +1477,63 @@
|
||||
"debug_write": "Naar een andere resource schrijven",
|
||||
"finish": "Voltooien"
|
||||
}
|
||||
},
|
||||
"cloud_manual": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
|
||||
"data": {
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
}
|
||||
},
|
||||
"cloud_courses": {
|
||||
"title": "Gedownloade programma's",
|
||||
"description": "Begeleide installatie loodst je stap voor stap langs het apparaat: selecteer op het apparaat een gedownload programma en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Begeleide installatie",
|
||||
"cloud_manual": "Namen bewerken"
|
||||
}
|
||||
},
|
||||
"cloud_wait": {
|
||||
"title": "Gedownloade programma's"
|
||||
},
|
||||
"cloud_name": {
|
||||
"title": "Geef dit programma een naam",
|
||||
"description": "Het apparaat is overgeschakeld naar een gedownload programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en selecteer daarna het volgende op het apparaat. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
|
||||
"data": {
|
||||
"name": "Naam",
|
||||
"download_course": "Programmacode van \"Gedownload\""
|
||||
}
|
||||
},
|
||||
"cloud_timeout": {
|
||||
"title": "Geen programma geselecteerd",
|
||||
"description": "Er is niets geselecteerd op het apparaat. Zorg dat het is ingesteld op het programma \"Gedownload\" en doorloop dan de gedownloade programma's.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
|
||||
"menu_options": {
|
||||
"cloud_guided": "Opnieuw wachten",
|
||||
"cloud_finish": "Voltooien"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"empty_payload": "Voer ten minste één veld in om te schrijven.",
|
||||
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie."
|
||||
"write_failed": "De schrijfbewerking is mislukt. Raadpleeg de Home Assistant-logboeken voor meer informatie.",
|
||||
"cloud_course_name_duplicate": "Twee programma's hebben dezelfde naam. Namen moeten uniek zijn.",
|
||||
"cloud_course_unknown_course": "Dat programma biedt dit apparaat niet aan. Kies er een uit de lijst."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
},
|
||||
"progress": {
|
||||
"cloud_wait": "Selecteer nu een gedownload programma op het apparaat.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Onvolledige ondersteuning van mogelijkheden voor {device_name}",
|
||||
"description": "Niet alle mogelijkheden van dit apparaat worden ondersteund. Het apparaattype is niet herkend of sommige beschikbare resources zijn nog niet gemodelleerd. Het apparaat blijft werken met de mogelijkheden die al worden ondersteund. Je kunt helpen de ondersteuning uit te breiden: ga naar Instellingen > Apparaten & diensten > {device_name} > het menu (rechtsboven) > Diagnostische gegevens downloaden en voeg het bestand daarna bij via de gekoppelde issue-template."
|
||||
},
|
||||
"cloud_courses_undiscovered": {
|
||||
"title": "Gedownloade programma's niet ingesteld voor {device_name}",
|
||||
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -1356,6 +1554,27 @@
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
|
||||
},
|
||||
"command_failed": {
|
||||
"message": "Het commando naar {href} is ook na opnieuw verbinden mislukt: {error}"
|
||||
},
|
||||
"debug_too_many_writes": {
|
||||
"message": "Geef tussen de 1 en 10 schrijfacties op."
|
||||
},
|
||||
"debug_settle_out_of_range": {
|
||||
"message": "settle moet tussen 0 en 30 seconden liggen."
|
||||
},
|
||||
"debug_verify_after_out_of_range": {
|
||||
"message": "verify_after moet tussen 0 en 60 seconden liggen."
|
||||
},
|
||||
"service_device_target_invalid": {
|
||||
"message": "Deze service vereist precies één doelapparaat."
|
||||
},
|
||||
"service_device_not_found": {
|
||||
"message": "Er is geen bijpassend apparaat gevonden voor dat doel."
|
||||
},
|
||||
"service_device_not_loaded": {
|
||||
"message": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# Composite AC subdevices: where else a sibling's hrefs could live
|
||||
|
||||
Open question behind issue #335 (`ARTIK051_FAC_BORA_19K`, a 2-in-1 floor +
|
||||
wall AC): the board reports a sibling in `/subdevices/vs/0`'s
|
||||
`subdeviceIdList`, but every seed `registry/subdevices.enumerate_subdevices`
|
||||
tries comes back 4.04, so `subdevices` and `subdevices_skipped` are both
|
||||
empty and the wall unit never becomes an entity.
|
||||
|
||||
This file records what that actually rules out (less than it looks like),
|
||||
why, and which hrefs are worth reading next.
|
||||
|
||||
## `/oic/res` does not enumerate the resource tree on modern firmware
|
||||
|
||||
This is the finding that reopens the question. Across every fixture that
|
||||
carries a captured `/oic/res`:
|
||||
|
||||
| board | links | `sec:true` | lists `/device/0`? |
|
||||
| --- | --- | --- | --- |
|
||||
| `ARTIK051_DONGLE_FAC_18K` | 91 | 78 | yes |
|
||||
| `TP2X_FAC_BORA_21K` (2-in-1) | 17 | 6 | no |
|
||||
| `TP2X_FAC_BORA_21K` (#205 flat) | 17 | 6 | no |
|
||||
| `TP1X_DA_KS_RANGE_0101X` | 10 | 6 | no |
|
||||
| `AWM-WW-AID-26-ONEBODY` | 15 | 9 | no |
|
||||
| `ARTIK051_FAC_BORA_19K` (#335) | 18 | 6 | no |
|
||||
|
||||
The `ARTIK051_DONGLE_FAC_18K` board — the one Pattern A was built against —
|
||||
is the outlier, not the model. Everywhere else `/oic/res` lists the
|
||||
onboarding surface and nothing else: `/oic/d`, `/oic/p`, the security and
|
||||
EasySetup/WiFiConf/CoapCloudConf/DevConf resources, file transfer, and the
|
||||
`sec/*` pair. On issue #335's board the six `sec:true` links are exactly
|
||||
doxm, pstat and the four setup URIs; every other listed link is `sec:false`.
|
||||
The entire secure operational tree — `/device/0` included, which
|
||||
demonstrably answers, since the dump comes from it — is absent.
|
||||
|
||||
Two consequences, both load-bearing:
|
||||
|
||||
1. **Nothing is learned from an href's absence in `/oic/res`.** On the range
|
||||
board (issue #324) `/oic/res` lists ten onboarding links and no
|
||||
`/device/0`, yet `/device/1` answers a full indexed dual-cavity sibling.
|
||||
It was found only by `_SPECULATIVE_DEVICE_INDICES`, never by enumeration
|
||||
of the links.
|
||||
2. **Pattern A's `/oic/res` index scan is dead weight on these boards.** It
|
||||
contributes nothing anywhere except the dongle board, so in practice
|
||||
indexed siblings are found by the speculative `/device/1`, `/device/2`
|
||||
probe alone.
|
||||
|
||||
## What issue #335 has actually ruled out
|
||||
|
||||
All 26 probes in the report returned false. Twenty-three of them are the
|
||||
issue #205 flat fallback walking the master's own href list under the
|
||||
sibling's UUID prefix, plus `/<uuid>/device/0`, `/device/1`, `/device/2`
|
||||
and `/multidevice/vs/0`. Four more were read by hand from the issue thread
|
||||
(`/<uuid>/information/vs/{1,2}`, `/<uuid>/device/{1,2}`), all 4.04.
|
||||
|
||||
So what is ruled out is: the UUID-prefixed namespace (Pattern B/C), and the
|
||||
indexed **Collection** (`/device/<n>`). What has never been read on this
|
||||
board — or on any `FAC_BORA` board — is **a bare indexed leaf**:
|
||||
`/mode/vs/1`, `/temperatures/vs/1`, and friends. Every indexed href ever
|
||||
probed by this project arrived via a `/device/<n>` batch; none was ever
|
||||
GETed directly.
|
||||
|
||||
That gap matters because the "leaves exist, their Collection does not" shape
|
||||
is already confirmed on this exact product family, just in the other
|
||||
namespace: issue #205's `TP2X_FAC_BORA_21K` answers
|
||||
`/<uuid>/information/vs/0` while `/<uuid>/device/0` comes back empty. A
|
||||
board that mounts sibling leaves without mounting a sibling Collection is
|
||||
the documented BORA behavior, so `/device/1`'s 4.04 is evidence about the
|
||||
Collection and not about `/mode/vs/1`.
|
||||
|
||||
## What the OCF spec says about composite devices
|
||||
|
||||
The Core/Device specifications model this as a *Composite Device*: one
|
||||
Platform representing the whole appliance, `/oic/d` carrying the Device
|
||||
Types of every constituent Device, and — the relevant part — a **Collection
|
||||
per distinct Device in the composition**, each Collection's `rt` including
|
||||
the Device Type it represents.
|
||||
|
||||
Issue #335's `/oic/d` reports `["oic.wk.d", "oic.d.airconditioner"]`, which
|
||||
is consistent with a two-indoor-unit composite (both constituents are air
|
||||
conditioners, so the type appears once) and equally consistent with a single
|
||||
unit. It does not discriminate.
|
||||
|
||||
The Collection half does suggest something untried. `x.com.samsung.devcol`
|
||||
is Samsung's Collection type, carried by `/device/0` — and on the dongle
|
||||
board `/oic/res` advertises a second resource with the same
|
||||
`["x.com.samsung.devcol", "oic.wk.col"]` pair: **`/sec/devices`**. A
|
||||
collection of devices, sitting alongside `/device/0`, never read by this
|
||||
project or by any issue thread. If the composite enumeration is exposed
|
||||
anywhere as a first-class resource, that is the shape it would take.
|
||||
|
||||
## Results of the second probe round
|
||||
|
||||
The reporter ran these live. Three answers, all informative.
|
||||
|
||||
**Indexed leaves do not exist.** `/information/vs/1`, `/power/vs/1`,
|
||||
`/mode/vs/1` → 4.04. Pattern A is ruled out on this board properly now:
|
||||
not just the `/device/1` Collection, but the leaf namespace it would have
|
||||
carried.
|
||||
|
||||
**The UUID prefix routes, and is empty of operational resources.** The
|
||||
control pair settles it:
|
||||
|
||||
/c24e25e9-.../file/list/vs/0 → 2.05, two items
|
||||
/file/list/vs/0 → 2.05, the same two items
|
||||
(/opt/data/energy.db, /opt/data/hass.db)
|
||||
|
||||
So the sibling's prefix is a live, routed namespace — the 23 flat-fallback
|
||||
4.04s under it are the firmware answering "no such resource", not a dead
|
||||
prefix swallowing everything. Pattern B/C is ruled out on this board on
|
||||
positive evidence rather than on absence. That the two listings are
|
||||
identical is expected either way: one board, one flash, one filesystem.
|
||||
|
||||
**`/sec/devices` exists — and this project could not see what's in it.**
|
||||
It answered `2.05` with `rep: {}`, which reads as "the resource is there and
|
||||
has nothing in it". It is not. `coordinator._raw_read_blocking` decoded the
|
||||
CBOR body and then kept it *only if it was a Property map*:
|
||||
|
||||
```python
|
||||
if isinstance(body, dict):
|
||||
rep = body
|
||||
```
|
||||
|
||||
A Collection answers a **list** — the `[devcol rep, {href, rep}, ...]` batch
|
||||
`parse_device0_batch` reads. `/device/0` itself would have rendered exactly
|
||||
the same accepted-but-empty `2.05 {}` through `read_resource`. Fixed: the
|
||||
read path now returns the decoded body alongside `rep`, and the service
|
||||
response carries it as `body` whenever it isn't the map already in `rep`.
|
||||
|
||||
`/sec/devices` therefore remains the one open lead, and needs one re-read on
|
||||
a build carrying that fix.
|
||||
|
||||
## Still worth reading
|
||||
|
||||
**1 — `/sec/devices`, again.** Same `x.com.samsung.devcol` + `oic.wk.col`
|
||||
pair as `/device/0`, so its body should be a batch naming its members. If a
|
||||
composite enumeration is exposed anywhere, it is here.
|
||||
|
||||
**2 — the file-transfer pair.** `/oic/res` advertises
|
||||
`/c24e25e9-.../file/transfer/vs/0` alongside the master's, and the prefix is
|
||||
now known to route. Issue #301 documents the shape: a baseline GET returns
|
||||
one item, `x.com.samsung.name` plus `x.com.samsung.blob`, no write needed to
|
||||
see whatever it currently serves. If the prefixed endpoint serves *different
|
||||
bytes* than the master's, that is the first hard local evidence the wall
|
||||
unit exists as a data producer, and `/opt/data/energy.db` would be where its
|
||||
runtime history lives.
|
||||
|
||||
/file/transfer/vs/0
|
||||
/c24e25e9-55dd-ba18-d567-000000000001/file/transfer/vs/0
|
||||
|
||||
Mind the blob: #301 measured 2172 B on a `KRAC_18K`, and a raw `bytes` value
|
||||
in a service response is not guaranteed to survive rendering in Developer
|
||||
Tools. Ask for `x.com.samsung.name` and whether a blob field appears, not
|
||||
for the blob pasted into a comment.
|
||||
|
||||
## Dead ends, so they aren't re-tried
|
||||
|
||||
- `/hass/state/vs/0`, `/hass/command/vs/0` — advertised in `/oic/res` on
|
||||
every board here, and indexed per subdevice on the dongle board
|
||||
(`/hass/state/vs/{0,1,2}`), which makes them look like a subdevice-aware
|
||||
state channel. They are not: 4.04 on every interface on
|
||||
`ARTIK051_KRAC_18K` (see `ac-filter-reset.md`). Cheap enough to retry once
|
||||
on #335's newer build, but expect nothing.
|
||||
- `/multidevice/vs/0` — probed, 4.04. Absent on this board; only the dongle
|
||||
family exposes it.
|
||||
- `/actions/vs/0` — GET returns `{}` on baseline and `oic.if.a`; publishes
|
||||
no schema (`ac-filter-reset.md`).
|
||||
|
||||
## Where this lands if `/sec/devices` is empty too
|
||||
|
||||
Then the sibling is named in `subdeviceIdList` for the cloud's benefit and
|
||||
has no local operational surface at all on this firmware — every namespace
|
||||
it could occupy has now been read directly, and the UUID one was confirmed
|
||||
routable first, so the negatives mean what they say. That closes issue #335
|
||||
as a firmware limitation rather than leaving it open against a probe
|
||||
strategy that was never actually exercised.
|
||||
|
||||
Worth keeping in view for the enumeration code either way: both remaining
|
||||
patterns hinge on a Collection, and this board answers neither `/device/1`
|
||||
nor a prefixed `/device/0`. An indexed flat-probe fallback — the mirror of
|
||||
issue #205's prefixed one, gated on a board that claims a sibling but
|
||||
materialized nothing — would have cost 8 round trips here and returned the
|
||||
same 4.04s the reporter got by hand. It is worth building only if some
|
||||
other board turns out to serve indexed leaves without their Collection;
|
||||
this one does not.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Laundry cloud "Download" cycles: solved, with one dead end
|
||||
|
||||
`registry/capabilities/laundry.py`'s cycle select offers a washer's
|
||||
downloaded ("Download" / "Downloaded") programs alongside its ordinary
|
||||
courses now, driven by the store in `cloudcourse.py` (issue #342). This file
|
||||
records the byte-level work behind it, including a decode that fit one
|
||||
device perfectly and collapsed on the second — the reason nothing in the
|
||||
shipped code interprets a program payload at all.
|
||||
|
||||
Four devices in the corpus carry these tokens (a survey of every laundry
|
||||
diagnostics dump attached to an issue turned up 14 devices; the other 10 have
|
||||
no cloud tokens at all, so this is a minority feature):
|
||||
|
||||
| dump | model | slots advertised | payloads seen | blob width |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `washer_ww5000c_cloud` | WW5000C `_B06C`, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 2 | 20 bytes |
|
||||
| `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 1 | 16 bytes |
|
||||
| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 (but see below) | **0** | — |
|
||||
| (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes |
|
||||
|
||||
Three things follow immediately from that table:
|
||||
|
||||
- **`CloudExtraCourse_` does not mean the same thing on every family.** On
|
||||
the DW5000C all four of its bytes (`8E 8D 8F 02`) are course codes in that
|
||||
dishwasher's *own* course list, three already translated (Plastic, Pots and
|
||||
pans, Baby Care). There it tags which ordinary courses came from the cloud;
|
||||
they select with a plain `Course_` write and need no payload — consistent
|
||||
with it carrying no payload token at all. It also has a
|
||||
`DownloadCourseList_8F` token the washers lack.
|
||||
On both washers the slots share **zero** overlap with the course list and a
|
||||
payload is required. Subtracting the course list is what tells the two
|
||||
apart (`cloudcourse.cloud_slots`), so the feature engages on the washers
|
||||
and correctly does nothing on the dishwasher.
|
||||
- **A device can advertise a slot it has never loaded.** True on the washers
|
||||
too — nothing about a program is learnable until its owner runs it, which
|
||||
is what the Repairs issue exists to explain.
|
||||
- **Both WW5000C units advertise the byte-identical slot list**
|
||||
(`0A5C286B2D0C55301A`, same nine slots in the same order) despite different
|
||||
firmware builds. Either the set is a factory/regional default rather than
|
||||
something each owner curates, or the two dumps share an owner — unresolved,
|
||||
but worth knowing before assuming a user picked their own programs.
|
||||
|
||||
## The three tokens
|
||||
|
||||
All on `/course/vs/0`'s `x.com.samsung.da.options` array, same
|
||||
prefix-match/replace merge as every other token there.
|
||||
|
||||
- `CloudExtraCourse_<slot><slot>…` — the device's own list of downloaded
|
||||
program slots, one byte each. The cloud counterpart of `EditCourseList_`.
|
||||
- `CloudCourse_<blob>` — the persisted default program.
|
||||
- `OneTimeCloudCourse_<blob>` — a this-run-only override.
|
||||
|
||||
### `CloudExtraCourse_` is an enumeration, and byte 2 of a blob is its slot
|
||||
|
||||
The WW5000C reports `CloudExtraCourse_0A5C286B2D0C55301A` — nine bytes for
|
||||
its nine downloaded programs. Byte 2 of each of the nine blobs its owner
|
||||
captured is exactly one of those nine, no repeats, sets equal:
|
||||
|
||||
```
|
||||
blob byte2 program
|
||||
00 21 55 04 49 28 4D 13 4A A0 4C 00 … 55 Sports
|
||||
00 20 28 04 49 00 4D 00 4A B8 4C 00 … 28 Spin only
|
||||
00 02 5C 04 49 28 4D 13 4A B0 4C 00 … 5C Outdoor
|
||||
00 1F 6B 04 49 28 4D 13 4A A0 4C 00 … 6B Jeans
|
||||
00 2E 2D 04 49 30 4D 12 4A A0 4C 00 … 2D Super quiet
|
||||
00 2F 0C 04 49 58 4D 14 4A B8 4C 00 … 0C Baby care intensive
|
||||
00 0D 30 04 49 30 4D 12 4A B8 4C 00 … 30 Cloudy heaven
|
||||
00 30 1A 04 49 28 4D 12 4A A0 4C 00 … 1A Shirts
|
||||
00 04 0A 04 49 40 4D 13 4A B8 4C 00 … 0A Towels
|
||||
|
||||
CloudExtraCourse_ 0A 5C 28 6B 2D 0C 55 30 1A
|
||||
```
|
||||
|
||||
Confirmed independently on the WA55A7700AV: `CloudExtraCourse_5958`, and its
|
||||
`CloudCourse` blob `00 1C 59 05 …` has byte 2 = `59`. Its
|
||||
`OneTimeCloudCourse` is `FF FF 01 …` — byte 2 = `01`, which is *not* an
|
||||
advertised slot, and the `FFFF` prefix marks it as "nothing loaded" rather
|
||||
than naming a program. That sentinel is why `cloudcourse.is_loaded` exists.
|
||||
|
||||
This is what makes "3 of 9 discovered" answerable, and it is why no catalog
|
||||
of program ids is hardcoded anywhere: the appliance already knows which
|
||||
programs it has.
|
||||
|
||||
## Writing: the two-token rule
|
||||
|
||||
Confirmed on hardware by the issue #342 reporter. Writing
|
||||
`OneTimeCloudCourse_<blob>` alone while some other course is selected is
|
||||
accepted at the protocol level (no error) and then silently ignored by the
|
||||
machine. It takes effect only when the same write also switches `Course_` to
|
||||
the Download course — which is what `laundry._cloud_cycle_write` does, and
|
||||
the only two-token options write in the codebase:
|
||||
|
||||
```yaml
|
||||
x.com.samsung.da.options:
|
||||
- Course_87
|
||||
- OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00
|
||||
```
|
||||
|
||||
`CloudCourse_` was separately confirmed writable on its own: set while on
|
||||
Download it changes the running program; set from another course it becomes
|
||||
what gets preselected the next time Download is chosen. The integration
|
||||
doesn't write it today — a "default download cycle" control is a possible
|
||||
follow-up, deliberately left out of the first pass.
|
||||
|
||||
### There is no single "Download" course code
|
||||
|
||||
The WW5000C's Download is `Course_87`; the WA55A7700AV's is `17`
|
||||
("Downloaded" in `washer_cycle_table_02`). **Same course table, different
|
||||
code.** Any per-table lookup of "the Download code" would have been wrong on
|
||||
one of the only two devices available to check it against, which is why the
|
||||
code is learned by observation and confirmed by the user in the options flow
|
||||
instead of tabled.
|
||||
|
||||
The observation signal is "whatever `Course_` reads at the moment a
|
||||
non-sentinel `OneTimeCloudCourse_` *appears or changes*" — a transition that
|
||||
was actually watched, not a state. Tokens in this array are replaced by
|
||||
prefix and never evicted, so a payload merely sitting there says nothing
|
||||
about when it got there; on the first rep after a restart it is equally
|
||||
consistent with "just loaded" and "left over from last week". Believing it
|
||||
would propose whatever ordinary course the appliance happens to be sitting
|
||||
on, and accepting that prefill starts a real wash cycle. Even a genuine
|
||||
transition is only ever a *candidate*, confirmed by the user before use.
|
||||
|
||||
Both dumps in the corpus taken while off the Download course
|
||||
(`washer_wa55a7700av` on `Course_01`, the `_B048` washer on `Course_1C`)
|
||||
show the appliance clearing its one-time token to the `FFFF` sentinel, so
|
||||
the saved default persists but the one-shot does not. That makes the stale
|
||||
case unlikely on these boards — which is a reason to expect it to behave,
|
||||
not a reason to depend on it.
|
||||
|
||||
## The dead end: bytes 5/7/9 do not decode portably
|
||||
|
||||
With the nine WW5000C programs and their app-reported settings side by side,
|
||||
three of the varying bytes fit perfectly:
|
||||
|
||||
| byte | meaning | formula | fit |
|
||||
| --- | --- | --- | --- |
|
||||
| 5 | wash temperature | `(b - 0x10) / 0.8` °C, `0x00` = n/a | 9/9 |
|
||||
| 7 | rinse count | `b - 0x10`, `0x00` = off | 9/9 |
|
||||
| 9 | spin level | `(b - 0x90) / 8` | 9/9 |
|
||||
|
||||
Nine for nine, including the internally consistent case: "Spin only" is the
|
||||
only program with `0x00` in *both* byte 5 and byte 7, matching a cycle that
|
||||
skips washing entirely while still reporting a spin level.
|
||||
|
||||
It does not survive the second device. Against the WA55A7700AV's
|
||||
`CloudCourse` blob `00 1C 59 05 49 16 4D 11 4A 22 4C 20 37 F0 AC 22`:
|
||||
|
||||
- temperature: `(0x16 - 0x10) / 0.8` = **7.5 °C**
|
||||
- spin: `(0x22 - 0x90) / 8` = **negative**
|
||||
- rinse: `0x11 - 0x10` = 1 — the only plausible one
|
||||
|
||||
### What *does* survive is the grammar
|
||||
|
||||
The two boards' payloads are different lengths (20 vs 16 bytes) but not a
|
||||
different format — same header, same leading fields, two fewer optional
|
||||
trailing ones:
|
||||
|
||||
```
|
||||
WW5000C 00 | 2155 | 04 | 49:28 4D:13 4A:A0 4C:00 | 35:F0 04:F0 05:F0 | AC:00
|
||||
WA55 00 | 1C59 | 05 | 49:16 4D:11 4A:22 4C:20 | 37:F0 | AC:22
|
||||
```
|
||||
|
||||
- `00`, then the 2-byte program id, then one byte (`04` vs `05`) — identical
|
||||
layout on both.
|
||||
- Then a tag/value stream whose **first four tags are the same, in the same
|
||||
order, at the same offsets**: `49`, `4D`, `4A`, `4C`. These are exactly the
|
||||
four whose values vary per program.
|
||||
- Then a fixed tail, terminated on both by an `AC:<value>` pair. The entire
|
||||
width difference is two trailing pairs the WA55 doesn't carry.
|
||||
|
||||
The tail is not program data. Across all nine WW5000C programs bytes 12–19
|
||||
are byte-identical — every trailing pair carries value `F0` except the `AC`
|
||||
terminator, and `35` vs `37` looks like a board or profile marker rather than
|
||||
a field. (It is not a field count either: the board with the *higher* leading
|
||||
byte has *fewer* pairs.)
|
||||
|
||||
### Byte 3 is not part of a program's identity
|
||||
|
||||
Worth its own heading, because it is the single strongest argument against
|
||||
ever shipping a table of payloads. The second WW5000C (issues #259/#343,
|
||||
firmware `_B048`) has its saved `CloudCourse` set to the same program as the
|
||||
first one's "Towels" capture — and the two payloads differ at exactly one
|
||||
byte:
|
||||
|
||||
```
|
||||
_B06C "Towels" 00 04 0A 04 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
|
||||
_B048 CloudCourse 00 04 0A 06 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00
|
||||
^^
|
||||
```
|
||||
|
||||
Same program id, same slot, same values on all four varying tags, same tail.
|
||||
Only byte 3 moves, `04` → `06`. So it is neither a per-board constant (both
|
||||
are WW5000C) nor a property of the program (identical in every other
|
||||
respect) — most likely a download revision or sequence counter.
|
||||
|
||||
A hardcoded catalog keyed on program id would therefore have shipped one
|
||||
unit's byte 3 to the other unit. Whether the appliance would reject that, or
|
||||
accept it and do something unintended, is untested and does not need to be:
|
||||
every payload is learned from the device it will be replayed to.
|
||||
|
||||
### Sentinels
|
||||
|
||||
The `FFFF` "nothing loaded" payload takes its board's own width (16 bytes on
|
||||
the WA55, 20 on the WW5000C `_B048`) and always carries byte 3 = `00`. Its
|
||||
byte 2 is *not* reliably meaningful: it equals the currently selected course
|
||||
on the WA55 (`01`, on `Course_01`) and does not on the `_B048` (`1B`, on
|
||||
`Course_1C`). Nothing keys off it — a sentinel is rejected on its `FFFF`
|
||||
prefix, and its byte 2 is not an advertised slot in either dump anyway.
|
||||
|
||||
So the payload is tag/value, not fixed offsets — but knowing the grammar
|
||||
doesn't recover the values. The same four tags carry non-overlapping ranges
|
||||
between the two boards:
|
||||
|
||||
| tag | WW5000C (9 programs) | WA55 |
|
||||
| --- | --- | --- |
|
||||
| `49` | `00, 28, 30, 40, 58` | `16` |
|
||||
| `4D` | `00, 12, 13, 14` | `11` |
|
||||
| `4A` | `A0, B0, B8` | `22` |
|
||||
| `4C` | `00` (all nine) | `20` |
|
||||
|
||||
Same field, board-specific encoding. Decoding it properly needs a third
|
||||
device; one device's fit is a coincidence-shaped hypothesis, not a format.
|
||||
|
||||
**A trap for whoever picks this up:** the WA55's `/washer/vs/0` reads
|
||||
Warm / High / 1, which looks like it could confirm a decode of that unit's
|
||||
`CloudCourse`. It can't — that appliance is sitting on `Course_01` (Normal),
|
||||
not on its cloud course, so those values describe the local cycle it has
|
||||
selected, not the saved cloud program. A cross-check like this is only
|
||||
evidence when the machine is actually loaded with the program being decoded.
|
||||
|
||||
So the shipped code never interprets a payload: a blob is recorded whole and
|
||||
replayed byte-for-byte, exactly as the device reported it, and never
|
||||
decomposed or rebuilt. The read-only "loaded program's temperature/spin"
|
||||
sensors this decode would have enabled were dropped for the same reason.
|
||||
|
||||
## What is deliberately not done
|
||||
|
||||
- **No hardcoded program catalog.** Blobs are cloud-assigned per
|
||||
account/region. One owner's captured payload is not evidence about anyone
|
||||
else's appliance, and a table of them would offer options that write
|
||||
another household's wash settings.
|
||||
- **No invented names.** The appliance reports an opaque slot id and nothing
|
||||
else. Names come from the user in the options flow, the same rule that
|
||||
stops an unrecognized local course code from getting a made-up English
|
||||
label (PR #251 review).
|
||||
- **No blob synthesis.** Even with the byte 5/7/9 decode in hand, nothing
|
||||
builds a payload from parts — the device was never tested with one, and a
|
||||
fabricated blob is an untested write to a wash cycle.
|
||||
|
||||
## Open questions for the next dump
|
||||
|
||||
1. Does `OneTimeCloudCourse_` clear itself when a cycle finishes, or when the
|
||||
course changes? Behavior suggests the appliance falls back to
|
||||
`CloudCourse_` when Download is re-entered, but the token's own lifecycle
|
||||
is unconfirmed. `laundry.cloud_current` is written to be correct either
|
||||
way.
|
||||
2. What does byte 1 mean? It is distinct per program and *sometimes*
|
||||
coincides with a plausible local course code for that program (`2E` Baby
|
||||
Care for "Baby care intensive", `30` Cloudy Day for "Cloudy heaven") and
|
||||
sometimes doesn't (`21` Colors for "Sports"). Probably a base-course
|
||||
reference; not reliable enough to use.
|
||||
3. What does byte 3 count? It moves between two units holding the identical
|
||||
program (`04` vs `06`) and is `00` on every sentinel. A revision or
|
||||
download counter is the obvious guess; a dump taken before and after
|
||||
re-downloading the same program would confirm it.
|
||||
4. **The value encoding is still open, and a third *washer* won't
|
||||
necessarily settle it.** The survey found one, but its saved program is a
|
||||
duplicate of one already captured, so it adds no new tag values. What is
|
||||
actually needed is a dump from a board whose `/washer/vs/0` speaks in
|
||||
named levels (Cold/Warm/Hot, Low/High) *while that unit is sitting on a
|
||||
downloaded program* — then the payload's `49`/`4A` values can be read
|
||||
against settings that describe the same program. The WA55 is such a
|
||||
board but was captured on a local course, which is why it can't be used
|
||||
(see the trap above).
|
||||
5. The DW5000C's four slots (`8E 8D 8F 02`) are in the same numeric range as
|
||||
dishwasher course codes (its selected course is `86`), unlike the
|
||||
washers' slots. One payload from that machine would show whether slot ids
|
||||
are drawn from the course-code space on some boards.
|
||||
+8
-1
@@ -70,7 +70,7 @@ class FakeCoapSession:
|
||||
pass
|
||||
|
||||
|
||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list], device_types=()):
|
||||
"""Run the *whole* subdevice-aware discovery pipeline against fixture
|
||||
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
||||
_enumerate_subdevices_blocking + _run_discovery (issue #177), so a test
|
||||
@@ -78,6 +78,12 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
it. See the adding-device-support skill's section 2 for the plain
|
||||
(non-subdevice) equivalent this extends.
|
||||
|
||||
`device_types` is the master's own /oic/d `rt` (see
|
||||
discover_partitioned's `oic_device_types` param) -- only needed for a
|
||||
board with no /information/vs/0 at all to route from (issue #324's
|
||||
range, whose modelNum-based fallback has nothing to read), so it
|
||||
defaults to () for every fixture that resolves by board token instead.
|
||||
|
||||
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
||||
- `bound`: every BoundEntity, main + every materialized subdevice.
|
||||
- `materialized`/`skipped`: Subdevice / SkippedSubdevice lists straight from
|
||||
@@ -101,6 +107,7 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
||||
candidates,
|
||||
resolve,
|
||||
CAPABILITIES,
|
||||
oic_device_types=device_types,
|
||||
)
|
||||
return bound, materialized, skipped, full_resources, device_type_name
|
||||
|
||||
|
||||
+1056
File diff suppressed because it is too large
Load Diff
+297
@@ -0,0 +1,297 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "DishA_Disable",
|
||||
"x.com.samsung.da.triggeredTime": "2022-07-28T13:45:57",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0812",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"CourseDefaultTimeSet_008B008E009A006D003C000A006600A50059009D",
|
||||
"CourseDefaultTempSet_3C41363F41413737373700003C3C464649493C3C",
|
||||
"CourseDefaultFahrenheitSet_8C95819195958383838300008C8C9E9EA3A38C8C",
|
||||
"Course_86",
|
||||
"DetergentOnce_0",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_0",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_0",
|
||||
"DetergentTotal_0",
|
||||
"ProgressTimeSet_420622820456B20384",
|
||||
"SendToDevice_Off",
|
||||
"GMT_F2",
|
||||
"SavingModeCondition_01010207828485868E8D020300",
|
||||
"SavingMode_Off",
|
||||
"DownloadCourseList_8F",
|
||||
"StormWashZone_Off",
|
||||
"AutoDoorRelease_On",
|
||||
"Sound_On",
|
||||
"WaterLevelSet_050404050302010205010400",
|
||||
"CloudExtraCourse_8E8D8F02",
|
||||
"EnergyLevelSet_050403050202010305040300",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_Empty",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"482830CB002C002D00283830CB002C002D00284830CB002C002D00285830CB000C000D00086830CB002C002D002908308B000C000D0008E830CB000C000D0008D830CB002C002D0028F8308B000C000D00002830CB002C002D002"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Run",
|
||||
"x.com.samsung.da.remainingTime": "00:44:00",
|
||||
"x.com.samsung.da.progressPercentage": "28",
|
||||
"x.com.samsung.da.delayStartTime": "00:00:00",
|
||||
"x.com.samsung.da.progress": "Wash",
|
||||
"x.com.samsung.da.supportedProgress": [
|
||||
"None",
|
||||
"Predrain",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Drying",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Predrain",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Drying",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "Wash",
|
||||
"remainingTime": "00:44:00",
|
||||
"progressPercentage": "28"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000",
|
||||
"x.com.samsung.da.description": "DA_DW_TP1_21_COMMON_DW5000C/DD92-0010741_0001",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "WD0",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "00081A230213(A214)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Firmware_1_DB_30010741230602142FFFFFFFFFFFFFFFFFFFFFFFFFFE(081230010741FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "23060214,FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "false",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dishwasher/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.highTemperatureDry": "Off",
|
||||
"x.com.samsung.da.sanitize": "Off",
|
||||
"x.com.samsung.da.selectedZone": "ON_ON",
|
||||
"x.com.samsung.da.rinseLevel": "0",
|
||||
"x.com.samsung.da.supportedSelectedZone": [
|
||||
"OFF_ON",
|
||||
"ON_ON"
|
||||
],
|
||||
"x.com.samsung.da.supportedSanitize": [
|
||||
"Off",
|
||||
"On"
|
||||
],
|
||||
"x.com.samsung.da.supportedHighTemperatureDry": [
|
||||
"Off",
|
||||
"On"
|
||||
],
|
||||
"x.com.samsung.da.supportedRinseLevel": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "CA"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/waterfilter/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"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": "/water/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeWater": "2662000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/submode/vs/0",
|
||||
"rep": {
|
||||
"setTemperatureUnit": "F"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "CT_E",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-06T14:24:02"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bluetooth/hood/status/vs/0",
|
||||
"rep": {
|
||||
"connectionState": "disconnected",
|
||||
"micomModelId": "",
|
||||
"firmwareVersion": "",
|
||||
"power": "off",
|
||||
"fanSpeed": 0,
|
||||
"lampState": "off",
|
||||
"timer": {},
|
||||
"rt": [
|
||||
"bluetoothHoodStatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.connected"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NV8000T-/KO0",
|
||||
"Pause_Off",
|
||||
"SyncFlex_Off",
|
||||
"FlexCoil_0",
|
||||
"MainTimerCurrent_0",
|
||||
"MainTimerSet_0",
|
||||
"MainTimerState_Ready",
|
||||
"IndependentTimerCheck_Enable",
|
||||
"OperationState0_Ready",
|
||||
"HotSurface0_Normal",
|
||||
"PowerLevel0_0",
|
||||
"OperationState1_Ready",
|
||||
"HotSurface1_Normal",
|
||||
"PowerLevel1_0",
|
||||
"OperationState2_Ready",
|
||||
"HotSurface2_Normal",
|
||||
"PowerLevel2_0",
|
||||
"OperationState3_Ready",
|
||||
"HotSurface3_Normal",
|
||||
"PowerLevel3_0",
|
||||
"OperationState4_Ready",
|
||||
"HotSurface4_Normal",
|
||||
"PowerLevel4_0",
|
||||
"OperationState5_Ready",
|
||||
"HotSurface5_Normal",
|
||||
"PowerLevel5_0"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"absence_clean",
|
||||
"ai_energy_level",
|
||||
"air_filter_status",
|
||||
"air_filter_threshold",
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
"auto_clean_running",
|
||||
"beep",
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"display",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_saving_mode",
|
||||
"energy_saving_operating_status",
|
||||
"energy_saving_state",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sound_mode",
|
||||
"sound_output",
|
||||
"tropical_night_mode",
|
||||
"uv_led",
|
||||
"ventilation_alarm"
|
||||
]
|
||||
}
|
||||
+18
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"absence_clean",
|
||||
"absence_power_saving_active",
|
||||
"absence_power_saving_mode",
|
||||
"air_filter_pm1_status",
|
||||
@@ -9,6 +10,7 @@
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
@@ -17,20 +19,36 @@
|
||||
"climate",
|
||||
"current_temperature_c",
|
||||
"dust",
|
||||
"edge_lighting",
|
||||
"edge_lighting_color",
|
||||
"edge_lighting_mode",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"indicator_light",
|
||||
"indicator_light_mode",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"motion_detect_wind_active",
|
||||
"motion_detect_wind_mode",
|
||||
"mute_once",
|
||||
"odor_controller_active",
|
||||
"odor_controller_progress",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sensing_interval",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"sound_mode",
|
||||
"sound_output",
|
||||
"sound_volume",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode",
|
||||
"uv_led"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"air_filter_usage",
|
||||
"air_filter_usage_hours",
|
||||
"air_purify",
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"auto_clean_progress",
|
||||
@@ -19,10 +20,18 @@
|
||||
"fine_dust",
|
||||
"firmware_update",
|
||||
"humidity",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"mute_once",
|
||||
"periodic_air_sensing",
|
||||
"periodic_sensing_skip_status",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"sensing_interval",
|
||||
"sensing_mode",
|
||||
"sensing_skip_end",
|
||||
"sensing_skip_start",
|
||||
"super_fine_dust",
|
||||
"tropical_night_mode"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"auto_release_dry",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"diagnosis_status",
|
||||
"energy_saved_kwh",
|
||||
"filter_status",
|
||||
"filter_usage",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"heated_dry",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sanitize",
|
||||
"storm_wash",
|
||||
"water_liters"
|
||||
]
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"any_burner_active",
|
||||
"burner_0_state",
|
||||
"burner_1_state",
|
||||
"burner_2_state",
|
||||
"burner_3_state",
|
||||
"burner_4_state",
|
||||
"burner_5_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"firmware_update",
|
||||
"main_timer_current",
|
||||
"main_timer_state",
|
||||
"paired_hood_connected",
|
||||
"paired_hood_fan_speed",
|
||||
"paired_hood_firmware",
|
||||
"paired_hood_light",
|
||||
"paired_hood_model",
|
||||
"paired_hood_power",
|
||||
"power_state"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"diagnosis_status",
|
||||
"door_open",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooktop_on_alert",
|
||||
"cooktop_running_state",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"energy_saving",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sound",
|
||||
"subdevice1_cloud_connected",
|
||||
"subdevice1_cook_time",
|
||||
"subdevice1_current_temp_c",
|
||||
"subdevice1_cycle_active",
|
||||
"subdevice1_finish_time",
|
||||
"subdevice1_machine_state",
|
||||
"subdevice1_operation_time_minutes",
|
||||
"subdevice1_oven_mode",
|
||||
"subdevice1_oven_setpoint",
|
||||
"subdevice1_oven_state",
|
||||
"subdevice1_progress_percentage",
|
||||
"subdevice1_sound",
|
||||
"warming_center_state"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"brightness_level",
|
||||
"cabinet_light_dim",
|
||||
"cabinet_light_switch",
|
||||
"cooler_setpoint",
|
||||
"cooler_temperature",
|
||||
"day_brightness",
|
||||
"door_alert",
|
||||
"door_cooler_open",
|
||||
"door_freezer_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"fridge_sound",
|
||||
"ice_night_mode",
|
||||
"icemaker_one_enabled",
|
||||
"icemaker_one_making_status",
|
||||
"night_end",
|
||||
"night_start",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"ai_energy_level",
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"auto_door_timer",
|
||||
"auto_door_voice_control",
|
||||
"defrost_active",
|
||||
"door_onedoorfreezer_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"freezer_setpoint",
|
||||
"freezer_temperature",
|
||||
"fridge_sound",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"rapid_freezing",
|
||||
"rapid_fridge",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"auto_door_timer",
|
||||
"auto_door_voice_control",
|
||||
"door_onedoorkimchi_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"fridge_sound",
|
||||
"onedoor_mode",
|
||||
"onedoor_rack_count",
|
||||
"onedoor_ripening_remaining",
|
||||
"onedoor_ripening_status",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"auto_door_opener",
|
||||
"auto_door_sound_control",
|
||||
"auto_door_timer",
|
||||
"auto_door_voice_control",
|
||||
"cabinet_light_dim",
|
||||
"cabinet_light_switch",
|
||||
"deodor_filter_status",
|
||||
"deodor_filter_usage",
|
||||
"door_winecellar_open",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"fridge_sound",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"selfcheck_error",
|
||||
"selfcheck_result",
|
||||
"selfcheck_status",
|
||||
"winecellar_bottom_setpoint",
|
||||
"winecellar_pantry_zone_mode",
|
||||
"winecellar_top_setpoint"
|
||||
]
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"bubble_soak",
|
||||
"buzzer_sound",
|
||||
"child_lock",
|
||||
"completion_minutes",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"detergent_low",
|
||||
"diagnosis_status",
|
||||
"drum_clean_cycles_remaining",
|
||||
"drum_clean_last_cleaned",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"finish_sound",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"intensive",
|
||||
"job_beginning_status",
|
||||
"machine_state",
|
||||
"power_switch",
|
||||
"pre_wash",
|
||||
"progress",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"rinse_cycles",
|
||||
"softener_low",
|
||||
"spin_speed",
|
||||
"wash_temperature",
|
||||
"water_liters"
|
||||
]
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "OV_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-08-05T14:20:14"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.connected"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.lock": "Unlock"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.doors"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_DA-KS-WALLOVEN-000002|40441841|5002011E021011150100000000000000",
|
||||
"x.com.samsung.da.description": "NW9000KD/AA1",
|
||||
"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": "240205",
|
||||
"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": "DE92-04418A_20062500, DE92-04011A_17041300",
|
||||
"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": "610",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"ConvectionBake",
|
||||
"ConvectionRoast",
|
||||
"Bake",
|
||||
"Broil",
|
||||
"SteamBake",
|
||||
"SteamRoast",
|
||||
"Easycook3",
|
||||
"Descale",
|
||||
"PyroFree",
|
||||
"Drain",
|
||||
"SelfClean",
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NW9000KD/AA1",
|
||||
"keepWarmReservation_Off",
|
||||
"meatprobe_disconnected",
|
||||
"NoPreheat_Off",
|
||||
"waterInlet_Closed",
|
||||
"steamAddLevel_0",
|
||||
"descaleAlarm_Normal",
|
||||
"descaleNewWaterAlarm_Off",
|
||||
"descaleEmptyWaterAlarm_Off",
|
||||
"steamGeneratorLevel_Empty",
|
||||
"steamUsingTime_66",
|
||||
"drainRequired_00",
|
||||
"steamState_Standby",
|
||||
"descaleState_Standby",
|
||||
"waterTankInSwitch_On",
|
||||
"pyroFreeState_Standby",
|
||||
"waterTankOutSwitch_Off",
|
||||
"Sound_On",
|
||||
"AdjustingTemp_0",
|
||||
"Sabbath_Off",
|
||||
"EnergySaving_On"
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.operationTime": "00:00:00",
|
||||
"x.com.samsung.da.remainingTime": "00:00:00",
|
||||
"x.com.samsung.da.progressPercentage": "1",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"rt": [
|
||||
"x.com.samsung.da.oven"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false",
|
||||
"rt": [
|
||||
"x.com.samsung.da.configuration"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.increment": "0",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "0",
|
||||
"x.com.samsung.da.current": "0",
|
||||
"x.com.samsung.da.increment": "0",
|
||||
"x.com.samsung.da.unit": "Fahrenheit"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.temperatures"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,623 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"href": "/alarms/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On",
|
||||
"href": "/bespoke/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/enhanced/vs/0",
|
||||
"rep": {
|
||||
"light.control.status": "On",
|
||||
"level.brightness.daytime": "100",
|
||||
"level.brightness.nighttime": "33",
|
||||
"night.starttime": "2026-08-07T12:00:00",
|
||||
"night.duration.minute": "540",
|
||||
"timezone.offset": "+09:00",
|
||||
"href": "/cabinet/light/enhanced/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.light.enhanced"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.lightLevel": "100",
|
||||
"x.com.samsung.da.lightResolution": "3",
|
||||
"x.com.samsung.da.lightControl.off.include": "Off",
|
||||
"x.com.samsung.da.lightControl": "Off",
|
||||
"x.com.samsung.da.lightControl.hide": "true",
|
||||
"light.dimming.status": "On",
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.cabinetlight"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "",
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"href": "/configuration/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/defrost/prediction/vs/0",
|
||||
"rep": {
|
||||
"ai.cooling.care": "Off",
|
||||
"href": "/defrost/prediction/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Normal",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/cooler/0",
|
||||
"rep": {
|
||||
"openState": "Close",
|
||||
"href": "/door/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/freezer/0",
|
||||
"rep": {
|
||||
"openState": "Close",
|
||||
"href": "/door/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
}
|
||||
],
|
||||
"href": "/doors/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1441",
|
||||
"x.com.samsung.da.start": "2026-08-07T00:00:41Z",
|
||||
"x.com.samsung.da.realSaving": "On",
|
||||
"href": "/drlc/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/ailevel/vs/0",
|
||||
"rep": {
|
||||
"aiLevel": "1",
|
||||
"supportedAiLevel": [
|
||||
"1",
|
||||
"2"
|
||||
],
|
||||
"href": "/energy/ailevel/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "25344",
|
||||
"x.com.samsung.da.instantaneousPower": "48",
|
||||
"x.com.samsung.da.cumulativePower": "413385",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "50694",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1786020360"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1,
|
||||
"href": "/file/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"washable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"href": "/filter/airdustfilter/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/nighttime/vs/0",
|
||||
"rep": {
|
||||
"ice.night.status": "On",
|
||||
"ice.night.starttime": "2026-08-07T12:00:00",
|
||||
"ice.night.duration": "540",
|
||||
"ice.night.timezone": "+09:00",
|
||||
"href": "/icemaker/nighttime/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.ice.night"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker.name": "ICE_MAKER",
|
||||
"x.com.samsung.da.iceMaker.state": "On",
|
||||
"x.com.samsung.da.iceType.desired": "NORMAL",
|
||||
"x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP",
|
||||
"x.com.samsung.da.iceMaker.type": "toggle",
|
||||
"href": "/icemaker/one/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.icemaker"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/icemaker/status/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.iceMaker": "On",
|
||||
"href": "/icemaker/status/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|70664141|0000033C011913114100000041FB5F00",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "RR7",
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "260618",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "2605071D, 24120906, 26040701, FFFFFFFF, 25082208, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"href": "/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
],
|
||||
"href": "/mode/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "26040701",
|
||||
"x.com.samsung.da.currentVersionInfo": "10000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnTarget": "inverter",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Refrigerator",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-24-T4-RE1",
|
||||
"versions": [
|
||||
"20260618"
|
||||
],
|
||||
"visVersion": "260618"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "823070664141FFFFFFFF",
|
||||
"versions": [
|
||||
"2605071D",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260507"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "823070664041FFFFFFFF",
|
||||
"versions": [
|
||||
"24120906",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "241209"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02307066414170664041",
|
||||
"versions": [
|
||||
"2605071D",
|
||||
"24120906"
|
||||
],
|
||||
"visVersion": "260507"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "023070680641FFFFFFFF",
|
||||
"versions": [
|
||||
"26040701",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "260407"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "023070668841FFFFFFFF",
|
||||
"versions": [
|
||||
"25082208",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250822"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/realtimenotiforclient/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFridge": "Off",
|
||||
"x.com.samsung.da.rapidFreezing": "Off",
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.fridge"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/rm/control/vs/0",
|
||||
"rep": {
|
||||
"minPeriod": "9000",
|
||||
"href": "/rm/control/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runningmode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0,
|
||||
"href": "/runningmode/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
],
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"ErrorCode_None"
|
||||
],
|
||||
"href": "/selfcheck/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/settings/sound/alert/door/vs/0",
|
||||
"rep": {
|
||||
"alert.door": "1",
|
||||
"supportedAlert.door": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4"
|
||||
],
|
||||
"href": "/settings/sound/alert/door/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.alert.door"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.device.sound": "On",
|
||||
"x.com.samsung.da.preciseCooling": "On",
|
||||
"x.com.samsung.da.doorAlarmSound": "On",
|
||||
"cleaning.status": "On",
|
||||
"cleaning.type": "SPI_AND_UV",
|
||||
"href": "/status/lock/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.lockstatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/cooler/0",
|
||||
"rep": {
|
||||
"temperature": 2.0,
|
||||
"range": [
|
||||
1.0,
|
||||
7.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/current/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rep": {
|
||||
"temperature": -19.0,
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/cooler/0",
|
||||
"rep": {
|
||||
"temperature": 2.0,
|
||||
"range": [
|
||||
1.0,
|
||||
7.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/cooler/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rep": {
|
||||
"temperature": -19.0,
|
||||
"range": [
|
||||
-23.0,
|
||||
-15.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"temperature.unit.control": "true",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "-19",
|
||||
"x.com.samsung.da.current": "-19",
|
||||
"x.com.samsung.da.maximum": "-15",
|
||||
"x.com.samsung.da.minimum": "-23",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Fridge",
|
||||
"x.com.samsung.da.desired": "2",
|
||||
"x.com.samsung.da.current": "2",
|
||||
"x.com.samsung.da.maximum": "7",
|
||||
"x.com.samsung.da.minimum": "1",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
],
|
||||
"href": "/temperatures/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "eomkim_IoT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Freezer",
|
||||
"x.com.samsung.da.desired": "-19",
|
||||
"x.com.samsung.da.current": "-19",
|
||||
"x.com.samsung.da.maximum": "-17",
|
||||
"x.com.samsung.da.minimum": "-23",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/current/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-23.0,
|
||||
-17.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": -19.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/freezer/0",
|
||||
"rep": {
|
||||
"range": [
|
||||
-23.0,
|
||||
-17.0
|
||||
],
|
||||
"units": "C",
|
||||
"temperature": -19.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
],
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "41042",
|
||||
"x.com.samsung.da.cumulativePower": "466708",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "4",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "15759"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_REF_OUTDOOR_CONTROL_V2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00168541|00080023001713104100000041010000",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"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": "RO6",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "250422",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "24101709, 23031302, 24062000, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/onedoorfreezer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"x.com.samsung.da.region": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/0",
|
||||
"rep": {
|
||||
"defrost": false,
|
||||
"rapidFreeze": false,
|
||||
"rapidCool": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/refrigeration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.rapidFreezing": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 2,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1441",
|
||||
"x.com.samsung.da.start": "2026-08-06T00:34:47Z",
|
||||
"x.com.samsung.da.realSaving": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-23-COMMON",
|
||||
"versions": [
|
||||
"20250422"
|
||||
],
|
||||
"visVersion": "250422"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02800016854100168441",
|
||||
"versions": [
|
||||
"24101709",
|
||||
"23031302"
|
||||
],
|
||||
"visVersion": "241017"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "028070655341FFFFFFFF",
|
||||
"versions": [
|
||||
"24062000",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240620"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.voicecontrol": "Off",
|
||||
"x.com.samsung.da.device.sound": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/ailevel/vs/0",
|
||||
"rep": {
|
||||
"aiLevel": "1",
|
||||
"supportedAiLevel": [
|
||||
"1",
|
||||
"2"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/timer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.time.desired": "1",
|
||||
"x.com.samsung.da.time.supportedOptions": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/single/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.openOptions": [
|
||||
"Single"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"ble_ocf",
|
||||
null
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"DA_ERROR_NONE"
|
||||
],
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "53539",
|
||||
"x.com.samsung.da.cumulativePower": "174590",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "4",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_KIMCHI_OUTDOOR_CONTROL"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"KIMCHI_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIT_BOX_COUNT_[0]",
|
||||
"KIMCHIM_BOX_COUNT_[0]",
|
||||
"KIMCHIB_BOX_COUNT_[0]",
|
||||
"KIMCHI_BOX_COUNT_[8]"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"KIMCHI_KIMCHI_STORAGE_NORMAL_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_STORAGE_COLD_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_STORAGE_WARM_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_STORAGE_CRUNFCH_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_STORAGE_BUY_[0]:[0]",
|
||||
"KIMCHI_STORAGE_FRIDGE_[0]:[0]",
|
||||
"KIMCHI_STORAGE_FREEZER_[0]:[0]",
|
||||
"KIMCHI_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]",
|
||||
"KIMCHI_KIMCHI_RIPE_LOW_TEMP_[5]:[17]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_KIMCHI_OUTDOOR_CONTROL"
|
||||
],
|
||||
"modes": [
|
||||
"KIMCHI_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIT_BOX_COUNT_[0]",
|
||||
"KIMCHIM_BOX_COUNT_[0]",
|
||||
"KIMCHIB_BOX_COUNT_[0]",
|
||||
"KIMCHI_BOX_COUNT_[8]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00168041|10010022011713004101800031010000",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"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": "CO0",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "250422",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "24101006, 23031302, 24102200, FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "8",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/onedoorkimchi/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"x.com.samsung.da.region": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 2,
|
||||
"override": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1441",
|
||||
"x.com.samsung.da.start": "2026-08-06T00:34:47Z",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-23-COMMON",
|
||||
"versions": [
|
||||
"20250422"
|
||||
],
|
||||
"visVersion": "250422"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02120016804100168441",
|
||||
"versions": [
|
||||
"24101006",
|
||||
"23031302"
|
||||
],
|
||||
"visVersion": "241010"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "021270659241FFFFFFFF",
|
||||
"versions": [
|
||||
"24102200",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "241022"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.voicecontrol": "Off",
|
||||
"x.com.samsung.da.device.sound": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/kimchi/onedoor/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL",
|
||||
"x.com.samsung.da.ripeStatus": "Off",
|
||||
"x.com.samsung.da.ripeRemaintime": "0",
|
||||
"x.com.samsung.da.rackCount": "8",
|
||||
"x.com.samsung.da.ripeTotaltime": "0",
|
||||
"x.com.samsung.da.supportMode": [
|
||||
"KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_STORAGE_COLD",
|
||||
"KIMCHI_STORAGE_WARM",
|
||||
"KIMCHI_STORAGE_CRUNFCH",
|
||||
"KIMCHI_STORAGE_BUY",
|
||||
"STORAGE_FRIDGE",
|
||||
"STORAGE_FREEZER",
|
||||
"KIMCHI_RIPE_NORMAL_TEMP",
|
||||
"KIMCHI_RIPE_LOW_TEMP",
|
||||
"NEWMODE_KIMCHI_0000",
|
||||
"NEWMODE_KIMCHI_0000",
|
||||
"NEWMODE_KIMCHI_0000"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/timer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.time.desired": "1",
|
||||
"x.com.samsung.da.time.supportedOptions": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/kimchi/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.openOptions": [
|
||||
"Single"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"ble_ocf",
|
||||
null
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+466
@@ -0,0 +1,466 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"href": "/alarms/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.alarms"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/winecellar/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.openOptions": [
|
||||
"Single"
|
||||
],
|
||||
"href": "/autodoor/winecellar/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/autodoor/timer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.time.desired": "1",
|
||||
"x.com.samsung.da.time.supportedOptions": [
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6"
|
||||
],
|
||||
"href": "/autodoor/timer/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On",
|
||||
"href": "/bespoke/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.lightLevel": "100",
|
||||
"x.com.samsung.da.lightResolution": "1",
|
||||
"x.com.samsung.da.lightControl.off.include": "Off",
|
||||
"x.com.samsung.da.lightControl": "Off",
|
||||
"x.com.samsung.da.timeout.desired": "0",
|
||||
"x.com.samsung.da.timeout.supportedList": [
|
||||
"0",
|
||||
"15",
|
||||
"30",
|
||||
"60"
|
||||
],
|
||||
"href": "/cabinet/light/total/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.cabinetlight"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "",
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"href": "/configuration/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/door/winecellar/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"href": "/door/winecellar/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.doorwinecellar"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.openState": "Close",
|
||||
"x.com.samsung.da.id": "9",
|
||||
"x.com.samsung.da.description": "Door"
|
||||
}
|
||||
],
|
||||
"href": "/doors/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "2",
|
||||
"x.com.samsung.da.override": "Not_Supported",
|
||||
"x.com.samsung.da.durationminutes": "1345",
|
||||
"x.com.samsung.da.start": "2026-08-06T02:08:48Z",
|
||||
"x.com.samsung.da.realSaving": "On",
|
||||
"href": "/drlc/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "19073",
|
||||
"x.com.samsung.da.instantaneousPower": "5",
|
||||
"x.com.samsung.da.cumulativePower": "210483",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "15696",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"href": "/energy/consumption/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+00:00",
|
||||
"x.com.samsung.supprtedtype": 1,
|
||||
"href": "/file/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/deodorfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "-1",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable"
|
||||
],
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"href": "/filter/deodorfilter/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_REF_21K|00146141|000B0020001513924100000011000000",
|
||||
"x.com.samsung.da.description": "TP1X_REF_21K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "531",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "260619",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "24102313, 2201030E",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"href": "/information/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/lock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.ado.voicecontrol": "Off",
|
||||
"x.com.samsung.da.ado.soundcontrol": "On",
|
||||
"href": "/status/lock/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.lockstatus"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": [
|
||||
"AIRFILTER_DISABLE"
|
||||
],
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"WINE_T_TOTRACK_[6]",
|
||||
"WINE_T_BOTTLE_RACK_[7]",
|
||||
"WINE_B_TOTRACK_[4]",
|
||||
"WINE_B_BOTTLE_RACK_[7]",
|
||||
"WINE_PRESENTATION_[6]",
|
||||
"WINE_BTM_BOTTLE_[20]",
|
||||
"WINE_PANTRY_BOTTLE_[5]"
|
||||
],
|
||||
"href": "/mode/vs/0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true",
|
||||
"href": "/realtimenotiforclient/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/runningmode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.runningMode": 0,
|
||||
"href": "/runningmode/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/selfcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedActions": [
|
||||
"Start"
|
||||
],
|
||||
"x.com.samsung.da.status": "Ready",
|
||||
"x.com.samsung.da.result": "Success",
|
||||
"x.com.samsung.da.error": [
|
||||
"ErrorCode_None"
|
||||
],
|
||||
"href": "/selfcheck/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/winecellar/top/vs/0",
|
||||
"rep": {
|
||||
"temperature": 13.0,
|
||||
"range": [
|
||||
4.0,
|
||||
18.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/winecellar/top/vs/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/desired/winecellar/bottom/vs/0",
|
||||
"rep": {
|
||||
"temperature": 7.0,
|
||||
"range": [
|
||||
4.0,
|
||||
18.0
|
||||
],
|
||||
"units": "C",
|
||||
"href": "/temperature/desired/winecellar/bottom/vs/0",
|
||||
"rt": [
|
||||
"oic.r.temperature"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "WineCellar-top",
|
||||
"x.com.samsung.da.desired": "13",
|
||||
"x.com.samsung.da.current": "13",
|
||||
"x.com.samsung.da.maximum": "18",
|
||||
"x.com.samsung.da.minimum": "4",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "WineCellar-bottom",
|
||||
"x.com.samsung.da.desired": "7",
|
||||
"x.com.samsung.da.current": "7",
|
||||
"x.com.samsung.da.maximum": "18",
|
||||
"x.com.samsung.da.minimum": "4",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
],
|
||||
"href": "/temperatures/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/winecellar/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.tbl.revision": "1",
|
||||
"href": "/information/winecellar/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/winecellar/pantry/one/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.mode": "Fruit",
|
||||
"x.com.samsung.range": [
|
||||
"4",
|
||||
"13"
|
||||
],
|
||||
"x.com.samsung.da.room": "0x10",
|
||||
"x.com.samsung.da.name": "MULTI_PANTRY",
|
||||
"x.com.samsung.da.unit": "Celsius",
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"Processed_Meat",
|
||||
"Cheese",
|
||||
"Nuts",
|
||||
"Fruit",
|
||||
"Wine"
|
||||
],
|
||||
"href": "/status/winecellar/pantry/one/vs/0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "0",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"scheduledTime": "None",
|
||||
"swVersionInfo": {
|
||||
"platform": "Tizen Lite",
|
||||
"oneUiVersion": "7.0 Refrigerator",
|
||||
"osVersion": "4.0"
|
||||
},
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-RFWW-TP1-24-T4-RE2",
|
||||
"versions": [
|
||||
"20260619"
|
||||
],
|
||||
"visVersion": "260619"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "02900014614100146441",
|
||||
"versions": [
|
||||
"24102313",
|
||||
"2201030E"
|
||||
],
|
||||
"visVersion": "241023"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connectionconfig/vs/0",
|
||||
"rep": {
|
||||
"autoReconnectionMinVersion": "1.0",
|
||||
"autoReconnection": "true",
|
||||
"autoReconnectionProtocolType": [
|
||||
"helper_hotspot",
|
||||
"ble_ocf"
|
||||
],
|
||||
"supportedWiFiAuthType": [
|
||||
"OPEN",
|
||||
"WEP",
|
||||
"WPA-PSK",
|
||||
"WPA2-PSK",
|
||||
"SAE"
|
||||
],
|
||||
"supportedWiFiCryptoType": [
|
||||
"TKIP",
|
||||
"AES",
|
||||
"WEP-64",
|
||||
"WEP-128"
|
||||
],
|
||||
"supportedWiFiFreq": [
|
||||
"2.4G"
|
||||
],
|
||||
"calmConnectionCare": {
|
||||
"version": "1.0",
|
||||
"role": [
|
||||
"things"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**",
|
||||
"connectedApSsid": "Home_IoT"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/dginformation/vs/0",
|
||||
"rep": {
|
||||
"enrolmentstatus": "Unknown",
|
||||
"devicestate": "Unknown",
|
||||
"lockstatus": "Unknown",
|
||||
"nextduedate": "",
|
||||
"workingminutes": 0,
|
||||
"paymentinfo": {
|
||||
"emiplan": "Unknown",
|
||||
"currency": "Unknown",
|
||||
"totalemi": 0,
|
||||
"totalemipaid": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "10",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "74800",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.cumulativeDate": "1786287600",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1786280400",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/course/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_0167",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"Course_87",
|
||||
"LaundryOutTime_0",
|
||||
"SeamlessControl_Enable",
|
||||
"KidsLockBypass_On",
|
||||
"WashingTimes_0",
|
||||
"DrumCleanProposal_40",
|
||||
"DetergentOnce_0",
|
||||
"DetergentLeft_0",
|
||||
"DetergentBase_0",
|
||||
"DetergentAlarm_Off",
|
||||
"DetergentType_0",
|
||||
"DetergentTotal_0",
|
||||
"SoftenerOnce_0",
|
||||
"SoftenerLeft_0",
|
||||
"SoftenerBase_0",
|
||||
"SoftenerAlarm_Off",
|
||||
"SoftenerType_0",
|
||||
"SoftenerTotal_0",
|
||||
"SpecialFunction_4",
|
||||
"AvailableDelayTime_74",
|
||||
"BubbleSoak_Off",
|
||||
"LaundryPlannerUserSetTime_0",
|
||||
"ProgressTimeSet_421C20820437A2042C",
|
||||
"SendToDevice_On",
|
||||
"GMT_04",
|
||||
"PreWashSetting_Off",
|
||||
"IntensiveSetting_Off",
|
||||
"CloudCourse_0021550449284D134AA04C0035F004F005F0AC00",
|
||||
"CloudExtraCourse_0A5C286B2D0C55301A",
|
||||
"OneTimeCloudCourse_001F6B0449284D134AA04C0035F004F005F0AC00",
|
||||
"BubbleSoakSet_00F0F0F00000F0F000000000F000",
|
||||
"EnergyLevelSet_050304040501050404030201020401",
|
||||
"MostUsed_1B841E923FA67F00000000000000",
|
||||
"PreWashAvailableSet_F0F0F0F00000F0F000F0F000F000",
|
||||
"IntensiveAvailableSet_F0F0F0F00000F0F000F0F000F000",
|
||||
"TextureLevel_None",
|
||||
"SavingModeCondition_010102151C1BA0961C8F251C0A061A207F5C55656B0C2D3034030149035E2830",
|
||||
"SavingMode_Off",
|
||||
"WelcomeLighting_1",
|
||||
"SupportedWelcomeLighting_000102",
|
||||
"GeoFenceAlarm",
|
||||
"UsagesDB_ok",
|
||||
"EnergyKW_396",
|
||||
"DrumCleanLog_2025-08-18T14:56:34|2025-11-02T21:08:39|2026-01-19T13:00:50|2026-03-23T12:10:12|2026-06-27T11:07:22|2026-08-09T10:13:44",
|
||||
"TimeSync_NotSupported"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F088000913FA67F7485209204A5208780009000A00006841E930FA30F7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F34841E923FA67FA0811E933FA33F"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/cycleinterface/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.remainingTime": "01:14: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",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/0",
|
||||
"rep": {
|
||||
"currentMachineState": "**REDACTED**",
|
||||
"machineStates": "**REDACTED**",
|
||||
"jobStates": [
|
||||
"None",
|
||||
"Wash",
|
||||
"Rinse",
|
||||
"Spin",
|
||||
"Finish"
|
||||
],
|
||||
"currentJobState": "None",
|
||||
"remainingTime": "01:14:00",
|
||||
"progressPercentage": "1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20348141|20010002001711124ACB020200080000",
|
||||
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B06C",
|
||||
"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": "WF1",
|
||||
"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_WM_TP1_21_COMMON|20348141|20010002001711124ACB020200080000",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02986A260118(A182)",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Firmware_1_DB_20348141240110090FFFFF203495412406195503FFFF(01672034814120349541_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "03481A24011009,03495A24061955",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Firmware_2_DB_2025984624053003032FFFFFFFFFFFFFFFFFFFFFFFFE(016720259846FFFFFFFF_30000000)(FileDown:0)(Type:0)",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "02598F24053003,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+02:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.waterTemperature": "30",
|
||||
"x.com.samsung.da.supportedWaterTemperature": [
|
||||
"None",
|
||||
"Cold",
|
||||
"20",
|
||||
"30",
|
||||
"40",
|
||||
"60",
|
||||
"90"
|
||||
],
|
||||
"x.com.samsung.da.spinLevel": "800",
|
||||
"x.com.samsung.da.supportedSpinLevel": [
|
||||
"RinseHold",
|
||||
"NoSpin",
|
||||
"400",
|
||||
"800",
|
||||
"1000",
|
||||
"1200",
|
||||
"1400"
|
||||
],
|
||||
"x.com.samsung.da.rinseCycles": "3",
|
||||
"x.com.samsung.da.supportedRinseCycles": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/st/washercourse/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.st.washerMode": "Table_02_Course_87",
|
||||
"x.com.samsung.da.st.courseTable": "Table_02"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/water/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeWater": "7512800"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/setting/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedSetLanguage": [
|
||||
"ko_KR",
|
||||
"en_US"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/editcourse/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/wm/setinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.isModelSettingWithoutSC": "true",
|
||||
"x.com.samsung.da.isModelSettingPowerOnOff": "false",
|
||||
"x.com.samsung.da.modelCode": "M(None),W(WW8XCGC04AAEEG)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wm/jobbeginingstatus/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentStatus": "None"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-03-04",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "DA_WM_TP1_21_COMMON",
|
||||
"versions": [
|
||||
"30260118"
|
||||
],
|
||||
"visVersion": "260118"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "01672034814120349541",
|
||||
"versions": [
|
||||
"24011009",
|
||||
"24061955"
|
||||
],
|
||||
"visVersion": "240619"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "016720259846FFFFFFFF",
|
||||
"versions": [
|
||||
"24053003",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240530"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "016720259846FFFFFFFF",
|
||||
"versions": [
|
||||
"24053003",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "240530"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/buzzersound/vs/0",
|
||||
"rep": {
|
||||
"supportedBuzzerSound": [
|
||||
"Volume_Off",
|
||||
"Volume_Low",
|
||||
"Volume_Med",
|
||||
"Volume_High"
|
||||
],
|
||||
"setBuzzerSound": "Volume_Low",
|
||||
"supportedFinishSound": [
|
||||
"FinishSound_1",
|
||||
"FinishSound_2",
|
||||
"FinishSound_3"
|
||||
],
|
||||
"setFinishSound": "FinishSound_2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.countryCode": "DE"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/Berlin",
|
||||
"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**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,6 +17,8 @@ from custom_components.localthings.const import (
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEARN_MODES,
|
||||
CONF_LEARNED_MODES,
|
||||
CONF_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -75,6 +77,41 @@ async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None:
|
||||
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
|
||||
|
||||
|
||||
async def test_setup_normalizes_messy_pasted_pem(hass: HomeAssistant, mock_probe) -> None:
|
||||
"""A PEM with a leading UTF-8 BOM, CRLF line endings, and a stray blank
|
||||
line -- the kind a Windows text editor's copy produces, as opposed to a
|
||||
`type` dump (issue #291) -- must still be accepted and stored in its
|
||||
normalized form, not rejected with an opaque InvalidHeader."""
|
||||
messy_cert = "\ufeff" + MOCK_CA_CERT_PEM.replace("\n", "\r\n") + "\r\n\r\n"
|
||||
messy_key = "\ufeff" + MOCK_CA_KEY_PEM.replace("\n", "\r\n")
|
||||
|
||||
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_HOST: MOCK_HOST,
|
||||
CONF_CA_CERT_PEM: messy_cert,
|
||||
CONF_CA_KEY_PEM: messy_key,
|
||||
},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
|
||||
assert result["data"][CONF_CA_KEY_PEM] == MOCK_CA_KEY_PEM
|
||||
|
||||
|
||||
def test_normalize_pem_strips_bom_crlf_and_blank_lines() -> None:
|
||||
"""Unit-level check of the helper itself, isolated from the flow."""
|
||||
from custom_components.localthings.config_flow import _normalize_pem
|
||||
|
||||
messy = "\ufeff-----BEGIN CERTIFICATE-----\r\nTEST-CA\r\n\r\n-----END CERTIFICATE-----\r\n"
|
||||
assert _normalize_pem(messy) == (
|
||||
"-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
)
|
||||
# A clean PEM (the `type`-dump case) passes through unchanged.
|
||||
clean = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
|
||||
assert _normalize_pem(clean) == clean
|
||||
|
||||
|
||||
def test_order_candidates_prefers_known_ports() -> None:
|
||||
"""Live ports are ordered with the historically known DTLS ports first,
|
||||
then the rest ascending."""
|
||||
@@ -900,7 +937,11 @@ async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
|
||||
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
assert set(cast(Iterable[str], result["menu_options"])) == {"settings", "debug_write"}
|
||||
assert set(cast(Iterable[str], result["menu_options"])) == {
|
||||
"settings",
|
||||
"forget_learned_modes",
|
||||
"debug_write",
|
||||
}
|
||||
|
||||
|
||||
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
|
||||
@@ -939,6 +980,72 @@ async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
|
||||
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
|
||||
|
||||
|
||||
async def test_learned_modes_option_defaults_to_on(hass: HomeAssistant) -> None:
|
||||
"""Issue #327's remembering is on by default -- a device that hides a
|
||||
mode it's in should just work, not need the option found first."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "settings"}
|
||||
)
|
||||
|
||||
data_schema = result["data_schema"]
|
||||
assert data_schema is not None
|
||||
assert data_schema({})[CONF_LEARN_MODES] is True
|
||||
|
||||
|
||||
async def test_learned_modes_option_can_be_turned_off(hass: HomeAssistant) -> None:
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "settings"}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_BYPASS_REMOTE_CONTROL: False, CONF_LEARN_MODES: False},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.options[CONF_LEARN_MODES] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("stored", "listed"),
|
||||
[
|
||||
({"/mode/convenient/vs/0": ["Quiet"]}, "Quiet"),
|
||||
# Malformed -- nothing writes this shape, but a hand-edited
|
||||
# .storage can hold it, and this step is the one screen that can
|
||||
# clear it, so it must not be the one screen that trips over it.
|
||||
({"/mode/convenient/vs/0": None}, "(none)"),
|
||||
],
|
||||
)
|
||||
async def test_forget_learned_modes_clears_the_entry(hass: HomeAssistant, stored, listed) -> None:
|
||||
"""The reset step works on an unloaded entry too, by dropping the
|
||||
persisted copy directly -- that's all a reload would restore from."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, CONF_LEARNED_MODES: stored},
|
||||
unique_id=f"localthings_{MOCK_SERIAL}",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"], user_input={"next_step_id": "forget_learned_modes"}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["description_placeholders"] == {"codes": listed}
|
||||
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"], user_input={})
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.data[CONF_LEARNED_MODES] == {}
|
||||
|
||||
|
||||
async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None:
|
||||
"""Reopening the form shows the currently-saved choice as the default,
|
||||
not always False."""
|
||||
@@ -1001,9 +1108,10 @@ async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
hass: HomeAssistant,
|
||||
mock_coordinator_session,
|
||||
) -> None:
|
||||
"""Picking an href, then submitting a payload, drives
|
||||
coordinator.async_raw_write and lands on the result menu with the
|
||||
device's response."""
|
||||
"""Picking an href, then submitting a payload, calls the write_resource
|
||||
service (issue #300) -- which drives
|
||||
coordinator.async_raw_write_sequence -- and lands on the result menu
|
||||
with the device's response."""
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
@@ -1021,8 +1129,20 @@ async def test_options_flow_debug_edit_writes_and_shows_result(
|
||||
assert result["step_id"] == "debug_edit"
|
||||
|
||||
with patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
|
||||
return_value=(0x44, {"a": 1}),
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write_sequence",
|
||||
return_value={
|
||||
"results": [
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"code": "2.04",
|
||||
"raw_code": 0x44,
|
||||
"accepted": True,
|
||||
"before": {},
|
||||
"after": {"a": 1},
|
||||
"changed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
@@ -1088,8 +1208,20 @@ async def test_options_flow_finish_preserves_existing_options(
|
||||
user_input={"href": "/washer/vs/0"},
|
||||
)
|
||||
with patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
|
||||
return_value=(0x44, {"a": 1}),
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write_sequence",
|
||||
return_value={
|
||||
"results": [
|
||||
{
|
||||
"href": "/washer/vs/0",
|
||||
"code": "2.04",
|
||||
"raw_code": 0x44,
|
||||
"accepted": True,
|
||||
"before": {},
|
||||
"after": {"a": 1},
|
||||
"changed": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -21,6 +22,7 @@ from custom_components.localthings.const import (
|
||||
SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from custom_components.localthings.coordinator import (
|
||||
_RECOVERY_RETRY_S,
|
||||
LocalThingsCoordinator,
|
||||
_local_source_port,
|
||||
)
|
||||
@@ -30,7 +32,7 @@ from custom_components.localthings.registry.capabilities.common import (
|
||||
remote_control_required_for_write,
|
||||
)
|
||||
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL
|
||||
from .conftest import ENTRY_DATA, MOCK_MODEL, MOCK_SERIAL, FakeObserveSession
|
||||
from .conftest import _load_fridge_resources as _load_fridge
|
||||
|
||||
|
||||
@@ -478,6 +480,57 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll(
|
||||
assert coordinator._observe.mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_total_poll_failure_downgrades_observe_mode_to_poll(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources
|
||||
) -> None:
|
||||
"""A device that drops off the network entirely -- both the poll and its
|
||||
reconnect retry fail -- must not leave the connection-mode sensor
|
||||
reporting 'Push' forever (issue #287). Only the *successful* reconnect
|
||||
branch used to touch observe mode (see
|
||||
test_reconnect_while_observe_mode_downgrades_to_poll); this covers the
|
||||
branch where the device stays unreachable."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
last_notify_ts = coordinator._observe._last_notify_ts
|
||||
assert last_notify_ts is not None
|
||||
coordinator._observe._last_notify_ts = last_notify_ts - (PUSH_HEALTH_WINDOW_S + 1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
|
||||
side_effect=[RuntimeError("connection lost"), RuntimeError("still lost")],
|
||||
),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
await coordinator.async_request_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The update still "succeeds" with the last-known snapshot (issue #254's
|
||||
# degraded-data path) -- but the connection mode must reflect reality
|
||||
# now, not the stale OBSERVE state from before the outage.
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
assert coordinator.last_update_success is True
|
||||
|
||||
|
||||
async def test_poll_timeout_skips_reconnect_when_push_is_healthy(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -691,6 +744,149 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately(
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_discards_stale_commit_after_session_swap(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""A reconnect (the poll path's own, or a command's retry) can swap
|
||||
self._session while this attempt's grace wait is in flight -- it runs
|
||||
without holding _session_lock precisely so a write isn't blocked behind
|
||||
it (issue #294). Committing observe mode against the now-stale local
|
||||
`sess` reference would claim "Push" on a session that's already gone,
|
||||
with nothing left to notice -- the identity re-check under the lock
|
||||
right before committing must catch this and abandon instead.
|
||||
|
||||
The new session is never-tried, though, not just abandoned: it must
|
||||
flag an immediate resubscribe rather than let _last_observe_attempt_ts
|
||||
(stamped for the now-abandoned attempt) throttle it for up to
|
||||
_RECOVERY_RETRY_S.
|
||||
|
||||
Simulates the swap from inside await_observe_notifies itself rather
|
||||
than via real concurrency: subscribe_hrefs (and its lock) has already
|
||||
returned by the time that call runs, so this lands exactly in the
|
||||
window the identity check exists to cover, deterministically."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator._resubscribe_due is False
|
||||
|
||||
other = FakeObserveSession()
|
||||
|
||||
def _swap_session_mid_wait(subscribed, grace_period_s, success_fraction=None):
|
||||
coordinator._session = other # ty: ignore[invalid-assignment]
|
||||
return True
|
||||
|
||||
with patch.object(
|
||||
coordinator._observe, "await_observe_notifies", side_effect=_swap_session_mid_wait
|
||||
):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
assert coordinator._observe.subscribed_hrefs == set()
|
||||
assert coordinator._observe._refresh_thread is None
|
||||
assert coordinator._resubscribe_due is True
|
||||
|
||||
|
||||
async def test_maybe_retry_observe_mode_uses_most_recent_attempt_not_just_mode_change(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""_set_mode only stamps last_mode_change_ts on an actual transition,
|
||||
so a device that never successfully enters observe mode leaves that
|
||||
timestamp stuck at construction time forever -- a failed attempt keeps
|
||||
calling _set_mode(MODE_POLL) while already in MODE_POLL, a no-op.
|
||||
Gating solely on that timestamp would make the 600s throttle open once
|
||||
and then never close again, re-attempting (and paying the subscribe
|
||||
burst) on every single poll cycle instead of every _RECOVERY_RETRY_S."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.observe_mode == MODE_POLL # never notified during setup
|
||||
|
||||
# Simulate exactly the scenario above: mode_change_ts is old (as it
|
||||
# would be forever, for a device that never gets push), but an attempt
|
||||
# really did just run.
|
||||
coordinator._observe.last_mode_change_ts = time.monotonic() - _RECOVERY_RETRY_S - 1
|
||||
coordinator._last_observe_attempt_ts = time.monotonic()
|
||||
|
||||
with patch.object(fake, "subscribe") as mock_subscribe:
|
||||
await coordinator._maybe_retry_observe_mode()
|
||||
|
||||
mock_subscribe.assert_not_called()
|
||||
|
||||
|
||||
async def test_maybe_retry_observe_mode_also_respects_a_mode_change_outside_an_attempt(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The mirror of the case above: last_mode_change_ts can be the more
|
||||
recent of the two as well, e.g. right after the poll or command path's
|
||||
own downgrade (neither goes through _attempt_observe_mode, so neither
|
||||
stamps _last_observe_attempt_ts). Dropping last_mode_change_ts from the
|
||||
max() would let a device that was *just* downgraded get re-attempted
|
||||
immediately instead of respecting _RECOVERY_RETRY_S."""
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
coordinator._last_observe_attempt_ts = time.monotonic() - _RECOVERY_RETRY_S - 1
|
||||
coordinator._observe.last_mode_change_ts = time.monotonic()
|
||||
|
||||
with patch.object(fake, "subscribe") as mock_subscribe:
|
||||
await coordinator._maybe_retry_observe_mode()
|
||||
|
||||
mock_subscribe.assert_not_called()
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_releases_lock_before_the_grace_wait(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The subscribe burst holds _session_lock (it touches the session);
|
||||
the grace wait after it must not, or a command write could stall
|
||||
behind up to _OBSERVE_GRACE_PERIOD_S of an unrelated observe-mode-entry
|
||||
attempt (issue #294). By construction, subscribe_hrefs's own
|
||||
`async with self._session_lock:` has already exited by the time
|
||||
await_observe_notifies is even called -- checked here rather than
|
||||
inferred from timing."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
locked_during_wait = {"value": None}
|
||||
|
||||
def _check_lock(subscribed, grace_period_s, success_fraction=None):
|
||||
locked_during_wait["value"] = coordinator._session_lock.locked()
|
||||
return True
|
||||
|
||||
with patch.object(coordinator._observe, "await_observe_notifies", side_effect=_check_lock):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert locked_during_wait["value"] is False
|
||||
|
||||
|
||||
async def test_attempt_observe_mode_holds_lock_during_the_subscribe_burst(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""The other half of the split above: the subscribe burst does touch
|
||||
the session, so it must hold _session_lock -- that's what actually
|
||||
stops a concurrent close from landing mid-subscribe (issue #294)."""
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
locked_during_subscribe = {"value": None}
|
||||
real_subscribe_hrefs = coordinator._observe.subscribe_hrefs
|
||||
|
||||
def _check_lock(session, hrefs):
|
||||
locked_during_subscribe["value"] = coordinator._session_lock.locked()
|
||||
return real_subscribe_hrefs(session, hrefs)
|
||||
|
||||
with patch.object(coordinator._observe, "subscribe_hrefs", side_effect=_check_lock):
|
||||
await coordinator._attempt_observe_mode()
|
||||
|
||||
assert locked_during_subscribe["value"] is True
|
||||
|
||||
|
||||
async def test_sweep_mismatch_never_downgrades_a_live_observe_session(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
@@ -956,6 +1152,192 @@ async def test_send_command_survives_stale_confirm_poll(
|
||||
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
|
||||
|
||||
|
||||
async def test_send_command_reconnects_and_retries_after_socket_closed(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""A command lost to a session Samsung's firmware closed between polls
|
||||
must not just vanish (issue #294): `_do_put` failing once is now
|
||||
followed by a reconnect and a single retry, mirroring the poll path's
|
||||
own recovery in `_async_update_data`.
|
||||
|
||||
`mock_coordinator_observe_session` patches `_close_session` to a no-op,
|
||||
which would leave `self._session` never actually going `None` -- and
|
||||
with it, `_do_put`'s own `if self._session is None: self._connect_session()`
|
||||
guard never exercised, so a broken reconnect could still pass. Overridden
|
||||
here to actually drop the session, so the retry only succeeds if that
|
||||
guard really rebuilds it."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("socket closed")
|
||||
return (0x44, b"")
|
||||
|
||||
def _drop_session():
|
||||
coordinator._session = None
|
||||
|
||||
reconnects = {"n": 0}
|
||||
|
||||
def _reconnect():
|
||||
reconnects["n"] += 1
|
||||
coordinator._session = fake
|
||||
|
||||
with (
|
||||
patch.object(fake, "subscribe"),
|
||||
patch.object(coordinator, "_close_session", side_effect=_drop_session),
|
||||
patch.object(coordinator, "_connect_session", side_effect=_reconnect),
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert reconnects["n"] == 1
|
||||
|
||||
assert calls["n"] == 2
|
||||
assert coordinator._cache.get("/test/vs/0") == {"value": 5}
|
||||
|
||||
|
||||
async def test_send_command_raises_after_reconnect_retry_also_fails(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""If the command still fails on the reconnected session, the user must
|
||||
see it -- previously this was swallowed into a log line with no
|
||||
feedback at all (issue #294).
|
||||
|
||||
Also covers a sibling bug the fix for that same issue introduced: the
|
||||
session is closed the moment the first attempt fails, so any OBSERVE
|
||||
subscriptions on it are already dead regardless of whether the retry
|
||||
that follows succeeds -- a failed retry must still downgrade mode, or
|
||||
it's left claiming "Push" on a session that no longer exists."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
raise ConnectionError("socket closed")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
pytest.raises(HomeAssistantError),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_send_command_reconnect_downgrades_observe_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""A command's own successful reconnect hands back a session with zero
|
||||
OBSERVE registrations too, same as the poll path's reconnect -- must
|
||||
downgrade the same way and flag a resubscribe, or observe mode stays
|
||||
claimed against a session the write just replaced underneath it
|
||||
(issue #294)."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
|
||||
hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs
|
||||
|
||||
fake.notify_on_subscribe = {"notified": True}
|
||||
entered = await hass.async_add_executor_job(
|
||||
coordinator._observe.try_enter_observe_mode,
|
||||
fake,
|
||||
hrefs,
|
||||
0.02,
|
||||
0.8,
|
||||
)
|
||||
assert entered is True
|
||||
assert coordinator.observe_mode == MODE_OBSERVE
|
||||
|
||||
def _write_fn(payload, rep, href=None):
|
||||
return (["test", "vs", "0"], {"value": payload})
|
||||
|
||||
desc = NumberDesc(key="test", field="value", write_fn=_write_fn)
|
||||
bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _post(*args, **kwargs):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise ConnectionError("socket closed")
|
||||
return (0x44, b"")
|
||||
|
||||
with (
|
||||
patch.object(fake, "subscribe") as mock_subscribe,
|
||||
patch(
|
||||
"custom_components.localthings.coordinator.asyncio.sleep",
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
fake.post = _post
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
# _resubscribe_due is consumed by this same call's own trailing
|
||||
# refresh (async_request_refresh is awaited, not fire-and-forget),
|
||||
# so the visible effect is a resubscribe attempt, not a lingering
|
||||
# flag value to assert on afterward.
|
||||
assert mock_subscribe.called
|
||||
|
||||
assert coordinator.observe_mode == MODE_POLL
|
||||
|
||||
|
||||
async def test_second_write_to_same_href_lands_during_first_writes_settle_window(
|
||||
hass: HomeAssistant,
|
||||
mock_entry,
|
||||
|
||||
@@ -58,6 +58,49 @@ def test_apply_merges_partial_update_onto_prior_rep():
|
||||
assert cached["x.com.samsung.da.supportedOptions"] == ["CV_FDR_WINE", "CV_FDR_MEAT"]
|
||||
|
||||
|
||||
def test_apply_fully_replaces_alarms_href_instead_of_merging():
|
||||
"""Regression test for issue #348: /alarms/vs/0's `items` array is a
|
||||
complete snapshot of every currently-active alarm, not a partial field
|
||||
update like /mode/vs/0 (issue #27). A washer's board reports a cleared
|
||||
alarm by omitting `items` entirely -- a live read_resource GET showed
|
||||
`{}` -- so merging that onto the prior rep (as every other href does)
|
||||
left the stale ErrorCode_DC entry in the cache forever. This must
|
||||
instead behave like a full replace: the empty rep wins outright."""
|
||||
mgr = _manager()
|
||||
active = {
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.code": "ErrorCode_DC", "x.com.samsung.da.state": "Created"}
|
||||
]
|
||||
}
|
||||
mgr.apply("/alarms/vs/0", active, source="poll")
|
||||
assert mgr.cache.get("/alarms/vs/0") == active
|
||||
|
||||
cleared = mgr.apply("/alarms/vs/0", {}, source="poll")
|
||||
|
||||
assert cleared is True
|
||||
assert mgr.cache.get("/alarms/vs/0") == {}
|
||||
|
||||
|
||||
def test_apply_fully_replaces_alarms_href_for_subdevice_shapes():
|
||||
"""The same full-replace behavior must hold for both hrefs
|
||||
`Subdevice.to_actual` can produce: an indexed subdevice renumbers only
|
||||
the trailing '0' (/alarms/vs/1), and a prefixed one prepends a UUID
|
||||
(/<uuid>/alarms/vs/0) -- neither ever touches the 'alarms/vs' stem
|
||||
itself (registry/subdevices.py)."""
|
||||
for href in ("/alarms/vs/1", "/6c2dff6d-ee5c-dad1-6a5e-000000000001/alarms/vs/0"):
|
||||
mgr = _manager()
|
||||
mgr.apply(
|
||||
href,
|
||||
{"x.com.samsung.da.items": [{"x.com.samsung.da.code": "ErrorCode_UB"}]},
|
||||
source="poll",
|
||||
)
|
||||
|
||||
cleared = mgr.apply(href, {}, source="poll")
|
||||
|
||||
assert cleared is True
|
||||
assert mgr.cache.get(href) == {}
|
||||
|
||||
|
||||
def test_apply_drops_update_during_settle_window():
|
||||
mgr = _manager()
|
||||
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""The particulate sensors must declare a state_class so Home Assistant keeps
|
||||
long-term statistics for them; the graded readings must not.
|
||||
|
||||
Without a state_class a sensor only lives in the short-term recorder history
|
||||
and is dropped at the next purge, so a long-range air-quality graph is not
|
||||
possible -- that is the bug this guards against reappearing.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.capabilities import air_purifier
|
||||
from custom_components.localthings.registry.entities import SensorDesc
|
||||
|
||||
PARTICULATE = ("dust", "fine_dust", "super_fine_dust")
|
||||
GRADED = ("odor", "clean_level")
|
||||
|
||||
|
||||
def _desc(key):
|
||||
return next(d for d in air_purifier.AIR_QUALITY.entities if d.key == key)
|
||||
|
||||
|
||||
def test_particulate_sensors_record_long_term_statistics():
|
||||
for key in PARTICULATE:
|
||||
assert _desc(key).state_class == "measurement", key
|
||||
|
||||
|
||||
def test_graded_sensors_are_left_without_a_state_class():
|
||||
"""Odor and CleanLevel read 0-2 on every fixture -- graded indices, not
|
||||
concentrations. Whether averaging a grade is meaningful is a separate
|
||||
call, so they stay unstamped rather than being guessed into statistics."""
|
||||
for key in GRADED:
|
||||
assert _desc(key).state_class is None, key
|
||||
|
||||
|
||||
def test_no_unit_or_device_class_is_asserted():
|
||||
"""state_class alone makes the series recordable. pm1/pm25/pm10 with
|
||||
µg/m³ would additionally assert the reading is a mass concentration,
|
||||
which no dump states."""
|
||||
for key in PARTICULATE + GRADED:
|
||||
desc = _desc(key)
|
||||
assert desc.unit is None, key
|
||||
assert desc.device_class is None, key
|
||||
|
||||
|
||||
def test_state_class_comes_from_the_shared_tuples_fourth_column():
|
||||
"""The rows carry their own state_class rather than a parallel lookup, so
|
||||
a new sensor can't be added here without deciding the question."""
|
||||
for row in air_purifier._AIR_QUALITY_SENSORS:
|
||||
assert len(row) == 4, row
|
||||
assert row[3] in ("measurement", None), row
|
||||
|
||||
|
||||
def test_air_monitor_keeps_stamping_every_shared_sensor():
|
||||
"""air_monitor imports _AIR_QUALITY_SENSORS and discards the fourth column
|
||||
on purpose: that board (issue #210) has stamped all five as `measurement`
|
||||
since it was added, and consuming the column would silently drop long-term
|
||||
statistics for Odor/CleanLevel there. Guards the import end to end and the
|
||||
deliberate divergence together."""
|
||||
from custom_components.localthings.registry.capabilities import air_monitor
|
||||
|
||||
assert air_monitor.SENSORS.href == "/sensors/vs/0"
|
||||
for key in PARTICULATE + GRADED:
|
||||
desc = next(
|
||||
d for d in air_monitor.SENSORS.entities if d.key == key and isinstance(d, SensorDesc)
|
||||
)
|
||||
assert desc.state_class == "measurement", key
|
||||
|
||||
|
||||
def test_every_air_quality_sensor_still_reads_a_plain_int():
|
||||
"""A state_class is only honoured for a numeric state, so the value
|
||||
contract this depends on is asserted here too."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("air_purifier")
|
||||
rep = resources["/sensors/vs/0"]
|
||||
for key in PARTICULATE + GRADED:
|
||||
value = _desc(key).value_fn(rep["x.com.samsung.da.items"])
|
||||
assert isinstance(value, int), (key, value)
|
||||
@@ -59,5 +59,5 @@ def test_air_quality_and_filter_sensors_present():
|
||||
assert state["dust"] == 10
|
||||
assert state["fine_dust"] == 9
|
||||
assert state["odor"] == 1
|
||||
assert state["hepa_filter_usage"] == 0
|
||||
assert state["hepa_filter_usage"] == 30
|
||||
assert state["hepa_filter_status"] == "normal"
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for the AILP_DA-AC-FAC-02011_0000 air conditioner (issue #319).
|
||||
|
||||
This board resolves to the airconditioner registry two independent ways --
|
||||
modelNum's 'FAC' board token (for_device_by_model) and /oic/d's
|
||||
oic.d.airconditioner type (for_device_by_oic_type) agree -- and reports
|
||||
several resources the sibling TP1X_DA-AC-CAC-01001 board (issue #191) left
|
||||
as a documented gap: /display/vs/0 and the /settings/sound/* trio (now
|
||||
shared with air_purifier.py's identical shapes), plus two genuinely new
|
||||
hrefs (/csi/absenceclean/vs/0, /csi/energysaving/vs/0).
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.entity import _is_included
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
for_device_by_model,
|
||||
for_device_by_oic_type,
|
||||
resolve,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
_DEVICE_TYPES = ("oic.wk.d", "oic.d.airconditioner")
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
"""Minimal stand-in for entity._is_included's coordinator dependency --
|
||||
same shape as test_entity.py's own fake, kept local rather than shared
|
||||
across test modules."""
|
||||
|
||||
def __init__(self, last_resources):
|
||||
self.last_resources = last_resources
|
||||
|
||||
def canonical_resources(self, subdevice):
|
||||
return self.last_resources
|
||||
|
||||
|
||||
def _resources():
|
||||
return _load_device("airconditioner_ailp_fac")
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _resources()
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_oic_device_type_resolves_to_airconditioner_registry():
|
||||
reg = for_device_by_oic_type(_DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "airconditioner"
|
||||
|
||||
|
||||
def test_board_token_resolves_to_airconditioner_registry():
|
||||
"""'FAC' is a real _BOARD_TOKEN_TO_KEY entry -- for_device_by_model
|
||||
alone (no device_types) already resolves this board correctly, same as
|
||||
for_device_by_oic_type above; resolve()'s device_types-agreement isn't
|
||||
covering an otherwise-unreachable path."""
|
||||
resources = _resources()
|
||||
info = resources["/information/vs/0"]
|
||||
reg = for_device_by_model(
|
||||
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
|
||||
)
|
||||
assert reg is not None and reg.name == "airconditioner"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
resources = _resources()
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_sound_mode_registers_despite_no_live_mode_value():
|
||||
"""This dump has no current `mode` value yet, only supportedModes
|
||||
(mute/tone/voice). entity.py's default field-presence gate would
|
||||
otherwise keep the select from ever being created in HA even though
|
||||
adapter.flatten() (checked below) has no such gate and would look
|
||||
bound regardless -- exercise the real _is_included gate, not just
|
||||
flatten(), so a regression here can't hide behind that gap again."""
|
||||
bound, resources = _bound()
|
||||
entity = next(b for b in bound if b.desc.key == "sound_mode")
|
||||
assert entity.desc.options_field == "supportedModes"
|
||||
coord = cast(LocalThingsCoordinator, _FakeCoordinator(resources))
|
||||
assert _is_included(entity, coord) is True
|
||||
state = flatten(bound, resources)
|
||||
assert "sound_mode" in state
|
||||
assert state["sound_mode"] is None
|
||||
|
||||
|
||||
def test_absence_clean_and_energy_saving_bound():
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["absence_clean"] is True
|
||||
assert state["energy_saving_mode"] == "Off_100"
|
||||
assert state["energy_saving_state"] == "Off"
|
||||
|
||||
|
||||
def test_sound_volume_self_gates_off_without_max_level():
|
||||
"""This board's /settings/sound/volume/vs/0 reports minLevel/resolution
|
||||
but no maxLevel -- air_purifier.SOUND_VOLUME's exists_fn should keep it
|
||||
unbound-but-covered rather than shipping a min=0/max=0 slider."""
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert "sound_volume" not in state
|
||||
@@ -64,6 +64,9 @@ class _FakeCoordinator:
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
def learned_modes(self, href):
|
||||
return []
|
||||
|
||||
|
||||
def _discover(resources, registry=airconditioner.REGISTRY):
|
||||
unbound = []
|
||||
@@ -217,6 +220,70 @@ def _desc(resources, key):
|
||||
return None
|
||||
|
||||
|
||||
def test_good_sleep_is_hours_while_the_token_counts_half_hours():
|
||||
"""The appliance's own app pairs its duration picker with the values it sends
|
||||
one to one -- 0:30 -> 1, 1:00 -> 2, 3:00 -> 6, 12:00 -> 24 -- so the token is
|
||||
half hours and the entity, which is in hours, has to halve and double. The
|
||||
fixture's own Sleep_0 is the one value that reads the same either way, hence
|
||||
the injected token here."""
|
||||
resources = _load_device(FIXTURE)
|
||||
mode = resources["/mode/vs/0"]
|
||||
mode["x.com.samsung.da.options"] = [
|
||||
option for option in mode["x.com.samsung.da.options"] if option != "Sleep_0"
|
||||
] + ["Sleep_5"]
|
||||
bound, _ = _discover(resources)
|
||||
assert flatten(bound, resources)["good_sleep"] == 2.5
|
||||
|
||||
desc = _desc(resources, "good_sleep")
|
||||
assert (desc.native_max, desc.step) == (12, 0.5)
|
||||
assert desc.write_fn(2.5, {}) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["Comode_Sleep", "Sleep_5"]},
|
||||
)
|
||||
# 12 hours is the app's maximum and has to be reachable -- it was not while
|
||||
# the token was published as hours.
|
||||
assert desc.write_fn(12, {})[1]["x.com.samsung.da.options"] == ["Comode_Sleep", "Sleep_24"]
|
||||
|
||||
|
||||
def _options(rep_options):
|
||||
return {"x.com.samsung.da.options": list(rep_options)}
|
||||
|
||||
|
||||
def test_good_sleep_write_carries_the_mode_token_the_duration_belongs_to():
|
||||
"""`Sleep_<n>` on its own does nothing. Measured on an ARTIK051_KRAC_18K:
|
||||
writing `["Sleep_4"]` was answered 2.04 Changed and the token still read
|
||||
`Sleep_0` at +8s and +45s, while `["Comode_Sleep", "Sleep_4"]` held. The
|
||||
duration is a parameter of the mode, so both go in one write -- which is
|
||||
also the only form the appliance's own app sends."""
|
||||
desc = _desc(_load_device(FIXTURE), "good_sleep")
|
||||
|
||||
assert desc.write_fn(2, _options(["Comode_Off", "Sleep_0"])) == (
|
||||
["mode", "vs", "0"],
|
||||
{"x.com.samsung.da.options": ["Comode_Sleep", "Sleep_4"]},
|
||||
)
|
||||
# Off means leaving the mode as well as zeroing the duration.
|
||||
assert desc.write_fn(0, _options(["Comode_Sleep", "Sleep_4"]))[1] == _options(
|
||||
["Comode_Off", "Sleep_0"]
|
||||
)
|
||||
|
||||
|
||||
def test_good_sleep_and_nano_wind_share_one_token():
|
||||
"""Nano wind and Good Sleep are one Comode_ slot, so running both is
|
||||
Comode_NanoSleep -- the board produces that code by itself when nano is
|
||||
asked for while the timer runs. Turning the timer off then has to leave nano
|
||||
running rather than switching the mode off entirely, which is how the app
|
||||
reads it back."""
|
||||
desc = _desc(_load_device(FIXTURE), "good_sleep")
|
||||
|
||||
for comode in ("Comode_Nano", "Comode_NanoSleep"):
|
||||
assert desc.write_fn(2, _options([comode, "Sleep_0"]))[1] == _options(
|
||||
["Comode_NanoSleep", "Sleep_4"]
|
||||
)
|
||||
assert desc.write_fn(0, _options(["Comode_NanoSleep", "Sleep_4"]))[1] == _options(
|
||||
["Comode_Nano", "Sleep_0"]
|
||||
)
|
||||
|
||||
|
||||
def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
|
||||
"""The interval FilterTime_ is measured against, offered by the app as a
|
||||
180/300/500/700 hour radio. All four were walked on hardware while watching
|
||||
@@ -361,17 +428,22 @@ def test_preset_comes_from_the_comode_token():
|
||||
resources = _load_device(FIXTURE)
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == "none" # Comode_Off in the fixture
|
||||
# Codes learned by driving this unit through its cloud integration and
|
||||
# reading the token back. They go through the same dynamic resolver as a
|
||||
# real convenient resource's supportedModes, so 'Nano' resolves to the
|
||||
# existing 'nano' preset -- already labelled WindFree in the catalog.
|
||||
# The codes go through the same dynamic resolver as a real convenient
|
||||
# resource's supportedModes, so 'Nano' resolves to the existing 'nano' preset
|
||||
# -- already labelled WindFree in the catalog. Which of them are offered
|
||||
# depends on the HVAC mode and on the unit's own capability bits; the fixture
|
||||
# is in Cool, and test_presets_follow_the_hvac_mode_and_the_capability_bits
|
||||
# covers every mode.
|
||||
assert entity.preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"speed",
|
||||
"2step",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
"smart",
|
||||
"sleep",
|
||||
"nanosleep",
|
||||
]
|
||||
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
@@ -391,6 +463,252 @@ async def test_preset_write_uses_the_token_path():
|
||||
assert coordinator.commands[-1][1] == ("preset_legacy", "Nano")
|
||||
|
||||
|
||||
def test_the_sleep_modes_are_presets_too():
|
||||
"""Good Sleep lives in the same Comode_ slot as the presets, so a unit
|
||||
running it reports a code that was not in the list -- and a preset_mode
|
||||
outside preset_modes is not a state HA allows. Verified against a live unit:
|
||||
with the board on Comode_Sleep, the entity reported preset_mode 'sleep'
|
||||
while preset_modes offered only none/nano/quiet/comfort/2step/speed."""
|
||||
resources = _load_device(FIXTURE)
|
||||
assert _climate(resources).preset_modes[-2:] == ["sleep", "nanosleep"]
|
||||
|
||||
for token, preset in (("Comode_Sleep", "sleep"), ("Comode_NanoSleep", "nanosleep")):
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
token if option.startswith("Comode_") else option for option in options
|
||||
]
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == preset
|
||||
assert preset in entity.preset_modes
|
||||
|
||||
|
||||
def test_boards_without_the_sleep_token_do_not_get_the_sleep_presets():
|
||||
"""The codes come with the Sleep_ token; a unit that has no such token has
|
||||
nothing to report them from."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
option for option in options if not option.startswith("Sleep_")
|
||||
]
|
||||
presets = _climate(resources).preset_modes
|
||||
assert "sleep" not in presets and "nanosleep" not in presets
|
||||
|
||||
|
||||
def _in_mode(mode, fixture=FIXTURE):
|
||||
resources = _load_device(fixture)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
return _climate(resources).preset_modes
|
||||
|
||||
|
||||
def test_presets_follow_the_hvac_mode_and_the_capability_bits():
|
||||
"""The fixture's own OptionCode_35882 / ExtendOptionCode_7 say: WindFree yes
|
||||
(eoc[31]), 18K yes (eoc[30]), Quiet yes (oc[10]), Turbo/Comfort in Heat yes
|
||||
(oc[12]), d'light no (oc[2]), Single User no (oc[3], oc[11]).
|
||||
|
||||
Its owner read the same lists off the remote and the appliance's app --
|
||||
2-Step/Fast Turbo/Comfort/Quiet/WindFree in Cool, Fast Turbo/Comfort/Quiet in
|
||||
Heat with WindFree impossible, WindFree alone in Dry and Fan, nothing in Auto
|
||||
beyond WindFree (which the app reaches by switching to Cool) -- and the
|
||||
appliance itself refused Comode_Nano in Auto and accepted it in Cool.
|
||||
"""
|
||||
assert _in_mode("Cool") == [
|
||||
"none",
|
||||
"nano",
|
||||
"speed",
|
||||
"2step",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"smart",
|
||||
"sleep",
|
||||
"nanosleep",
|
||||
]
|
||||
# Heat: no WindFree at all, and no Smart Saver (Cool-only).
|
||||
heat = _in_mode("Heat")
|
||||
assert "nano" not in heat
|
||||
assert "smart" not in heat
|
||||
assert [c for c in ("speed", "comfort", "quiet") if c in heat] == ["speed", "comfort", "quiet"]
|
||||
# Dry and Fan: WindFree is the only one, and it keeps the mode.
|
||||
for mode in ("Dry", "Wind"):
|
||||
assert _in_mode(mode) == ["none", "nano"]
|
||||
# Auto: WindFree only because this is an 18K model.
|
||||
assert _in_mode("Auto") == ["none", "nano"]
|
||||
# d'light Cool is a live rule and this unit's oc[2] denies it everywhere.
|
||||
for mode in ("Cool", "Heat", "Dry", "Wind", "Auto"):
|
||||
assert "dlightcool" not in _in_mode(mode), mode
|
||||
|
||||
|
||||
def test_a_board_with_only_the_old_map_keeps_the_unconditional_list():
|
||||
"""One map is not enough to judge by. `airconditioner_artik051_dongle_fac_18k`
|
||||
is a legacy board that publishes OptionCode and no ExtendOptionCode, so every
|
||||
eoc-gated rule would read None -- and None means "this board does not publish
|
||||
the map", not "the feature is absent". Deriving from it would have cost that
|
||||
unit WindFree in every mode, and left it with ['none'] alone in its own
|
||||
fixture mode.
|
||||
|
||||
Its OptionCode is also 521, three orders of magnitude below the RAC-class
|
||||
values these bit positions were read from, which is the second reason not to
|
||||
interpret it: the FAC and CAC families use the field differently.
|
||||
"""
|
||||
resources = _load_device("airconditioner_artik051_dongle_fac_18k")
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
assert any(o.startswith("OptionCode_") for o in options)
|
||||
assert not any(o.startswith("ExtendOptionCode_") for o in options)
|
||||
|
||||
baseline = ["none", "nano", "quiet", "comfort", "2step", "speed"]
|
||||
for mode in ("Auto", "Cool", "Heat", "Dry", "Wind"):
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
assert _climate(resources).preset_modes == baseline, mode
|
||||
|
||||
|
||||
def test_the_other_board_with_both_maps_still_derives():
|
||||
"""`airconditioner_artik051_krac_energy` is the same model as the fixture
|
||||
above with a different OptionCode (56378), and carries both maps -- so it
|
||||
stays on the derived path rather than the fallback."""
|
||||
presets = _in_mode("Cool", fixture="airconditioner_artik051_krac_energy")
|
||||
assert presets[:1] == ["none"]
|
||||
assert "nano" in presets and "smart" in presets
|
||||
assert presets != ["none", "nano", "quiet", "comfort", "2step", "speed"]
|
||||
|
||||
|
||||
def test_an_unknown_hvac_mode_falls_back_instead_of_deriving():
|
||||
"""An HVAC mode these rules have never seen is the same "cannot judge" case
|
||||
as an absent map, so it gets the same answer rather than a derived-but-wrong
|
||||
one. Reachable with a partial or malformed rep, where `modes` is missing."""
|
||||
resources = _load_device(FIXTURE)
|
||||
for modes in ([], ["CoolClean"]):
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = modes
|
||||
assert _climate(resources).preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
], modes
|
||||
|
||||
|
||||
async def test_aicomfort_neither_offers_nano_nor_switches_the_mode():
|
||||
"""The app disables WindFree in AIComfort, so it is not offered -- and the
|
||||
Cool-first write is therefore Auto-only, with no unreachable branch for a
|
||||
mode that can never ask for it."""
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["AIComfort"]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _climate(resources, coordinator)
|
||||
|
||||
assert "nano" not in entity.preset_modes
|
||||
await entity.async_set_preset_mode("quiet")
|
||||
assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Quiet")]
|
||||
|
||||
|
||||
def test_a_bit_that_is_zero_removes_its_preset():
|
||||
"""oc[12] is what puts Fast Turbo and Comfort in Heat; without it the app
|
||||
hides both, and so does this."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
# 35882 with oc[12] cleared, everything else untouched.
|
||||
assert format(35882, "016b")[12] == "1"
|
||||
cleared = int(format(35882, "016b")[:12] + "0" + format(35882, "016b")[13:], 2)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
f"OptionCode_{cleared}" if option.startswith("OptionCode_") else option
|
||||
for option in options
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Heat"]
|
||||
presets = _climate(resources).preset_modes
|
||||
assert "speed" not in presets and "comfort" not in presets
|
||||
assert "quiet" in presets # oc[10], untouched
|
||||
|
||||
|
||||
def test_a_board_without_the_bit_maps_keeps_the_unconditional_list():
|
||||
"""An absent map is not a claim that nothing is supported -- issue #136's unit
|
||||
of this same model publishes a different token set, and a board that omits
|
||||
OptionCode entirely must not lose every preset."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
option for option in options if not option.startswith(("OptionCode_", "ExtendOptionCode_"))
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
assert _climate(resources).preset_modes == [
|
||||
"none",
|
||||
"nano",
|
||||
"quiet",
|
||||
"comfort",
|
||||
"2step",
|
||||
"speed",
|
||||
]
|
||||
|
||||
|
||||
def test_the_active_code_is_always_listed_even_where_the_rules_deny_it():
|
||||
"""A remote can put the unit in a mode these rules would not offer -- and a
|
||||
preset_mode outside preset_modes is not a state HA allows, so the appliance
|
||||
has the last word. Measured: Comode_2Step was accepted and held while the
|
||||
unit was in Auto, where neither the remote nor the app offers it."""
|
||||
resources = _load_device(FIXTURE)
|
||||
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
||||
"Comode_2Step" if option.startswith("Comode_") else option for option in options
|
||||
]
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
entity = _climate(resources)
|
||||
assert entity.preset_mode == "2step"
|
||||
assert "2step" in entity.preset_modes
|
||||
|
||||
|
||||
async def test_nano_in_auto_switches_the_board_to_cool_first():
|
||||
"""Comode_Nano written while the unit is in Auto is answered 2.04 Changed and
|
||||
dropped; the same token after a separate mode write holds. Putting modes and
|
||||
options in one POST does not work either, so the mode goes first, on its own.
|
||||
"""
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _climate(resources, coordinator)
|
||||
|
||||
await entity.async_set_preset_mode("nano")
|
||||
|
||||
assert [payload for _, payload in coordinator.commands] == [
|
||||
("mode", "Cool"),
|
||||
("preset_legacy", "Nano"),
|
||||
]
|
||||
|
||||
|
||||
async def test_nano_outside_auto_writes_only_the_preset():
|
||||
"""In Cool the token holds on its own, so nothing else is sent -- and in Dry
|
||||
the mode must be left alone, since WindFree keeps it."""
|
||||
for mode in ("Cool", "Dry"):
|
||||
resources = _load_device(FIXTURE)
|
||||
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
await _climate(resources, coordinator).async_set_preset_mode("nano")
|
||||
assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Nano")], mode
|
||||
|
||||
|
||||
def test_nano_preset_keeps_a_running_good_sleep_at_its_own_duration():
|
||||
"""Writing a bare Comode_Nano over a live Comode_Sleep/Sleep_4 came back as
|
||||
Comode_NanoSleep/Sleep_16 -- the board upgrades the code by itself and then
|
||||
supplies a duration of its own, turning two hours into eight without anyone
|
||||
asking. Sending the pair keeps the user's value."""
|
||||
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
|
||||
|
||||
assert _climate_write(("preset_legacy", "Nano"), _options(["Comode_Sleep", "Sleep_4"]))[
|
||||
1
|
||||
] == _options(["Comode_NanoSleep", "Sleep_4"])
|
||||
# Idle timer: nano is just nano, exactly as before.
|
||||
assert _climate_write(("preset_legacy", "Nano"), _options(["Comode_Off", "Sleep_0"]))[
|
||||
1
|
||||
] == _options(["Comode_Nano"])
|
||||
# A sleep preset selected outright has no duration to reuse, so it takes the
|
||||
# one the appliance itself falls back to (Sleep_16, eight hours).
|
||||
assert _climate_write(("preset_legacy", "Sleep"), _options(["Comode_Off", "Sleep_0"]))[
|
||||
1
|
||||
] == _options(["Comode_Sleep", "Sleep_16"])
|
||||
# Any other preset is untouched by all of this.
|
||||
assert _climate_write(("preset_legacy", "Quiet"), _options(["Comode_Sleep", "Sleep_4"]))[
|
||||
1
|
||||
] == _options(["Comode_Quiet"])
|
||||
|
||||
|
||||
async def test_newer_boards_keep_the_resource_paths():
|
||||
"""The legacy fallbacks are gated on this board's resource shape, so a
|
||||
board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""
|
||||
|
||||
@@ -6,18 +6,40 @@ token -- this board was the one exception (its oneUiVersion self-reports
|
||||
"7.0 Air conditioner", but 'CAC' had never been added to the board-token
|
||||
table), so it silently fell back to common caps and lost its climate entity.
|
||||
|
||||
This dump is NOT fully covered yet -- eight hrefs remain unbound (edge
|
||||
lighting, a second stateful light resource, absence-clean, four
|
||||
`/settings/sound/*` resources, smart-sensing-cooling), all genuinely new to
|
||||
this board generation. That's a real device-support gap, left documented
|
||||
here rather than guessed at, per the 'don't guess' rule -- fixing the
|
||||
routing regression was the scope of #191.
|
||||
This dump is NOT fully covered yet -- two hrefs remain unbound
|
||||
(`/settings/sound/optimization/vs/0`, smart-sensing-cooling), both
|
||||
genuinely new to this board generation. That's a real device-support gap,
|
||||
left documented here rather than guessed at, per the 'don't guess' rule --
|
||||
fixing the routing regression was the scope of #191.
|
||||
|
||||
/settings/sound/mode/vs/0, /settings/sound/output/vs/0 and
|
||||
/settings/sound/volume/vs/0 used to be on this list too, until issue #319
|
||||
(a sibling TP1X_DA-AC-FAC-class board) supplied a live dump for them --
|
||||
airconditioner.SOUND_MODE and the reused air_purifier.SOUND_OUTPUT/
|
||||
SOUND_VOLUME now cover all three here as well.
|
||||
|
||||
/edgelighting/vs/0 and /light/stateful/vs/0 used to be on this list too,
|
||||
until issue #288 (six System A/C cassette units on this same board) gave
|
||||
real dump evidence for both -- airconditioner.EDGE_LIGHTING and
|
||||
LIGHT_STATEFUL now cover them.
|
||||
|
||||
/mds/absenceclean/vs/0 used to be on this list too -- its {mode,
|
||||
supportedModes: [On, Off]} shape is byte-identical to issue #319's
|
||||
/csi/absenceclean/vs/0, confirmed on that sibling board rather than
|
||||
guessed, so airconditioner.MDS_ABSENCE_CLEAN now covers it too.
|
||||
|
||||
/uvled/vs/0 and /filter/airdustPM1filter/vs/0 used to be on this list too,
|
||||
until issue #270 (TP1X_FAC_TIME_23K) added real capabilities for both --
|
||||
this board's own live filterUsage/filterStatus data on the PM1 filter binds
|
||||
through the same exists_fn-gated entities #270's dump (which has neither
|
||||
field) leaves empty.
|
||||
|
||||
/airlevelcheck/vs/0 was never on this unbound list (this fixture's rep
|
||||
already carried a full set of periodicSensing*/autoExeState fields), but
|
||||
until PR #316 it was globally ignored by airconditioner.py's own
|
||||
_AC_IGNORED as "scheduler plumbing" -- this fixture's own populated values
|
||||
were the proof that description was wrong. air_purifier.AIR_LEVEL_CHECK
|
||||
now covers it (see test_airlevelcheck_binds_real_ai_purify_state below).
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
@@ -27,13 +49,7 @@ from tests.conftest import _load_device
|
||||
|
||||
_STILL_UNBOUND = frozenset(
|
||||
{
|
||||
"/edgelighting/vs/0",
|
||||
"/light/stateful/vs/0",
|
||||
"/mds/absenceclean/vs/0",
|
||||
"/settings/sound/mode/vs/0",
|
||||
"/settings/sound/optimization/vs/0",
|
||||
"/settings/sound/output/vs/0",
|
||||
"/settings/sound/volume/vs/0",
|
||||
"/smartsensingcooling/vs/0",
|
||||
}
|
||||
)
|
||||
@@ -65,6 +81,32 @@ def test_documented_coverage_gap_is_exactly_this_set():
|
||||
assert set(unbound) == _STILL_UNBOUND
|
||||
|
||||
|
||||
def test_mds_absenceclean_shares_csi_absenceclean_key():
|
||||
"""/mds/absenceclean/vs/0's mode=='Off' on this dump -- confirms
|
||||
MDS_ABSENCE_CLEAN actually binds (not just that the href stops
|
||||
reporting as unbound)."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state["absence_clean"] is False
|
||||
|
||||
|
||||
def test_airlevelcheck_binds_real_ai_purify_state():
|
||||
"""This fixture's /airlevelcheck/vs/0 has real, populated values --
|
||||
periodic_air_sensing on, sensing_mode 'Alarm' -- confirming
|
||||
air_purifier.AIR_LEVEL_CHECK binds real AI-Purify state here rather
|
||||
than the inert plumbing _AC_IGNORED used to describe."""
|
||||
resources = _resources()
|
||||
reg = _reg(resources)
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
state = flatten(bound, resources)
|
||||
assert state["periodic_air_sensing"] is True
|
||||
assert state["sensing_mode"] == "Alarm"
|
||||
assert state["sensing_interval"] == 30 # 1800s
|
||||
assert state["air_sensing_state"] == "NonProcessing"
|
||||
|
||||
|
||||
def test_non_legacy_board_uses_the_generic_energy_scale():
|
||||
"""This board reports /wind/strength/vs/0 (not /airflow/vs/0), so
|
||||
is_legacy_board() is False and it must use the plain wh_to_kwh scale,
|
||||
|
||||
@@ -95,10 +95,13 @@ def test_power_and_convenient_folded_into_climate():
|
||||
assert "convenient_mode" not in state
|
||||
|
||||
|
||||
def test_air_filter_usage_is_percentage_of_capacity():
|
||||
"""filterUsage is a raw count in the capacity unit (100 of 500), surfaced as
|
||||
a percentage rather than the misleading raw value."""
|
||||
assert _state()["air_filter_usage"] == 20
|
||||
def test_air_filter_usage_is_already_a_percentage():
|
||||
"""filterUsage is already 0-100, not a raw count to divide by
|
||||
filterCapacity (issue #330): this fixture (ARTIK051_PRAC_20K) reports
|
||||
filterStatus == 'wash' at filterUsage == '100', which only holds if
|
||||
filterUsage is already a percent -- dividing by the 500 capacity again
|
||||
would read a filter due for washing as 20% fresh."""
|
||||
assert _state()["air_filter_usage"] == 100
|
||||
|
||||
|
||||
def test_climate_write_targets():
|
||||
@@ -149,6 +152,81 @@ def test_climate_write_targets():
|
||||
assert write(("bogus", 1), {}) is None
|
||||
|
||||
|
||||
def test_climate_write_preserves_half_degree_temperature_steps():
|
||||
"""CAC/TP1X FAC boards advertise `0.5` on both temperature resources.
|
||||
The increment on /temperatures/vs/0 lives inside its items[] entry (the
|
||||
same shape every fixture in the corpus uses), not at the resource's top
|
||||
level -- a fabricated flat `{"/temperatures/vs/0": {"increment": ...}}`
|
||||
resource would pass a step-reading bug like that silently.
|
||||
|
||||
Calls _climate_write directly rather than through ClimateDesc.write_fn
|
||||
(as test_climate_write_targets above does): WriteFn only declares the
|
||||
(payload, rep) shape every other write_fn honors, so the type checker
|
||||
rejects a call carrying the climate-only href/resources params through
|
||||
that narrower alias -- same reason test_coordinator_send_command.py and
|
||||
test_airconditioner_artik051_krac.py import the function directly too.
|
||||
"""
|
||||
resources = _load_device("airconditioner_cac")
|
||||
assert airconditioner._climate_write(("temperature_ocf", 24.5), {}, None, resources) == (
|
||||
["temperature", "desired", "0"],
|
||||
{"temperature": 24.5},
|
||||
)
|
||||
assert airconditioner._climate_write(("temperature", 24.5), {}, None, resources) == (
|
||||
["temperatures", "vs", "0"],
|
||||
{
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "24.5"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_temperature_step_falls_back_to_temps_vs_items_when_no_control_resource():
|
||||
"""Isolates the /temperatures/vs/0 fallback: every fixture that carries
|
||||
an increment there also carries /temperature/control/vs/0, which
|
||||
_temperature_step checks first -- so without dropping that resource,
|
||||
this fallback branch is never actually exercised, and reinstating the
|
||||
original bug (reading the increment off /temperatures/vs/0's top level
|
||||
instead of unwrapping its items[0]) would still pass every other test."""
|
||||
resources = dict(_load_device("airconditioner_cac"))
|
||||
del resources[airconditioner.HREF_TEMP_CONTROL]
|
||||
assert airconditioner._temperature_step(resources) == 0.5
|
||||
assert airconditioner._climate_write(("temperature", 24.5), {}, None, resources) == (
|
||||
["temperatures", "vs", "0"],
|
||||
{
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "24.5"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_climate_write_rounds_to_whole_degree_with_no_advertised_increment():
|
||||
"""ARTIK051 boards have neither /temperature/control/vs/0 nor an
|
||||
increment field on /temperatures/vs/0's item -- target_temperature_step
|
||||
(climate.py) defaults to 1.0 there, so the write path must match rather
|
||||
than pass the raw value through unrounded."""
|
||||
resources = _load_device("airconditioner_artik051_krac_18k")
|
||||
assert airconditioner._temperature_step(resources) is None
|
||||
assert airconditioner._climate_write(("temperature", 23.6), {}, None, resources) == (
|
||||
["temperatures", "vs", "0"],
|
||||
{
|
||||
"x.com.samsung.da.items": [
|
||||
{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "24"}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_climate_write_rejects_non_numeric_temperature():
|
||||
"""A non-numeric payload must reject the write (return None) rather than
|
||||
build a body with `{"temperature": None}` -- coordinator.py's
|
||||
async_send_command logs and drops a write_fn result of None instead of
|
||||
POSTing it."""
|
||||
assert airconditioner._climate_write(("temperature_ocf", "not-a-number"), {}) is None
|
||||
assert airconditioner._climate_write(("temperature", None), {}) is None
|
||||
|
||||
|
||||
def test_climate_consumed_hrefs_declared_as_coverage():
|
||||
"""The climate-consumed and ambiguous hrefs are declared in the AC registry
|
||||
(as no-entity coverage caps) so they don't leak as gaps -- but produce no
|
||||
@@ -221,6 +299,19 @@ def test_tp1x_climate_entity_is_bound():
|
||||
assert len(climate) == 1 and climate[0].href == "/mode/vs/0"
|
||||
|
||||
|
||||
def test_tp1x_airlevelcheck_binds_real_ai_purify_state():
|
||||
"""This fixture's /airlevelcheck/vs/0 has real, populated
|
||||
periodicSensing*/autoExeState values too (PR #316's finding that
|
||||
_AC_IGNORED's old "scheduler plumbing" description was wrong wasn't
|
||||
specific to one board) -- air_purifier.AIR_LEVEL_CHECK now covers it."""
|
||||
reg, resources = _ac_tp1x()
|
||||
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert state["periodic_air_sensing"] is True
|
||||
assert state["sensing_mode"] == "Alarm"
|
||||
assert state["sensing_interval"] == 30 # 1800s
|
||||
assert state["air_sensing_state"] == "NonProcessing"
|
||||
|
||||
|
||||
def test_tp2x_rac_20k_model_resolves_via_model_fallback():
|
||||
"""TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token
|
||||
-- resolved via the '_RAC_' modelNum fallback added for this device."""
|
||||
@@ -758,19 +849,34 @@ def test_tropical_night_state_levels_across_fixtures():
|
||||
assert level("airconditioner_tp2x_rac_20k") == 16
|
||||
|
||||
|
||||
def test_air_filter_usage_hours_reads_raw_count():
|
||||
"""filterUsage is a lifetime hour counter (41 of 500) that resets on
|
||||
filter replacement -- total_increasing, not measurement. Unit comes from
|
||||
def test_air_filter_usage_hours_derived_from_percent_and_capacity():
|
||||
"""filterUsage is a percent, not an hour count (issue #330): the lifetime
|
||||
hour reading is derived from percent * capacity rather than read off
|
||||
filterUsage directly. total_increasing, not measurement; unit comes from
|
||||
filterCapacityUnit via unit_fn, not a hardcoded 'h'."""
|
||||
desc = next(
|
||||
e
|
||||
for e in airconditioner.AIR_FILTER.entities
|
||||
if e.key == "air_filter_usage_hours" and isinstance(e, SensorDesc)
|
||||
)
|
||||
assert desc.value_fn("41") == 41
|
||||
assert desc.value_fn(41) == 41
|
||||
assert desc.value_fn(None) is None
|
||||
assert desc.value_fn("not-a-number") is None
|
||||
assert desc.rep_fn is not None
|
||||
assert (
|
||||
desc.rep_fn(
|
||||
{"x.com.samsung.da.filterUsage": "91", "x.com.samsung.da.filterCapacity": "500"}
|
||||
)
|
||||
== 455.0
|
||||
)
|
||||
assert (
|
||||
desc.rep_fn(
|
||||
{
|
||||
"x.com.samsung.da.filterUsage": "not-a-number",
|
||||
"x.com.samsung.da.filterCapacity": "500",
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert desc.rep_fn({"x.com.samsung.da.filterCapacity": "500"}) is None
|
||||
assert desc.rep_fn({"x.com.samsung.da.filterUsage": "91"}) is None
|
||||
assert desc.device_class == "duration"
|
||||
assert desc.state_class == "total_increasing"
|
||||
assert desc.unit_fn is not None
|
||||
@@ -817,8 +923,8 @@ def test_air_filter_threshold_absent_without_supported_enum():
|
||||
reg, resources = _ac_windfree()
|
||||
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
||||
assert "air_filter_threshold" not in state
|
||||
assert state["air_filter_usage_hours"] == 41
|
||||
assert state["air_filter_usage"] == 8 # 41/500 -> 8%
|
||||
assert state["air_filter_usage"] == 41
|
||||
assert state["air_filter_usage_hours"] == 205.0 # 41% of 500
|
||||
|
||||
|
||||
def test_air_filter_threshold_binds_on_enum_board():
|
||||
|
||||
@@ -48,6 +48,9 @@ class _FakeCoordinator:
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
def learned_modes(self, href):
|
||||
return []
|
||||
|
||||
|
||||
def _climate(resources, coordinator=None):
|
||||
info = resources["/information/vs/0"]
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for the ventilation-mode/Wind-Free/Wind-Sleep additions extracted
|
||||
from PR #316 (Samsung "System Fresh Air Ventilator", model
|
||||
ACA-KR-TP2-21-AN9000, vid DA-AC-DIFFUSER-01001).
|
||||
|
||||
No raw diagnostics dump for this model was available -- PR #316 never
|
||||
attached one, only Korean code comments describing field shapes the
|
||||
contributor said they observed. Per this project's fixture-integrity rule
|
||||
(a fixture must record what hardware actually did, not a third party's
|
||||
prose about it), there's no `airconditioner_*_device.json` fixture for
|
||||
this model here. These tests instead exercise the gating logic directly
|
||||
against hand-built reps matching those quoted shapes, clearly distinct
|
||||
from this suite's fixture-backed tests, and check the new gate doesn't
|
||||
false-positive against every real AC fixture already in the corpus.
|
||||
|
||||
If a real diagnostics dump for this model ever surfaces (tracked as a
|
||||
follow-up device-support issue), replace this file with a proper
|
||||
fixture + golden + capability test per the usual workflow, and drop the
|
||||
disclaimer above.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities.airconditioner import (
|
||||
WINDFREE,
|
||||
WINDSLEEP,
|
||||
_is_ventilation_mode_device,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||
|
||||
|
||||
def _all_airconditioner_fixture_names():
|
||||
names = []
|
||||
for path in glob.glob(os.path.join(_FIXTURES_DIR, "airconditioner*_device.json")):
|
||||
name = os.path.basename(path)[: -len("_device.json")]
|
||||
with open(path) as f:
|
||||
info = json.load(f)
|
||||
rep = next(
|
||||
(e["rep"] for e in info.get("device0", []) if e.get("href") == "/information/vs/0"),
|
||||
None,
|
||||
)
|
||||
if rep is not None: # for_device_by_model needs /information/vs/0
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def test_ventilation_mode_gate_never_false_positives_on_real_ac_fixtures():
|
||||
"""None of the real air-conditioner fixtures in this corpus use the
|
||||
Purification/Ventilation/SmartVentilation vocabulary -- confirms
|
||||
_is_ventilation_mode_device can't turn a real AC's climate card into
|
||||
this select."""
|
||||
for name in _all_airconditioner_fixture_names():
|
||||
resources = _load_device(name)
|
||||
reg = for_device_by_model(
|
||||
resources["/information/vs/0"]["x.com.samsung.da.modelNum"],
|
||||
resources["/information/vs/0"]["x.com.samsung.da.description"],
|
||||
)
|
||||
if reg is None or reg.name != "airconditioner":
|
||||
continue
|
||||
mode_rep = resources.get("/mode/vs/0")
|
||||
if not mode_rep:
|
||||
continue
|
||||
assert _is_ventilation_mode_device(mode_rep, resources) is False, name
|
||||
|
||||
|
||||
def test_ventilation_mode_gate_matches_diffuser_shape():
|
||||
"""PR #316: supportedModes exactly {Purification, Ventilation,
|
||||
SmartVentilation} -- the vocabulary that makes climate.py's hvac_mode
|
||||
collapse to one stuck value with no way to tell the three apart."""
|
||||
rep = {
|
||||
"x.com.samsung.da.modes": ["Purification"],
|
||||
"x.com.samsung.da.supportedModes": ["Purification", "Ventilation", "SmartVentilation"],
|
||||
}
|
||||
assert _is_ventilation_mode_device(rep, {}) is True
|
||||
|
||||
|
||||
def test_ventilation_mode_gate_rejects_partial_overlap():
|
||||
"""A real AC reporting an unrelated mode alongside one of these three
|
||||
words (coincidence, not this device) must not gate in -- the check is
|
||||
'subset of', not 'intersects'."""
|
||||
rep = {"x.com.samsung.da.supportedModes": ["Ventilation", "Cool", "Heat"]}
|
||||
assert _is_ventilation_mode_device(rep, {}) is False
|
||||
|
||||
|
||||
def _bind(capability, href, rep):
|
||||
resources = {href: rep}
|
||||
bound = discover(resources, {href: [capability]}, [])
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_windfree_and_windsleep_read_their_own_hrefs():
|
||||
"""PR #316's quoted rep shape: {'x.com.samsung.da.windfree': 'On'/'Off',
|
||||
'x.com.samsung.da.displaycondition': 'normal'} (displaycondition is a
|
||||
read-only UI-visibility flag, deliberately not modeled)."""
|
||||
windfree_state = _bind(
|
||||
WINDFREE,
|
||||
"/modeoption/windfree/vs/0",
|
||||
{"x.com.samsung.da.windfree": "On", "x.com.samsung.da.displaycondition": "normal"},
|
||||
)
|
||||
assert windfree_state["windfree"] is True
|
||||
|
||||
windsleep_state = _bind(
|
||||
WINDSLEEP,
|
||||
"/modeoption/windsleep/vs/0",
|
||||
{"x.com.samsung.da.windsleep": "Off", "x.com.samsung.da.displaycondition": "normal"},
|
||||
)
|
||||
assert windsleep_state["windsleep"] is False
|
||||
@@ -119,6 +119,9 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
||||
# canonical view is just the raw snapshot (issue #177).
|
||||
return self.last_resources
|
||||
|
||||
def learned_modes(self, href):
|
||||
return []
|
||||
|
||||
resources = _load_device("airconditioner_fac_bora")
|
||||
info = resources["/information/vs/0"]
|
||||
reg = by_type.for_device_by_model(
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""Cloud "Download" programs on laundry devices (issue #342).
|
||||
|
||||
The store and blob parsing live in cloudcourse.py; how they reach the cycle
|
||||
select lives in registry/capabilities/laundry.py. Both are covered here,
|
||||
against the two real dumps in the corpus that carry these tokens:
|
||||
|
||||
washer_ww5000c_cloud the issue #342 reporter's WW5000C, sitting on
|
||||
Download with a one-time Jeans override loaded over
|
||||
a saved Sports default. Advertises nine programs.
|
||||
washer_wa55a7700av a WA55A7700AV sitting on an ordinary local course,
|
||||
with a saved program and the FFFF "none" sentinel in
|
||||
the one-time slot. Advertises two programs.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from custom_components.localthings import cloudcourse
|
||||
from custom_components.localthings.registry.capabilities import laundry, washer
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
# The reporter's nine captured programs, keyed by the slot byte the device
|
||||
# itself advertises. Only used to drive the tests -- nothing in the shipped
|
||||
# code carries a table of these (see cloudcourse.py's module docstring).
|
||||
SPORTS = "0021550449284D134AA04C0035F004F005F0AC00"
|
||||
JEANS = "001F6B0449284D134AA04C0035F004F005F0AC00"
|
||||
TOWELS = "00040A0449404D134AB84C0035F004F005F0AC00"
|
||||
|
||||
|
||||
def _cycle_desc():
|
||||
return next(
|
||||
e for e in washer.WASHER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
|
||||
)
|
||||
|
||||
|
||||
def _rep(options, cloud=None):
|
||||
rep = {"x.com.samsung.da.options": list(options)}
|
||||
if cloud is not None:
|
||||
rep[cloudcourse.FIELD] = cloud
|
||||
return rep
|
||||
|
||||
|
||||
class TestBlobParsing:
|
||||
def test_slot_is_byte_two(self):
|
||||
"""Confirmed against every program in both dumps: byte 2 of a blob
|
||||
is the slot id its own CloudExtraCourse_ token advertises."""
|
||||
assert cloudcourse.slot_of(SPORTS) == "55"
|
||||
assert cloudcourse.slot_of(JEANS) == "6B"
|
||||
assert cloudcourse.slot_of(TOWELS) == "0A"
|
||||
|
||||
def test_ffff_prefix_is_not_a_program(self):
|
||||
"""WA55A7700AV reports this while sitting on a local course -- it
|
||||
means 'no one-time override', not a program, and its byte 2 is not
|
||||
one of the slots that device advertises."""
|
||||
sentinel = "FFFF010049004D004A804C0037F0AC00"
|
||||
assert cloudcourse.is_loaded(sentinel) is False
|
||||
assert cloudcourse.slot_of(sentinel) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[None, "", "zz", "0021", "0021550449284D134AA04C0035F004F005F0AC0"],
|
||||
)
|
||||
def test_malformed_blobs_yield_no_slot(self, bad):
|
||||
assert cloudcourse.slot_of(bad) is None
|
||||
|
||||
def test_advertised_slots_preserve_device_order(self):
|
||||
rep = _rep(["CloudExtraCourse_0A5C286B2D0C55301A"])
|
||||
assert cloudcourse.advertised_slots(rep) == [
|
||||
"0A",
|
||||
"5C",
|
||||
"28",
|
||||
"6B",
|
||||
"2D",
|
||||
"0C",
|
||||
"55",
|
||||
"30",
|
||||
"1A",
|
||||
]
|
||||
|
||||
def test_no_cloud_tokens_means_unsupported(self):
|
||||
assert cloudcourse.supports_cloud_courses(_rep(["Course_1C"]), ["1C"]) is False
|
||||
|
||||
|
||||
class TestRealDumps:
|
||||
def test_reporter_dump_advertises_nine_and_learns_both_loaded_blobs(self):
|
||||
"""One poll teaches two programs: the saved default and the loaded
|
||||
one-time override are different programs on this dump."""
|
||||
rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"]
|
||||
assert len(cloudcourse.advertised_slots(rep)) == 9
|
||||
|
||||
store = cloudcourse.CloudCourses()
|
||||
assert store.observe(rep) is True
|
||||
assert store.snapshot()["slots"]["55"]["blob"] == SPORTS
|
||||
assert store.snapshot()["slots"]["6B"]["blob"] == JEANS
|
||||
# Learned but unnamed -- nothing is offerable yet.
|
||||
assert store.named() == {}
|
||||
assert store.view() == {}
|
||||
|
||||
def test_reporter_dump_proposes_its_download_course(self):
|
||||
"""Only once a load has actually been watched. A single observation
|
||||
can't tell "just loaded" from "left over", so the dump alone proposes
|
||||
nothing -- see test_a_single_observation_proposes_nothing."""
|
||||
rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"]
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]))
|
||||
store.observe(rep) # one-time token moves to Jeans while on Course_87
|
||||
assert store.download_candidates() == ["87"]
|
||||
# A candidate is never used until confirmed.
|
||||
assert store.snapshot()["download_course"] is None
|
||||
|
||||
def test_a_single_observation_proposes_nothing(self):
|
||||
"""The case a user hits after any restart: the appliance has cloud
|
||||
payloads from previous runs and is sitting on an ordinary course. If
|
||||
the board doesn't clear its one-time token, believing the first
|
||||
observation would propose that ordinary course as the Download one --
|
||||
and accepting the prefill would start a real wash cycle."""
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"]))
|
||||
assert store.download_candidates() == []
|
||||
# The payload is still learned -- that part is device fact.
|
||||
assert store.snapshot()["slots"]["55"]["blob"] == SPORTS
|
||||
|
||||
def test_wa55_learns_its_saved_program_but_not_the_sentinel(self):
|
||||
rep = _load_device("washer_wa55a7700av")["/course/vs/0"]
|
||||
assert cloudcourse.advertised_slots(rep) == ["59", "58"]
|
||||
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(rep)
|
||||
assert store.snapshot()["slots"]["59"]["blob"] == "001C590549164D114A224C2037F0AC22"
|
||||
assert "01" not in store.snapshot()["slots"] # the FFFF sentinel's byte 2
|
||||
|
||||
def test_wa55_proposes_no_download_course(self):
|
||||
"""It is sitting on an ordinary local course with no override
|
||||
loaded, so there is nothing to infer -- exactly the case where
|
||||
guessing a code would start the wrong cycle."""
|
||||
rep = _load_device("washer_wa55a7700av")["/course/vs/0"]
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(rep)
|
||||
assert store.download_candidates() == []
|
||||
|
||||
def test_a_dishwasher_tags_its_own_courses_not_payload_slots(self):
|
||||
"""DW5000C (issues #113/#123). Its CloudExtraCourse_ names four
|
||||
bytes, and all four are course codes in its *own* course list --
|
||||
8E/8D/8F/02, three of them already translated (Plastic, Pots and
|
||||
pans, Baby Care). There it marks which ordinary courses came from
|
||||
the cloud; they select with a plain Course_ write and need nothing
|
||||
from this module. It carries no payload token at all, consistent
|
||||
with that.
|
||||
|
||||
So none of this feature applies to it, and offering its owner a
|
||||
naming flow for programs that already work would be nonsense. The
|
||||
washers are the other shape: zero overlap with their course lists,
|
||||
and a payload required to select one."""
|
||||
resources = _load_device("dishwasher_dw5000c_cloud")
|
||||
rep = resources["/course/vs/0"]
|
||||
courses = laundry.cycle_options(resources)
|
||||
|
||||
assert cloudcourse.advertised_slots(rep) == ["8E", "8D", "8F", "02"]
|
||||
assert set(cloudcourse.advertised_slots(rep)) <= set(courses)
|
||||
assert cloudcourse.cloud_slots(rep, courses) == []
|
||||
assert cloudcourse.supports_cloud_courses(rep, courses) is False
|
||||
assert cloudcourse.undiscovered(rep, cloudcourse.CloudCourses().snapshot(), courses) == []
|
||||
|
||||
def test_washer_slots_share_nothing_with_the_course_list(self):
|
||||
"""The distinguishing property, on both washers -- which is what
|
||||
makes subtracting the course list a safe way to tell the two
|
||||
meanings of CloudExtraCourse_ apart."""
|
||||
for name in ("washer_ww5000c_cloud", "washer_wa55a7700av"):
|
||||
resources = _load_device(name)
|
||||
rep = resources["/course/vs/0"]
|
||||
courses = laundry.cycle_options(resources)
|
||||
assert set(cloudcourse.advertised_slots(rep)).isdisjoint(courses), name
|
||||
assert cloudcourse.supports_cloud_courses(rep, courses) is True, name
|
||||
|
||||
def test_byte_three_is_not_part_of_a_programs_identity(self):
|
||||
"""Two WW5000C units on different firmware (issue #342's _B06C and
|
||||
issues #259/#343's _B048) report the same program -- same id, same
|
||||
slot, same field values, same tail -- differing only at byte 3.
|
||||
|
||||
This is the evidence against a shipped catalog of payloads: keyed on
|
||||
program id, one unit's capture would have carried the other unit's
|
||||
byte 3. Both are recorded and replayed per device instead, so
|
||||
whatever byte 3 tracks never has to be understood."""
|
||||
b06c = "00040A0449404D134AB84C0035F004F005F0AC00"
|
||||
b048 = "00040A0649404D134AB84C0035F004F005F0AC00"
|
||||
assert b06c != b048
|
||||
# Same program by every identifier the device gives us.
|
||||
assert cloudcourse.slot_of(b06c) == cloudcourse.slot_of(b048) == "0A"
|
||||
assert b06c[:6] == b048[:6]
|
||||
assert b06c[8:] == b048[8:]
|
||||
# Learned separately per device, and each replays what it saw.
|
||||
for blob in (b06c, b048):
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_0A", f"CloudCourse_{blob}"]))
|
||||
assert store.snapshot()["slots"]["0A"]["blob"] == blob
|
||||
|
||||
def test_a_sentinel_slot_byte_is_not_the_current_course(self):
|
||||
"""The two sentinels in the corpus disagree about this -- WA55's byte
|
||||
2 happens to equal its selected course, the WW5000C _B048's does not
|
||||
(1B against Course_1C). Nothing keys off it: a sentinel is rejected
|
||||
on its FFFF prefix, and its byte 2 is not an advertised slot either
|
||||
way."""
|
||||
wa55 = "FFFF010049004D004A804C0037F0AC00"
|
||||
b048 = "FFFF1B0049004D004A804C0035F004F005F0AC00"
|
||||
assert cloudcourse.slot_of(wa55) is None
|
||||
assert cloudcourse.slot_of(b048) is None
|
||||
# Different board widths, same sentinel rule.
|
||||
assert len(wa55) != len(b048)
|
||||
|
||||
def test_undiscovered_counts_against_what_the_device_advertises(self):
|
||||
rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"]
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(rep)
|
||||
# Both learned slots are still unnamed, so all nine are outstanding.
|
||||
courses = laundry.cycle_options(_load_device("washer_ww5000c_cloud"))
|
||||
assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 9
|
||||
store.set_name("55", "Sports")
|
||||
assert "55" not in cloudcourse.undiscovered(rep, store.snapshot(), courses)
|
||||
assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 8
|
||||
|
||||
|
||||
class TestStoreRules:
|
||||
def test_only_advertised_slots_are_recorded(self):
|
||||
"""A blob for a slot this appliance doesn't list isn't a program it
|
||||
offers."""
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_55", f"OneTimeCloudCourse_{JEANS}"]))
|
||||
assert "6B" not in store.snapshot()["slots"]
|
||||
|
||||
def test_a_relearned_blob_replaces_the_old_payload(self):
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]))
|
||||
rewritten = SPORTS.replace("F005F0AC00", "F005F0AC11")
|
||||
assert store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{rewritten}"])) is True
|
||||
assert store.snapshot()["slots"]["55"]["blob"] == rewritten
|
||||
|
||||
def test_observing_the_same_rep_twice_changes_nothing(self):
|
||||
store = cloudcourse.CloudCourses()
|
||||
rep = _rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"])
|
||||
assert store.observe(rep) is True
|
||||
assert store.observe(rep) is False
|
||||
|
||||
def test_candidates_rank_by_program_loads_not_by_dwell_time(self):
|
||||
"""A stale OneTimeCloudCourse_ token outlives the run it belonged to,
|
||||
so it is still reported through every poll the appliance spends on
|
||||
whatever ordinary course comes next. Counting polls would rank that
|
||||
course first and suggest it as the Download course -- and accepting
|
||||
the suggestion would start a real wash cycle. Only the poll where the
|
||||
payload actually changed is evidence."""
|
||||
store = cloudcourse.CloudCourses()
|
||||
loaded = _rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"])
|
||||
stale = _rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"])
|
||||
store.observe(_rep(["CloudExtraCourse_55", "Course_87"])) # baseline
|
||||
store.observe(loaded)
|
||||
for _ in range(200): # ~100 minutes sitting on a cotton cycle
|
||||
store.observe(stale)
|
||||
assert store.download_candidates() == ["87"]
|
||||
|
||||
def test_a_second_distinct_load_is_counted(self):
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_556B", "Course_87"])) # baseline
|
||||
store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]))
|
||||
store.observe(_rep(["CloudExtraCourse_556B", "Course_87", f"OneTimeCloudCourse_{JEANS}"]))
|
||||
assert store.download_candidates() == ["87"]
|
||||
|
||||
def test_view_is_empty_until_a_download_course_is_confirmed(self):
|
||||
"""Both halves are required: without the course code there is no
|
||||
write to build, so nothing should reach the select."""
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]))
|
||||
store.set_name("55", "Sports")
|
||||
assert store.view() == {}
|
||||
store.set_download_course("87")
|
||||
assert store.view()["programs"] == {"55": {"blob": SPORTS, "name": "Sports"}}
|
||||
|
||||
def test_round_trips_through_the_entry(self):
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]))
|
||||
store.set_name("55", "Sports")
|
||||
store.set_download_course("87")
|
||||
restored = cloudcourse.CloudCourses(store.snapshot())
|
||||
assert restored.snapshot() == store.snapshot()
|
||||
assert restored.view() == store.view()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"junk",
|
||||
[
|
||||
"not a dict",
|
||||
{"slots": "nope"},
|
||||
{"slots": {"55": {"blob": "garbage", "name": "Sports"}}},
|
||||
# blob's own byte 2 disagrees with the key it is filed under
|
||||
{"slots": {"99": {"blob": SPORTS, "name": "Sports"}}},
|
||||
],
|
||||
)
|
||||
def test_a_hand_edited_entry_cannot_crash_setup(self, junk):
|
||||
assert cloudcourse.CloudCourses(junk).snapshot()["slots"] == {}
|
||||
|
||||
|
||||
class TestCycleSelectIntegration:
|
||||
"""The cloud programs ride in the ordinary cycle select -- on the
|
||||
appliance's dial, Download is one course among the rest."""
|
||||
|
||||
def _cloud(self):
|
||||
return {
|
||||
"download_course": "87",
|
||||
"programs": {"55": {"blob": SPORTS, "name": "Sports"}},
|
||||
}
|
||||
|
||||
def test_local_courses_are_unchanged_without_cloud_data(self):
|
||||
desc = _cycle_desc()
|
||||
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C1D"}}
|
||||
assert desc.options(live) == ["1C", "1D"]
|
||||
|
||||
def test_named_programs_join_the_option_list_after_local_courses(self):
|
||||
desc = _cycle_desc()
|
||||
live = {
|
||||
"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C87"},
|
||||
"/course/vs/0": _rep(["Course_87"], self._cloud()),
|
||||
}
|
||||
assert desc.options(live) == ["1C", "87", "cloud:55"]
|
||||
|
||||
def test_an_unnamed_program_is_never_offered(self):
|
||||
desc = _cycle_desc()
|
||||
live = {
|
||||
"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C"},
|
||||
"/course/vs/0": _rep(
|
||||
["Course_87"],
|
||||
{"download_course": "87", "programs": {}},
|
||||
),
|
||||
}
|
||||
assert desc.options(live) == ["1C"]
|
||||
|
||||
def test_label_is_the_users_own_name(self):
|
||||
desc = _cycle_desc()
|
||||
resources = {"/course/vs/0": _rep(["Course_87"], self._cloud())}
|
||||
assert desc.display_fn("cloud:55", resources) == "Sports"
|
||||
|
||||
def test_unknown_cloud_value_gets_no_invented_label(self):
|
||||
desc = _cycle_desc()
|
||||
resources = {"/course/vs/0": _rep(["Course_87"], self._cloud())}
|
||||
assert desc.display_fn("cloud:99", resources) is None
|
||||
|
||||
def test_state_reports_the_loaded_program_while_on_download(self):
|
||||
desc = _cycle_desc()
|
||||
rep = _rep(["Course_87", f"OneTimeCloudCourse_{SPORTS}"], self._cloud())
|
||||
assert desc.rep_fn(rep) == "cloud:55"
|
||||
|
||||
def test_state_falls_back_to_the_saved_program(self):
|
||||
"""No one-time override loaded: the appliance runs the saved default,
|
||||
which the reporter confirmed by leaving Download and returning."""
|
||||
desc = _cycle_desc()
|
||||
rep = _rep(
|
||||
["Course_87", f"CloudCourse_{SPORTS}", "OneTimeCloudCourse_FFFF010049004D004A804C00"],
|
||||
self._cloud(),
|
||||
)
|
||||
assert desc.rep_fn(rep) == "cloud:55"
|
||||
|
||||
def test_a_stale_program_token_is_not_reported_on_a_local_course(self):
|
||||
"""Tokens are replaced by prefix and never evicted, so a one-time
|
||||
program outlives its run. Reporting 'Sports' while a cotton cycle
|
||||
runs would be a live lie about what the machine is doing."""
|
||||
desc = _cycle_desc()
|
||||
rep = _rep(["Course_1C", f"OneTimeCloudCourse_{SPORTS}"], self._cloud())
|
||||
assert desc.rep_fn(rep) == "1C"
|
||||
|
||||
def test_selecting_a_program_switches_course_and_loads_it_in_one_write(self):
|
||||
"""Confirmed on hardware: writing the program token alone while
|
||||
another course is selected is silently ignored."""
|
||||
desc = _cycle_desc()
|
||||
rep = _rep(["Course_1C"], self._cloud())
|
||||
path, body = desc.write_fn("cloud:55", rep)
|
||||
assert path == ["course", "vs", "0"]
|
||||
assert body == {"x.com.samsung.da.options": ["Course_87", f"OneTimeCloudCourse_{SPORTS}"]}
|
||||
|
||||
def test_selecting_a_local_course_still_writes_one_token(self):
|
||||
desc = _cycle_desc()
|
||||
rep = _rep(["Course_87"], self._cloud())
|
||||
_, body = desc.write_fn("1C", rep)
|
||||
assert body == {"x.com.samsung.da.options": ["Course_1C"]}
|
||||
|
||||
def test_no_write_without_a_confirmed_download_course(self):
|
||||
desc = _cycle_desc()
|
||||
rep = _rep(["Course_1C"], {"programs": {"55": {"blob": SPORTS, "name": "Sports"}}})
|
||||
assert desc.write_fn("cloud:55", rep) is None
|
||||
|
||||
def test_no_write_for_an_unknown_program(self):
|
||||
desc = _cycle_desc()
|
||||
rep = _rep(["Course_1C"], self._cloud())
|
||||
assert desc.write_fn("cloud:99", rep) is None
|
||||
|
||||
|
||||
class TestOptionsMergePreservesSiblingTokens:
|
||||
def test_two_token_write_merges_like_the_device_does(self):
|
||||
"""The write carries only the changed tokens; merge_options_field is
|
||||
what keeps the optimistic cache entry complete during the settle
|
||||
window. Both tokens must land, and unrelated ones must survive."""
|
||||
from custom_components.localthings.registry.capabilities.common import merge_options_field
|
||||
|
||||
cached = [
|
||||
"DeviceType_0167",
|
||||
"Course_1C",
|
||||
f"CloudCourse_{SPORTS}",
|
||||
"CloudExtraCourse_0A5C286B2D0C55301A",
|
||||
f"OneTimeCloudCourse_{TOWELS}",
|
||||
"GMT_04",
|
||||
]
|
||||
merged = merge_options_field(cached, ["Course_87", f"OneTimeCloudCourse_{SPORTS}"])
|
||||
assert "Course_87" in merged
|
||||
assert f"OneTimeCloudCourse_{SPORTS}" in merged
|
||||
# The saved default and the device's own slot list are untouched.
|
||||
assert f"CloudCourse_{SPORTS}" in merged
|
||||
assert "CloudExtraCourse_0A5C286B2D0C55301A" in merged
|
||||
assert "DeviceType_0167" in merged
|
||||
|
||||
def test_cloud_prefixes_do_not_poison_the_course_lookup(self):
|
||||
"""'Course_' is a prefix of neither 'CloudCourse_' nor
|
||||
'OneTimeCloudCourse_' only because option_value anchors at position
|
||||
0 -- one character away from being wrong, so it is pinned."""
|
||||
options = [f"CloudCourse_{SPORTS}", f"OneTimeCloudCourse_{JEANS}", "Course_1C"]
|
||||
assert laundry.option_value(options, "Course") == "1C"
|
||||
assert cloudcourse.option_value(options, "Course") == "1C"
|
||||
|
||||
def test_a_payload_appearing_while_watching_is_a_real_transition(self):
|
||||
"""Absent-then-loaded is the user selecting a program, and must count
|
||||
-- only "never observed at all" suppresses a candidate."""
|
||||
store = cloudcourse.CloudCourses()
|
||||
store.observe(_rep(["CloudExtraCourse_55", "Course_87"]))
|
||||
store.observe(_rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]))
|
||||
assert store.download_candidates() == ["87"]
|
||||
|
||||
def test_a_stale_payload_across_a_restart_proposes_nothing(self):
|
||||
"""A restored store starts unobserved again on purpose: the first rep
|
||||
after a restart is indistinguishable from a stale one, whatever was
|
||||
persisted."""
|
||||
seeded = cloudcourse.CloudCourses()
|
||||
seeded.observe(_rep(["CloudExtraCourse_55", "Course_87", f"OneTimeCloudCourse_{SPORTS}"]))
|
||||
restored = cloudcourse.CloudCourses(seeded.snapshot())
|
||||
restored.observe(_rep(["CloudExtraCourse_55", "Course_1B", f"OneTimeCloudCourse_{SPORTS}"]))
|
||||
assert restored.download_candidates() == []
|
||||
@@ -0,0 +1,786 @@
|
||||
"""End-to-end wiring for cloud "Download" cycles (issue #342).
|
||||
|
||||
The store's own rules are covered in test_cloud_courses.py. This file covers
|
||||
the parts that only exist once a coordinator is running: learning from an
|
||||
applied rep, persisting to the config entry, surfacing the store to entity
|
||||
descriptors through the synthetic field, the Repairs nudge, and the options
|
||||
flow that supplies the names.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings import cloudcourse
|
||||
from custom_components.localthings.const import CONF_CLOUD_COURSES, DOMAIN
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
from tests.conftest import _load_device
|
||||
from tests.test_subdevice_discovery import ENTRY_DATA
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fast_guided_waits(monkeypatch):
|
||||
"""Guided setup polls the appliance on a human timescale. Left at its
|
||||
real values a single lingering round would hold the suite for its whole
|
||||
timeout, since async_block_till_done waits on the task."""
|
||||
from custom_components.localthings import config_flow as cf
|
||||
|
||||
monkeypatch.setattr(cf, "_CLOUD_PROBE_INTERVAL_S", 0.01)
|
||||
monkeypatch.setattr(cf, "_CLOUD_WAIT_TIMEOUT_S", 0.5)
|
||||
|
||||
|
||||
FIXTURE = "washer_ww5000c_cloud"
|
||||
COURSE = cloudcourse.COURSE_HREF
|
||||
|
||||
SPORTS = "0021550449284D134AA04C0035F004F005F0AC00"
|
||||
JEANS = "001F6B0449284D134AA04C0035F004F005F0AC00"
|
||||
|
||||
|
||||
async def _flush(hass: HomeAssistant) -> None:
|
||||
"""hass.add_job hops used by the persist path."""
|
||||
for _ in range(3):
|
||||
await asyncio.sleep(0)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
def _entry(hass: HomeAssistant, data=None) -> MockConfigEntry:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, **(data or {})},
|
||||
unique_id="localthings_CLOUD-COURSE-TEST",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
async def _coordinator(hass: HomeAssistant, entry=None) -> LocalThingsCoordinator:
|
||||
coordinator = LocalThingsCoordinator(hass, entry or _entry(hass))
|
||||
resources = _load_device(FIXTURE)
|
||||
coordinator._run_discovery(resources)
|
||||
for href, rep in resources.items():
|
||||
coordinator._observe.apply(href, rep, source="poll")
|
||||
await _flush(hass)
|
||||
return coordinator
|
||||
|
||||
|
||||
def _cycle_state(coordinator: LocalThingsCoordinator):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
|
||||
return flatten(coordinator.bound, coordinator.entity_resources()).get("cycle")
|
||||
|
||||
|
||||
def _cycle_desc(coordinator: LocalThingsCoordinator) -> SelectDesc:
|
||||
return cast(SelectDesc, next(b.desc for b in coordinator.bound if b.desc.key == "cycle"))
|
||||
|
||||
|
||||
def _cycle_options(coordinator: LocalThingsCoordinator):
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
|
||||
return _cycle_desc(coordinator).options(coordinator.canonical_resources(MAIN))
|
||||
|
||||
|
||||
async def test_a_poll_learns_and_persists_the_loaded_programs(hass: HomeAssistant):
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
|
||||
assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
|
||||
assert coordinator.cloud_courses.snapshot()["slots"]["6B"]["blob"] == JEANS
|
||||
# Survives a restart -- the payload is only visible while loaded.
|
||||
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS
|
||||
|
||||
|
||||
async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant):
|
||||
"""An optimistic rep is what this integration just wrote, not what the
|
||||
appliance reported."""
|
||||
coordinator = LocalThingsCoordinator(hass, _entry(hass))
|
||||
coordinator._observe.apply(
|
||||
COURSE,
|
||||
{"x.com.samsung.da.options": ["CloudExtraCourse_55", f"CloudCourse_{SPORTS}"]},
|
||||
source="optimistic",
|
||||
)
|
||||
await _flush(hass)
|
||||
assert "55" not in coordinator.cloud_courses.snapshot()["slots"]
|
||||
|
||||
|
||||
async def test_learned_but_unnamed_programs_stay_out_of_the_cycle_select(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
options = _cycle_options(coordinator)
|
||||
assert all(not o.startswith(cloudcourse.RAW_PREFIX) for o in options)
|
||||
# The local courses are untouched.
|
||||
assert "87" in options
|
||||
|
||||
|
||||
async def test_naming_a_program_puts_it_in_the_cycle_select(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
assert "cloud:55" in _cycle_options(coordinator)
|
||||
# The fixture is sitting on Download with a Jeans one-time override, and
|
||||
# Jeans is still unnamed -- so the state falls through to the raw course.
|
||||
assert _cycle_state(coordinator) == "87"
|
||||
|
||||
coordinator.apply_cloud_courses({"6B": "Jeans"}, "87")
|
||||
await _flush(hass)
|
||||
assert _cycle_state(coordinator) == "cloud:6B"
|
||||
|
||||
|
||||
async def test_the_synthetic_field_never_reaches_the_device_snapshot(hass: HomeAssistant):
|
||||
"""It is merged at read time only: last_resources stays exactly what the
|
||||
appliance reported, so it can't be polled over, written back, or land in
|
||||
a diagnostics dump."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
assert cloudcourse.FIELD in coordinator.entity_resources()[COURSE]
|
||||
assert cloudcourse.FIELD not in coordinator.last_resources[COURSE]
|
||||
assert cloudcourse.FIELD not in coordinator.resource(COURSE)
|
||||
|
||||
|
||||
async def test_selecting_a_named_program_writes_both_tokens(hass: HomeAssistant):
|
||||
"""Driven through async_send_command rather than write_fn directly: the
|
||||
command path builds its own rep, and a rep taken straight off the state
|
||||
cache carries no cloud programs, so the write would silently no-op."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
sent: list[tuple[list[str], bytes]] = []
|
||||
|
||||
class _FakeSession:
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
sent.append((path_segs, payload))
|
||||
return 0x44, b""
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
coordinator._session = cast(Any, _FakeSession())
|
||||
# Same shape as test_coordinator_send_command's fixture: a write schedules
|
||||
# a debounced refresh, which would poll through this fake and leave its
|
||||
# timer behind after the test.
|
||||
coordinator.async_request_refresh = AsyncMock()
|
||||
bound = next(b for b in coordinator.bound if b.desc.key == "cycle")
|
||||
await coordinator.async_send_command(bound, "cloud:55")
|
||||
|
||||
assert len(sent) == 1
|
||||
path_segs, payload = sent[0]
|
||||
assert path_segs == ["course", "vs", "0"]
|
||||
assert cbor2.loads(payload)["x.com.samsung.da.options"] == [
|
||||
"Course_87",
|
||||
f"OneTimeCloudCourse_{SPORTS}",
|
||||
]
|
||||
# The optimistic merge keeps the rest of the array intact for the settle
|
||||
# window -- including the device's own slot list and the saved default.
|
||||
merged = coordinator.resource(COURSE)["x.com.samsung.da.options"]
|
||||
assert "Course_87" in merged
|
||||
assert f"OneTimeCloudCourse_{SPORTS}" in merged
|
||||
assert "CloudExtraCourse_0A5C286B2D0C55301A" in merged
|
||||
|
||||
|
||||
async def test_a_repair_is_raised_until_every_program_is_named(hass: HomeAssistant):
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator._refresh_cloud_course_issue()
|
||||
await _flush(hass)
|
||||
|
||||
registry = ir.async_get(hass)
|
||||
issue_id = f"cloud_courses_{entry.entry_id}"
|
||||
issue = registry.async_get_issue(DOMAIN, issue_id)
|
||||
assert issue is not None
|
||||
assert (issue.translation_placeholders or {})["total"] == "9"
|
||||
|
||||
for slot in cloudcourse.advertised_slots(coordinator.cloud_course_rep()):
|
||||
# Only two are learned; name every advertised slot to close the gap.
|
||||
coordinator.cloud_courses.observe(
|
||||
{
|
||||
"x.com.samsung.da.options": [
|
||||
"CloudExtraCourse_0A5C286B2D0C55301A",
|
||||
f"CloudCourse_0000{slot}0449284D134AA04C0035F004F005F0AC00",
|
||||
]
|
||||
}
|
||||
)
|
||||
coordinator.apply_cloud_courses({slot: f"Program {slot}"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
assert registry.async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
async def test_no_repair_for_a_device_without_downloaded_programs(hass: HomeAssistant):
|
||||
entry = _entry(hass)
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
resources = _load_device("washer")
|
||||
coordinator._run_discovery(resources)
|
||||
for href, rep in resources.items():
|
||||
coordinator._observe.apply(href, rep, source="poll")
|
||||
coordinator._refresh_cloud_course_issue()
|
||||
await _flush(hass)
|
||||
|
||||
registry = ir.async_get(hass)
|
||||
assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None
|
||||
|
||||
|
||||
async def test_a_malformed_entry_record_does_not_block_setup(hass: HomeAssistant):
|
||||
entry = _entry(hass, data={CONF_CLOUD_COURSES: {"slots": {"55": {"blob": "junk"}}}})
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
# Dropped on restore, then relearned from the live poll.
|
||||
assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Options flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _options_handler(hass: HomeAssistant, coordinator: LocalThingsCoordinator):
|
||||
from custom_components.localthings.config_flow import LocalThingsOptionsFlow
|
||||
|
||||
entry = coordinator.config_entry
|
||||
assert entry is not None
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
handler = LocalThingsOptionsFlow()
|
||||
handler.hass = hass
|
||||
# OptionsFlow.config_entry resolves through `handler`, which the flow
|
||||
# manager normally sets when it starts the flow.
|
||||
handler.handler = entry.entry_id
|
||||
return handler
|
||||
|
||||
|
||||
async def test_the_menu_offers_download_cycles_only_when_the_device_has_them(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_init()
|
||||
assert "cloud_courses" in result["menu_options"]
|
||||
|
||||
|
||||
async def test_naming_through_the_flow_persists(hass: HomeAssistant):
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
await handler.async_step_cloud_manual()
|
||||
await handler.async_step_cloud_manual(
|
||||
{"name_55": "Sports", "name_6B": "Jeans", "download_course": "87"}
|
||||
)
|
||||
await _flush(hass)
|
||||
|
||||
assert coordinator.cloud_courses.named() == {"55": "Sports", "6B": "Jeans"}
|
||||
assert entry.data[CONF_CLOUD_COURSES]["download_course"] == "87"
|
||||
|
||||
|
||||
async def test_the_flow_rejects_two_programs_sharing_a_name(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
result = await handler.async_step_cloud_manual(
|
||||
{"name_55": "Sports", "name_6B": "sports", "download_course": "87"}
|
||||
)
|
||||
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
|
||||
assert coordinator.cloud_courses.named() == {}
|
||||
|
||||
|
||||
async def test_clearing_a_name_removes_the_program_from_the_select(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": "87"})
|
||||
await _flush(hass)
|
||||
assert "cloud:55" in _cycle_options(coordinator)
|
||||
|
||||
await handler.async_step_cloud_manual({"name_55": "", "download_course": "87"})
|
||||
await _flush(hass)
|
||||
assert "cloud:55" not in _cycle_options(coordinator)
|
||||
|
||||
|
||||
async def test_without_a_confirmed_download_course_nothing_is_offered(hass: HomeAssistant):
|
||||
"""Names alone aren't enough -- there'd be no course code to write."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": ""})
|
||||
await _flush(hass)
|
||||
|
||||
assert coordinator.cloud_courses.named() == {"55": "Sports"}
|
||||
assert "cloud:55" not in _cycle_options(coordinator)
|
||||
|
||||
|
||||
async def test_the_form_proposes_the_observed_download_course(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_cloud_manual()
|
||||
|
||||
assert result["step_id"] == "cloud_manual"
|
||||
assert result["description_placeholders"]["total"] == "9"
|
||||
# Two learned so far, seven still to walk through on the appliance.
|
||||
assert result["description_placeholders"]["found"] == "2"
|
||||
_observe_program_load(coordinator, SPORTS)
|
||||
assert coordinator.cloud_courses.download_candidates() == ["87"]
|
||||
|
||||
|
||||
async def test_no_repair_until_a_program_has_actually_been_seen(hass: HomeAssistant):
|
||||
"""The DW5000C advertises four downloaded programs and has never loaded
|
||||
one, so nothing about them is learnable and no name field can be offered.
|
||||
Warning its owner about a feature they may never use -- with no action
|
||||
that could clear it -- is worse than staying quiet until they run one."""
|
||||
entry = _entry(hass)
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
resources = _load_device("dishwasher_dw5000c_cloud")
|
||||
coordinator._run_discovery(resources)
|
||||
for href, rep in resources.items():
|
||||
coordinator._observe.apply(href, rep, source="poll")
|
||||
coordinator._refresh_cloud_course_issue()
|
||||
await _flush(hass)
|
||||
|
||||
assert cloudcourse.advertised_slots(coordinator.cloud_course_rep()) == ["8E", "8D", "8F", "02"]
|
||||
assert coordinator.cloud_courses.snapshot()["slots"] == {}
|
||||
registry = ir.async_get(hass)
|
||||
assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None
|
||||
|
||||
|
||||
async def test_the_synthetic_field_never_reaches_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
enable_custom_integrations,
|
||||
):
|
||||
"""canonical_resources carries it so entity descriptors can read it, and
|
||||
diagnostics reads canonical_resources -- so the drop has to happen at the
|
||||
redaction boundary. A dump is meant to be what the appliance said, and
|
||||
cloud program names are text the user typed."""
|
||||
from custom_components.localthings.diagnostics import (
|
||||
async_get_config_entry_diagnostics,
|
||||
)
|
||||
|
||||
entry = _entry(hass)
|
||||
coordinator = await _coordinator(hass, entry)
|
||||
coordinator.apply_cloud_courses({"55": "Marc's weekend towels"}, "87")
|
||||
await _flush(hass)
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, entry)
|
||||
assert cloudcourse.FIELD not in json.dumps(diag["resources"])
|
||||
# The device's own tokens are still there -- resources stays what it said.
|
||||
assert "CloudExtraCourse_0A5C286B2D0C55301A" in json.dumps(diag["resources"])
|
||||
|
||||
# The store is reported in full in its own block: what was discovered,
|
||||
# what the user named it, and which course they confirmed. Half of what
|
||||
# goes wrong here is a configuration question and none of it is
|
||||
# answerable from the payloads alone.
|
||||
cloud = diag["cloud_courses"]
|
||||
assert cloud["advertised_slots"] == ["0A", "5C", "28", "6B", "2D", "0C", "55", "30", "1A"]
|
||||
assert cloud["download_course"] == "87"
|
||||
assert cloud["slots"]["55"] == {"blob": SPORTS, "name": "Marc's weekend towels"}
|
||||
# A slot seen but never named is reported too -- that gap is the thing a
|
||||
# "why isn't my download cycle showing up" report needs to reveal.
|
||||
assert cloud["slots"]["6B"] == {"blob": JEANS, "name": ""}
|
||||
|
||||
|
||||
async def test_the_debug_read_service_reports_only_device_state(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
stripped = coordinator.device_resources(MAIN)
|
||||
assert cloudcourse.FIELD not in stripped[COURSE]
|
||||
assert "x.com.samsung.da.options" in stripped[COURSE]
|
||||
|
||||
|
||||
async def test_the_flow_rejects_a_download_course_the_device_does_not_offer(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Whatever lands here becomes the Course_ token of a real write."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
result = await handler.async_step_cloud_manual({"name_55": "Sports", "download_course": "FF"})
|
||||
assert result["errors"] == {"base": "cloud_course_unknown_course"}
|
||||
assert coordinator.cloud_courses.snapshot()["download_course"] is None
|
||||
assert coordinator.cloud_courses.named() == {}
|
||||
|
||||
|
||||
async def test_the_flow_rejects_a_name_shadowing_a_personal_course(hass: HomeAssistant):
|
||||
"""Personal course names come from the device, not the catalog, and the
|
||||
select renders them the same way -- so they collide the same way."""
|
||||
coordinator = await _coordinator(hass)
|
||||
# 'MyCo' as a personal course label on a code the device offers.
|
||||
rep = dict(coordinator.resource("/wm/personalcourse/vs/0") or {})
|
||||
rep["x.com.samsung.da.courses"] = ["1C_01044D79436F"]
|
||||
coordinator._observe.apply("/wm/personalcourse/vs/0", rep, source="poll")
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_cloud_manual({"name_55": "MyCo", "download_course": "87"})
|
||||
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Guided setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _observe_program_load(coordinator, blob, course="87"):
|
||||
"""Stand in for the user loading a program while Home Assistant watches.
|
||||
A candidate Download course comes only from a transition that was
|
||||
actually observed, so tests that expect one have to produce it."""
|
||||
coordinator._observe.apply(
|
||||
COURSE,
|
||||
{
|
||||
"x.com.samsung.da.options": [
|
||||
"CloudExtraCourse_0A5C286B2D0C55301A",
|
||||
f"Course_{course}",
|
||||
f"OneTimeCloudCourse_{blob}",
|
||||
]
|
||||
},
|
||||
source="poll",
|
||||
)
|
||||
|
||||
|
||||
def _stub_probe(coordinator, sequence):
|
||||
"""Drive async_probe_cloud_courses from a scripted list of loaded slots,
|
||||
standing in for the user turning the dial. The last entry repeats."""
|
||||
seq = list(sequence)
|
||||
|
||||
async def probe() -> str | None:
|
||||
return seq.pop(0) if len(seq) > 1 else seq[0]
|
||||
|
||||
# setattr, not assignment: the stub stands in for a bound method.
|
||||
setattr(coordinator, "async_probe_cloud_courses", probe) # noqa: B010
|
||||
|
||||
|
||||
async def _run_wait(handler):
|
||||
"""Re-enter the progress step the way Home Assistant does until its task
|
||||
settles, then follow the completion through."""
|
||||
result = await handler.async_step_cloud_wait()
|
||||
for _ in range(200):
|
||||
if result["type"] != "progress":
|
||||
return result
|
||||
if handler._cloud_task is not None:
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(asyncio.shield(handler._cloud_task), 5)
|
||||
result = await handler.async_step_cloud_wait()
|
||||
raise AssertionError("progress step never settled")
|
||||
|
||||
|
||||
async def test_guided_waits_for_a_change_not_a_state(hass: HomeAssistant):
|
||||
"""The trap this design exists to avoid. After naming a program the
|
||||
appliance is still sitting on it, so a loop that fires on "a known slot is
|
||||
loaded" would re-offer the same one forever. Each round baselines on what
|
||||
is loaded when it starts and completes only on a change."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
# The fixture is already loaded with 6B, so that is the baseline.
|
||||
await handler.async_step_cloud_guided()
|
||||
assert handler._cloud_baseline == "6B"
|
||||
|
||||
# The appliance stays on 6B: no round completes.
|
||||
_stub_probe(coordinator, ["6B"])
|
||||
handler._cloud_task = None
|
||||
task = hass.async_create_task(handler._await_cloud_selection(coordinator))
|
||||
await asyncio.sleep(0)
|
||||
assert not task.done()
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def test_guided_names_a_newly_selected_program(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
|
||||
_stub_probe(coordinator, ["55"])
|
||||
result = await _run_wait(handler)
|
||||
assert result["step_id"] == "cloud_name"
|
||||
|
||||
form = await handler.async_step_cloud_name()
|
||||
assert form["description_placeholders"]["slot"] == "55"
|
||||
# Persisted immediately -- closing the dialog now would lose nothing.
|
||||
await handler.async_step_cloud_name({"name": "Sports"})
|
||||
await _flush(hass)
|
||||
assert coordinator.cloud_courses.named() == {"55": "Sports"}
|
||||
|
||||
|
||||
async def test_guided_rebaselines_so_the_named_program_cannot_refire(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
await handler.async_step_cloud_name({"name": "Sports"})
|
||||
await _flush(hass)
|
||||
# The next round starts from the program just named, so the appliance
|
||||
# sitting on it is not a new selection.
|
||||
assert handler._cloud_baseline == "55"
|
||||
|
||||
|
||||
async def test_reselecting_a_named_program_offers_an_edit_not_an_error(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Re-picking one is how someone checks their work. It gets the existing
|
||||
name pre-filled, and the counter deliberately does not move."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
|
||||
form = await handler.async_step_cloud_name()
|
||||
assert form["errors"] == {}
|
||||
assert form["data_schema"]({})["name"] == "Sports"
|
||||
before = form["description_placeholders"]["named"]
|
||||
await handler.async_step_cloud_name({"name": "Sports"})
|
||||
await _flush(hass)
|
||||
after = handler._cloud_progress_placeholders(coordinator)["named"]
|
||||
assert before == after == "1"
|
||||
|
||||
|
||||
async def test_guided_times_out_into_a_retry_or_finish_menu(hass: HomeAssistant, monkeypatch):
|
||||
from custom_components.localthings import config_flow as cf
|
||||
|
||||
monkeypatch.setattr(cf, "_CLOUD_WAIT_TIMEOUT_S", 0.0)
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["6B"])
|
||||
|
||||
result = await _run_wait(handler)
|
||||
assert result["step_id"] == "cloud_timeout"
|
||||
menu = await handler.async_step_cloud_timeout()
|
||||
assert set(menu["menu_options"]) == {"cloud_guided", "cloud_finish"}
|
||||
|
||||
|
||||
async def test_a_failed_probe_does_not_end_the_round(hass: HomeAssistant):
|
||||
"""A dropped read is one missed poll, not a reason to bail on someone
|
||||
standing at the appliance."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, [None, None, "55"])
|
||||
|
||||
result = await _run_wait(handler)
|
||||
assert result["step_id"] == "cloud_name"
|
||||
assert handler._cloud_slot == "55"
|
||||
|
||||
|
||||
async def test_the_entry_step_is_a_menu_offering_both_paths(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_cloud_courses()
|
||||
assert set(result["menu_options"]) == {"cloud_guided", "cloud_manual"}
|
||||
|
||||
|
||||
async def test_closing_the_dialog_stops_probing_the_appliance(hass: HomeAssistant):
|
||||
"""Closing the dialog is the documented way to leave guided setup, so it
|
||||
has to actually stop -- an abandoned round would otherwise keep taking the
|
||||
session lock every few seconds for a user who has walked away."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
|
||||
probes = 0
|
||||
|
||||
async def probe() -> str | None:
|
||||
nonlocal probes
|
||||
probes += 1
|
||||
return "6B" # never changes, so the round would run to timeout
|
||||
|
||||
# setattr, not assignment: the stub stands in for a bound method.
|
||||
setattr(coordinator, "async_probe_cloud_courses", probe) # noqa: B010
|
||||
result = await handler.async_step_cloud_wait()
|
||||
assert result["type"] == "progress"
|
||||
task = handler._cloud_task
|
||||
assert task is not None and not task.done()
|
||||
|
||||
handler.async_remove()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
assert task.cancelled()
|
||||
settled = probes
|
||||
await asyncio.sleep(0.05)
|
||||
assert probes == settled
|
||||
|
||||
|
||||
async def test_guided_lists_the_names_assigned_so_far(hass: HomeAssistant):
|
||||
"""The only orientation available on a long walk -- the programs still to
|
||||
do are unnamed by definition, so 'done so far' is all there is to show.
|
||||
It doubles as duplicate avoidance, since the form rejects a repeat."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
assert handler._cloud_progress_placeholders(coordinator)["named_list"] == "none yet"
|
||||
|
||||
coordinator.apply_cloud_courses({"55": "Sports", "6B": "Jeans"}, "87")
|
||||
await _flush(hass)
|
||||
placeholders = handler._cloud_progress_placeholders(coordinator)
|
||||
assert placeholders["named"] == "2"
|
||||
assert placeholders["total"] == "9"
|
||||
# The appliance's own advertised order, not naming order: 6B precedes 55
|
||||
# in CloudExtraCourse_0A5C286B2D0C55301A.
|
||||
assert placeholders["named_list"] == "Jeans, Sports"
|
||||
|
||||
|
||||
async def test_the_name_form_shows_the_other_names_while_typing(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"6B": "Jeans"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
|
||||
form = await handler.async_step_cloud_name()
|
||||
assert form["description_placeholders"]["named_list"] == "Jeans"
|
||||
|
||||
|
||||
async def test_guided_naming_never_clears_a_confirmed_download_course(hass: HomeAssistant):
|
||||
"""A program needs both a name and the Download course before it can be
|
||||
offered. The guided name form says nothing about the course, and passing
|
||||
that silence through as None wiped a confirmed one -- so naming a program
|
||||
removed every already-named program from the cycle list."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"6B": "Jeans"}, "87")
|
||||
await _flush(hass)
|
||||
assert "cloud:6B" in _cycle_options(coordinator)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
await handler.async_step_cloud_name({"name": "Sports"})
|
||||
await _flush(hass)
|
||||
|
||||
assert coordinator.cloud_courses.snapshot()["download_course"] == "87"
|
||||
assert {"cloud:55", "cloud:6B"} <= set(_cycle_options(coordinator))
|
||||
|
||||
|
||||
async def test_guided_setup_alone_produces_a_selectable_cycle(hass: HomeAssistant):
|
||||
"""The walk has to stand on its own: someone who only ever uses guided
|
||||
setup must end up with something in the cycle list. So the first name
|
||||
form also asks for the Download course, prefilled from what was just
|
||||
observed, and drops the field once it is confirmed."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
|
||||
first = await handler.async_step_cloud_name()
|
||||
assert "download_course" in str(first["data_schema"].schema)
|
||||
# Prefilled once a load has actually been watched -- in real use the
|
||||
# guided probe loop feeds observe() and produces exactly this.
|
||||
_observe_program_load(coordinator, SPORTS)
|
||||
assert handler._cloud_course == "87"
|
||||
|
||||
await handler.async_step_cloud_name({"name": "Sports", "download_course": "87"})
|
||||
await _flush(hass)
|
||||
assert "cloud:55" in _cycle_options(coordinator)
|
||||
|
||||
# Confirmed now, so the next program is asked for its name only.
|
||||
handler._cloud_slot = "6B"
|
||||
second = await handler.async_step_cloud_name()
|
||||
assert "download_course" not in str(second["data_schema"].schema)
|
||||
|
||||
|
||||
async def test_guided_rejects_a_download_course_the_device_does_not_offer(hass: HomeAssistant):
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
|
||||
result = await handler.async_step_cloud_name({"name": "Sports", "download_course": "FF"})
|
||||
assert result["errors"] == {"base": "cloud_course_unknown_course"}
|
||||
assert coordinator.cloud_courses.snapshot()["download_course"] is None
|
||||
|
||||
|
||||
async def test_guided_rejects_a_name_another_program_already_has(hass: HomeAssistant):
|
||||
"""Guided setup submits one program at a time, so a within-form check
|
||||
can't see the others. Two programs sharing a label resolve to whichever
|
||||
option comes first, meaning picking the second would run the first one's
|
||||
payload."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"6B": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
|
||||
result = await handler.async_step_cloud_name({"name": "Sports"})
|
||||
await _flush(hass)
|
||||
assert result["errors"] == {"base": "cloud_course_name_duplicate"}
|
||||
assert coordinator.cloud_courses.named() == {"6B": "Sports"}
|
||||
|
||||
|
||||
async def test_renaming_a_program_to_its_own_name_is_allowed(hass: HomeAssistant):
|
||||
"""The slot being edited is excluded from the taken set, or confirming an
|
||||
unchanged name would reject itself."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
_stub_probe(coordinator, ["55"])
|
||||
await _run_wait(handler)
|
||||
|
||||
result = await handler.async_step_cloud_name({"name": "Sports"})
|
||||
assert result["type"] == "progress" # accepted, straight back to waiting
|
||||
|
||||
|
||||
async def test_the_timeout_screen_fills_in_its_counters(hass: HomeAssistant):
|
||||
"""Its copy interpolates the same placeholders as the other screens, so
|
||||
without them the dialog renders literal braces."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
|
||||
await _flush(hass)
|
||||
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
result = await handler.async_step_cloud_timeout()
|
||||
assert result["description_placeholders"]["named"] == "1"
|
||||
assert result["description_placeholders"]["named_list"] == "Sports"
|
||||
|
||||
|
||||
async def test_guided_ignores_a_payload_the_store_would_not_record(hass: HomeAssistant):
|
||||
"""The probe reports whatever payload is loaded; observe() declines one
|
||||
whose slot the device doesn't advertise. Offering to name that would take
|
||||
a name and discard it -- there is no record to hang it on."""
|
||||
coordinator = await _coordinator(hass)
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
await handler.async_step_cloud_guided()
|
||||
|
||||
# 'ZZ' is not in this appliance's CloudExtraCourse_ list.
|
||||
_stub_probe(coordinator, ["ZZ"])
|
||||
result = await _run_wait(handler)
|
||||
assert result["step_id"] == "cloud_timeout"
|
||||
|
||||
|
||||
async def test_the_prefilled_course_is_always_one_the_dropdown_offers(hass: HomeAssistant):
|
||||
"""An observed candidate the appliance's course list no longer contains
|
||||
would prefill a value the selector rejects, failing validation on
|
||||
something the user never chose."""
|
||||
coordinator = await _coordinator(hass)
|
||||
coordinator.cloud_courses._candidates["FF"] = 99 # not in the course list
|
||||
handler = await _options_handler(hass, coordinator)
|
||||
|
||||
offered = handler._cloud_course_selector(coordinator).config["options"]
|
||||
assert handler._cloud_course is None or handler._cloud_course in offered
|
||||
@@ -201,3 +201,33 @@ async def test_main_climate_write_unaffected_by_subdevice_translation(coordinato
|
||||
|
||||
posted_path, _ = coordinator._session.post_calls[0]
|
||||
assert posted_path == ["power", "vs", "0"]
|
||||
|
||||
|
||||
async def test_indexed_subdevice_climate_write_quantizes_with_its_own_temperature_step(
|
||||
coordinator,
|
||||
) -> None:
|
||||
"""A composite AC's write_fn (airconditioner._climate_write) reads the
|
||||
temperature step off write_fn's own `resources` argument -- if
|
||||
async_send_command handed it the raw cache snapshot (real, on-the-wire
|
||||
hrefs) instead of this subdevice's canonical_resources() view, the
|
||||
subdevice's own /temperature/control/vs/1 would be invisible under the
|
||||
canonical HREF_TEMP_CONTROL key, and the write would quantize against
|
||||
the master's step (or none at all) instead of its own."""
|
||||
sub1 = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
coordinator._observe.apply(
|
||||
"/temperature/control/vs/0",
|
||||
{"x.com.samsung.da.increment": "1"},
|
||||
source="poll",
|
||||
)
|
||||
coordinator._observe.apply(
|
||||
"/temperature/control/vs/1",
|
||||
{"x.com.samsung.da.increment": "0.5"},
|
||||
source="poll",
|
||||
)
|
||||
bound = _climate_bound("/mode/vs/1", sub1)
|
||||
|
||||
await coordinator.async_send_command(bound, ("temperature_ocf", 24.5))
|
||||
|
||||
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
||||
assert posted_path == ["temperature", "desired", "1"]
|
||||
assert cbor2.loads(posted_bytes) == {"temperature": 24.5}
|
||||
|
||||
@@ -5,7 +5,7 @@ cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we
|
||||
check the dishwasher wiring and its device-specific options.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.capabilities import dishwasher, laundry
|
||||
from custom_components.localthings.registry.capabilities import dishwasher
|
||||
from custom_components.localthings.registry.entities import SwitchDesc
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ class TestCycleOptions:
|
||||
|
||||
def test_cycle_desc_uses_shared_cycle_options(self):
|
||||
desc = self._cycle()
|
||||
assert desc.options is laundry.cycle_options
|
||||
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_0E82"}}
|
||||
assert desc.options(live) == ["0E", "82"]
|
||||
assert desc.translation_key == "dishwasher_cycle"
|
||||
|
||||
def test_exists_only_when_edit_course_list_is_live(self):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import dryer, ignored, laundry
|
||||
from custom_components.localthings.registry.capabilities import dryer, ignored
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from tests.conftest import _load_device
|
||||
@@ -82,12 +82,27 @@ def test_course_bound_to_shared_course_vs_0():
|
||||
table_03 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_03"}}
|
||||
assert desc.translation_key(table_03) == "dryer_cycle_table_03"
|
||||
assert desc.translation_key({}) == "cycle"
|
||||
assert desc.options is laundry.cycle_options
|
||||
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1620"}}
|
||||
assert desc.options(live) == ["16", "20"]
|
||||
rep = {"x.com.samsung.da.options": ["Course_16", "GMT_02"]}
|
||||
assert desc.rep_fn is not None
|
||||
assert desc.rep_fn(rep) == "16"
|
||||
|
||||
|
||||
def test_reported_table_00_course_codes_are_translated():
|
||||
"""The reporter confirmed these codes on a DVE45R6300W/A3 by selecting
|
||||
each cycle and reading back the raw course code (issue #357)."""
|
||||
from custom_components.localthings.catalog import translated_states
|
||||
|
||||
desc = next(
|
||||
e for e in dryer.DRYER_COURSE.entities if e.key == "cycle" and isinstance(e, SelectDesc)
|
||||
)
|
||||
table_00 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
|
||||
assert desc.translation_key(table_00) == "dryer_cycle_table_00"
|
||||
confirmed = {"01", "9c", "a5", "9e", "9b", "27", "a0", "a4", "a6", "a3", "a2"}
|
||||
assert confirmed <= translated_states("select", "dryer_cycle_table_00")
|
||||
|
||||
|
||||
def test_st_dryercourse_is_ignored():
|
||||
"""/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and
|
||||
is globally ignored -- the mirror of /st/washercourse/vs/0."""
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Tests for the TP2X_DA-KS-COOKTOP-000001 gas cooktop (issue #314).
|
||||
|
||||
The reporter's actual complaint (six advertised burner slots when only
|
||||
three are physically present) is expected: the registry declares a
|
||||
generous static superset of slots and gates each on the live options array
|
||||
per cooktop.py's own comment; there's no per-device signal that
|
||||
distinguishes an advertised-but-nonexistent slot from a real one, so the
|
||||
extra entities are disabled by the user, not filtered by the integration.
|
||||
This file locks in the actual gap from that issue -- /alarms/vs/0 and
|
||||
/kidslock/vs/0 -- now bound via common.ALARMS/KIDS_LOCK_VS_FALLBACK.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _load_device("gas_cooktop_tp2x_ks")
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_resolves_to_gas_cooktop_registry():
|
||||
resources = _load_device("gas_cooktop_tp2x_ks")
|
||||
reg = resolve(resources)
|
||||
assert reg is not None and reg.name == "gas_cooktop"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
_, resources = _bound()
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_alarm_code_and_child_lock_bound():
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["alarm_code"] == "CT_E"
|
||||
# x.com.samsung.da.kidsLock == "Ready" reads as unlocked, same polarity
|
||||
# as every other board KIDS_LOCK_VS_FALLBACK covers.
|
||||
assert state["child_lock"] is True
|
||||
|
||||
|
||||
# The reporter's original complaint -- six advertised burner slots when
|
||||
# only three are physically present -- isn't tested here beyond what the
|
||||
# golden regression test already locks in (burner_0_state..burner_5_state
|
||||
# all present). It's expected, not a bug: the registry declares a generous
|
||||
# static superset of slots per cooktop.py's own comment, and this board's
|
||||
# /mode/vs/0 rep carries no field distinguishing a real slot from an
|
||||
# advertised-but-nonexistent one for a test to assert against -- the user
|
||||
# disabling the three extra entities is the correct fix, not something the
|
||||
# integration can filter automatically.
|
||||
@@ -6,12 +6,12 @@ import pytest
|
||||
GOLDEN = Path(__file__).parent / "fixtures" / "golden"
|
||||
|
||||
|
||||
def _new_state_keys(name, resources):
|
||||
def _new_state_keys(name, resources, device_types=()):
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
reg = resolve(resources)
|
||||
reg = resolve(resources, device_types=device_types)
|
||||
if reg is None:
|
||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||
|
||||
@@ -134,6 +134,49 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_airconditioner_ailp_fac():
|
||||
"""AILP_DA-AC-FAC-02011_0000 (issue #319) resolves both ways --
|
||||
modelNum's 'FAC' board token and /oic/d's oic.d.airconditioner type
|
||||
independently agree on the airconditioner registry. Passes
|
||||
device_types through resolve() to exercise that agreement, not because
|
||||
the board token is absent."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("airconditioner_ailp_fac")
|
||||
golden = json.loads((GOLDEN / "airconditioner_ailp_fac.json").read_text())
|
||||
state_keys = _new_state_keys(
|
||||
"airconditioner_ailp_fac", resources, device_types=("oic.wk.d", "oic.d.airconditioner")
|
||||
)
|
||||
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_oven_tp2x_ks_walloven():
|
||||
"""TP2X_DA-KS-WALLOVEN-000002 (issue #300), a steam-oven-class board
|
||||
(water tank, descale, pyro-free) quite unlike the NV7000BS-class board
|
||||
oven.py's write comments were written against. /diagnosis/vs/0 was the
|
||||
dump's only unbound href, now covered via dishwasher.DIAGNOSIS. The
|
||||
board's options[] also has no UpperLamp_ token at all -- confirms
|
||||
LAMP's new exists_fn keeps it from registering a phantom switch here,
|
||||
same fastpreheat/NaturalSteam-class gap issue #183 fixed on its
|
||||
siblings."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("oven_tp2x_ks_walloven")
|
||||
golden = json.loads((GOLDEN / "oven_tp2x_ks_walloven.json").read_text())
|
||||
state_keys = _new_state_keys(
|
||||
"oven_tp2x_ks_walloven", resources, device_types=("oic.wk.d", "oic.d.oven")
|
||||
)
|
||||
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_dehumidifier():
|
||||
"""TP1X_DA_AC_DHM_01001_0000 (issue #88, AY18CG7500GED) shares the DA_AC_
|
||||
board family with the room-AC models but carries the '_DHM_' token;
|
||||
@@ -213,6 +256,25 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier_coffee():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_gas_cooktop_tp2x_ks():
|
||||
"""TP2X_DA-KS-COOKTOP-000001 (issue #314) -- routes via
|
||||
for_device_by_resources' DeviceType_/OperationState signature, same as
|
||||
the original cooktop fixture. /alarms/vs/0 and /kidslock/vs/0 were the
|
||||
dump's only unbound hrefs; both are common.UNIVERSAL shapes this
|
||||
registry now picks up individually (common.ALARMS,
|
||||
common.KIDS_LOCK_VS_FALLBACK)."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("gas_cooktop_tp2x_ks")
|
||||
golden = json.loads((GOLDEN / "gas_cooktop_tp2x_ks.json").read_text())
|
||||
state_keys = _new_state_keys("gas_cooktop_tp2x_ks", 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_cooktop():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
@@ -259,6 +321,24 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_eu():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_airfilter():
|
||||
"""TP1X_REF_21K, air-filter-equipped variant (issue #318) -- reports
|
||||
/filter/airdustfilter/vs/0 (internal deodorizing filter), the one
|
||||
unbound href on this dump. Unlike airconditioner.AIR_FILTER's
|
||||
filterUsage/filterCapacity pair, this board's filterUsage is already a
|
||||
0-100 percentage with no filterCapacity to divide by."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("refrigerator_tp1x_ref_21k_airfilter")
|
||||
golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_airfilter.json").read_text())
|
||||
state_keys = _new_state_keys("refrigerator_tp1x_ref_21k_airfilter", resources)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_range_hood():
|
||||
from tests.conftest import _load_device
|
||||
|
||||
@@ -1052,12 +1132,16 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora():
|
||||
)
|
||||
|
||||
|
||||
def _new_subdevice_aware_state_keys(name):
|
||||
def _new_subdevice_aware_state_keys(name, device_types=()):
|
||||
"""Like _new_state_keys, but runs the full subdevice-aware pipeline
|
||||
(enumerate_subdevices + discover_partitioned, issue #177) instead of a
|
||||
single discover() call, so the golden for a composite-device fixture
|
||||
captures every materialized subdevice's keys
|
||||
(subdevice1_-/subdevice_<uuid>_-prefixed), not just the master's."""
|
||||
(subdevice1_-/subdevice_<uuid>_-prefixed), not just the master's.
|
||||
|
||||
`device_types` threads through to discover_partitioned's own
|
||||
`oic_device_types` -- needed for a board with no /information/vs/0 at
|
||||
all to route from (issue #324's range)."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from tests.conftest import _discover_full, _load_device_full
|
||||
|
||||
@@ -1066,6 +1150,7 @@ def _new_subdevice_aware_state_keys(name):
|
||||
resources,
|
||||
oic_res,
|
||||
seeds,
|
||||
device_types,
|
||||
)
|
||||
state = flatten(bound, full_resources)
|
||||
return sorted(state.keys())
|
||||
@@ -1095,10 +1180,9 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac():
|
||||
0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never
|
||||
been added to the modelNum board-token table. Resolved via the new 'CAC'
|
||||
token onto the existing airconditioner registry. Not fully covered yet --
|
||||
ten hrefs remain unbound (edge lighting, PM1 filter, stateful light,
|
||||
absence-clean, four sound-settings resources, smart-sensing-cooling, UV
|
||||
LED), all genuinely new to this board generation and out of scope for
|
||||
the routing fix; see test_airconditioner_cac.py for the documented gap."""
|
||||
two hrefs remain unbound (sound-optimization, smart-sensing-cooling),
|
||||
both genuinely new to this board generation and out of scope for the
|
||||
routing fix; see test_airconditioner_cac.py for the documented gap."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("airconditioner_cac")
|
||||
@@ -1328,6 +1412,78 @@ def test_registry_reproduces_golden_state_keys_for_refrigerator_tp1x_ref_21k_def
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_refrigerator_tp1x_ref_21k_autodoor():
|
||||
"""TP1X_REF_21K, single-freezer-door Auto Door Open variant (issue
|
||||
#328): /autodoor/single/vs/0 + /autodoor/timer/vs/0, plus
|
||||
STATUS_LOCK's ado.voicecontrol sibling toggle."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("refrigerator_tp1x_ref_21k_autodoor")
|
||||
golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_autodoor.json").read_text())
|
||||
state_keys = _new_state_keys("refrigerator_tp1x_ref_21k_autodoor", resources)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_refrigerator_tp1x_ref_21k_kimchi():
|
||||
"""Kimchi-refrigerator Auto Door Open variant (issue #328) -- resolves
|
||||
via /oic/d's oic.d.krefrigerator, not modelNum (the board is the same
|
||||
TP1X_REF_21K as the regular fridge, so only the OCF type tells them
|
||||
apart before checking hrefs)."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("refrigerator_tp1x_ref_21k_kimchi")
|
||||
golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_kimchi.json").read_text())
|
||||
state_keys = _new_state_keys(
|
||||
"refrigerator_tp1x_ref_21k_kimchi",
|
||||
resources,
|
||||
device_types=("oic.wk.d", "oic.d.krefrigerator"),
|
||||
)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_refrigerator_winecellar():
|
||||
"""Wine-cellar refrigerator variant (issue #328) -- resolves via /oic/d's
|
||||
x.com.st.d.winecellar, same TP1X_REF_21K board as the other two. Adds
|
||||
the deodorizing filter at its own href, the multi-compartment pantry
|
||||
select, and the table-revision info resource."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("refrigerator_winecellar")
|
||||
golden = json.loads((GOLDEN / "refrigerator_winecellar.json").read_text())
|
||||
state_keys = _new_state_keys(
|
||||
"refrigerator_winecellar",
|
||||
resources,
|
||||
device_types=("oic.wk.d", "x.com.st.d.winecellar"),
|
||||
)
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_range_tp1x_da_ks_range_0101x():
|
||||
"""Dual-cavity range (issue #324): no /information/vs/0 at all, so
|
||||
routing depends entirely on /oic/d's oic.d.range -- and the second
|
||||
cavity is a genuine Pattern A indexed subdevice at /device/1."""
|
||||
name = "range_tp1x_da_ks_range_0101x"
|
||||
golden = json.loads((GOLDEN / f"{name}.json").read_text())
|
||||
state_keys = _new_subdevice_aware_state_keys(name, device_types=("oic.wk.d", "oic.d.range"))
|
||||
assert set(state_keys) == set(golden["state_keys"]), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_resources_from_batch_preferred_over_flat():
|
||||
from tests.conftest import _resources_from_dump
|
||||
|
||||
@@ -1340,3 +1496,46 @@ def test_resources_from_batch_preferred_over_flat():
|
||||
}
|
||||
result = _resources_from_dump(dump)
|
||||
assert result == {"/foo": {"x": 1}}
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_washer_ww5000c_cloud():
|
||||
"""The issue #342 reporter's WW5000C (DA_WM_TP1_21_COMMON, Table_02),
|
||||
captured while sitting on its Download course with a one-time Jeans
|
||||
program loaded over a saved Sports default.
|
||||
|
||||
The cloud "Download" programs it advertises add no entity of their own
|
||||
-- they ride in the existing cycle select -- so this golden is the guard
|
||||
that discovering them never grows the entity set.
|
||||
|
||||
Its /wm/editcourse/vs/0 is empty, so its course list comes from the
|
||||
supportedOptions fallback (issue #1)."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("washer_ww5000c_cloud")
|
||||
golden = json.loads((GOLDEN / "washer_ww5000c_cloud.json").read_text())
|
||||
state_keys = _new_state_keys("washer_ww5000c_cloud", 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_dishwasher_dw5000c_cloud():
|
||||
"""A DW5000C dishwasher (issues #113/#123) that advertises four
|
||||
downloaded programs it has never loaded -- CloudExtraCourse_ with no
|
||||
CloudCourse_/OneTimeCloudCourse_ payload alongside it (issue #342).
|
||||
|
||||
Proves the cloud-cycle machinery is not washer-only and, more usefully,
|
||||
that a device can advertise programs whose payloads have never been
|
||||
seen. It adds no entity of its own either way."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device("dishwasher_dw5000c_cloud")
|
||||
golden = json.loads((GOLDEN / "dishwasher_dw5000c_cloud.json").read_text())
|
||||
state_keys = _new_state_keys("dishwasher_dw5000c_cloud", 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))}"
|
||||
)
|
||||
|
||||
@@ -54,11 +54,99 @@ class TestCourseHelpers:
|
||||
assert laundry.cycle_options({}) == []
|
||||
assert laundry.cycle_options({"/wm/editcourse/vs/0": {}}) == []
|
||||
|
||||
def test_decodes_reported_washer_course_table_including_personal_slots(self):
|
||||
"""Real DA_WM_TP1_21_COMMON diagnostics, with every selectable
|
||||
standard and personal-course code preserved in the device's order.
|
||||
F1/F3 are part of the packed list; their labels come from the
|
||||
companion personal-course resource tested below.
|
||||
"""
|
||||
resources = {
|
||||
"/wm/editcourse/vs/0": {
|
||||
"x.com.samsung.da.editCourseList": (
|
||||
"EditCourseList_696F73757801719688706D6A76726C6E6B777479F1F3"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
assert laundry.cycle_options(resources) == [
|
||||
"69",
|
||||
"6F",
|
||||
"73",
|
||||
"75",
|
||||
"78",
|
||||
"01",
|
||||
"71",
|
||||
"96",
|
||||
"88",
|
||||
"70",
|
||||
"6D",
|
||||
"6A",
|
||||
"76",
|
||||
"72",
|
||||
"6C",
|
||||
"6E",
|
||||
"6B",
|
||||
"77",
|
||||
"74",
|
||||
"79",
|
||||
"F1",
|
||||
"F3",
|
||||
]
|
||||
|
||||
def test_option_value(self):
|
||||
opts = ["DeviceType_0167", "Course_1C", "GMT_04"]
|
||||
assert laundry.option_value(opts, "Course") == "1C"
|
||||
assert laundry.option_value(opts, "Missing") is None
|
||||
|
||||
def test_decodes_device_provided_personal_course_names(self):
|
||||
"""Real /wm/personalcourse/vs/0 entries from the reported washer.
|
||||
|
||||
The first TLV field contains a byte-counted UTF-8 name. It is the only
|
||||
course-name metadata in the diagnostics and is safe to display without
|
||||
assigning an inferred meaning to any standard course code.
|
||||
"""
|
||||
resources = {
|
||||
"/wm/personalcourse/vs/0": {
|
||||
"x.com.samsung.da.courses": [
|
||||
"F1_0106EC868DEC98B7021EED8CACED8BB020EB93B1",
|
||||
"F2_00",
|
||||
"F3_0109EC9A94EAB8B0EBB3B40220ECA084EC9AA9",
|
||||
],
|
||||
},
|
||||
}
|
||||
first_name = bytes.fromhex("EC868DEC98B7").decode("utf-8")
|
||||
second_name = bytes.fromhex("EC9A94EAB8B0EBB3B4").decode("utf-8")
|
||||
assert laundry.personal_course_labels(resources) == {
|
||||
"F1": first_name,
|
||||
"F3": second_name,
|
||||
}
|
||||
|
||||
def test_personal_course_names_reject_malformed_payloads(self):
|
||||
resources = {
|
||||
"/wm/personalcourse/vs/0": {
|
||||
"x.com.samsung.da.courses": [
|
||||
"F1_not-hex",
|
||||
"F2_0106AA",
|
||||
"F3_020141",
|
||||
None,
|
||||
],
|
||||
},
|
||||
}
|
||||
assert laundry.personal_course_labels(resources) == {}
|
||||
|
||||
def test_washer_cycle_fallback_only_labels_personal_courses(self):
|
||||
"""No invented English label for an unrecognized code (PR #251 review)."""
|
||||
resources = {
|
||||
"/wm/personalcourse/vs/0": {
|
||||
"x.com.samsung.da.courses": ["F1_0106EC868DEC98B7"],
|
||||
},
|
||||
}
|
||||
expected = bytes.fromhex("EC868DEC98B7").decode("utf-8")
|
||||
assert laundry.washer_cycle_fallback("F1", resources) == expected
|
||||
assert laundry.washer_cycle_fallback("69", resources) is None
|
||||
assert laundry.washer_cycle_fallback("6f", resources) is None
|
||||
assert laundry.washer_cycle_fallback("Normal", resources) is None
|
||||
|
||||
|
||||
class TestCourseCodesFromSupportedOptions:
|
||||
"""cycle_options()'s fallback for boards that populate
|
||||
@@ -177,6 +265,37 @@ class TestCourseCodesFromSupportedOptions:
|
||||
"8F",
|
||||
]
|
||||
|
||||
def test_decodes_reported_dishwasher_course_table_without_editcourse(self):
|
||||
"""Real DA_DW_A51_20_COMMON diagnostics: the board has no
|
||||
/wm/editcourse/vs/0, so every selectable code must come from its
|
||||
packed supportedOptions table, including the newer 82/8A/A7/A8/8C
|
||||
family that previously appeared as raw UI text.
|
||||
"""
|
||||
resources = {
|
||||
"/course/vs/0": {
|
||||
"x.com.samsung.da.options": ["Course_82"],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"482E0026002F00270028AE0026002F0027002A7E0026002F00270028"
|
||||
"0E0006000F0027000A8E0026002F002700288E0006000F00070008C"
|
||||
"E0026002F00270028DE0026002F00270028EE0006000F00270028FE"
|
||||
"0006002F0027002"
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert laundry.cycle_options(resources) == [
|
||||
"82",
|
||||
"8A",
|
||||
"A7",
|
||||
"80",
|
||||
"A8",
|
||||
"88",
|
||||
"8C",
|
||||
"8D",
|
||||
"8E",
|
||||
"8F",
|
||||
]
|
||||
|
||||
|
||||
class TestCycleSelect:
|
||||
def test_builds_labelled_cycle_select(self):
|
||||
@@ -184,7 +303,10 @@ class TestCycleSelect:
|
||||
assert desc.key == "cycle"
|
||||
assert desc.translation_key == "dryer_cycle"
|
||||
assert desc.icon == "mdi:tumble-dryer"
|
||||
assert desc.options is laundry.cycle_options
|
||||
# Behavior, not identity: the option list is the device's own live
|
||||
# course list (plus any named cloud programs -- see cycle_select).
|
||||
live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_161C"}}
|
||||
assert desc.options(live) == ["16", "1C"]
|
||||
|
||||
def test_reads_raw_course_code_from_options(self):
|
||||
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
|
||||
@@ -247,7 +369,7 @@ class TestCycleSelectTableGating:
|
||||
def test_untranslated_table_uses_generic_cycle_key(self):
|
||||
"""An unknown table does not claim another board's state labels."""
|
||||
desc = self._desc()
|
||||
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}}
|
||||
resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_99"}}
|
||||
assert desc.translation_key(resources) == "cycle"
|
||||
|
||||
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Tests for issue #327: remembering a mode the device reports itself in
|
||||
but never advertises in supportedModes.
|
||||
|
||||
The reporters' unit (ARTIK051_PRAC_20K) isn't in the corpus, so the
|
||||
scenario runs on an existing fixture whose /mode/convenient/vs/0 genuinely
|
||||
lacks Quiet -- airconditioner_tp1x_fac_time_23k, supportedModes [Off,
|
||||
Sleep, Nano, NanoSleep] -- by applying the rep their firmware sends: a
|
||||
current mode of 'Quiet' with that same supportedModes list unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.climate import LocalThingsClimate
|
||||
from custom_components.localthings.const import (
|
||||
CONF_LEARN_MODES,
|
||||
CONF_LEARNED_MODES,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.learned import (
|
||||
LEARNABLE,
|
||||
MODES_FIELD,
|
||||
SUPPORTED_FIELD,
|
||||
LearnedModes,
|
||||
)
|
||||
from custom_components.localthings.registry.entities import ClimateDesc
|
||||
from tests.test_subdevice_discovery import ENTRY_DATA, _climate_bound, _discover
|
||||
|
||||
FIXTURE = "airconditioner_tp1x_fac_time_23k"
|
||||
CONVENIENT = "/mode/convenient/vs/0"
|
||||
ADVERTISED = ["Off", "Sleep", "Nano", "NanoSleep"]
|
||||
|
||||
# What the reporters' firmware sends while the unit is in Quiet: the mode
|
||||
# named as current, and a supported list that still omits it. `modes` is a
|
||||
# bare string here, matching the dump in the issue.
|
||||
QUIET_REP = {MODES_FIELD: "Quiet", SUPPORTED_FIELD: ADVERTISED}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The store itself
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_learns_a_current_mode_missing_from_the_supported_list():
|
||||
learned = LearnedModes()
|
||||
assert learned.observe(CONVENIENT, QUIET_REP) == ["Quiet"]
|
||||
assert learned.codes(CONVENIENT) == ["Quiet"]
|
||||
|
||||
|
||||
def test_relearning_the_same_mode_is_not_a_change():
|
||||
"""The device reports the same rep on every poll while it sits in the
|
||||
mode, so only the first one may report back as something to persist."""
|
||||
learned = LearnedModes()
|
||||
assert learned.observe(CONVENIENT, QUIET_REP) == ["Quiet"]
|
||||
assert learned.observe(CONVENIENT, QUIET_REP) == []
|
||||
assert learned.codes(CONVENIENT) == ["Quiet"]
|
||||
|
||||
|
||||
def test_an_advertised_mode_is_never_learned():
|
||||
learned = LearnedModes()
|
||||
rep = {MODES_FIELD: "Sleep", SUPPORTED_FIELD: ADVERTISED}
|
||||
assert learned.observe(CONVENIENT, rep) == []
|
||||
assert learned.codes(CONVENIENT) == []
|
||||
|
||||
|
||||
def test_a_rep_with_no_supported_list_teaches_nothing():
|
||||
""" "Missing from the list" is only meaningful against a list that
|
||||
exists -- a board publishing none would otherwise get an option list
|
||||
invented out of whatever it happened to be doing."""
|
||||
learned = LearnedModes()
|
||||
assert learned.observe(CONVENIENT, {MODES_FIELD: "Quiet"}) == []
|
||||
assert learned.codes(CONVENIENT) == []
|
||||
|
||||
|
||||
def test_the_allowlist_is_only_the_convenient_href():
|
||||
"""The guard that keeps this feature off resources whose current value
|
||||
isn't a selectable option -- an oven idling in 'NoOperation' on
|
||||
/mode/vs/0, whose codes would become permanent junk options in that
|
||||
unit's cook-mode select."""
|
||||
assert set(LEARNABLE) == {CONVENIENT}
|
||||
|
||||
|
||||
def test_two_subdevices_learn_separately():
|
||||
"""Keyed by the actual on-the-wire href (issue #177), so a composite
|
||||
appliance's second indoor unit doesn't inherit the first's gap."""
|
||||
learned = LearnedModes()
|
||||
learned.observe("/mode/convenient/vs/1", QUIET_REP)
|
||||
assert learned.codes("/mode/convenient/vs/1") == ["Quiet"]
|
||||
assert learned.codes(CONVENIENT) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"stored",
|
||||
[
|
||||
None,
|
||||
"not-a-dict",
|
||||
{"/mode/convenient/vs/0": None},
|
||||
{"/mode/convenient/vs/0": [""]},
|
||||
{"/mode/convenient/vs/0": [1, 2]},
|
||||
{"/mode/convenient/vs/0": {"x.com.samsung.da.supportedModes": ["Quiet"]}},
|
||||
],
|
||||
)
|
||||
def test_malformed_stored_data_restores_as_empty(stored):
|
||||
"""It round-trips through the config entry as plain JSON, so a
|
||||
hand-edited .storage file must not be able to break setup."""
|
||||
assert LearnedModes(stored).snapshot() == {}
|
||||
|
||||
|
||||
def test_well_formed_stored_data_restores():
|
||||
learned = LearnedModes({CONVENIENT: ["Quiet"]})
|
||||
assert learned.codes(CONVENIENT) == ["Quiet"]
|
||||
|
||||
|
||||
def test_clear_forgets_everything():
|
||||
learned = LearnedModes({CONVENIENT: ["Quiet"]})
|
||||
learned.clear()
|
||||
assert learned.snapshot() == {}
|
||||
|
||||
|
||||
def test_every_learnable_href_is_one_climate_resolves():
|
||||
"""climate._supported is the only consumer that unions learned codes
|
||||
in today, so an href added to LEARNABLE that climate never reads would
|
||||
be learned, persisted, and never offered anywhere. The coordinator
|
||||
enforces the device half of this (only hrefs a climate entity binds
|
||||
are learnable); this is the static half."""
|
||||
from custom_components.localthings.climate import (
|
||||
CONVENIENT_HREF,
|
||||
MODE_HREF,
|
||||
WIND_DIRECTION_HREF,
|
||||
WIND_STRENGTH_HREF,
|
||||
)
|
||||
|
||||
assert set(LEARNABLE) <= {MODE_HREF, WIND_STRENGTH_HREF, WIND_DIRECTION_HREF, CONVENIENT_HREF}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End to end: device reports it -> HA offers it -> the entry remembers it
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _entry(hass: HomeAssistant, data: dict | None = None, options: dict | None = None):
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={**ENTRY_DATA, **(data or {})},
|
||||
options=options or {},
|
||||
unique_id="localthings_LEARNED-TEST",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
async def _flush(hass: HomeAssistant) -> None:
|
||||
"""Let a persist scheduled from the applying thread land.
|
||||
|
||||
The coordinator marshals it with hass.add_job (the same way it
|
||||
marshals its state push), which goes through call_soon_threadsafe --
|
||||
async_block_till_done alone doesn't pick that up in this harness.
|
||||
"""
|
||||
await asyncio.sleep(0)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def _climate(hass: HomeAssistant, entry) -> tuple[LocalThingsCoordinator, Any]:
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
await _discover(coordinator, FIXTURE)
|
||||
return coordinator, LocalThingsClimate(coordinator, _climate_bound(coordinator, None))
|
||||
|
||||
|
||||
async def test_the_fixture_really_does_not_advertise_quiet(hass: HomeAssistant):
|
||||
"""Guard for every assertion below: they'd all pass vacuously against a
|
||||
unit that advertised Quiet in the first place."""
|
||||
_, entity = await _climate(hass, _entry(hass))
|
||||
assert "quiet" not in entity.preset_modes
|
||||
|
||||
|
||||
async def test_a_reported_mode_becomes_a_preset(hass: HomeAssistant):
|
||||
coordinator, entity = await _climate(hass, _entry(hass))
|
||||
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
|
||||
|
||||
assert entity.preset_mode == "quiet"
|
||||
assert "quiet" in entity.preset_modes
|
||||
# Alongside, not instead of, what the device does advertise.
|
||||
assert "sleep" in entity.preset_modes
|
||||
|
||||
|
||||
async def test_a_learned_preset_is_writable(hass: HomeAssistant, monkeypatch):
|
||||
"""The reason the union belongs in _supported: async_set_preset_mode
|
||||
reverse-resolves the device code from the same list, so read and write
|
||||
are fixed by one change."""
|
||||
coordinator, entity = await _climate(hass, _entry(hass))
|
||||
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
|
||||
|
||||
sent: list = []
|
||||
|
||||
async def _record(bound, payload):
|
||||
sent.append(payload)
|
||||
|
||||
monkeypatch.setattr(coordinator, "async_send_command", _record)
|
||||
await entity.async_set_preset_mode("quiet")
|
||||
assert sent == [("preset", "Quiet")]
|
||||
|
||||
|
||||
async def test_learning_persists_onto_the_config_entry(hass: HomeAssistant):
|
||||
entry = _entry(hass)
|
||||
coordinator, _ = await _climate(hass, entry)
|
||||
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
|
||||
await _flush(hass)
|
||||
|
||||
assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: ["Quiet"]}
|
||||
|
||||
|
||||
async def test_a_learned_preset_survives_a_restart(hass: HomeAssistant):
|
||||
"""The point of persisting: the unit only names Quiet while it is in
|
||||
Quiet, so a restart in any other mode would otherwise lose it until
|
||||
someone reached for the physical remote again."""
|
||||
entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: ["Quiet"]}})
|
||||
_, entity = await _climate(hass, entry)
|
||||
|
||||
# Nothing applied this run -- the fixture's own rep says Off, and its
|
||||
# supportedModes still omits Quiet.
|
||||
assert entity.preset_mode == "none"
|
||||
assert "quiet" in entity.preset_modes
|
||||
|
||||
|
||||
async def test_a_device_with_no_climate_entity_learns_nothing(hass: HomeAssistant):
|
||||
"""The href alone isn't a sufficient key: the dehumidifier registry
|
||||
declares this same /mode/convenient/vs/0 explicitly unmodeled (no live
|
||||
current-value field), so a code learned there would be persisted for a
|
||||
resource nothing will ever offer."""
|
||||
entry = _entry(hass)
|
||||
coordinator = LocalThingsCoordinator(hass, entry)
|
||||
await _discover(coordinator, "dehumidifier")
|
||||
assert not any(isinstance(b.desc, ClimateDesc) for b in coordinator.bound)
|
||||
|
||||
coordinator._observe.apply(
|
||||
CONVENIENT, {MODES_FIELD: "Quiet", SUPPORTED_FIELD: ADVERTISED}, source="poll"
|
||||
)
|
||||
|
||||
assert coordinator.learned_snapshot() == {}
|
||||
|
||||
|
||||
async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant):
|
||||
"""An optimistic cache entry is the value this integration just wrote,
|
||||
not something the device reported."""
|
||||
coordinator, _ = await _climate(hass, _entry(hass))
|
||||
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="optimistic")
|
||||
|
||||
assert coordinator.learned_snapshot() == {}
|
||||
|
||||
|
||||
async def test_a_partial_notify_still_learns(hass: HomeAssistant):
|
||||
"""An OBSERVE notify can carry `modes` alone (issue #27). Learning sees
|
||||
the merged rep, so the supported list from the last full poll is still
|
||||
there to judge it against."""
|
||||
coordinator, entity = await _climate(hass, _entry(hass))
|
||||
coordinator._observe.apply(CONVENIENT, {MODES_FIELD: "Quiet"}, source="observe")
|
||||
|
||||
assert "quiet" in entity.preset_modes
|
||||
|
||||
|
||||
async def test_the_option_turns_off_both_halves(hass: HomeAssistant):
|
||||
"""Off means the stock list, immediately -- nothing new is learned and
|
||||
nothing already learned is offered."""
|
||||
entry = _entry(
|
||||
hass,
|
||||
data={CONF_LEARNED_MODES: {CONVENIENT: ["Smart"]}},
|
||||
options={CONF_LEARN_MODES: False},
|
||||
)
|
||||
coordinator, entity = await _climate(hass, entry)
|
||||
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
|
||||
await _flush(hass)
|
||||
|
||||
assert "smart" not in entity.preset_modes
|
||||
assert "quiet" not in entity.preset_modes
|
||||
# Kept, not discarded -- turning the option back on restores it, and
|
||||
# nothing new was written while it was off.
|
||||
assert coordinator.learned_snapshot() == {CONVENIENT: ["Smart"]}
|
||||
assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: ["Smart"]}
|
||||
|
||||
|
||||
async def test_forgetting_clears_the_store_and_the_entry(hass: HomeAssistant):
|
||||
entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: ["Quiet"]}})
|
||||
coordinator, entity = await _climate(hass, entry)
|
||||
|
||||
coordinator.forget_learned_modes()
|
||||
await _flush(hass)
|
||||
|
||||
assert "quiet" not in entity.preset_modes
|
||||
assert entry.data[CONF_LEARNED_MODES] == {}
|
||||
|
||||
|
||||
async def test_forgetting_clears_a_record_the_store_rejected(hass: HomeAssistant):
|
||||
"""A malformed persisted record is dropped on restore, so the store is
|
||||
empty while the entry still holds it. Forget has to reach it anyway --
|
||||
it's the only control that can."""
|
||||
entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: None}})
|
||||
coordinator, _ = await _climate(hass, entry)
|
||||
assert coordinator.learned_snapshot() == {}
|
||||
|
||||
coordinator.forget_learned_modes()
|
||||
await _flush(hass)
|
||||
|
||||
assert entry.data[CONF_LEARNED_MODES] == {}
|
||||
|
||||
|
||||
async def test_diagnostics_report_what_was_learned(
|
||||
hass: HomeAssistant,
|
||||
enable_custom_integrations,
|
||||
):
|
||||
"""Kept out of the `resources` dump on purpose -- that stays exactly
|
||||
what the device said, so a triager can still see the gap."""
|
||||
from custom_components.localthings.diagnostics import (
|
||||
async_get_config_entry_diagnostics,
|
||||
)
|
||||
|
||||
entry = _entry(hass)
|
||||
coordinator, _ = await _climate(hass, entry)
|
||||
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
|
||||
diag = await async_get_config_entry_diagnostics(hass, cast(Any, entry))
|
||||
|
||||
assert diag["learned_modes"] == {"enabled": True, "codes": {CONVENIENT: ["Quiet"]}}
|
||||
assert diag["resources"][CONVENIENT][SUPPORTED_FIELD] == ADVERTISED
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Unit tests for operational state capabilities."""
|
||||
|
||||
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
|
||||
from custom_components.localthings.registry.capabilities.operational import (
|
||||
OPERATIONAL_STATE,
|
||||
_just_finished,
|
||||
_new_cycle_running,
|
||||
)
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
|
||||
@@ -11,6 +15,74 @@ def test_machine_state_maps_samsung_to_ocf():
|
||||
assert ms.value_fn("Ready") == "idle"
|
||||
|
||||
|
||||
class TestJustFinished:
|
||||
"""`_just_finished` is progress/progress_percentage's sticky_fn arm
|
||||
condition (issue #345) -- see sensor.py's _apply_sticky."""
|
||||
|
||||
def test_true_when_progress_is_finish_while_active(self):
|
||||
assert _just_finished(
|
||||
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Finish"}
|
||||
)
|
||||
|
||||
def test_true_even_once_state_has_already_left_active(self):
|
||||
"""The exact issue #345 scenario this exists for: a washer's
|
||||
`state` can already read idle by the time `progress` is observed
|
||||
at 'Finish' -- unlike _is_active, this must still arm."""
|
||||
assert _just_finished(
|
||||
{"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progress": "Finish"}
|
||||
)
|
||||
|
||||
def test_false_while_active_but_not_yet_finished(self):
|
||||
assert not _just_finished(
|
||||
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Spin"}
|
||||
)
|
||||
|
||||
def test_false_when_progress_absent(self):
|
||||
assert not _just_finished({"x.com.samsung.da.state": "Run"})
|
||||
|
||||
|
||||
class TestNewCycleRunning:
|
||||
"""`_new_cycle_running` is progress/progress_percentage's
|
||||
sticky_bypass_fn -- the early-release condition for the #345 hold.
|
||||
See sensor.py's _apply_sticky."""
|
||||
|
||||
def test_true_for_a_concrete_non_finish_code_while_active(self):
|
||||
assert _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Wash"}
|
||||
)
|
||||
|
||||
def test_false_for_a_running_stage_reported_after_state_left_active(self):
|
||||
"""Issue #358, the whole reason for the `state` gate: the reporting
|
||||
dryer replays a running stage ('Drying', its first supportedProgress
|
||||
entry) for a few seconds after Finish while winding down. That is
|
||||
the finished cycle's tail, not a new cycle -- releasing the hold on
|
||||
it is what produced 'Drying, Cooling, Finish, Drying, Idle'."""
|
||||
assert not _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progress": "Drying"}
|
||||
)
|
||||
|
||||
def test_false_while_paused(self):
|
||||
"""Paused is not evidence a new cycle is running, and the tail
|
||||
above can't be told apart from it. rep_fn shows 'Idle' whenever
|
||||
state isn't active anyway, so there is no live value being
|
||||
withheld here -- only a hold that expires on its own instead of
|
||||
being released early."""
|
||||
assert not _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Pause", "x.com.samsung.da.progress": "Wash"}
|
||||
)
|
||||
|
||||
def test_false_for_finish(self):
|
||||
assert not _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "Finish"}
|
||||
)
|
||||
|
||||
def test_false_when_absent_or_none(self):
|
||||
assert not _new_cycle_running({})
|
||||
assert not _new_cycle_running(
|
||||
{"x.com.samsung.da.state": "Run", "x.com.samsung.da.progress": "None"}
|
||||
)
|
||||
|
||||
|
||||
class TestProgressPercentage:
|
||||
"""issue #9: device firmware leaves progressPercentage stale (e.g. '1')
|
||||
after a cycle ends instead of resetting it, so it must be gated on
|
||||
@@ -139,7 +211,7 @@ class TestDelayFieldFallback:
|
||||
assert result is not None
|
||||
path, body = result
|
||||
assert path == ["operational", "state", "vs", "0"]
|
||||
assert body == {"x.com.samsung.da.delayEndTime": "1:30:00"}
|
||||
assert body == {"x.com.samsung.da.delayEndTime": "01:30:00"}
|
||||
|
||||
def test_write_targets_delay_start_time_by_default(self):
|
||||
from custom_components.localthings.registry.capabilities.operational import (
|
||||
@@ -156,4 +228,4 @@ class TestDelayFieldFallback:
|
||||
result = desc.write_fn(1.5, rep)
|
||||
assert result is not None
|
||||
_path, body = result
|
||||
assert body == {"x.com.samsung.da.delayStartTime": "1:30:00"}
|
||||
assert body == {"x.com.samsung.da.delayStartTime": "01:30:00"}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tests for the TP2X_DA-KS-WALLOVEN-000002 steam oven (issue #300).
|
||||
|
||||
The reporter's "not fully supported" notification was /diagnosis/vs/0,
|
||||
now covered via dishwasher.DIAGNOSIS. Their "doesn't respond to commands"
|
||||
complaint is broader: oven.py's own module docstring already documents
|
||||
setpoint/cook-time/mode writes as unproven on this local-OCF firmware, and
|
||||
this board's options[] carries no UpperLamp_ token at all -- confirming
|
||||
`lamp` was previously a phantom, write-does-nothing switch here (same gap
|
||||
issue #183 fixed for fast_preheat/natural_steam on a different board).
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
_DEVICE_TYPES = ("oic.wk.d", "oic.d.oven")
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _load_device("oven_tp2x_ks_walloven")
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_oic_device_type_resolves_to_oven_registry():
|
||||
reg = for_device_by_oic_type(_DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "oven"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
resources = _load_device("oven_tp2x_ks_walloven")
|
||||
reg = resolve(resources, device_types=_DEVICE_TYPES)
|
||||
assert reg is not None
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_lamp_not_bound_without_an_upperlamp_token():
|
||||
"""This board's options[] has no UpperLamp_ entry -- confirms the new
|
||||
exists_fn keeps `lamp` from registering a switch that would always
|
||||
read Off and never actually do anything on write."""
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert "lamp" not in state
|
||||
|
||||
|
||||
def test_sound_and_energy_saving_bound_from_real_options_tokens():
|
||||
"""Unlike lamp, this board's options[] does carry Sound_On and
|
||||
EnergySaving_On -- these switches should reflect real state."""
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["sound"] is True
|
||||
assert state["energy_saving"] is True
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Dual-cavity range (TP1X_DA-KS-RANGE-0101X, NE63T8751SG/AA-class, issue
|
||||
#324): no /information/vs/0 at all, so the modelNum-based fallback has
|
||||
nothing to read -- routing depends entirely on /oic/d's own device type
|
||||
(`oic.d.range`), read separately from the /device/0 batch (see
|
||||
registry/identity.py and by_type/__init__.py's `_OIC_TYPE_TO_KEY`).
|
||||
|
||||
The second oven cavity answers as an indexed sibling at /device/1 (Pattern
|
||||
A, same mechanism as the AC family's issue #177 fixtures) -- the master's
|
||||
/mode/vs/0 (defaultMode 'UpperConvectionBake') is the upper cavity, the
|
||||
subdevice's canonical /mode/vs/0 (defaultMode 'LowerConvectionBake') is the
|
||||
lower one.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
|
||||
from tests.conftest import _discover_full, _load_device_full
|
||||
|
||||
FIXTURE = "range_tp1x_da_ks_range_0101x"
|
||||
DEVICE_TYPES = ("oic.wk.d", "oic.d.range")
|
||||
|
||||
|
||||
def _discover():
|
||||
resources, oic_res, seeds = _load_device_full(FIXTURE)
|
||||
return _discover_full(resources, oic_res, seeds, DEVICE_TYPES)
|
||||
|
||||
|
||||
def test_resolves_via_oic_type():
|
||||
reg = for_device_by_oic_type(DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "range"
|
||||
|
||||
|
||||
def test_resolves_via_the_full_resolve_entrypoint():
|
||||
resources, _oic_res, _seeds = _load_device_full(FIXTURE)
|
||||
reg = resolve(resources, device_types=DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "range"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the issue #324 dump binds or is ignored, on both
|
||||
the master (upper cavity) and the materialized second cavity -- clears
|
||||
the coverage-gap repair."""
|
||||
resources, oic_res, seeds = _load_device_full(FIXTURE)
|
||||
unbound = []
|
||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||
from custom_components.localthings.registry.subdevices import (
|
||||
discover_partitioned,
|
||||
enumerate_subdevices,
|
||||
)
|
||||
from tests.conftest import FakeCoapSession
|
||||
|
||||
sess = FakeCoapSession(seeds)
|
||||
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
|
||||
full_resources = {**resources, **extra}
|
||||
discover_partitioned(
|
||||
full_resources,
|
||||
candidates,
|
||||
resolve,
|
||||
CAPABILITIES,
|
||||
log=unbound.append,
|
||||
oic_device_types=DEVICE_TYPES,
|
||||
)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_second_cavity_materializes_as_an_indexed_subdevice():
|
||||
_bound, materialized, skipped, _full_resources, device_type_name = _discover()
|
||||
assert device_type_name == "range"
|
||||
assert skipped == []
|
||||
assert [(s.kind, s.key) for s in materialized] == [("indexed", "1")]
|
||||
assert materialized[0].seed_path == ("device", "1")
|
||||
|
||||
|
||||
def test_both_cavities_expose_distinct_oven_state():
|
||||
bound, _materialized, _skipped, full_resources, _name = _discover()
|
||||
state = flatten(bound, full_resources)
|
||||
for key in ("oven_mode", "oven_setpoint", "oven_state", "machine_state", "door_open"):
|
||||
assert key in state, key
|
||||
# Second cavity's entities carry the subdevice1_ prefix (adapter._key) --
|
||||
# the master's own keys stay unprefixed, so both cavities get distinct
|
||||
# unique_ids rather than colliding on the same entity key.
|
||||
assert "subdevice1_oven_mode" in state
|
||||
assert "subdevice1_oven_setpoint" in state
|
||||
|
||||
|
||||
def test_cooktop_monitoring_present_on_the_master_only():
|
||||
"""The cooktop half belongs to the appliance as a whole, not either oven
|
||||
cavity -- /cooktopmonitoring/vs/0 has no per-cavity index."""
|
||||
bound, _materialized, _skipped, full_resources, _name = _discover()
|
||||
state = flatten(bound, full_resources)
|
||||
assert "cooktop_running_state" in state
|
||||
assert "warming_center_state" in state
|
||||
assert "subdevice1_cooktop_running_state" not in state
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Tests for the TP1X_REF_21K air-filter-equipped variant (issue #318).
|
||||
|
||||
The dump's only unbound href was /filter/airdustfilter/vs/0, this board's
|
||||
internal deodorizing filter -- same filterUsage/filterStatus field pair as
|
||||
common.WATER_FILTER, but filterUsage here is already a 0-100 percentage
|
||||
(no filterCapacity to divide by, unlike airconditioner.AIR_FILTER).
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import resolve
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _bound():
|
||||
resources = _load_device("refrigerator_tp1x_ref_21k_airfilter")
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
_, resources = _bound()
|
||||
unbound = []
|
||||
reg = resolve(resources)
|
||||
assert reg is not None
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_air_filter_usage_and_status():
|
||||
bound, resources = _bound()
|
||||
state = flatten(bound, resources)
|
||||
assert state["air_filter_usage"] == 100
|
||||
assert state["air_filter_status"] == "wash"
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Auto Door Open (issue #328, TP1X_REF_21K single-freezer-door variant).
|
||||
|
||||
`/status/lock/vs/0`'s ado.devicecontrol switch was already modeled
|
||||
(STATUS_LOCK.auto_door_opener); this dump adds the paired voice-feedback
|
||||
toggle, the door-hold timer, and the per-variant capability declaration
|
||||
href -- see fridge.py's AUTO_DOOR_TIMER/AUTO_DOOR_VARIANT comments.
|
||||
"""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import fridge
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
FIXTURE = "refrigerator_tp1x_ref_21k_autodoor"
|
||||
|
||||
|
||||
def _fridge():
|
||||
resources = _load_device(FIXTURE)
|
||||
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 = _fridge()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_resolves_to_refrigerator_registry():
|
||||
reg, _ = _fridge()
|
||||
assert reg is not None and reg.name == "refrigerator"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
reg, resources = _fridge()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_auto_door_voice_control_reads_status_lock():
|
||||
state = _state()
|
||||
assert state["auto_door_opener"] is False
|
||||
assert state["auto_door_voice_control"] is False
|
||||
# This variant's dump has no ado.soundcontrol field -- gated absent.
|
||||
assert "auto_door_sound_control" not in state
|
||||
|
||||
|
||||
def test_auto_door_timer_reads_its_own_options():
|
||||
desc = next(
|
||||
e
|
||||
for e in fridge.AUTO_DOOR_TIMER.entities
|
||||
if e.key == "auto_door_timer" and isinstance(e, SelectDesc)
|
||||
)
|
||||
rep = {
|
||||
"x.com.samsung.da.time.desired": "1",
|
||||
"x.com.samsung.da.time.supportedOptions": ["1", "2", "3", "4", "5", "6"],
|
||||
}
|
||||
assert desc.value_fn(rep["x.com.samsung.da.time.desired"]) == "1"
|
||||
assert desc.options_field == "x.com.samsung.da.time.supportedOptions"
|
||||
assert desc.write_fn is not None
|
||||
assert desc.write_fn("3", rep) == (
|
||||
["autodoor", "timer", "vs", "0"],
|
||||
{"x.com.samsung.da.time.desired": "3"},
|
||||
)
|
||||
|
||||
|
||||
def test_auto_door_single_variant_href_bound_with_no_entities():
|
||||
"""/autodoor/single/vs/0 only ever declares openOptions=['Single'] --
|
||||
no paired current/desired field to expose, so it's coverage-only via
|
||||
the shared AUTO_DOOR_VARIANT pattern cap, not an entry of its own."""
|
||||
assert fridge.AUTO_DOOR_VARIANT.entities == ()
|
||||
assert fridge.AUTO_DOOR_VARIANT.match_fn is not None
|
||||
_reg, resources = _fridge()
|
||||
assert fridge.AUTO_DOOR_VARIANT.match_fn(resources["/autodoor/single/vs/0"], resources)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Kimchi-refrigerator Auto Door Open variant (issue #328,
|
||||
oic.d.krefrigerator device type -- TP1X_REF_21K board, single kimchi
|
||||
compartment). Same STATUS_LOCK/AUTO_DOOR_TIMER shape as the regular
|
||||
TP1X_REF_21K, at the kimchi-specific /autodoor/kimchi/vs/0 declaration
|
||||
href.
|
||||
"""
|
||||
|
||||
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.capabilities import fridge
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
FIXTURE = "refrigerator_tp1x_ref_21k_kimchi"
|
||||
DEVICE_TYPES = ("oic.wk.d", "oic.d.krefrigerator")
|
||||
|
||||
|
||||
def _fridge():
|
||||
resources = _load_device(FIXTURE)
|
||||
reg = resolve(resources, device_types=DEVICE_TYPES)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _state():
|
||||
reg, resources = _fridge()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_resolves_via_oic_type():
|
||||
reg = for_device_by_oic_type(DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "refrigerator"
|
||||
|
||||
|
||||
def test_resolves_to_refrigerator_registry():
|
||||
reg, _ = _fridge()
|
||||
assert reg is not None and reg.name == "refrigerator"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
reg, resources = _fridge()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_auto_door_opener_present():
|
||||
state = _state()
|
||||
assert "auto_door_opener" in state
|
||||
assert "auto_door_timer" in state
|
||||
|
||||
|
||||
def test_auto_door_kimchi_variant_href_bound_with_no_entities():
|
||||
assert fridge.AUTO_DOOR_VARIANT.entities == ()
|
||||
assert fridge.AUTO_DOOR_VARIANT.match_fn is not None
|
||||
_reg, resources = _fridge()
|
||||
assert fridge.AUTO_DOOR_VARIANT.match_fn(resources["/autodoor/kimchi/vs/0"], resources)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Wine-cellar refrigerator variant (issue #328, x.com.st.d.winecellar
|
||||
device type -- RW33C99B1TFG-class, TP1X_REF_21K board). Auto Door Open's
|
||||
winecellar-specific declaration href, a deodorizing filter reported at a
|
||||
different href than the regular AIR_FILTER, a multi-compartment pantry
|
||||
select, and a table-revision info resource -- all new coverage this dump
|
||||
surfaced.
|
||||
"""
|
||||
|
||||
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.capabilities import fridge
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
FIXTURE = "refrigerator_winecellar"
|
||||
DEVICE_TYPES = ("oic.wk.d", "x.com.st.d.winecellar")
|
||||
|
||||
|
||||
def _fridge():
|
||||
resources = _load_device(FIXTURE)
|
||||
reg = resolve(resources, device_types=DEVICE_TYPES)
|
||||
return reg, resources
|
||||
|
||||
|
||||
def _state():
|
||||
reg, resources = _fridge()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_resolves_via_oic_type():
|
||||
reg = for_device_by_oic_type(DEVICE_TYPES)
|
||||
assert reg is not None and reg.name == "refrigerator"
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
reg, resources = _fridge()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_auto_door_sound_control_present_here_only():
|
||||
"""This variant's dump is the only one that carries
|
||||
ado.soundcontrol -- the other TP1X_REF_21K auto-door fixtures don't."""
|
||||
state = _state()
|
||||
assert state["auto_door_sound_control"] is True
|
||||
assert state["auto_door_voice_control"] is False
|
||||
|
||||
|
||||
def test_deodor_filter_has_its_own_keys_not_air_filters():
|
||||
"""Same shape/reasoning as fridge.AIR_FILTER (percentage-not-raw-count,
|
||||
see its own comment) but its own 'deodor_'-prefixed keys, not a shared
|
||||
tuple -- AIR_FILTER's own docstring picked 'air_' specifically to avoid
|
||||
colliding with WATER_FILTER's filter_usage/filter_status, and reusing
|
||||
AIR_FILTER.entities verbatim here would silently recreate that same
|
||||
collision if a unit ever reports both hrefs."""
|
||||
assert fridge.DEODOR_FILTER.entities != fridge.AIR_FILTER.entities
|
||||
state = _state()
|
||||
assert state["deodor_filter_status"] == "normal"
|
||||
# The device's own sentinel, relayed as-is -- not a real 0-100 reading
|
||||
# on this unit, and no second dump to say what else -1 could mean.
|
||||
assert state["deodor_filter_usage"] == -1
|
||||
assert "air_filter_usage" not in state
|
||||
assert "air_filter_status" not in state
|
||||
|
||||
|
||||
def test_winecellar_pantry_zone_mode_options_and_write():
|
||||
desc = next(
|
||||
e
|
||||
for e in fridge.WINECELLAR_PANTRY_ZONE.entities
|
||||
if e.key == "winecellar_pantry_zone_mode" and isinstance(e, SelectDesc)
|
||||
)
|
||||
assert desc.options_field == "x.com.samsung.da.supportedOptions"
|
||||
assert desc.write_fn is not None
|
||||
assert desc.write_fn("Cheese", {}) == (
|
||||
["status", "winecellar", "pantry", "one", "vs", "0"],
|
||||
{"x.com.samsung.da.mode": "Cheese"},
|
||||
)
|
||||
state = _state()
|
||||
assert state["winecellar_pantry_zone_mode"] == "Fruit"
|
||||
|
||||
|
||||
def test_winecellar_variant_and_info_hrefs_bound_with_no_entities():
|
||||
assert fridge.AUTO_DOOR_VARIANT.entities == ()
|
||||
assert fridge.WINECELLAR_INFO.entities == ()
|
||||
assert fridge.AUTO_DOOR_VARIANT.match_fn is not None
|
||||
_reg, resources = _fridge()
|
||||
assert fridge.AUTO_DOOR_VARIANT.match_fn(resources["/autodoor/winecellar/vs/0"], resources)
|
||||
@@ -46,3 +46,23 @@ def test_known_camel_case_state_uses_snake_case_translation_key():
|
||||
|
||||
def test_display_passes_through_non_string_values():
|
||||
assert _display(None, _UNTRANSLATED) is None
|
||||
|
||||
|
||||
def test_display_uses_fallback_when_translation_has_no_state_table():
|
||||
assert _display("69", "cycle", lambda value: f"Unknown (0x{value})") == ("Unknown (0x69)")
|
||||
|
||||
|
||||
def test_uncatalogued_value_stays_raw_when_the_fallback_declines_it():
|
||||
"""The "no state table" escape hatch keys off whether anything actually
|
||||
named the value, not off whether a fallback was supplied.
|
||||
|
||||
laundry.cycle_select always supplies one now (it labels cloud "Download"
|
||||
programs) and returns None for everything else. Keyed on the fallback's
|
||||
presence instead, every dryer/dishwasher/air-dresser on an unrecognized
|
||||
course table would have its options and state cosmetically reshaped --
|
||||
course '0E' rendered '0 E' -- silently breaking automations and recorder
|
||||
history."""
|
||||
assert _display("0E", "cycle", None) == "0E"
|
||||
assert _display("0E", "cycle", lambda value: None) == "0E"
|
||||
# A fallback that does name the value still wins.
|
||||
assert _display("0E", "cycle", lambda value: f"Course {value}") == "Course 0E"
|
||||
|
||||
@@ -6,6 +6,10 @@ and callable forms of SelectDesc.options.
|
||||
from typing import ClassVar, cast
|
||||
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities.laundry import (
|
||||
cycle_select,
|
||||
washer_cycle_fallback,
|
||||
)
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
@@ -113,3 +117,79 @@ async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
|
||||
assert entity.options[-1] == "Future Vendor Mode"
|
||||
await entity.async_select_option("Future Vendor Mode")
|
||||
assert coordinator.writes == ["FutureVendorMode"]
|
||||
|
||||
|
||||
async def test_washer_diagnostic_cycle_values_share_one_display_and_write_path():
|
||||
"""Regression for Course_69/EditCourseList_696F... from real hardware."""
|
||||
|
||||
class _WritableCoordinator(_FakeCoordinator):
|
||||
data: ClassVar[dict] = {"cycle": "69"}
|
||||
|
||||
def __init__(self, last_resources):
|
||||
super().__init__(last_resources)
|
||||
self.writes = []
|
||||
|
||||
async def async_send_command(self, bound, value):
|
||||
self.writes.append(value)
|
||||
|
||||
desc = cycle_select(
|
||||
translation_key="washer_cycle",
|
||||
icon="mdi:washing-machine",
|
||||
table_href="/st/washercourse/vs/0",
|
||||
display_fn=washer_cycle_fallback,
|
||||
)
|
||||
capability = Capability(href="/course/vs/0", entities=(desc,))
|
||||
bound = BoundEntity(href="/course/vs/0", capability=capability, desc=desc)
|
||||
resources = {
|
||||
"/course/vs/0": {"x.com.samsung.da.options": ["Course_69"]},
|
||||
"/st/washercourse/vs/0": {
|
||||
"x.com.samsung.da.st.courseTable": "Table_02",
|
||||
},
|
||||
"/wm/editcourse/vs/0": {
|
||||
"x.com.samsung.da.editCourseList": (
|
||||
"EditCourseList_696F757801719688706D6A7376726C6E6B777479F1F3"
|
||||
),
|
||||
},
|
||||
"/wm/personalcourse/vs/0": {
|
||||
"x.com.samsung.da.courses": [
|
||||
"F1_0106EC868DEC98B7",
|
||||
"F3_0109EC9A94EAB8B0EBB3B4",
|
||||
],
|
||||
},
|
||||
}
|
||||
coordinator = _WritableCoordinator(resources)
|
||||
entity = LocalThingsSelect(cast(LocalThingsCoordinator, coordinator), bound)
|
||||
first_name = bytes.fromhex("EC868DEC98B7").decode("utf-8")
|
||||
second_name = bytes.fromhex("EC9A94EAB8B0EBB3B4").decode("utf-8")
|
||||
|
||||
# Known catalog states stay as HA translation keys; the frontend renders
|
||||
# this confirmed Table_02 mapping as "AI Wash".
|
||||
assert entity.current_option == "69"
|
||||
assert entity.options == [
|
||||
"69",
|
||||
"6f",
|
||||
"75",
|
||||
"78",
|
||||
"01",
|
||||
"71",
|
||||
"96",
|
||||
"88",
|
||||
"70",
|
||||
"6d",
|
||||
"6a",
|
||||
"73",
|
||||
"76",
|
||||
"72",
|
||||
"6c",
|
||||
"6e",
|
||||
"6b",
|
||||
"77",
|
||||
"74",
|
||||
"79",
|
||||
first_name,
|
||||
second_name,
|
||||
]
|
||||
|
||||
await entity.async_select_option("6f")
|
||||
await entity.async_select_option(first_name)
|
||||
assert coordinator.writes == ["6F", "F1"]
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Unit tests for LocalThingsSensor's sticky gate (issue #345).
|
||||
|
||||
Modeled on test_sensor_hysteresis.py: a _FakeCoordinator with just enough
|
||||
surface for LocalThingsEntity/LocalThingsSensor, driven directly rather
|
||||
than through a full coordinator/HA setup. Unlike that file, `data` here
|
||||
is derived from the real `flatten()` over the same `resources` dict
|
||||
`resource()` serves -- a single source of truth, so a test can't hide a
|
||||
production desync bug by hand-computing the two independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from typing import cast
|
||||
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
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
|
||||
|
||||
_PROGRESS_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress")
|
||||
_PROGRESS_PERCENTAGE_DESC = next(
|
||||
e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage"
|
||||
)
|
||||
_MACHINE_STATE_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == "machine_state")
|
||||
|
||||
_HREF = "/operational/state/vs/0"
|
||||
_ALL_BOUND = [
|
||||
BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
|
||||
for desc in OPERATIONAL_STATE.entities
|
||||
]
|
||||
|
||||
|
||||
class _FakeConfigEntry:
|
||||
def __init__(self):
|
||||
self.options: dict = {}
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
"""Just enough surface for LocalThingsEntity/LocalThingsSensor.
|
||||
|
||||
`resources` is the one source of truth (standing in for the real
|
||||
coordinator's live cache); `data` and `resource()` are both derived
|
||||
from it exactly as the real coordinator derives `.data` from
|
||||
`flatten(self.bound, self._cache.snapshot())` and `.resource()` from
|
||||
the same snapshot -- so a test can't drift them apart in a way
|
||||
production couldn't.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.device_serial = "TEST-SERIAL"
|
||||
self.config_entry = _FakeConfigEntry()
|
||||
self.resources: dict[str, dict] = {}
|
||||
|
||||
def resource(self, href: str) -> dict:
|
||||
return self.resources.get(href) or {}
|
||||
|
||||
@property
|
||||
def data(self) -> dict:
|
||||
return flatten(_ALL_BOUND, self.resources)
|
||||
|
||||
|
||||
def _sensor(desc):
|
||||
coordinator = _FakeCoordinator()
|
||||
bound = BoundEntity(href=_HREF, capability=OPERATIONAL_STATE, desc=desc)
|
||||
sensor = LocalThingsSensor(cast(LocalThingsCoordinator, coordinator), bound)
|
||||
return sensor, coordinator
|
||||
|
||||
|
||||
def _apply(coordinator, **fields):
|
||||
"""Merge `fields` onto the href's existing rep, the same shallow
|
||||
{**cached, **rep} merge ObserveManager.apply() does in production
|
||||
(issue #27) -- so a field this call doesn't mention (e.g. a stale
|
||||
`progress` the device didn't repeat) stays exactly as a real partial
|
||||
update would leave it, rather than being silently wiped."""
|
||||
href_fields = {f"x.com.samsung.da.{k}": v for k, v in fields.items()}
|
||||
coordinator.resources[_HREF] = {**coordinator.resources.get(_HREF, {}), **href_fields}
|
||||
|
||||
|
||||
def _replace(coordinator, **fields):
|
||||
"""A full rep replacement -- for a device reporting fresh from
|
||||
scratch (e.g. a full poll GET), unlike _apply's partial-update merge."""
|
||||
coordinator.resources[_HREF] = {f"x.com.samsung.da.{k}": v for k, v in fields.items()}
|
||||
|
||||
|
||||
def test_holds_finish_after_state_leaves_active():
|
||||
"""The #345 regression this guards: progress used to fall straight to
|
||||
'Idle' the instant machine_state left active, even right after
|
||||
reporting Finish."""
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
_replace(coordinator, state="Ready") # device has moved on
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
|
||||
def test_holds_finish_even_when_state_already_idle_at_first_observation():
|
||||
"""issue #345's actual reported failure mode: state can already read
|
||||
idle by the time `progress: Finish` is ever observed (the report's
|
||||
washer skips a Run+Finish moment the same-family dryer still shows) --
|
||||
the hold must still arm from that single rep."""
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
# Still held on a later poll, even once the device stops repeating it.
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
|
||||
def test_progress_percentage_holds_100_regardless_of_the_raw_field_at_finish():
|
||||
"""The hold pins 100 explicitly (sticky_value_fn), not whatever the
|
||||
raw field happened to hold at the finish moment -- a device that
|
||||
never populates progressPercentage at all, or (issue #9's failure
|
||||
mode) leaves a stale non-100 value there, must still show 100 while
|
||||
held, not None/unknown or the stale figure."""
|
||||
sensor, coordinator = _sensor(_PROGRESS_PERCENTAGE_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish") # no progressPercentage at all
|
||||
assert sensor.native_value == 100
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == 100
|
||||
|
||||
|
||||
def test_real_data_flows_through_unheld_while_active():
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Spin")
|
||||
assert sensor.native_value == "Spin"
|
||||
|
||||
_replace(coordinator, state="Run", progress="Rinse")
|
||||
assert sensor.native_value == "Rinse"
|
||||
|
||||
|
||||
def test_never_finished_stays_idle():
|
||||
"""A device that never actually reached Finish (e.g. Stop pressed
|
||||
mid-cycle) must not be held at some prior mid-cycle value."""
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Spin")
|
||||
assert sensor.native_value == "Spin"
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Idle"
|
||||
|
||||
|
||||
def test_a_new_cycle_starting_overrides_the_hold():
|
||||
"""Starting a second cycle must show its own real progress
|
||||
immediately, not the held Finish from the previous one."""
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Finish" # still held
|
||||
|
||||
_replace(coordinator, state="Run", progress="Wash")
|
||||
assert sensor.native_value == "Wash"
|
||||
|
||||
|
||||
def test_a_running_stage_after_finish_does_not_break_the_hold():
|
||||
"""Issue #358: the reporting dryer resets `progress` to its course's
|
||||
first stage in the same moment `state` goes idle -- observed twice,
|
||||
identically, as Cooling -> +60s Finish -> +24s 'Drying' -> +4s
|
||||
settled, with the reporter's machine_state history flipping to idle on
|
||||
the exact second progress reads 'Drying'. The hold must survive that,
|
||||
so the cycle still reads Drying, Cooling, Finish, Idle rather than the
|
||||
reported Drying, Cooling, Finish, Drying, Idle."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.2)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Drying", progressPercentage="40")
|
||||
assert sensor.native_value == "Drying"
|
||||
|
||||
_replace(coordinator, state="Run", progress="Cooling", progressPercentage="95")
|
||||
assert sensor.native_value == "Cooling"
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
# The tail: a running stage again, state already idle.
|
||||
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
# ...then the device settles, still inside the window.
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
time.sleep(0.25)
|
||||
assert sensor.native_value == "Idle"
|
||||
|
||||
|
||||
def test_progress_percentage_survives_the_same_tail():
|
||||
"""#358's tail hits progress_percentage through the identical bypass;
|
||||
it must stay pinned at 100 rather than being released back to a raw
|
||||
mid-cycle figure."""
|
||||
desc = replace(_PROGRESS_PERCENTAGE_DESC, sticky_seconds=0.2)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == 100
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Drying", progressPercentage="40")
|
||||
assert sensor.native_value == 100
|
||||
|
||||
time.sleep(0.25)
|
||||
assert sensor.native_value == 0
|
||||
|
||||
|
||||
def test_a_paused_new_cycle_is_left_to_the_window_rather_than_released():
|
||||
"""'Paused' isn't positive evidence of a new cycle, and #358's tail is
|
||||
indistinguishable from it. Nothing live is withheld by waiting --
|
||||
rep_fn shows 'Idle' while paused with or without a hold -- so the
|
||||
stale Finish just expires on schedule instead of being cut short."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
_replace(coordinator, state="Pause", progress="Wash")
|
||||
assert sensor.native_value == "Finish" # held out, not released
|
||||
|
||||
time.sleep(0.1)
|
||||
assert sensor.native_value == "Idle" # what a paused appliance always shows
|
||||
|
||||
_replace(coordinator, state="Run", progress="Wash")
|
||||
assert sensor.native_value == "Wash"
|
||||
|
||||
|
||||
def test_a_flapping_finish_cannot_ratchet_an_open_window_forward():
|
||||
"""Edge-triggering stops a *stuck* Finish from extending the hold; a
|
||||
progress that flaps out of and back into Finish must not restart it
|
||||
either, or the bound stops being a bound."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.3)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
for _ in range(3):
|
||||
time.sleep(0.05)
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "Finish"
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
# 0.15s of flapping so far -- the window still ends 0.3s after the
|
||||
# first Finish, not 0.3s after the most recent re-entry.
|
||||
time.sleep(0.2)
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Idle"
|
||||
|
||||
|
||||
def test_a_finish_after_the_window_closes_does_not_re_arm_it():
|
||||
"""Expiry doesn't re-open the door: with no new cycle in between, a
|
||||
second Finish is the same Finish. Re-arming on it would strobe the
|
||||
entity Finish -> Idle -> Finish once per window, re-firing exactly the
|
||||
announcements #345 is about -- so it takes a bypass (a cycle actually
|
||||
running) to make the hold available again.
|
||||
|
||||
Distinct from the flap test above: there the window is still open, and
|
||||
the last read before expiry leaves the sticky condition *matching*.
|
||||
Here it has already closed, and the flap ends on a non-matching read,
|
||||
which is the state a spent-on-arm-only guard would let re-arm."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
time.sleep(0.1)
|
||||
assert sensor.native_value == "Idle"
|
||||
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "Idle"
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Idle"
|
||||
|
||||
# A real cycle in between is what makes it available again.
|
||||
_replace(coordinator, state="Run", progress="Drying")
|
||||
assert sensor.native_value == "Drying"
|
||||
_replace(coordinator, state="Run", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
_replace(coordinator, state="Ready", progress="None")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
|
||||
def test_hold_expires_after_sticky_seconds():
|
||||
# A fresh desc (frozen dataclass -- replace(), not mutation, so the
|
||||
# module-level _PROGRESS_DESC other tests share stays untouched) with
|
||||
# a short real window -- consistent with this codebase's own
|
||||
# settle-guard tests (test_observe.py's mark_write_pending(...,
|
||||
# settle_s=0.05)) -- rather than a zero window, which exercises "held
|
||||
# disabled" rather than "an armed hold actually expires".
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "Finish" # still within the window
|
||||
|
||||
time.sleep(0.1)
|
||||
assert sensor.native_value == "Idle"
|
||||
|
||||
|
||||
def test_a_progress_stuck_at_finish_does_not_hold_open_the_window_forever():
|
||||
"""Edge-triggering: sticky_fn keeps matching on every poll if the
|
||||
device's own `progress` field never resets on its own (the same class
|
||||
of quirk _completion_minutes' remainingTime-freeze workaround already
|
||||
documents) -- the hold must still expire on schedule from its first
|
||||
sighting, not have its deadline pushed out by every subsequent poll
|
||||
that still (correctly, from the device's perspective) reports Finish
|
||||
while machine_state has already gone idle."""
|
||||
desc = replace(_PROGRESS_DESC, sticky_seconds=0.05)
|
||||
sensor, coordinator = _sensor(desc)
|
||||
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
time.sleep(0.03)
|
||||
# Device still (incorrectly) reports Finish on every subsequent poll --
|
||||
# must not restart the window.
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
time.sleep(0.03) # 0.06s total since the first sighting -- past 0.05s
|
||||
_replace(coordinator, state="Ready", progress="Finish")
|
||||
assert sensor.native_value == "Idle"
|
||||
|
||||
|
||||
def test_non_sticky_sensor_is_unaffected():
|
||||
"""machine_state has no sticky_fn -- reads straight through, unchanged."""
|
||||
sensor, coordinator = _sensor(_MACHINE_STATE_DESC)
|
||||
_replace(coordinator, state="Run")
|
||||
assert sensor.native_value == "active"
|
||||
_replace(coordinator, state="Ready")
|
||||
assert sensor.native_value == "idle"
|
||||
|
||||
|
||||
def test_cycle_active_and_machine_state_are_never_held():
|
||||
"""The whole point of scoping the hold to progress/progress_percentage
|
||||
only: cycle_active (Running) and machine_state must keep reflecting
|
||||
the device's real-time state throughout, never claiming the appliance
|
||||
is still running once it isn't."""
|
||||
progress_sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
machine_state_bound = BoundEntity(
|
||||
href=_HREF, capability=OPERATIONAL_STATE, desc=_MACHINE_STATE_DESC
|
||||
)
|
||||
machine_state_sensor = LocalThingsSensor(
|
||||
cast(LocalThingsCoordinator, coordinator), machine_state_bound
|
||||
)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert progress_sensor.native_value == "Finish"
|
||||
assert machine_state_sensor.native_value == "active"
|
||||
|
||||
_replace(coordinator, state="Ready")
|
||||
assert progress_sensor.native_value == "Finish" # held
|
||||
assert machine_state_sensor.native_value == "idle" # real-time, unaffected
|
||||
|
||||
|
||||
def test_a_partial_update_that_omits_progress_does_not_erase_the_hold():
|
||||
"""A device's partial poll/notify that doesn't repeat `progress` at
|
||||
all (issue #27's documented shape) merges onto the cache rather than
|
||||
replacing it -- confirms the hold reads the merged rep, still showing
|
||||
'Finish' from the earlier full rep, not a wiped/absent field."""
|
||||
sensor, coordinator = _sensor(_PROGRESS_DESC)
|
||||
|
||||
_replace(coordinator, state="Run", progress="Finish", progressPercentage="100")
|
||||
assert sensor.native_value == "Finish"
|
||||
|
||||
_apply(coordinator, state="Ready") # partial merge, doesn't restate progress
|
||||
assert coordinator.resources[_HREF]["x.com.samsung.da.progress"] == "Finish"
|
||||
assert sensor.native_value == "Finish"
|
||||
@@ -0,0 +1,663 @@
|
||||
"""Tests for the write_resource/read_resource services (issue #300) and the
|
||||
options-flow debug panel now that it goes through write_resource instead of
|
||||
calling coordinator.async_raw_write directly (config_flow.py). See
|
||||
tests/test_coordinator_raw_write.py for the underlying single-write
|
||||
primitive's own session/validation tests -- these focus on the service
|
||||
layer: device target resolution, subdevice href translation, ordered
|
||||
multi-write sequencing, settle/verify_after, and the options-flow rewiring.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.config_flow import LocalThingsOptionsFlow
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_PORT,
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
from custom_components.localthings.services import async_setup_services
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: "10.0.0.199",
|
||||
CONF_PORT: 49154,
|
||||
CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----",
|
||||
CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
_SLEEP_TARGET = "custom_components.localthings.coordinator.asyncio.sleep"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Stand-in for DtlsCoapSession: records every POST verbatim and answers
|
||||
GET from a per-href queue of canned representations, so a test can model
|
||||
a value that changes across successive reads of the same href (the
|
||||
write's own follow-up GET vs. a later verify_after re-read) -- no real
|
||||
DTLS/network involved. Modeled on test_coordinator_raw_write.py's
|
||||
_FakeRawWriteSession, extended for multi-step sequences."""
|
||||
|
||||
def __init__(self, post_code: int = 0x44):
|
||||
self.post_calls: list[tuple[list[str], bytes]] = []
|
||||
self.get_calls: list[list[str]] = []
|
||||
self._post_code = post_code
|
||||
self._get_reps: dict[str, list[dict]] = {}
|
||||
|
||||
def queue_get(self, href: str, rep: dict | list) -> None:
|
||||
"""Queue one more canned rep for `href`'s next GET. Once an href's
|
||||
queue is down to one entry, that entry keeps answering every
|
||||
further GET -- a test only needs to queue the values that
|
||||
actually change across calls.
|
||||
|
||||
A list models a Collection's answer (the `[devcol rep, {href, rep},
|
||||
...]` batch), which is not a Property map and so is a shape the
|
||||
read path has to carry separately -- see the collection test below.
|
||||
"""
|
||||
self._get_reps.setdefault(href.strip("/"), []).append(rep)
|
||||
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
self.post_calls.append((list(path_segs), payload))
|
||||
return self._post_code, b""
|
||||
|
||||
def get(self, path_segs, timeout=None):
|
||||
self.get_calls.append(list(path_segs))
|
||||
key = "/".join(path_segs)
|
||||
queue = self._get_reps.get(key)
|
||||
if not queue:
|
||||
return 0x45, cbor2.dumps({})
|
||||
rep = queue.pop(0) if len(queue) > 1 else queue[0]
|
||||
return 0x45, cbor2.dumps(rep)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id="localthings_SERVICES-TEST",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
coord = LocalThingsCoordinator(hass, entry)
|
||||
# async_raw_write_sequence kicks a refresh after the write; a real
|
||||
# refresh would try to poll a session that doesn't exist for this unit
|
||||
# test, so replace it with a no-op the same way
|
||||
# test_coordinator_raw_write.py does.
|
||||
coord.async_request_refresh = AsyncMock()
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coord
|
||||
async_setup_services(hass)
|
||||
return coord
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_id(hass: HomeAssistant, coordinator: LocalThingsCoordinator) -> str:
|
||||
"""Register MAIN's HA device under the same identifiers a real config
|
||||
entry setup would have used (coordinator.device_info)."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info["identifiers"],
|
||||
)
|
||||
return device.id
|
||||
|
||||
|
||||
async def _call_write(hass: HomeAssistant, device: str, **data):
|
||||
return await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
data,
|
||||
target={"device_id": device},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def _call_read(hass: HomeAssistant, device: str, **data):
|
||||
return await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_READ_RESOURCE,
|
||||
data,
|
||||
target={"device_id": device},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# write_resource: ordered multi-write sequencing
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_posts_in_order_with_exact_bodies(hass, coordinator, device_id):
|
||||
fake = _FakeSession(post_code=0x44)
|
||||
coordinator._session = fake
|
||||
|
||||
writes = [
|
||||
{"href": "/mode/vs/0", "payload": {"a": 1}},
|
||||
{"href": "/washer/vs/0", "payload": {"b": 2}},
|
||||
{"href": "/mode/vs/0", "payload": {"a": 3}},
|
||||
]
|
||||
response = await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
assert [path for path, _ in fake.post_calls] == [
|
||||
["mode", "vs", "0"],
|
||||
["washer", "vs", "0"],
|
||||
["mode", "vs", "0"],
|
||||
]
|
||||
assert [cbor2.loads(body) for _, body in fake.post_calls] == [
|
||||
{"a": 1},
|
||||
{"b": 2},
|
||||
{"a": 3},
|
||||
]
|
||||
assert response is not None
|
||||
assert response["device_id"] == device_id
|
||||
assert len(response["results"]) == 3
|
||||
for result, write in zip(response["results"], writes, strict=True):
|
||||
assert result["href"] == write["href"]
|
||||
assert result["actual_href"] == write["href"] # MAIN -> identity transform
|
||||
assert result["raw_code"] == 0x44
|
||||
assert result["code"] == "2.04"
|
||||
assert result["accepted"] is True
|
||||
coordinator.async_request_refresh.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_write_resource_settle_honored_between_writes(hass, coordinator, device_id):
|
||||
"""settle waits *before the next write*, not after the last one --
|
||||
asserted on the recorded durations rather than a real sleep."""
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
writes = [
|
||||
{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 2},
|
||||
{"href": "/b/vs/0", "payload": {"x": 2}, "settle": 5},
|
||||
{"href": "/c/vs/0", "payload": {"x": 3}, "settle": 9},
|
||||
]
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock) as mock_sleep:
|
||||
await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
assert [call.args[0] for call in mock_sleep.call_args_list] == [2.0, 5.0]
|
||||
|
||||
|
||||
async def test_write_resource_holds_the_session_across_settle_by_default(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""The default keeps the session for the whole sequence so nothing lands
|
||||
between two writes -- asserted on the lock's real state during the wait,
|
||||
not merely on the flag being accepted."""
|
||||
coordinator._session = _FakeSession()
|
||||
locked_during_settle = []
|
||||
|
||||
async def _record(_delay):
|
||||
locked_during_settle.append(coordinator._session_lock.locked())
|
||||
|
||||
with patch(_SLEEP_TARGET, new=_record):
|
||||
await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[
|
||||
{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 2},
|
||||
{"href": "/b/vs/0", "payload": {"x": 2}},
|
||||
],
|
||||
)
|
||||
|
||||
assert locked_during_settle == [True]
|
||||
# ...and it's handed back afterward, rather than leaked to the next call.
|
||||
assert not coordinator._session_lock.locked()
|
||||
|
||||
|
||||
async def test_write_resource_releases_the_session_across_settle_when_asked(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""hold_session_lock=False frees the session across the waits, so polls
|
||||
and entity writes keep working through a long sequence."""
|
||||
coordinator._session = _FakeSession()
|
||||
locked_during_settle = []
|
||||
|
||||
async def _record(_delay):
|
||||
locked_during_settle.append(coordinator._session_lock.locked())
|
||||
|
||||
with patch(_SLEEP_TARGET, new=_record):
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[
|
||||
{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 2},
|
||||
{"href": "/b/vs/0", "payload": {"x": 2}},
|
||||
],
|
||||
hold_session_lock=False,
|
||||
)
|
||||
|
||||
assert locked_during_settle == [False]
|
||||
assert not coordinator._session_lock.locked()
|
||||
# Both writes still go out, in order -- only the locking differs.
|
||||
assert [r["href"] for r in response["results"]] == ["/a/vs/0", "/b/vs/0"]
|
||||
|
||||
|
||||
async def test_write_resource_changed_true_when_readback_matches_payload(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}]
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is True
|
||||
|
||||
|
||||
async def test_write_resource_changed_false_when_readback_differs(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "unchanged"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}]
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is False
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# verify_after: the delayed re-read this feature exists for (issue #300)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_verify_after_reports_held(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # write's own follow-up read
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # verify_after re-read: held
|
||||
coordinator._session = fake
|
||||
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock) as mock_sleep:
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}],
|
||||
verify_after=30,
|
||||
)
|
||||
|
||||
assert 30.0 in [call.args[0] for call in mock_sleep.call_args_list]
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is True
|
||||
assert verified["rep"] == {"x.field": "target"}
|
||||
|
||||
|
||||
async def test_write_resource_verify_after_reports_reverted(hass, coordinator, device_id):
|
||||
"""Issue #300's own symptom: a Samsung wall oven board answers 2.04
|
||||
Changed to a settings write while idle and then silently reverts it
|
||||
once the follow-up read has already come back clean. verify_after's
|
||||
whole purpose is catching exactly this -- `changed` (immediate) and
|
||||
`held` (delayed) must be able to disagree."""
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "target"}) # looks accepted right after the write
|
||||
fake.queue_get("mode/vs/0", {"x.field": "original"}) # reverted by the time verify_after fires
|
||||
coordinator._session = fake
|
||||
|
||||
with patch(_SLEEP_TARGET, new_callable=AsyncMock):
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x.field": "target"}}],
|
||||
verify_after=30,
|
||||
)
|
||||
|
||||
assert response["results"][0]["changed"] is True
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is False
|
||||
assert verified["rep"] == {"x.field": "original"}
|
||||
|
||||
|
||||
async def test_write_resource_no_verified_key_when_verify_after_is_zero(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device_id, writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}]
|
||||
)
|
||||
|
||||
assert "verified" not in response
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Device target resolution
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_zero_devices_rejected(hass, coordinator):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": "/a/vs/0", "payload": {"x": 1}}]},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_two_devices_rejected(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
{"writes": [{"href": "/a/vs/0", "payload": {"x": 1}}]},
|
||||
target={"device_id": [device_id, "some-other-unrelated-device-id"]},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_unresolvable_device_rejected(hass, coordinator):
|
||||
"""The device exists in the registry but no loaded coordinator claims
|
||||
it -- e.g. a device belonging to a different integration entirely."""
|
||||
dev_reg = dr.async_get(hass)
|
||||
other_entry = MockConfigEntry(domain="other_domain")
|
||||
other_entry.add_to_hass(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=other_entry.entry_id,
|
||||
identifiers={("other_domain", "unrelated")},
|
||||
)
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device.id, writes=[{"href": "/a/vs/0", "payload": {"x": 1}}])
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Subdevice href translation (issue #177)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_translates_href_for_indexed_subdevice(hass, coordinator):
|
||||
sub = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
coordinator.subdevices = [sub]
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info_for(sub)["identifiers"],
|
||||
)
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device.id, writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}]
|
||||
)
|
||||
|
||||
assert fake.post_calls[0][0] == ["mode", "vs", "1"]
|
||||
result = response["results"][0]
|
||||
assert result["href"] == "/mode/vs/0"
|
||||
assert result["actual_href"] == "/mode/vs/1"
|
||||
|
||||
|
||||
async def test_write_resource_normalizes_href_before_subdevice_translation(hass, coordinator):
|
||||
"""A trailing slash must not silently retarget the write at the master.
|
||||
|
||||
`Subdevice.to_actual` is a textual transform that rewrites only a
|
||||
trailing '0' segment, so '/mode/vs/0/' passes through it unchanged and
|
||||
then normalizes downstream to the master's '/mode/vs/0' -- landing on
|
||||
the wrong cavity while still answering 2.04. Nothing in the response
|
||||
would have given that away.
|
||||
"""
|
||||
sub = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
coordinator.subdevices = [sub]
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info_for(sub)["identifiers"],
|
||||
)
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass, device.id, writes=[{"href": "/mode/vs/0/", "payload": {"x": 1}}]
|
||||
)
|
||||
|
||||
assert fake.post_calls[0][0] == ["mode", "vs", "1"]
|
||||
result = response["results"][0]
|
||||
assert result["href"] == "/mode/vs/0"
|
||||
assert result["actual_href"] == "/mode/vs/1"
|
||||
|
||||
|
||||
async def test_write_resource_verified_is_keyed_by_canonical_href_for_subdevice(hass, coordinator):
|
||||
"""`verified` promises canonical keys; the coordinator reports actual
|
||||
ones, so the translation back has to line up with what was sent."""
|
||||
sub = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
coordinator.subdevices = [sub]
|
||||
dev_reg = dr.async_get(hass)
|
||||
device = dev_reg.async_get_or_create(
|
||||
config_entry_id=coordinator._entry.entry_id,
|
||||
identifiers=coordinator.device_info_for(sub)["identifiers"],
|
||||
)
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("/mode/vs/1", {"x": 1})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device.id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}],
|
||||
verify_after=1,
|
||||
)
|
||||
|
||||
assert list(response["verified"]) == ["/mode/vs/0"]
|
||||
assert response["verified"]["/mode/vs/0"]["held"] is True
|
||||
|
||||
|
||||
async def test_write_resource_failure_midway_names_the_writes_that_landed(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""A drop partway through leaves the appliance holding a partial
|
||||
sequence; the error has to say how far it got, or the operator can't
|
||||
tell what state the device is in without starting over blind."""
|
||||
|
||||
class _DropsOnSecondWrite(_FakeSession):
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
if len(self.post_calls) == 1:
|
||||
raise OSError("session dropped")
|
||||
return super().post(path_segs, payload, timeout)
|
||||
|
||||
coordinator._session = _DropsOnSecondWrite()
|
||||
|
||||
with pytest.raises(HomeAssistantError) as excinfo:
|
||||
await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[
|
||||
{"href": "/mode/vs/0", "payload": {"a": 1}},
|
||||
{"href": "/temperatures/vs/0", "payload": {"b": 2}},
|
||||
{"href": "/operational/state/vs/0", "payload": {"c": 3}},
|
||||
],
|
||||
)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "1 of 3" in message
|
||||
assert "/mode/vs/0" in message
|
||||
# Entities still get pulled back in line with whatever did land.
|
||||
coordinator.async_request_refresh.assert_awaited()
|
||||
|
||||
|
||||
async def test_write_resource_held_is_none_when_the_verify_read_fails(hass, coordinator, device_id):
|
||||
"""A re-read that doesn't come back is not evidence of a revert.
|
||||
|
||||
_raw_read_blocking answers a non-2.05 with an empty rep, and every
|
||||
payload comparison against {} is False -- so without this, a 4.04 or a
|
||||
dropped verify read is reported as `held: false`, indistinguishable
|
||||
from the board quietly putting the old value back. That distinction is
|
||||
the whole reason verify_after exists (issue #300).
|
||||
"""
|
||||
|
||||
class _FailingVerifyRead(_FakeSession):
|
||||
def get(self, path_segs, timeout=None):
|
||||
self.get_calls.append(list(path_segs))
|
||||
# The write's own follow-up GET succeeds; the later verify
|
||||
# re-read of the same href is the one that fails.
|
||||
if len(self.get_calls) == 1:
|
||||
return 0x45, cbor2.dumps({"x": 1})
|
||||
return 0x84, b""
|
||||
|
||||
coordinator._session = _FailingVerifyRead()
|
||||
|
||||
response = await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/mode/vs/0", "payload": {"x": 1}}],
|
||||
verify_after=1,
|
||||
)
|
||||
|
||||
verified = response["verified"]["/mode/vs/0"]
|
||||
assert verified["held"] is None
|
||||
assert verified["code"] == "4.04"
|
||||
# The immediate readback still saw the value land -- only the delayed
|
||||
# confirmation is unknown, and the two must not be conflated.
|
||||
assert response["results"][0]["changed"] is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Validation caps
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_write_resource_rejects_more_than_ten_writes(hass, coordinator, device_id):
|
||||
writes = [{"href": f"/x/vs/{i}", "payload": {"a": i}} for i in range(11)]
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=writes)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_settle_above_thirty_seconds(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(
|
||||
hass,
|
||||
device_id,
|
||||
writes=[{"href": "/a/vs/0", "payload": {"x": 1}, "settle": 31}],
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_empty_payload(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=[{"href": "/a/vs/0", "payload": {}}])
|
||||
|
||||
|
||||
async def test_write_resource_rejects_non_dict_payload(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(
|
||||
hass, device_id, writes=[{"href": "/a/vs/0", "payload": ["not", "a", "dict"]}]
|
||||
)
|
||||
|
||||
|
||||
async def test_write_resource_rejects_empty_href(hass, coordinator, device_id):
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _call_write(hass, device_id, writes=[{"href": "", "payload": {"x": 1}}])
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# read_resource
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_read_resource_with_href_does_live_get(hass, coordinator, device_id):
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("mode/vs/0", {"x.field": "live"})
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_read(hass, device_id, href="/mode/vs/0")
|
||||
|
||||
assert fake.get_calls == [["mode", "vs", "0"]]
|
||||
assert response["href"] == "/mode/vs/0"
|
||||
assert response["actual_href"] == "/mode/vs/0"
|
||||
assert response["rep"] == {"x.field": "live"}
|
||||
# No duplicate copy of a Property map that `rep` already carries.
|
||||
assert "body" not in response
|
||||
|
||||
|
||||
async def test_read_resource_surfaces_a_collections_list_body(hass, coordinator, device_id):
|
||||
"""A Collection answers a CBOR list, not a Property map, so `rep` can't
|
||||
hold it (issue #335: `/sec/devices` came back as an accepted-but-empty
|
||||
2.05, which reads as "exists, nothing in it" -- the opposite of what a
|
||||
populated batch means)."""
|
||||
batch = [
|
||||
{"rt": ["x.com.samsung.devcol", "oic.wk.col"]},
|
||||
{"href": "/mode/vs/0", "rep": {"x.field": "live"}},
|
||||
]
|
||||
fake = _FakeSession()
|
||||
fake.queue_get("sec/devices", batch)
|
||||
coordinator._session = fake
|
||||
|
||||
response = await _call_read(hass, device_id, href="/sec/devices")
|
||||
|
||||
assert response["code"] == "2.05"
|
||||
assert response["rep"] == {}
|
||||
assert response["body"] == batch
|
||||
|
||||
|
||||
async def test_read_resource_without_href_returns_cached_snapshot_and_does_not_get(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
fake = _FakeSession()
|
||||
coordinator._session = fake
|
||||
# Seed the cache the way a poll would -- the fake session's `get` is
|
||||
# never touched by this test.
|
||||
coordinator._observe.apply("/mode/vs/0", {"x.field": "cached"}, source="poll")
|
||||
|
||||
response = await _call_read(hass, device_id)
|
||||
|
||||
assert fake.get_calls == []
|
||||
assert response["resources"]["/mode/vs/0"] == {"x.field": "cached"}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Options-flow debug panel, rewired onto write_resource (issue #300)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_options_flow_debug_write_goes_through_write_resource_service(
|
||||
hass, coordinator, device_id
|
||||
):
|
||||
"""Drives LocalThingsOptionsFlow's real step methods directly (rather
|
||||
than through hass.config_entries.options.async_init/async_configure,
|
||||
which needs the integration resolvable through HA's loader -- not
|
||||
exercised by any test in this repo and orthogonal to what this test is
|
||||
actually checking): the panel's async_step_debug_edit must call the
|
||||
write_resource service and get back a result the rest of the flow can
|
||||
render, not coord.async_raw_write directly."""
|
||||
fake = _FakeSession(post_code=0x44)
|
||||
fake.queue_get("course/vs/0", {"x.field": "after"})
|
||||
coordinator._session = fake
|
||||
|
||||
flow = LocalThingsOptionsFlow()
|
||||
flow.hass = hass
|
||||
flow.handler = coordinator._entry.entry_id
|
||||
|
||||
write_result = await flow.async_step_debug_write({"href": "/course/vs/0"})
|
||||
assert write_result["step_id"] == "debug_edit"
|
||||
|
||||
edit_result = await flow.async_step_debug_edit({"payload": {"x.com.samsung.da.field": "value"}})
|
||||
assert edit_result["step_id"] == "debug_result"
|
||||
placeholders = edit_result["description_placeholders"]
|
||||
assert placeholders is not None
|
||||
assert placeholders["code"] == "2.04 (0x44)"
|
||||
|
||||
assert len(fake.post_calls) == 1
|
||||
posted_path, posted_body = fake.post_calls[0]
|
||||
assert posted_path == ["course", "vs", "0"]
|
||||
assert cbor2.loads(posted_body) == {"x.com.samsung.da.field": "value"}
|
||||
|
||||
assert len(fake.post_calls) == 1
|
||||
posted_path, posted_body = fake.post_calls[0]
|
||||
assert posted_path == ["course", "vs", "0"]
|
||||
assert cbor2.loads(posted_body) == {"x.com.samsung.da.field": "value"}
|
||||
@@ -278,6 +278,19 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_flat_probe_priority_puts_live_climate_state_before_cold_metrics(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
"""Registry metadata drives fallback order without a model-specific list."""
|
||||
resources, _oic_res, _seeds = _load_device_full("airconditioner_fac_bora_205_flat")
|
||||
coordinator = _coordinator(hass)
|
||||
|
||||
priority = coordinator._subdevice_probe_priority(resources)
|
||||
|
||||
assert "/mode/vs/0" in priority[:4]
|
||||
assert priority.index("/mode/vs/0") < priority.index("/energy/consumption/vs/0")
|
||||
|
||||
|
||||
async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back(
|
||||
hass: HomeAssistant,
|
||||
):
|
||||
|
||||
+152
-2
@@ -10,6 +10,7 @@ from typing import cast
|
||||
|
||||
import cbor2
|
||||
|
||||
from custom_components.localthings.registry import subdevices as subdevices_module
|
||||
from custom_components.localthings.registry.by_type import DeviceRegistry
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc
|
||||
@@ -350,6 +351,118 @@ def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried():
|
||||
assert probes[f"/{_UUID}/mode/vs/0"] is True
|
||||
|
||||
|
||||
def test_enumeration_budget_bounds_silent_prefixed_fallback_and_uses_priority(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A firmware that drops unknown prefixed paths cannot stall setup.
|
||||
|
||||
The Collection probe gets the larger blockwise allowance. The preferred
|
||||
live-state href is then attempted first, and the last Property probe is
|
||||
clamped to exactly the time left in the shared enumeration budget.
|
||||
"""
|
||||
|
||||
class Clock:
|
||||
now = 0.0
|
||||
|
||||
def monotonic(self):
|
||||
return self.now
|
||||
|
||||
class SilentSession:
|
||||
def __init__(self, clock):
|
||||
self.clock = clock
|
||||
self.calls = []
|
||||
|
||||
def get(self, path, timeout=10.0):
|
||||
self.calls.append((tuple(path), timeout))
|
||||
self.clock.now += timeout
|
||||
raise TimeoutError
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
clock = Clock()
|
||||
session = SilentSession(clock)
|
||||
monkeypatch.setattr(subdevices_module.time, "monotonic", clock.monotonic)
|
||||
resources = {
|
||||
"/subdevices/vs/0": {
|
||||
"x.com.samsung.da.subdeviceIdList": [_UUID],
|
||||
},
|
||||
"/power/vs/0": {"power": "On"},
|
||||
"/mode/vs/0": {"mode": "Cool"},
|
||||
}
|
||||
|
||||
found, extra = enumerate_subdevices(
|
||||
session,
|
||||
resources,
|
||||
oic_res_links=[],
|
||||
preferred_hrefs=("/mode/vs/0",),
|
||||
time_budget=7.0,
|
||||
collection_timeout=4.0,
|
||||
property_timeout=2.0,
|
||||
)
|
||||
|
||||
assert found == []
|
||||
assert extra == {}
|
||||
assert clock.now == 7.0
|
||||
assert session.calls == [
|
||||
((_UUID, "device", "0"), 4.0),
|
||||
((_UUID, "mode", "vs", "0"), 2.0),
|
||||
((_UUID, "power", "vs", "0"), 1.0),
|
||||
]
|
||||
|
||||
|
||||
def test_enumeration_keeps_preferred_response_found_before_budget_expires(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A useful early response survives later silent probes hitting the cap."""
|
||||
|
||||
class Clock:
|
||||
now = 0.0
|
||||
|
||||
def monotonic(self):
|
||||
return self.now
|
||||
|
||||
class PartlyResponsiveSession:
|
||||
def __init__(self, clock):
|
||||
self.clock = clock
|
||||
|
||||
def get(self, path, timeout=10.0):
|
||||
if tuple(path) == (_UUID, "mode", "vs", "0"):
|
||||
return 0x45, cbor2.dumps({"mode": "Cool"})
|
||||
self.clock.now += timeout
|
||||
raise TimeoutError
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
clock = Clock()
|
||||
monkeypatch.setattr(subdevices_module.time, "monotonic", clock.monotonic)
|
||||
resources = {
|
||||
"/subdevices/vs/0": {
|
||||
"x.com.samsung.da.subdeviceIdList": [_UUID],
|
||||
},
|
||||
"/power/vs/0": {"power": "On"},
|
||||
"/mode/vs/0": {"mode": "Cool"},
|
||||
}
|
||||
|
||||
found, extra = enumerate_subdevices(
|
||||
PartlyResponsiveSession(clock),
|
||||
resources,
|
||||
oic_res_links=[],
|
||||
preferred_hrefs=("/mode/vs/0",),
|
||||
time_budget=7.0,
|
||||
collection_timeout=4.0,
|
||||
property_timeout=2.0,
|
||||
)
|
||||
|
||||
assert [(subdevice.kind, subdevice.key) for subdevice in found] == [
|
||||
("prefixed", _UUID),
|
||||
]
|
||||
assert found[0].flat_hrefs == ("/mode/vs/0",)
|
||||
assert extra == {f"/{_UUID}/mode/vs/0": {"mode": "Cool"}}
|
||||
assert clock.now == 7.0
|
||||
|
||||
|
||||
def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op():
|
||||
"""The master itself having nothing but /subdevices/vs/0 in its own
|
||||
resources this cycle (e.g. a very first, mostly-empty poll) must not
|
||||
@@ -512,7 +625,10 @@ def test_enumerate_prefixed_id_named_by_both_subdevice_id_list_and_oic_res_is_no
|
||||
{
|
||||
"di": "a",
|
||||
"links": [
|
||||
{"href": f"/{_UUID}/multidevice/vs/0"},
|
||||
{
|
||||
"href": f"/{_UUID}/multidevice/vs/0",
|
||||
"rt": ["x.com.samsung.da.multidevice"],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
@@ -537,7 +653,10 @@ def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated()
|
||||
{
|
||||
"di": "a",
|
||||
"links": [
|
||||
{"href": f"/{_UUID}/multidevice/vs/0"},
|
||||
{
|
||||
"href": f"/{_UUID}/multidevice/vs/0",
|
||||
"rt": ["x.com.samsung.da.multidevice"],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
@@ -553,6 +672,37 @@ def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated()
|
||||
assert subdevices[0].key.lower() == _UUID
|
||||
|
||||
|
||||
def test_uuid_prefixed_file_links_do_not_trigger_flat_subdevice_probe():
|
||||
"""A single-unit AC can advertise UUID-prefixed file-transfer links.
|
||||
|
||||
They identify a transfer namespace, not a sibling indoor unit. Treating
|
||||
the prefix as Pattern C makes setup probe every master href under a UUID
|
||||
that never answers, consuming the whole enumeration timeout budget.
|
||||
"""
|
||||
oic_res = [
|
||||
{
|
||||
"di": "a",
|
||||
"links": [
|
||||
{
|
||||
"href": f"/{_UUID}/file/transfer/vs/0",
|
||||
"rt": ["x.com.samsung.file.transfer"],
|
||||
},
|
||||
{
|
||||
"href": f"/{_UUID}/file/list/vs/0",
|
||||
"rt": ["x.com.samsung.file.list"],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
sess = _FakeSession({})
|
||||
probes: dict[str, bool] = {}
|
||||
|
||||
subdevices, _extra = enumerate_subdevices(sess, {}, oic_res, probe_log=probes.__setitem__)
|
||||
|
||||
assert subdevices == []
|
||||
assert not any(href.startswith(f"/{_UUID}/") for href in probes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# discover_partitioned
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user