Compare commits
49
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30d38d855c | ||
|
|
ee77e3bda8 | ||
|
|
ea986fe8e5 | ||
|
|
10c850f2b9 | ||
|
|
6281b40549 | ||
|
|
0b6cc6aa9f | ||
|
|
5d86dbe7e8 | ||
|
|
e47a71689d | ||
|
|
9cf1e46bf6 | ||
|
|
82acc05d38 | ||
|
|
da49825e2e | ||
|
|
b85af10ae1 | ||
|
|
f9ebc8e286 | ||
|
|
d0cfaeebc1 | ||
|
|
7276ff65a8 | ||
|
|
c731ecefe5 | ||
|
|
77cc875608 | ||
|
|
ae711038ca | ||
|
|
3b3dd54372 | ||
|
|
b2c32a90b6 | ||
|
|
b2c0517115 | ||
|
|
39dcf6a7b8 | ||
|
|
ed768d7257 | ||
|
|
efe87381b7 | ||
|
|
8e25e6dc71 | ||
|
|
60f8fdf488 | ||
|
|
dccd6e1209 | ||
|
|
037dc9722f | ||
|
|
dffe1ef455 | ||
|
|
49bd4ef0cb | ||
|
|
f7faf56689 | ||
|
|
85cca70b6b | ||
|
|
020402c82f | ||
|
|
cf18cc3ecc | ||
|
|
61e6035a36 | ||
|
|
7b155aabc2 | ||
|
|
788b8f98a1 | ||
|
|
83bd158049 | ||
|
|
618443db7b | ||
|
|
3ed277a33b | ||
|
|
42a80fffe6 | ||
|
|
b121d24966 | ||
|
|
615c5d5aa9 | ||
|
|
30e814f242 | ||
|
|
13f1214790 | ||
|
|
f7db568a43 | ||
|
|
62caf769f2 | ||
|
|
e7daa21931 | ||
|
|
c64f58aaa5 |
@@ -103,16 +103,44 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
|
||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
||||
entity on a hunch (`ignored.py`'s rule).
|
||||
|
||||
## 5. Enum selects need translation support
|
||||
## 5. Names and enum labels live in translations, never in Python
|
||||
|
||||
Any select whose options are raw device codes (course/cycle, and code-valued
|
||||
settings) must render through translations, not Python:
|
||||
- Set `translation_key='<family>_cycle'` (or similar) on the `SelectDesc`;
|
||||
`options`/`options_field` supply the **raw** codes.
|
||||
- Add the labels to **both** `strings.json` and `translations/en.json` under
|
||||
`entity.select.<translation_key>.state.<code>`, with the code **lowercased**
|
||||
(e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's
|
||||
the cue to identify and name them.
|
||||
Descriptors have **no `name` field**. Every entity is named from the shipped
|
||||
catalog, keyed by `translation_key` — which defaults to the descriptor's own
|
||||
`key`. So adding `SensorDesc(key='filter_status', ...)` obliges you to add:
|
||||
|
||||
```json
|
||||
"entity": { "sensor": { "filter_status": { "name": "Filter status" } } }
|
||||
```
|
||||
|
||||
to `translations/en.json`. Skip it and the entity ships nameless;
|
||||
`tests/test_translations.py` fails the build instead.
|
||||
|
||||
- **Sentence case** ("Filter status", not "Filter Status"), per HA's style
|
||||
guide — capitalize only proper nouns and Samsung feature names ("AI Energy
|
||||
Mode", "Storm Wash+").
|
||||
- Set `translation_key` explicitly only to **share** one catalog entry across
|
||||
descriptors, or to point at a differently-named one. Two descriptors on the
|
||||
same platform with the same `key` already share an entry — intended for
|
||||
`common.py`'s OCF/vendor fallback pairs, a silent mislabel otherwise.
|
||||
- Prefer HA's own vocabulary where it fits: a `device_class` gives you
|
||||
translated states for free (`binary_sensor` door/running, `sensor`
|
||||
timestamp/enum), so don't restate them.
|
||||
|
||||
Selects whose options are raw device codes (course/cycle, code-valued
|
||||
settings) additionally need those codes labelled:
|
||||
- `options`/`options_field` supply the **raw** codes; the catalog maps them.
|
||||
- Add labels under `entity.select.<translation_key>.state.<code>`, code
|
||||
**lowercased** (e.g. `"16": "Cotton"`). `select.py` derives which values it
|
||||
normalizes from the catalog itself, so there is no Python list to keep in
|
||||
sync — a code with no entry simply renders as the raw code, which is the cue
|
||||
to identify and name it.
|
||||
|
||||
`translations/en.json` is the only place any of this lives: there is no
|
||||
`strings.json` (Home Assistant doesn't read one from a custom integration) and
|
||||
no `[%key:...%]` resolution (that's Core build tooling). Every other language
|
||||
must mirror `en.json` key for key — also enforced by
|
||||
`tests/test_translations.py`.
|
||||
|
||||
## 6. Coverage discipline: bound or ignored
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
|
||||
| Type | Registry |
|
||||
|---|---|
|
||||
| Air conditioner | `by_type/airconditioner.py` |
|
||||
| Air purifier | `by_type/air_purifier.py` |
|
||||
| Dryer | `by_type/dryer.py` |
|
||||
| Oven | `by_type/oven.py` |
|
||||
| Range / cooktop-oven combo | `by_type/range.py` |
|
||||
| Cooktop (read-only burner status) | `by_type/cooktop.py` |
|
||||
| Range hood | `by_type/range_hood.py` |
|
||||
| Range | `by_type/range.py` |
|
||||
| Dishwasher | `by_type/dishwasher.py` |
|
||||
| Refrigerator | `by_type/refrigerator.py` |
|
||||
| Washer | `by_type/washer.py` |
|
||||
@@ -101,9 +104,12 @@ custom_components/localthings/
|
||||
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
|
||||
const.py Domain, config keys, probe ports
|
||||
entity.py Base entity wiring capability registry -> HA entity
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / climate.py
|
||||
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
|
||||
One module per HA platform
|
||||
strings.json / translations/ Config-flow copy + entity state translations
|
||||
catalog.py Reads the shipped translation catalog (which keys/states exist)
|
||||
translations/ Config-flow copy + entity name/state translations, one file per
|
||||
language; en.json is the source of truth (no strings.json —
|
||||
Home Assistant never reads one from a custom integration)
|
||||
registry/
|
||||
registry.py Builds the global capability registry, validates href collisions
|
||||
capability.py Capability dataclass (href, entities, transforms)
|
||||
@@ -113,7 +119,8 @@ custom_components/localthings/
|
||||
identity.py Reads device identity for type detection
|
||||
redact.py Strips account/identity data before diagnostics leave HA
|
||||
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
|
||||
dryer, oven, dishwasher, fridge, washer, laundry, operational, ignored)
|
||||
cooktop, range_hood, dryer, oven, dishwasher, fridge, washer,
|
||||
laundry, operational, ignored)
|
||||
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
|
||||
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
|
||||
behavior, and golden-file regression against captured device dumps
|
||||
@@ -139,7 +146,7 @@ support for hardware the maintainers don't have.
|
||||
1. Get a capture of the appliance's `/device/0` response. The easiest way: add the device to HA (type detection failing is fine) and pull its Diagnostics download from Settings > Devices & Services > the device > the menu > Download diagnostics — it already contains a redacted dump of the device's resources.
|
||||
2. Reuse existing `Capability` objects from `registry/capabilities/` wherever the resource matches one already declared. Most `common.py` capabilities (power, kids lock, remote control, alarms, energy/water meters) are shared verbatim across families; add new ones only for resources unique to the new type.
|
||||
3. Create `registry/by_type/<name>.py` with a `DeviceRegistry(name=..., capabilities=_build([...]))`. Use `pattern_capabilities` instead of `capabilities` for any resource whose `href` isn't fixed (for example per-compartment fridge resources); see `refrigerator.py` for the pattern.
|
||||
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion` — confirmed true for washers — add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` instead, so `for_device_by_model()` can route it.
|
||||
4. Register it in `_REGISTRY_BY_KEY` in `registry/by_type/__init__.py`, keyed on the lowercased, space/hyphen-to-underscore-converted suffix of the device's `oneUiVersion` string (see `_type_key()` in that file for the exact transform). If the device never reports `oneUiVersion`, add its consumer-model prefix to `_CONSUMER_PREFIX_TO_KEY` so `for_device_by_model()` can route it. If it also omits `/information/vs/0` (as the verified NA9300K cooktop does), add a distinctive, conservative resource-signature rule to `for_device_by_resources()`.
|
||||
5. Add golden-file coverage in `tests/` against a captured `/device/0` dump for the new type.
|
||||
|
||||
No config-flow changes are needed. Device-type detection and entity wiring are fully driven by the registry.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The integration's own shipped translation catalog, as data.
|
||||
|
||||
Home Assistant loads exactly one file per language for a custom integration:
|
||||
``translations/<lang>.json``. It does not read ``strings.json`` and it does
|
||||
not resolve ``[%key:...%]`` references -- both of those belong to Core's
|
||||
build tooling (``script/translations``), which custom integrations never run
|
||||
through. So ``translations/en.json`` is not a generated artifact here, it is
|
||||
the source of truth, and English is the one catalog required to be complete
|
||||
(hassfest validates it for custom integrations).
|
||||
|
||||
That makes the English catalog the authority on *which* translation keys and
|
||||
states exist, which is something the Python side genuinely needs to know:
|
||||
|
||||
* ``select`` has to decide whether to normalize a raw Samsung option into
|
||||
a lowercase state key (so Home Assistant can look it up) or leave the
|
||||
vendor's own casing alone as a readable fallback.
|
||||
* ``laundry.cycle_select`` has to decide whether a device-reported course
|
||||
table has translations at all before keying off it.
|
||||
|
||||
Reading it from the catalog instead of restating it in Python means adding a
|
||||
state or a course table is a one-file change, and the two can't drift.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Read at import, not lazily: custom integrations are imported in an executor
|
||||
# thread, so this stays off the event loop no matter who asks first.
|
||||
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
|
||||
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
|
||||
).get('entity', {})
|
||||
|
||||
|
||||
def has_entity_translation(platform: str, translation_key: str) -> bool:
|
||||
"""Whether `platform`.`translation_key` has an entry in the catalog."""
|
||||
return translation_key in _ENTITY_CATALOG.get(platform, {})
|
||||
|
||||
|
||||
def translated_states(platform: str, translation_key: str) -> frozenset[str]:
|
||||
"""The state keys translated for `platform`.`translation_key`.
|
||||
|
||||
Empty when the entity has no translation at all, and also when it has a
|
||||
translated *name* but deliberately no state table (a course list whose
|
||||
codes we can't map, for instance) -- callers treat both the same way:
|
||||
leave the device's value untouched.
|
||||
"""
|
||||
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
|
||||
return frozenset(entry.get('state', ())) if entry else frozenset()
|
||||
@@ -13,9 +13,10 @@ snapshot via `coordinator.resource(href)` -- the same cross-resource read that
|
||||
|
||||
Writes go through `coordinator.async_send_command(bound, (kind, value))`: the
|
||||
CLIMATE capability's `write_fn` maps each `(kind, value)` payload to the right
|
||||
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs (the
|
||||
bound href is only used for logging), so one descriptor drives writes to power,
|
||||
mode, temperature and wind resources.
|
||||
`(path_segs, body)`, and `async_send_command` POSTs to those path_segs and
|
||||
applies the optimistic value/settle guard to that same href -- not the bound
|
||||
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
|
||||
back for, power, mode, temperature and wind resources alike.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import selectors
|
||||
@@ -12,8 +13,13 @@ from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.selector import (
|
||||
ObjectSelector,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
@@ -24,6 +30,7 @@ from .const import (
|
||||
CONF_HOST, CONF_PORT,
|
||||
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
|
||||
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
|
||||
PROBE_GET_TIMEOUT_S,
|
||||
)
|
||||
@@ -195,7 +202,9 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
import cbor2
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model
|
||||
from .registry.by_type import (
|
||||
for_device, for_device_by_model, for_device_by_resources,
|
||||
)
|
||||
|
||||
_LOGGER.debug("Fetching Samsung cloud UUID from %s", _SAMSUNG_CLOUD_HOST)
|
||||
try:
|
||||
@@ -266,7 +275,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
|
||||
) or for_device_by_model(
|
||||
info_resource.get('x.com.samsung.da.modelNum', ''),
|
||||
info_resource.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
) or for_device_by_resources(resources)
|
||||
return {
|
||||
"port": port,
|
||||
"serial": serial,
|
||||
@@ -298,6 +307,13 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._ca_key_pem: str = ""
|
||||
self._pending_info: dict | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> LocalThingsOptionsFlow:
|
||||
return LocalThingsOptionsFlow()
|
||||
|
||||
def _create_entry(self, info: dict) -> FlowResult:
|
||||
return self.async_create_entry(
|
||||
title=f"Samsung Appliance ({self._host})",
|
||||
@@ -352,22 +368,27 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if has_creds:
|
||||
schema = vol.Schema({vol.Required(CONF_HOST): _TEXT})
|
||||
reuse_note = "CA credentials will be reused from the existing device."
|
||||
step_id = "user_reuse"
|
||||
else:
|
||||
schema = vol.Schema({
|
||||
vol.Required(CONF_HOST): _TEXT,
|
||||
vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
|
||||
vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
|
||||
})
|
||||
reuse_note = ""
|
||||
step_id = "user"
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
step_id=step_id,
|
||||
data_schema=schema,
|
||||
errors=errors,
|
||||
description_placeholders={"reuse_note": reuse_note},
|
||||
)
|
||||
|
||||
async def async_step_user_reuse(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle the localized host-only form for additional appliances."""
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
async def async_step_confirm_unknown_type(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@@ -375,10 +396,167 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
return self._create_entry(self._pending_info)
|
||||
|
||||
if not self._pending_info["one_ui_version"]:
|
||||
return await self.async_step_confirm_unknown_type_no_version()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm_unknown_type",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders={
|
||||
"one_ui_version": self._pending_info["one_ui_version"] or "(none reported)",
|
||||
"one_ui_version": self._pending_info["one_ui_version"],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_confirm_unknown_type_no_version(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm an unknown appliance that did not report oneUiVersion."""
|
||||
if user_input is not None:
|
||||
return self._create_entry(self._pending_info)
|
||||
return self.async_show_form(
|
||||
step_id="confirm_unknown_type_no_version",
|
||||
data_schema=vol.Schema({}),
|
||||
)
|
||||
|
||||
|
||||
class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
"""Per-device options: the remote-control-off write-block override
|
||||
(issue #54) plus a debug panel for writing an arbitrary body to an
|
||||
arbitrary resource href, so a user can pin down device-specific write
|
||||
behavior without waiting on a new release.
|
||||
|
||||
The remote-control override exists because most devices reject writes
|
||||
outright while remote control is off and a clear error beats a silent
|
||||
device-side rejection -- but not every model actually enforces that,
|
||||
so this lets a user who's confirmed their device accepts writes anyway
|
||||
turn the block off for just that device rather than it being
|
||||
hardcoded on for everyone. The debug panel goes further: it bypasses
|
||||
that block (and every write_fn/validate_fn) entirely, sending exactly
|
||||
the body the user types to whatever href they pick.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._debug_href: str = ""
|
||||
self._debug_result: tuple[int, dict] | None = None
|
||||
|
||||
def _coordinator(self):
|
||||
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
return self.async_show_menu(
|
||||
step_id="init",
|
||||
menu_options=["settings", "debug_write"],
|
||||
)
|
||||
|
||||
async def async_step_settings(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="settings",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_BYPASS_REMOTE_CONTROL, False
|
||||
),
|
||||
): bool,
|
||||
}),
|
||||
)
|
||||
|
||||
async def async_step_debug_write(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
if user_input is not None:
|
||||
self._debug_href = user_input["href"]
|
||||
return await self.async_step_debug_edit()
|
||||
|
||||
hrefs = sorted(coord.last_resources.keys())
|
||||
return self.async_show_form(
|
||||
step_id="debug_write",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required("href"): SelectSelector(SelectSelectorConfig(
|
||||
options=hrefs,
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)),
|
||||
}),
|
||||
)
|
||||
|
||||
def _show_debug_edit_form(
|
||||
self, href: str, current: dict, errors: dict[str, str], payload,
|
||||
) -> FlowResult:
|
||||
return self.async_show_form(
|
||||
step_id="debug_edit",
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(
|
||||
"payload", default=(payload if payload is not None else {}),
|
||||
): ObjectSelector(),
|
||||
}),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"href": href,
|
||||
"current_value": (
|
||||
json.dumps(current, indent=2, ensure_ascii=False)
|
||||
if current else "{}"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_debug_edit(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
coord = self._coordinator()
|
||||
if coord is None:
|
||||
return self.async_abort(reason="not_loaded")
|
||||
|
||||
href = self._debug_href
|
||||
current = coord.resource(href)
|
||||
|
||||
if user_input is not None:
|
||||
payload = user_input.get("payload") or {}
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"payload": "empty_payload"}, payload
|
||||
)
|
||||
try:
|
||||
code, new_rep = await coord.async_raw_write(href, payload)
|
||||
except Exception: # noqa: BLE001 - surfaced to the user below
|
||||
_LOGGER.exception("debug raw write failed for %s", href)
|
||||
return self._show_debug_edit_form(
|
||||
href, current, {"base": "write_failed"}, payload
|
||||
)
|
||||
self._debug_result = (code, new_rep)
|
||||
return await self.async_step_debug_result()
|
||||
|
||||
return self._show_debug_edit_form(href, current, {}, None)
|
||||
|
||||
async def async_step_debug_result(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
code, new_rep = self._debug_result or (0, {})
|
||||
return self.async_show_menu(
|
||||
step_id="debug_result",
|
||||
menu_options=["debug_write", "finish"],
|
||||
description_placeholders={
|
||||
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
|
||||
"new_value": (
|
||||
json.dumps(new_rep, indent=2, ensure_ascii=False)
|
||||
if new_rep else "{}"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
# Close the flow without altering saved options.
|
||||
return self.async_create_entry(data=dict(self.config_entry.options))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
DOMAIN = "localthings"
|
||||
|
||||
PLATFORMS = ["sensor", "binary_sensor", "switch", "number", "select", "button", "time", "climate"]
|
||||
PLATFORMS = [
|
||||
"sensor", "binary_sensor", "switch", "number", "select", "button",
|
||||
"time", "climate", "fan",
|
||||
]
|
||||
|
||||
CONF_HOST = "host"
|
||||
CONF_PORT = "port"
|
||||
@@ -9,6 +12,16 @@ CONF_CA_KEY_PEM = "ca_key_pem"
|
||||
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
|
||||
CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
|
||||
# Options-flow key (entry.options, not entry.data): lets a user override the
|
||||
# device-wide remote-control-off write block for a specific device (issue
|
||||
# #54). Some devices report remote control off yet still accept certain
|
||||
# writes (e.g. default detergent/softener dosing on a washer, applied even
|
||||
# to the built-in programs) -- the block exists to give a clear error
|
||||
# instead of a silent device-side rejection, but that assumption doesn't
|
||||
# hold for every model. Defaults to False (block stays on) everywhere it's
|
||||
# read, so devices this doesn't apply to see no behavior change.
|
||||
CONF_BYPASS_REMOTE_CONTROL = "bypass_remote_control_lock"
|
||||
|
||||
# The DTLS/CoAP local API binds somewhere in this ephemeral range; which port
|
||||
# depends on firmware. Newer builds answer on 49154/49155, but older ones have
|
||||
# been seen as low as 49153, so we sweep the whole range for a live UDP port
|
||||
|
||||
@@ -21,8 +21,8 @@ from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
|
||||
from .registry.batch import parse_device0_batch
|
||||
from .registry.by_type import for_device, for_device_by_model
|
||||
from .registry.capabilities.common import remote_control_enabled
|
||||
from .registry.by_type import for_device, for_device_by_model, for_device_by_resources
|
||||
from .registry.capabilities.common import merge_options_field, remote_control_enabled
|
||||
from .registry.discovery import discover, BoundEntity
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
@@ -31,20 +31,13 @@ from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
|
||||
|
||||
from .const import (
|
||||
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
|
||||
DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SEED_PATH = ['device', '0']
|
||||
|
||||
_REMOTE_CONTROL_DISABLED_MESSAGE = (
|
||||
"Remote control is turned off on this device. Check your appliance's "
|
||||
"manual for how to enable remote control before Home Assistant can "
|
||||
"control it."
|
||||
)
|
||||
|
||||
|
||||
class _NoOpDescriptor:
|
||||
"""StateCache requires a descriptor with an on_observation hook. This
|
||||
integration doesn't use per-capability observation hooks, so this is a
|
||||
@@ -77,6 +70,14 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# fine session shouldn't tear down a working OBSERVE subscription.
|
||||
_POLL_TIMEOUT_LIMIT: int = 3
|
||||
|
||||
# Timeouts for the two network round trips a write triggers: the PUT
|
||||
# itself (_do_put), then the confirming full /device/0 summary poll
|
||||
# async_send_command requests right after (_poll_once). Named here
|
||||
# (rather than left as inline literals) so the write-settle window
|
||||
# below can be sized to always outlast both — see async_send_command.
|
||||
_POST_TIMEOUT_S: float = 8.0
|
||||
_POLL_TIMEOUT_S: float = 35.0
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
# Per-device logger (module logger scoped to this device's host) so
|
||||
# every log line — including the base DataUpdateCoordinator's own
|
||||
@@ -211,7 +212,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# would have succeeded, not a dead session. 35s gives a slow
|
||||
# blockwise transfer room to actually finish instead of
|
||||
# generating a TimeoutError every cycle.
|
||||
code, payload = sess.get(_SEED_PATH, timeout=35.0)
|
||||
code, payload = sess.get(_SEED_PATH, timeout=self._POLL_TIMEOUT_S)
|
||||
except TimeoutError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -295,6 +296,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
if reg is None:
|
||||
reg = for_device_by_resources(resources)
|
||||
unbound: list[str] = []
|
||||
if reg is not None:
|
||||
self._log.debug("device type: %s (oneUiVersion=%r)", reg.name, one_ui)
|
||||
@@ -314,7 +317,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.device_serial = serial
|
||||
|
||||
ident = self._identity
|
||||
device_type = reg.name.title() if reg else 'Appliance'
|
||||
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
|
||||
model_num = info.get('x.com.samsung.da.modelNum', '')
|
||||
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
|
||||
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
|
||||
@@ -524,7 +527,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
user-facing message -- as opposed to write_fn's silent no-op below
|
||||
-- is available to every platform for free. The remote-control
|
||||
check runs first and applies to every platform unconditionally,
|
||||
ahead of any description-specific validate_fn."""
|
||||
ahead of any description-specific validate_fn -- unless the user has
|
||||
opted this device out of it via CONF_BYPASS_REMOTE_CONTROL (issue
|
||||
#54: some devices accept certain writes, e.g. a washer's default
|
||||
dosing levels, even while reporting remote control off, so the
|
||||
block's assumption doesn't hold for every model)."""
|
||||
desc = bound_entity.desc
|
||||
write_fn = getattr(desc, 'write_fn', None)
|
||||
if write_fn is None:
|
||||
@@ -532,19 +539,42 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
href = bound_entity.href
|
||||
rep = self._cache.get(href or '') or {}
|
||||
resources = self._cache.snapshot()
|
||||
if not remote_control_enabled(resources):
|
||||
raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE)
|
||||
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
|
||||
if not bypass_remote_control and not remote_control_enabled(resources):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="remote_control_disabled",
|
||||
)
|
||||
validate_fn = getattr(desc, 'validate_fn', None)
|
||||
if validate_fn is not None:
|
||||
error = validate_fn(payload, rep, resources)
|
||||
if error:
|
||||
raise ServiceValidationError(error)
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=error,
|
||||
)
|
||||
result = write_fn(payload, rep, href)
|
||||
if result is None:
|
||||
self._log.warning("write_fn rejected payload %r for %s", payload, href)
|
||||
return
|
||||
path_segs, body = result
|
||||
|
||||
# The write's actual target, not necessarily bound_entity.href. Most
|
||||
# descriptors write to the same resource they're bound to, but a
|
||||
# composite entity -- the AC's ClimateDesc, bound to /mode/vs/0 --
|
||||
# drives writes to several sibling resources via path_segs
|
||||
# (/power/0, /temperature/desired/0, /wind/strength/vs/0, ...) that
|
||||
# write_fn picks per payload (see airconditioner._climate_write).
|
||||
# Applying the optimistic value and settle guard below to
|
||||
# bound_entity.href instead of this target protected the wrong
|
||||
# resource: /mode/vs/0 got the (nonsensical, wrong-shaped) optimistic
|
||||
# merge while the resource the climate entity actually displays from
|
||||
# (e.g. /power/0) never got one, so HA kept showing the pre-write
|
||||
# state until the next real read of that resource -- the 20-60s lag
|
||||
# in issues #17/#53, which survived the earlier optimistic-apply fix
|
||||
# (issue #27) because that fix applied to the wrong href too.
|
||||
write_href = '/' + '/'.join(path_segs)
|
||||
|
||||
# Apply the write optimistically before starting the settle guard,
|
||||
# not after -- mark_write_pending gates every source (poll, sweep,
|
||||
# observe) through the same apply(), itself included, so flipping
|
||||
@@ -553,19 +583,127 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# onto, the settle window was just delaying the real device
|
||||
# confirmation for a few seconds on every write, which read exactly
|
||||
# like the write being silently reverted (issue #27).
|
||||
self._observe.apply(href, body, source='optimistic')
|
||||
self._observe.mark_write_pending(href)
|
||||
#
|
||||
# settle_s must outlast the PUT and the async_request_refresh()
|
||||
# below combined, not just DEFAULT_SETTLE_S's fixed few seconds --
|
||||
# that refresh is a full /device/0 summary poll, which
|
||||
# _POLL_TIMEOUT_S itself admits can legitimately take tens of
|
||||
# seconds on these devices (see _poll_once), and some writes settle
|
||||
# on the device itself well after that: issue #9's washer packs
|
||||
# cycle/detergent/softener selection into the same /course/vs/0
|
||||
# options[] array, and picking a new value there visibly needs a
|
||||
# few seconds of internal validation/dispenser movement before the
|
||||
# device's own state agrees -- while /washer/vs/0's temperature/
|
||||
# spin fields (plain flags, no device-side settling) confirm
|
||||
# instantly on the same device. A short fixed window expired while
|
||||
# the confirm poll was still in flight (or before the device had
|
||||
# caught up internally), so that stale read landed unprotected and
|
||||
# reverted the optimistic value, self-correcting again only once a
|
||||
# later poll finally saw the real change -- read by the user as the
|
||||
# write "reverting, then re-applying itself" a few seconds later.
|
||||
#
|
||||
# An earlier attempt at this also released the guard early, right
|
||||
# after the confirming refresh completed, to avoid shutting out
|
||||
# unrelated real updates (another automation, the physical remote)
|
||||
# for the rest of settle_s. That was reverted: releasing the guard
|
||||
# the moment one round trip finishes doesn't mean the device has
|
||||
# actually caught up (exactly the slow-settling case above), and it
|
||||
# introduced its own races around overlapping writes to the same
|
||||
# href. Simpler and safer to just hold the guard for the full,
|
||||
# generously-sized window and let it expire on its own.
|
||||
# write_fn bodies that touch x.com.samsung.da.options carry only the
|
||||
# changed token(s) now (issue #54: confirmed sufficient on the wire --
|
||||
# the device merges by prefix itself), not the whole packed array.
|
||||
# observe.apply()'s field-level {**cached, **rep} merge doesn't know
|
||||
# that -- handed the bare token list, it would replace the cached
|
||||
# field outright and wipe every sibling option for the rest of the
|
||||
# settle window. Pre-merge it here the same way the device does, so
|
||||
# the optimistic cache entry stays complete; the minimal `body` below
|
||||
# is still exactly what goes out over the wire.
|
||||
optimistic_body = body
|
||||
new_options = body.get('x.com.samsung.da.options')
|
||||
if isinstance(new_options, list):
|
||||
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
|
||||
optimistic_body = {
|
||||
**body,
|
||||
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
|
||||
}
|
||||
self._observe.apply(write_href, optimistic_body, source='optimistic')
|
||||
self._observe.mark_write_pending(
|
||||
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
|
||||
)
|
||||
|
||||
def _do_put():
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=8.0)
|
||||
self._log.info("PUT %s → code %#04x", href, code)
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
||||
self._log.info("PUT %s → code %#04x", write_href, code)
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_do_put)
|
||||
except Exception as e:
|
||||
self._log.error("command failed for %s: %s", href, e)
|
||||
self._log.error("command failed for %s: %s", write_href, e)
|
||||
else:
|
||||
await self.async_request_refresh()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Debug raw write (issue #54): a power-user escape hatch for the
|
||||
# options-flow debug panel, letting a user POST an arbitrary partial
|
||||
# body to an arbitrary href to pin down device-specific write behavior
|
||||
# without waiting on a new release. Deliberately bypasses the
|
||||
# remote-control block and every write_fn/validate_fn above -- that's
|
||||
# the whole point, so use with care.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _raw_write_blocking(self, path_segs: list[str], body: dict, href: str) -> tuple[int, dict]:
|
||||
"""Debug primitive: POST an arbitrary patch, then read the href
|
||||
back for ground truth. Blocking -- runs in executor."""
|
||||
if self._session is None:
|
||||
self._connect_session()
|
||||
sess = self._session
|
||||
if sess is None:
|
||||
raise RuntimeError("no session")
|
||||
code, _ = sess.post(path_segs, cbor2.dumps(body), timeout=self._POST_TIMEOUT_S)
|
||||
self._log.warning("DEBUG raw write POST %s %r → code %#04x", href, body, code)
|
||||
new_rep: dict = {}
|
||||
try:
|
||||
sess.pace()
|
||||
rcode, payload = sess.get(path_segs, timeout=10.0)
|
||||
if rcode == 0x45 and payload:
|
||||
rep = cbor2.loads(payload)
|
||||
if isinstance(rep, dict):
|
||||
self._observe.apply(href, rep, source='poll')
|
||||
new_rep = rep
|
||||
except Exception as e:
|
||||
self._log.debug("raw write follow-up read failed: %s", e)
|
||||
return code, new_rep
|
||||
|
||||
async def async_raw_write(self, href: str, body: dict) -> tuple[int, dict]:
|
||||
"""Debug-only arbitrary write (issue #54). Bypasses the
|
||||
remote-control block and all write_fn/validate_fn logic; sends
|
||||
`body` verbatim as a partial-rep PATCH to `href`. Returns
|
||||
(coap_code, new_rep) where new_rep is the href's value read back
|
||||
right after the write. Used by the options-flow debug panel to
|
||||
help users pin down device-specific write behavior without a new
|
||||
release."""
|
||||
if not isinstance(body, dict) or not body:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="debug_payload_empty",
|
||||
)
|
||||
path_segs = [s for s in href.strip('/').split('/') if s]
|
||||
if not path_segs:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="resource_href_required",
|
||||
)
|
||||
norm_href = '/' + '/'.join(path_segs)
|
||||
async with self._session_lock:
|
||||
code, new_rep = await self.hass.async_add_executor_job(
|
||||
self._raw_write_blocking, path_segs, body, norm_href
|
||||
)
|
||||
# Hasten a full summary poll so entities on other resources catch
|
||||
# up too -- a debug write can affect siblings, not just its href.
|
||||
await self.async_request_refresh()
|
||||
return code, new_rep
|
||||
|
||||
@@ -38,15 +38,32 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
|
||||
|
||||
def _derive_name(state_key: str) -> str:
|
||||
"""Turn a snake_case state key into a title-cased display name.
|
||||
"""Turn a snake_case state key into a title-cased label.
|
||||
|
||||
Strips a trailing instance number of 0 (singleton), promotes any other
|
||||
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
|
||||
|
||||
Entity names themselves come from the translation catalog; this only
|
||||
builds the {instance_name} placeholder those translations interpolate,
|
||||
for a device that named its own compartments/ice makers.
|
||||
"""
|
||||
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
|
||||
return _snake_to_title(name).strip()
|
||||
|
||||
|
||||
def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
|
||||
"""Return the stable vendor/href instance label used in a name placeholder."""
|
||||
if bound.instance_name:
|
||||
return bound.instance_name
|
||||
source = bound.key_override or state_key
|
||||
suffix = f"_{bound.desc.key}"
|
||||
if source.endswith(suffix):
|
||||
source = source[:-len(suffix)]
|
||||
elif bound.instance and source.endswith(bound.instance):
|
||||
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
|
||||
return _derive_name(source)
|
||||
|
||||
|
||||
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
"""Base class for all Local Things entities."""
|
||||
|
||||
@@ -57,21 +74,48 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
self._bound = bound
|
||||
self._state_key = _key(bound)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
||||
if bound.desc.name is not None:
|
||||
self._attr_name = bound.desc.name
|
||||
elif bound.instance_name:
|
||||
# A device-given instance name (e.g. an ice maker's "Cubed
|
||||
# Ice") takes the place of the href-derived instance label,
|
||||
# keeping the same entity-specific suffix (issue #27).
|
||||
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip()
|
||||
else:
|
||||
self._attr_name = _derive_name(self._state_key)
|
||||
self._attr_translation_key = bound.desc.translation_key
|
||||
if bound.desc.translation_placeholders is not None:
|
||||
self._attr_translation_placeholders = dict(
|
||||
bound.desc.translation_placeholders
|
||||
)
|
||||
elif bound.desc.use_instance_name:
|
||||
self._attr_translation_placeholders = {
|
||||
"instance_name": _instance_display_name(bound, self._state_key)
|
||||
}
|
||||
|
||||
# _attr_name is deliberately left unset: Home Assistant gives an
|
||||
# explicitly-set name precedence over the translation catalog, so
|
||||
# setting it here would make every entity untranslatable. Every
|
||||
# descriptor resolves to a catalog entry (see translation_key below);
|
||||
# a platform that wants the bare device name instead sets
|
||||
# _attr_name = None itself, as fan.py does for the hood's main entity.
|
||||
self._attr_icon = bound.desc.icon
|
||||
raw_cat = bound.desc.entity_category
|
||||
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
|
||||
self._attr_entity_registry_enabled_default = bound.desc.enabled_default
|
||||
|
||||
@property
|
||||
def translation_key(self) -> str | None:
|
||||
"""The descriptor's catalog key, defaulting to its own `key`.
|
||||
|
||||
Overrides Entity.translation_key (a property upstream, not a plain
|
||||
attribute) so a callable descriptor -- e.g. laundry.cycle_select's
|
||||
table-id-gated resolver -- is re-evaluated against live coordinator
|
||||
data on every access, not resolved once at construction time.
|
||||
|
||||
Discovery runs on the first /device/0 poll, which the entity
|
||||
registry already documents can hand a sibling resource an empty
|
||||
stub rep before it's actually been fetched (see _is_included's
|
||||
docstring) -- a static one-time resolution here would risk baking
|
||||
in a permanent None (no translation) for the entity's whole
|
||||
lifetime if that stub hadn't populated yet, even once the real
|
||||
value arrives on a later poll.
|
||||
"""
|
||||
tk = self._bound.desc.translation_key
|
||||
if callable(tk):
|
||||
return tk(self.coordinator.last_resources)
|
||||
return tk if tk is not None else self._bound.desc.key
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return self.coordinator.device_info
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Fan platform for Samsung range hoods."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util.percentage import (
|
||||
ordered_list_item_to_percentage,
|
||||
percentage_to_ordered_list_item,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.entities import FanDesc
|
||||
|
||||
|
||||
POWER_HREF = '/power/0'
|
||||
POWER_VS_HREF = '/power/vs/0'
|
||||
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
|
||||
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
LocalThingsRangeHoodFan(coordinator, bound)
|
||||
for bound in coordinator.bound
|
||||
if isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)
|
||||
)
|
||||
|
||||
|
||||
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
"""A hood fan combining sibling power and fan-speed resources."""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
self._attr_name = None
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _all_speed_codes(self) -> list[str]:
|
||||
rep = self._rep(self._bound.href)
|
||||
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
|
||||
|
||||
def _active_speed_codes(self) -> list[str]:
|
||||
# Power is carried by the separate /power resource. fanSpeed retains
|
||||
# the selected setting while power is off (as the lamp's `current`
|
||||
# field does), so every advertised code is an active ordered speed.
|
||||
return self._all_speed_codes()
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
resources = self.coordinator.last_resources
|
||||
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
|
||||
return 'power', enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
return str(
|
||||
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
|
||||
).lower() == 'on'
|
||||
|
||||
@property
|
||||
def speed_count(self) -> int:
|
||||
return len(self._active_speed_codes())
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
if not self.is_on:
|
||||
return 0
|
||||
codes = self._active_speed_codes()
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
|
||||
if not codes or current not in codes:
|
||||
return None
|
||||
return ordered_list_item_to_percentage(codes, current)
|
||||
|
||||
async def async_turn_on(
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(False),
|
||||
)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
if percentage <= 0:
|
||||
await self.async_turn_off()
|
||||
return
|
||||
codes = self._active_speed_codes()
|
||||
if not codes:
|
||||
return
|
||||
if not self.is_on:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
code = percentage_to_ordered_list_item(codes, percentage)
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', code))
|
||||
@@ -12,5 +12,5 @@
|
||||
"pyOpenSSL>=23.0",
|
||||
"smartthings-local>=0.1.0"
|
||||
],
|
||||
"version": "0.10.1"
|
||||
"version": "0.11.1"
|
||||
}
|
||||
|
||||
@@ -115,8 +115,22 @@ class ObserveManager:
|
||||
could each read the same prior rep and the second writer would
|
||||
silently lose the first's fields, reintroducing the exact bug
|
||||
this merge fixes.
|
||||
|
||||
`source == 'optimistic'` always bypasses the settle gate below,
|
||||
even while this href is already settling from an earlier write.
|
||||
Several independent selects can share one href -- issue #9's washer
|
||||
packs cycle, detergent, and softener settings onto the same
|
||||
/course/vs/0 -- so a second, different write to the same href
|
||||
while the first's settle window is still open (up to
|
||||
_POST_TIMEOUT_S + _POLL_TIMEOUT_S, tens of seconds -- see
|
||||
coordinator.async_send_command) is an expected, normal sequence,
|
||||
not a stale echo of the first write. Gating it the same as a
|
||||
poll/sweep/observe update would silently drop the user's own
|
||||
second selection from the cache until the first write's guard
|
||||
happened to expire, i.e. the exact symptom this guard exists to
|
||||
prevent, just relocated to whichever write loses the race.
|
||||
"""
|
||||
if self._is_settling(href):
|
||||
if source != 'optimistic' and self._is_settling(href):
|
||||
self.log.debug("dropping %s update for %s (settling)", source, href)
|
||||
return False
|
||||
with self._cache_lock:
|
||||
|
||||
@@ -2,18 +2,29 @@
|
||||
from typing import Optional
|
||||
|
||||
from ._base import DeviceRegistry
|
||||
from . import airconditioner, dishwasher, dryer, oven, range as _range, refrigerator, washer
|
||||
from . import (
|
||||
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
|
||||
range as _range, range_hood, refrigerator, washer,
|
||||
)
|
||||
|
||||
__all__ = ['DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model']
|
||||
__all__ = [
|
||||
'DeviceRegistry', '_type_key', 'for_device', 'for_device_by_model',
|
||||
'for_device_by_resources',
|
||||
]
|
||||
|
||||
|
||||
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'air_purifier': air_purifier.REGISTRY,
|
||||
'airpurifier': air_purifier.REGISTRY,
|
||||
'airconditioner': airconditioner.REGISTRY,
|
||||
'air_conditioner': airconditioner.REGISTRY,
|
||||
'cooktop': cooktop.REGISTRY,
|
||||
'dishwasher': dishwasher.REGISTRY,
|
||||
'dryer': dryer.REGISTRY,
|
||||
'oven': oven.REGISTRY,
|
||||
'hood': range_hood.REGISTRY,
|
||||
'range': _range.REGISTRY,
|
||||
'range_hood': range_hood.REGISTRY,
|
||||
'refrigerator': refrigerator.REGISTRY,
|
||||
'washer': washer.REGISTRY,
|
||||
}
|
||||
@@ -102,9 +113,61 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
# 'P' sits between the underscore and 'RAC' in that token).
|
||||
if key is None and '_RAC_' in (model_num or ''):
|
||||
key = 'airconditioner'
|
||||
# System air conditioners (multi-indoor-unit commercial installs, e.g.
|
||||
# A-CAWW-TP2-20-COMMON, issue #52) report no oneUiVersion either and
|
||||
# carry the '-CAWW-' board-family token instead of '_RAC_'/'_PRAC_'.
|
||||
# Same TP1X/TP2X-class resource surface as the room-AC models above
|
||||
# (confirmed by the issue #52 dump binding cleanly against the existing
|
||||
# airconditioner registry once routed here), plus one new SAC-specific
|
||||
# resource (see airconditioner.py's _AC_IGNORED).
|
||||
if key is None and '-CAWW-' in (model_num or '').upper():
|
||||
key = 'airconditioner'
|
||||
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
|
||||
# oneUiVersion either, and carry the '_TVTL_' board-family token.
|
||||
if key is None and '_TVTL_' in (model_num or ''):
|
||||
key = 'air_purifier'
|
||||
model_identity = f'{model_num} {description}'.upper()
|
||||
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
|
||||
key = 'cooktop'
|
||||
if key is None and model_identity.startswith('AHD-'):
|
||||
key = 'range_hood'
|
||||
# Range/cooktop-oven combos (e.g. TP1X_DA-KS-RANGE-0102X, issue #44) --
|
||||
# like the RAC/PRAC air conditioners above, these report no oneUiVersion
|
||||
# and don't match the washer/dryer/dishwasher consumer-prefix map either.
|
||||
if key is None and '-RANGE-' in (model_num or '').upper():
|
||||
key = 'range'
|
||||
# Wall ovens (e.g. TP1X_DA-KS-OVEN-0107X, issue #55) -- same board-family
|
||||
# naming as the range combo above, minus the burners; also reports no
|
||||
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
|
||||
if key is None and '-OVEN-' in (model_num or '').upper():
|
||||
key = 'oven'
|
||||
return _REGISTRY_BY_KEY.get(key) if key else None
|
||||
|
||||
|
||||
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
|
||||
"""Detect a device family from a distinctive local-resource signature.
|
||||
|
||||
Some newer cooktops omit both ``oneUiVersion`` and
|
||||
``/information/vs/0``. Their mode resource still identifies them: it
|
||||
contains a DeviceType option and multiple per-burner OperationState
|
||||
options. Require both shapes so an oven's unrelated ``/mode/vs/0`` is
|
||||
not misclassified.
|
||||
"""
|
||||
mode = resources.get('/mode/vs/0', {})
|
||||
options = mode.get('x.com.samsung.da.options') or ()
|
||||
has_device_type = any(
|
||||
isinstance(option, str) and option.startswith('DeviceType_')
|
||||
for option in options
|
||||
)
|
||||
operation_states = sum(
|
||||
1 for option in options
|
||||
if isinstance(option, str) and option.startswith('OperationState')
|
||||
)
|
||||
if has_device_type and operation_states >= 2:
|
||||
return _REGISTRY_BY_KEY['cooktop']
|
||||
if (
|
||||
'/hood/fanspeed/vs/0' in resources
|
||||
and '/hood/lamp/vs/0' in resources
|
||||
):
|
||||
return _REGISTRY_BY_KEY['range_hood']
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Air-purifier device registry (Samsung ARTIK051_TVTL-class, issue #56).
|
||||
|
||||
Reports no oneUiVersion; resolved via for_device_by_model's '_TVTL_' modelNum
|
||||
token (see registry.py). Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0
|
||||
(identical field/write contract).
|
||||
"""
|
||||
from ..capabilities import air_purifier, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='air_purifier',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
*common.UNIVERSAL,
|
||||
*common.POWER,
|
||||
dishwasher.DIAGNOSIS,
|
||||
air_purifier.AIR_QUALITY,
|
||||
air_purifier.FILTER,
|
||||
air_purifier.DEVICE_ACTIVE,
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||
air_purifier.MODE,
|
||||
*air_purifier.COVERAGE,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Cooktop device registry."""
|
||||
|
||||
from ..capabilities import common, cooktop, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='cooktop',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
cooktop.COOKTOP_POWER,
|
||||
cooktop.COOKTOP_MODE,
|
||||
cooktop.COOKTOP_CONNECTED,
|
||||
cooktop.PAIRED_HOOD_STATUS,
|
||||
common.FIRMWARE_UPDATE,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Range-hood device registry."""
|
||||
|
||||
from ..capabilities import common, ignored, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
name='range_hood',
|
||||
capabilities=_build([
|
||||
*ignored.IGNORED,
|
||||
range_hood.HOOD_ALARMS,
|
||||
common.ENERGY_METER,
|
||||
common.FIRMWARE_UPDATE,
|
||||
range_hood.HOOD_FAN,
|
||||
range_hood.HOOD_LAMP,
|
||||
range_hood.HOOD_FILTER,
|
||||
range_hood.AIR_QUALITY,
|
||||
range_hood.AIR_LEVEL_CHECK,
|
||||
range_hood.AUTO_VENTILATION,
|
||||
*range_hood.COVERAGE,
|
||||
]),
|
||||
)
|
||||
@@ -1,4 +1,7 @@
|
||||
from . import common, dishwasher, fridge, ignored, laundry, operational, oven
|
||||
from . import (
|
||||
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
|
||||
range_hood,
|
||||
)
|
||||
from ..capability import Capability
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Capabilities for the Samsung ARTIK051_TVTL-class air purifier family
|
||||
(model AX60R5080WD/SE, issue #56).
|
||||
|
||||
Power, kids-lock, remote-control, alarms, and the energy meter are the shared
|
||||
common.py capabilities (this family exposes the standard /power/0+/power/vs/0
|
||||
pair and /alarms/vs/0, /energy/consumption/vs/0). /diagnosis/vs/0 reuses
|
||||
dishwasher.DIAGNOSIS -- identical field/write contract
|
||||
(x.com.samsung.da.diagnosisStart, 'Ready' on both dumps).
|
||||
|
||||
/mode/vs/0's x.com.samsung.da.options array packs multiple independent
|
||||
'<Prefix>_<value>' flags into one list -- the same packed-list contract
|
||||
laundry.py's option_value/option_write already model for /course/vs/0's
|
||||
options[] (reused directly below, just against this family's own href). Per
|
||||
issue #56's follow-up (five diagnostics dumps captured with the physical unit
|
||||
set to Auto/Sleep/Low/Medium/High):
|
||||
Light_On / Light_Off -- a plain on/off flag; MODE below models it as a
|
||||
real switch, RMW-replacing just that one entry.
|
||||
Comode_Off -- read 'Off' on *every* one of the five dumps,
|
||||
including High/Low/Medium/Auto -- confirms this
|
||||
is NOT the fan-speed selector (ruling out the
|
||||
original guess); exposed read-only since its
|
||||
actual purpose is still unconfirmed.
|
||||
OptionCode_60282 -- confirmed opaque/not user-facing in the
|
||||
SmartThings app; not modeled (same treatment as
|
||||
range_hood's OptionCode_* token on the same
|
||||
href).
|
||||
Blooming_* -- confirmed to have no corresponding SmartThings
|
||||
app setting; dropped entirely rather than kept
|
||||
as an unexplained diagnostic (it did track 1:1
|
||||
with Sleep mode across the five dumps -- 0 in
|
||||
Sleep, 6 otherwise -- so it's plausibly an
|
||||
automatic side effect of sleep mode, e.g. a
|
||||
display-dimming level, but that's still a guess).
|
||||
|
||||
/airflow/0 and /airflow/vs/0's `speed` still isn't modeled as a real
|
||||
fan-speed control: across the same five dumps it read 0 for both Auto *and*
|
||||
High, and 3 for Low/Medium *and* Sleep -- not a monotonic mapping to any
|
||||
selectable level, and the dumps were all captured within about three minutes
|
||||
of each other (only one poll cycle apart at this integration's 30s summary
|
||||
interval), so the values may not have settled after each change before the
|
||||
diagnostics snapshot was taken. Exposed read-only pending a confirmed,
|
||||
stable capture -- see the issue #56 discussion for what's needed.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, sensor_item_value
|
||||
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
|
||||
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
('dust', 'mdi:blur', 'Dust'),
|
||||
('fine_dust', 'mdi:blur', 'FineDust'),
|
||||
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
|
||||
('odor', 'mdi:scent', 'Odor'),
|
||||
('clean_level', 'mdi:air-filter', 'CleanLevel'),
|
||||
)
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=tuple(
|
||||
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
|
||||
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
|
||||
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _consumable_state(items, name):
|
||||
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
|
||||
unlike AIR_QUALITY's {type, value} shape above."""
|
||||
for item in items or ():
|
||||
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
|
||||
return item.get('x.com.samsung.da.state')
|
||||
return None
|
||||
|
||||
|
||||
# FilterProgress is a 0-100 percentage counting up as the filter wears --
|
||||
# confirmed via issue #56: the SmartThings app shows "Filter needs changing"
|
||||
# once this reaches 100, so 100 means fully used, not "brand new." Named
|
||||
# after the raw field (matching the AC/range_hood filterUsage convention,
|
||||
# which counts the same direction) rather than "filter life," which would
|
||||
# imply the opposite direction.
|
||||
FILTER = Capability(
|
||||
href='/consumable/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda items: int_or_none(
|
||||
_consumable_state(items, 'FilterProgress'))),
|
||||
),
|
||||
)
|
||||
|
||||
DEVICE_ACTIVE = Capability(
|
||||
href='/devicespecificinfo/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
|
||||
icon='mdi:check-network-outline',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
|
||||
# OCF-native / vendor pair for fan speed+direction -- see module docstring for
|
||||
# why these are read-only for now.
|
||||
AIRFLOW_GENERIC = Capability(
|
||||
href='/airflow/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='speed',
|
||||
icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic'),
|
||||
SensorDesc(key='fan_direction', field='direction',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
AIRFLOW_VS_FALLBACK = Capability(
|
||||
href='/airflow/vs/0',
|
||||
match_fn=lambda rep, resources: '/airflow/0' not in resources,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
|
||||
icon='mdi:fan',
|
||||
state_class='measurement', entity_category='diagnostic',
|
||||
value_fn=int_or_none),
|
||||
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
|
||||
icon='mdi:rotate-3d-variant',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _light_write(payload, rep, href=None):
|
||||
# option_write's single-token write is confirmed on a washer's
|
||||
# /course/vs/0 (issue #54), NOT independently on this family's
|
||||
# /mode/vs/0 -- extrapolated on the assumption the same vendor field
|
||||
# merges the same way everywhere. If some unit replaces the field
|
||||
# outright instead, this would drop Comode/OptionCode alongside it on
|
||||
# the next light toggle; revisit if a real device report surfaces that.
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': option_write('Light', payload),
|
||||
}
|
||||
|
||||
|
||||
MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
rep_fn=bool_option_value('Light'),
|
||||
exists_fn=bool_option_exists('Light'),
|
||||
write_fn=_light_write),
|
||||
# Read-only -- confirmed NOT the fan-speed selector (see module
|
||||
# docstring), actual purpose still unconfirmed.
|
||||
SensorDesc(key='operating_mode', icon='mdi:fan',
|
||||
entity_category='diagnostic',
|
||||
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
|
||||
exists_fn=bool_option_exists('Comode')),
|
||||
),
|
||||
)
|
||||
|
||||
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
|
||||
# been verified against -- covered here (not globally, per ignored.py's
|
||||
# module docstring) since those hrefs collide with fridge/AC schemas
|
||||
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
|
||||
COVERAGE = [
|
||||
Capability(href='/humidity/0'),
|
||||
Capability(href='/humidity/vs/0'),
|
||||
]
|
||||
@@ -109,7 +109,7 @@ AIR_PURIFY = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='air_purify', field='x.com.samsung.da.modes',
|
||||
name='Air purification', icon='mdi:air-purifier',
|
||||
icon='mdi:air-purifier',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -123,7 +123,7 @@ AUTO_CLEAN = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus',
|
||||
name='Auto clean', icon='mdi:spray-bottle',
|
||||
icon='mdi:spray-bottle',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -137,12 +137,12 @@ AIR_FILTER = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
|
||||
name='Filter usage', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:air-filter', entity_category='diagnostic'),
|
||||
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
|
||||
name='Filter status', device_class='enum',
|
||||
device_class='enum',
|
||||
options=('normal', 'wash', 'replace'),
|
||||
translation_key='air_filter_status',
|
||||
translation_key='filter_status',
|
||||
icon='mdi:air-filter', entity_category='diagnostic',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
),
|
||||
@@ -153,7 +153,7 @@ DISPLAY_LIGHT = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SwitchDesc(key='display_light', field='mode',
|
||||
name='Display light', icon='mdi:led-on',
|
||||
icon='mdi:led-on',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -170,7 +170,7 @@ MUTE_ONCE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='mute_once', field='muteonce',
|
||||
name='Mute once', icon='mdi:volume-mute',
|
||||
icon='mdi:volume-mute',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -190,11 +190,11 @@ CURRENT_LIMIT = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
BinarySensorDesc(key='current_limit_enabled', field='operation',
|
||||
name='Current limit enabled', icon='mdi:current-ac',
|
||||
icon='mdi:current-ac',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
SensorDesc(key='current_limit_level', field='modes',
|
||||
name='Current limit level', icon='mdi:current-ac',
|
||||
icon='mdi:current-ac',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
@@ -243,6 +243,11 @@ _AC_IGNORED = [
|
||||
'/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit)
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/welcome/temperature/vs/0', # welcome-cooling plumbing
|
||||
# System-AC-only (multi-indoor-unit commercial installs, e.g.
|
||||
# A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation
|
||||
# topology -- indoor/outdoor unit pairing, per-unit serials, MCU info.
|
||||
# Commissioning-time plumbing, not user-actionable appliance state.
|
||||
'/sac/installationinfo/vs/0',
|
||||
]
|
||||
|
||||
# Built as bare no-entity caps; folded into the AC registry (not global).
|
||||
|
||||
@@ -23,6 +23,13 @@ def _num(v):
|
||||
return None
|
||||
|
||||
|
||||
def int_or_none(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def clamp_power(v):
|
||||
n = _num(v)
|
||||
return 0.0 if (n is not None and n < 0) else n
|
||||
@@ -58,6 +65,53 @@ def _active_alarm_codes(items):
|
||||
return ', '.join(codes) if codes else '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
|
||||
merges them: match by prefix, replace if present, append if not.
|
||||
|
||||
Confirmed on real hardware (issue #54) that a write only needs to carry
|
||||
the changed token(s), not the whole array -- see laundry.option_write /
|
||||
oven._option_write for the write side. This is the read side of that
|
||||
same fact: coordinator.async_send_command uses it to keep the
|
||||
optimistic cache entry for the written href complete (every sibling
|
||||
option still present) during the write-settle window, since the wire
|
||||
body it applies straight to the cache no longer carries them."""
|
||||
merged = list(cached or [])
|
||||
for token in new_tokens or ():
|
||||
if not isinstance(token, str) or '_' not in token:
|
||||
continue
|
||||
prefix = token.split('_', 1)[0]
|
||||
replaced = False
|
||||
for i, o in enumerate(merged):
|
||||
if isinstance(o, str) and o.startswith(prefix + '_'):
|
||||
merged[i] = token
|
||||
replaced = True
|
||||
if not replaced:
|
||||
merged.append(token)
|
||||
return merged
|
||||
|
||||
|
||||
def sensor_item_value(items, sensor_type, index=0):
|
||||
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
|
||||
item is `{type, value: [...]}`; `index` picks which slot of a possibly
|
||||
multi-value reading to read (index 0 is the raw measurement on every
|
||||
family seen so far). Shared by range_hood.AIR_QUALITY and
|
||||
air_purifier.AIR_QUALITY, which read the same resource shape."""
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get('x.com.samsung.da.type') != sensor_type:
|
||||
continue
|
||||
values = item.get('x.com.samsung.da.value') or ()
|
||||
if index < len(values):
|
||||
try:
|
||||
return int(values[index])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# OCF-native / vendor '-vs' fallback pairs for power, kids-lock, remote control.
|
||||
#
|
||||
# These three controls exist as both a standard OCF resource (/power/0,
|
||||
@@ -75,7 +129,6 @@ POWER_GENERIC = Capability(
|
||||
href='/power/0',
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='value',
|
||||
name='Power',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', '0'], {'value': p == 'On'})),
|
||||
@@ -87,7 +140,6 @@ POWER_VS_FALLBACK = Capability(
|
||||
match_fn=lambda rep, resources: '/power/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
|
||||
name='Power',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['power', 'vs', '0'],
|
||||
@@ -99,7 +151,7 @@ KIDS_LOCK_GENERIC = Capability(
|
||||
href='/kidslock/0',
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='value',
|
||||
name='Child lock', device_class='lock',
|
||||
device_class='lock',
|
||||
value_fn=lambda v: bool(v),
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', '0'], {'value': p == 'On'})),
|
||||
@@ -111,7 +163,7 @@ KIDS_LOCK_VS_FALLBACK = Capability(
|
||||
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
|
||||
entities=(
|
||||
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
|
||||
name='Child lock', device_class='lock',
|
||||
device_class='lock',
|
||||
value_fn=lambda v: v != 'Ready',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['kidslock', 'vs', '0'],
|
||||
@@ -145,7 +197,7 @@ REMOTE_CONTROL_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control', field='value',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: bool(v)),
|
||||
),
|
||||
)
|
||||
@@ -157,7 +209,7 @@ REMOTE_CONTROL_VS_FALLBACK = Capability(
|
||||
entities=(
|
||||
BinarySensorDesc(key='remote_control',
|
||||
field='x.com.samsung.da.remoteControlEnabled',
|
||||
name='Smart Control', device_class='connectivity',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda v: str(v).lower() == 'true'),
|
||||
),
|
||||
)
|
||||
@@ -167,7 +219,7 @@ ALARMS = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
|
||||
name='Alarm code', icon='mdi:alert',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic', value_fn=_active_alarm_codes),
|
||||
),
|
||||
)
|
||||
@@ -190,13 +242,13 @@ ENERGY_METER = Capability(
|
||||
# entity when /device/0 returns a not-yet-fetched stub. On a populated
|
||||
# rep, hide power only for the dead sentinel or an absent field.
|
||||
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
|
||||
name='Power', device_class='power', state_class='measurement',
|
||||
device_class='power', state_class='measurement',
|
||||
unit='W', value_fn=clamp_power,
|
||||
exists_fn=lambda rep, resources: not rep or (
|
||||
rep.get('x.com.samsung.da.instantaneousPower')
|
||||
not in (None, _DEAD_INSTANTANEOUS_POWER))),
|
||||
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
|
||||
name='Energy', device_class='energy',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativePower' in rep)),
|
||||
@@ -207,7 +259,7 @@ ENERGY_METER = Capability(
|
||||
# energy_kwh above -- without it, an exists_fn permanently drops the
|
||||
# entity if setup happens to land on a not-yet-fetched stub.
|
||||
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
|
||||
name='Power energy', device_class='energy',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
|
||||
@@ -215,7 +267,7 @@ ENERGY_METER = Capability(
|
||||
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
|
||||
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
|
||||
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
|
||||
name='Energy saved', device_class='energy',
|
||||
device_class='energy',
|
||||
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
|
||||
@@ -223,12 +275,12 @@ ENERGY_METER = Capability(
|
||||
# in-progress current month. Not ever-increasing (each resets at
|
||||
# month boundary), so no state_class.
|
||||
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
|
||||
name='Energy (last month)', device_class='energy',
|
||||
device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
|
||||
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
|
||||
name='Energy (this month)', device_class='energy',
|
||||
device_class='energy',
|
||||
unit='kWh', value_fn=wh_to_kwh,
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
|
||||
@@ -239,7 +291,7 @@ WATER_METER = Capability(
|
||||
href='/water/consumption/vs/0',
|
||||
entities=(
|
||||
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
|
||||
name='Water consumption', device_class='water',
|
||||
device_class='water',
|
||||
state_class='total_increasing', unit='L', icon='mdi:water',
|
||||
value_fn=_ml_to_l),
|
||||
),
|
||||
@@ -250,10 +302,14 @@ WATER_FILTER = Capability(
|
||||
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
|
||||
entities=(
|
||||
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
|
||||
name='Filter usage', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
icon='mdi:filter'),
|
||||
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
|
||||
name='Filter status', icon='mdi:filter-check'),
|
||||
icon='mdi:filter-check',
|
||||
device_class='enum', options=('normal', 'wash', 'replace'),
|
||||
value_fn=lambda value: (
|
||||
value.lower() if isinstance(value, str) else value
|
||||
)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -270,7 +326,7 @@ WATER_FILTER = Capability(
|
||||
#
|
||||
# No translation_key: aiLevel's values are plain digit strings, and
|
||||
# select.py's _display() already renders an untranslated numeric string
|
||||
# as-is -- there's nothing a strings.json entry adds that's worth maintaining
|
||||
# as-is -- there's nothing a catalog entry adds that's worth maintaining
|
||||
# against an unknown, growing number of future levels.
|
||||
|
||||
|
||||
@@ -317,14 +373,14 @@ AI_ENERGY_LEVEL = Capability(
|
||||
# cold-tier href, the same reload already required to fix which
|
||||
# platform got picked in that case.
|
||||
SwitchDesc(key='ai_energy_level', field='aiLevel',
|
||||
name='AI energy level', icon='mdi:leaf',
|
||||
icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != '0',
|
||||
exists_fn=lambda rep, resources: (
|
||||
len(_ai_energy_supported_levels(rep)) == 1),
|
||||
write_fn=_ai_energy_level_switch_write),
|
||||
SelectDesc(key='ai_energy_level', field='aiLevel',
|
||||
name='AI energy level', icon='mdi:leaf',
|
||||
icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
options=_ai_energy_level_options,
|
||||
exists_fn=lambda rep, resources: (
|
||||
@@ -340,7 +396,6 @@ FIRMWARE_UPDATE = Capability(
|
||||
BinarySensorDesc(
|
||||
key='firmware_update',
|
||||
field='x.com.samsung.da.newVersionAvailable',
|
||||
name='Firmware update available',
|
||||
device_class='update',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
|
||||
@@ -353,21 +408,21 @@ SELF_CHECK = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
|
||||
name='Self-check status', icon='mdi:stethoscope',
|
||||
icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
|
||||
name='Self-check result', icon='mdi:clipboard-check-outline',
|
||||
icon='mdi:clipboard-check-outline',
|
||||
entity_category='diagnostic'),
|
||||
# List of error codes from the last self-check; joined for display.
|
||||
# Not every fridge reports the field, hence the exists_fn.
|
||||
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
|
||||
name='Self-check error', icon='mdi:alert-circle-outline',
|
||||
icon='mdi:alert-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.error' in rep),
|
||||
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
|
||||
ButtonDesc(key='selfcheck_start', field='', name='Start self-check',
|
||||
payload='Start', icon='mdi:play-circle-outline',
|
||||
ButtonDesc(key='selfcheck_start', field='', payload='Start',
|
||||
icon='mdi:play-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Read-only capabilities for Samsung cooktops.
|
||||
|
||||
The first verified device is an NA9300K-class five-burner gas cooktop. Its
|
||||
local OCF API reports burner state as strings embedded in the
|
||||
``x.com.samsung.da.options`` array on ``/mode/vs/0``. Heat-producing controls
|
||||
are intentionally not exposed: the local write contract is unverified and a
|
||||
cooktop must not be remotely ignited by an automation.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc
|
||||
|
||||
|
||||
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
|
||||
|
||||
|
||||
def _option_value(options, prefix):
|
||||
"""Return the value from the first ``<prefix>_<value>`` option."""
|
||||
marker = prefix + '_'
|
||||
for option in options or ():
|
||||
if isinstance(option, str) and option.startswith(marker):
|
||||
return option[len(marker):]
|
||||
return None
|
||||
|
||||
|
||||
def _operation_slots(options) -> tuple[int, ...]:
|
||||
"""Return the numeric burner slots advertised in an options array."""
|
||||
slots = set()
|
||||
for option in options or ():
|
||||
if not isinstance(option, str):
|
||||
continue
|
||||
match = re.match(r'^OperationState(\d+)_', option)
|
||||
if match:
|
||||
slots.add(int(match.group(1)))
|
||||
return tuple(sorted(slots))
|
||||
|
||||
|
||||
def _any_burner_active(options):
|
||||
"""True when any advertised burner slot is not idle."""
|
||||
states = [
|
||||
_option_value(options, f'OperationState{slot}')
|
||||
for slot in _operation_slots(options)
|
||||
]
|
||||
states = [state for state in states if state is not None]
|
||||
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
|
||||
|
||||
|
||||
def _int_or_none(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
COOKTOP_POWER = Capability(
|
||||
href='/power/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='power_state',
|
||||
field='x.com.samsung.da.power',
|
||||
device_class='power',
|
||||
icon='mdi:stove',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# The verified NA9300K exposes physical slots 0, 1, 3, 4, and 5. Declare a
|
||||
# generous static superset so variants with other layouts are not silently
|
||||
# omitted; exists_fn hides every slot the live options array does not report.
|
||||
# Replace this bound with data-driven entity generation when #31 lands.
|
||||
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
|
||||
|
||||
COOKTOP_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='any_burner_active',
|
||||
field='x.com.samsung.da.options',
|
||||
device_class='running',
|
||||
icon='mdi:fire',
|
||||
value_fn=_any_burner_active,
|
||||
),
|
||||
*(
|
||||
SensorDesc(
|
||||
key=f'burner_{slot}_state',
|
||||
field='x.com.samsung.da.options',
|
||||
translation_key='burner_state',
|
||||
translation_placeholders={'number': str(slot)},
|
||||
icon='mdi:gas-burner',
|
||||
value_fn=lambda options, slot=slot: _option_value(
|
||||
options, f'OperationState{slot}'
|
||||
),
|
||||
exists_fn=lambda rep, resources, slot=slot: (
|
||||
not rep or _option_value(
|
||||
rep.get('x.com.samsung.da.options'),
|
||||
f'OperationState{slot}',
|
||||
) is not None
|
||||
),
|
||||
)
|
||||
for slot in _SUPPORTED_OPERATION_SLOTS
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_state',
|
||||
field='x.com.samsung.da.options',
|
||||
icon='mdi:timer-outline',
|
||||
value_fn=lambda options: _option_value(options, 'MainTimerState'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='main_timer_current',
|
||||
field='x.com.samsung.da.options',
|
||||
icon='mdi:timer-sand',
|
||||
enabled_default=False,
|
||||
value_fn=lambda options: _int_or_none(
|
||||
_option_value(options, 'MainTimerCurrent')
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
COOKTOP_CONNECTED = Capability(
|
||||
href='/connected/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='cloud_connected',
|
||||
field='x.com.samsung.da.connected',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
PAIRED_HOOD_STATUS = Capability(
|
||||
href='/bluetooth/hood/status/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_connected',
|
||||
field='connectionState',
|
||||
device_class='connectivity',
|
||||
value_fn=lambda value: str(value).lower() == 'connected',
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_power',
|
||||
field='power',
|
||||
device_class='running',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_fan_speed',
|
||||
field='fanSpeed',
|
||||
icon='mdi:fan',
|
||||
value_fn=_int_or_none,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='paired_hood_light',
|
||||
field='lampState',
|
||||
device_class='light',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_model',
|
||||
field='micomModelId',
|
||||
icon='mdi:information-outline',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
),
|
||||
SensorDesc(
|
||||
key='paired_hood_firmware',
|
||||
field='firmwareVersion',
|
||||
icon='mdi:chip',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -18,13 +18,13 @@ DISHWASHER_SETTINGS = Capability(
|
||||
href='/dishwasher/vs/0',
|
||||
entities=(
|
||||
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
|
||||
name='Sanitize', icon='mdi:bacteria',
|
||||
icon='mdi:bacteria',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dishwasher', 'vs', '0'],
|
||||
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
|
||||
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
|
||||
name='Smart Dry', icon='mdi:heat-wave',
|
||||
icon='mdi:heat-wave',
|
||||
options_field='x.com.samsung.da.supportedHeatedDry',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['dishwasher', 'vs', '0'],
|
||||
@@ -45,9 +45,9 @@ CYCLE_OPTIONS = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
|
||||
bool_option_switch('storm_wash', 'Storm Wash+', 'mdi:weather-lightning-rainy',
|
||||
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
|
||||
'StormWashZone'),
|
||||
bool_option_switch('auto_release_dry', 'Auto release dry', 'mdi:door-open',
|
||||
bool_option_switch('auto_release_dry', 'mdi:door-open',
|
||||
'AutoDoorRelease', gate_on_presence=True),
|
||||
),
|
||||
)
|
||||
@@ -61,10 +61,10 @@ DIAGNOSIS = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
|
||||
name='Diagnosis status', icon='mdi:stethoscope',
|
||||
icon='mdi:stethoscope',
|
||||
entity_category='diagnostic'),
|
||||
ButtonDesc(key='diagnosis_start', field='', name='Start diagnosis',
|
||||
payload='Start', icon='mdi:play-circle-outline',
|
||||
ButtonDesc(key='diagnosis_start', field='', payload='Start',
|
||||
icon='mdi:play-circle-outline',
|
||||
entity_category='diagnostic',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
|
||||
@@ -76,7 +76,7 @@ OPERATION_ORIGIN = Capability(
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='operation_origin', field='origin',
|
||||
name='Last operation source', icon='mdi:remote',
|
||||
icon='mdi:remote',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -24,13 +24,13 @@ DRYER_SETTINGS = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
name='Dry level', icon='mdi:water-percent'),
|
||||
icon='mdi:water-percent'),
|
||||
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
|
||||
name='Dry time', icon='mdi:timer'),
|
||||
icon='mdi:timer'),
|
||||
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
|
||||
name='Dryer type', icon='mdi:tumble-dryer'),
|
||||
icon='mdi:tumble-dryer'),
|
||||
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
|
||||
name='Wrinkle prevent', icon='mdi:iron',
|
||||
icon='mdi:iron',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_wrinkle_write),
|
||||
),
|
||||
@@ -48,7 +48,8 @@ DRYER_SETTINGS = Capability(
|
||||
DRYER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer'),
|
||||
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
|
||||
table_href='/st/dryercourse/vs/0'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -57,6 +58,6 @@ DRYER_DIAGNOSIS = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
|
||||
name='Diagnosis', entity_category='diagnostic'),
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ from ..entities import (
|
||||
from .common import normalize_temp_unit
|
||||
|
||||
# Display names for the beverage zone, flex zone, ice type, and
|
||||
# ice-making-status enums below live in strings.json / translations/en.json,
|
||||
# ice-making-status enums below live in translations/en.json,
|
||||
# keyed by the lowercased raw device value — select.py and SensorDesc.options
|
||||
# normalize to lowercase for HA's translation lookup and map back to this
|
||||
# original casing before writing to the device.
|
||||
@@ -55,7 +55,8 @@ TEMP_CURRENT_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='temperature', field='temperature',
|
||||
name=None, icon='mdi:thermometer',
|
||||
translation_key='instance_temperature',
|
||||
use_instance_name=True, icon='mdi:thermometer',
|
||||
device_class='temperature', unit_fn=_temp_unit,
|
||||
state_class='measurement'),
|
||||
),
|
||||
@@ -68,7 +69,8 @@ TEMP_SETPOINT_GENERIC = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
NumberDesc(key='setpoint', field='temperature',
|
||||
name=None, device_class='temperature', unit_fn=_temp_unit,
|
||||
translation_key='instance_setpoint',
|
||||
use_instance_name=True, device_class='temperature', unit_fn=_temp_unit,
|
||||
native_min=-20.0, native_max=50.0,
|
||||
range_field='range', entity_category='config',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -87,12 +89,12 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='ice_night_mode', field='ice.night.status',
|
||||
name='Nighttime ice quiet mode', icon='mdi:weather-night',
|
||||
icon='mdi:weather-night',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['icemaker', 'nighttime', 'vs', '0'],
|
||||
{'ice.night.status': 'On' if p else 'Off'})),
|
||||
{'ice.night.status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -101,8 +103,9 @@ ICEMAKER_NIGHTTIME = Capability(
|
||||
# /icemaker/status/vs/0 is kept as exact-href cap and binds first.
|
||||
# /icemaker/nighttime/vs/0 is excluded by match_fn (lacks iceMaker.state).
|
||||
#
|
||||
# Entity names are derived from x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two
|
||||
# Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
|
||||
# "ICE_BITES") -- read via name_field, reaching the translated name as the
|
||||
# {instance_name} placeholder -- not the href's "one"/"two" segment. These two
|
||||
# ice makers are independent on/off toggles that can both be enabled at once
|
||||
# (issue #27), so they stay separate entities rather than a single ice-type
|
||||
# select, but users still want them labeled with the device's own names.
|
||||
@@ -123,17 +126,18 @@ ICEMAKER_GENERIC = Capability(
|
||||
entities=(
|
||||
SensorDesc(key='making_status',
|
||||
field='x.com.samsung.da.iceMaker.iceMakingStatus',
|
||||
name=None, icon='mdi:cube-outline',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
device_class='enum',
|
||||
options=('icestatus_stop', 'icestatus_run'),
|
||||
translation_key='ice_making_status',
|
||||
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
|
||||
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state',
|
||||
name=None, icon='mdi:cube-outline',
|
||||
translation_key='instance_enabled',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
|
||||
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired',
|
||||
name=None, icon='mdi:cube-outline',
|
||||
use_instance_name=True, icon='mdi:cube-outline',
|
||||
translation_key='ice_type',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.iceType.supported',
|
||||
@@ -151,7 +155,7 @@ DOOR_ALERT = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='door_alert', field='alert.door',
|
||||
name='Door alarm', icon='mdi:bell-alert',
|
||||
icon='mdi:bell-alert',
|
||||
translation_key='door_alert',
|
||||
entity_category='config',
|
||||
options_field='supportedAlert.door',
|
||||
@@ -168,7 +172,7 @@ DOOR_ALERT = Capability(
|
||||
def _status_lock_write(field):
|
||||
return lambda p, rep, href=None: (
|
||||
['status', 'lock', 'vs', '0'],
|
||||
{field: 'On' if p else 'Off'}
|
||||
{field: 'On' if p == 'On' else 'Off'}
|
||||
)
|
||||
|
||||
|
||||
@@ -177,12 +181,12 @@ STATUS_LOCK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='auto_door_opener', field='x.com.samsung.da.ado.devicecontrol',
|
||||
name='Auto door opener', icon='mdi:door-open',
|
||||
icon='mdi:door-open',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')),
|
||||
SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound',
|
||||
name='Sound', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_status_lock_write('x.com.samsung.da.device.sound')),
|
||||
@@ -207,12 +211,12 @@ DEFROST_DELAY = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost',
|
||||
name='Defrost delay', icon='mdi:snowflake-off',
|
||||
icon='mdi:snowflake-off',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['defrost', 'delay', 'vs', '0'],
|
||||
{'x.com.samsung.da.delayDefrost': 'On' if p else 'Off'})),
|
||||
{'x.com.samsung.da.delayDefrost': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -229,7 +233,7 @@ DEFROST_BLOCK_STATUS = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
|
||||
name='Defrost active', icon='mdi:snowflake-melt',
|
||||
icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
|
||||
),
|
||||
@@ -252,12 +256,12 @@ REFRIGERATION = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge',
|
||||
name='Rapid fridge', icon='mdi:fridge-industrial',
|
||||
icon='mdi:fridge-industrial',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')),
|
||||
SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing',
|
||||
name='Rapid freezing', icon='mdi:snowflake',
|
||||
icon='mdi:snowflake',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')),
|
||||
@@ -279,7 +283,7 @@ AUTOFILL = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='autofill', field='x.com.samsung.da.autofill',
|
||||
name='Autofill pitcher', icon='mdi:cup-water',
|
||||
icon='mdi:cup-water',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_autofill_write),
|
||||
@@ -295,12 +299,12 @@ WELCOME_LIGHTING = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='welcome_lighting', field='status',
|
||||
name='Welcome lighting', icon='mdi:motion-sensor',
|
||||
icon='mdi:motion-sensor',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['proximity', 'vs', '0'],
|
||||
{'status': 'On' if p else 'Off'})),
|
||||
{'status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -386,15 +390,15 @@ CABINET_LIGHT_ENHANCED = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='day_brightness', field='level.brightness.daytime',
|
||||
name='Cabinet brightness', icon='mdi:brightness-5',
|
||||
translation_key='brightness_level',
|
||||
icon='mdi:brightness-5',
|
||||
translation_key='day_brightness',
|
||||
entity_category='config',
|
||||
options=_NIGHT_BRIGHTNESS_OPTIONS,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['cabinet', 'light', 'enhanced', 'vs', '0'],
|
||||
{'level.brightness.daytime': p})),
|
||||
SelectDesc(key='brightness_level', field='level.brightness.nighttime',
|
||||
name='Night brightness', icon='mdi:brightness-4',
|
||||
icon='mdi:brightness-4',
|
||||
translation_key='brightness_level',
|
||||
entity_category='config',
|
||||
options=_NIGHT_BRIGHTNESS_OPTIONS,
|
||||
@@ -402,12 +406,12 @@ CABINET_LIGHT_ENHANCED = Capability(
|
||||
['cabinet', 'light', 'enhanced', 'vs', '0'],
|
||||
{'level.brightness.nighttime': p})),
|
||||
TimeDesc(key='night_start', field='',
|
||||
name='Night light start', icon='mdi:clock-start',
|
||||
icon='mdi:clock-start',
|
||||
entity_category='config',
|
||||
rep_fn=_night_start_value,
|
||||
write_fn=_night_start_write),
|
||||
TimeDesc(key='night_end', field='',
|
||||
name='Night light end', icon='mdi:clock-end',
|
||||
icon='mdi:clock-end',
|
||||
entity_category='config',
|
||||
rep_fn=_night_end_value,
|
||||
write_fn=_night_end_write),
|
||||
@@ -431,16 +435,16 @@ CABINET_LIGHT = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl',
|
||||
name='Cabinet light', icon='mdi:fridge-outline',
|
||||
icon='mdi:fridge-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_cabinet_light_write),
|
||||
SwitchDesc(key='cabinet_light_dim', field='light.dimming.status',
|
||||
name='Brighten gradually', icon='mdi:brightness-auto',
|
||||
icon='mdi:brightness-auto',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['cabinet', 'light', 'total', 'vs', '0'],
|
||||
{'light.dimming.status': 'On' if p else 'Off'})),
|
||||
{'light.dimming.status': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -459,7 +463,7 @@ SABBATH = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode',
|
||||
name='Sabbath mode', icon='mdi:hands-pray',
|
||||
icon='mdi:hands-pray',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_sabbath_write),
|
||||
@@ -479,7 +483,7 @@ BEVERAGE_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
|
||||
name='Beverage zone mode', icon='mdi:glass-wine',
|
||||
icon='mdi:glass-wine',
|
||||
translation_key='beverage_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='roomSupportedModes',
|
||||
@@ -507,7 +511,7 @@ PANTRY_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode',
|
||||
name='Pantry zone mode', icon='mdi:glass-wine',
|
||||
icon='mdi:glass-wine',
|
||||
translation_key='pantry_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
@@ -554,7 +558,7 @@ FLEX_ZONE = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='flex_zone_mode',
|
||||
name='Flex zone mode', icon='mdi:thermostat',
|
||||
icon='mdi:thermostat',
|
||||
translation_key='flex_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
@@ -575,7 +579,8 @@ DOOR_GENERIC = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='open', field='openState',
|
||||
name=None, device_class='door',
|
||||
translation_key='instance_open',
|
||||
use_instance_name=True, device_class='door',
|
||||
value_fn=lambda v: v == 'Open'),
|
||||
),
|
||||
)
|
||||
@@ -604,7 +609,7 @@ DOORS_FALLBACK = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
||||
name='Door', device_class='door',
|
||||
device_class='door',
|
||||
value_fn=lambda items: any(
|
||||
i.get('x.com.samsung.da.openState') == 'Open'
|
||||
for i in (items or []))),
|
||||
@@ -636,13 +641,13 @@ TEMPERATURES_FALLBACK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items',
|
||||
name='Freezer temperature', icon='mdi:thermometer',
|
||||
icon='mdi:thermometer',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: _temp_item_unit(
|
||||
rep.get('x.com.samsung.da.items'), 'Freezer'),
|
||||
value_fn=lambda items: _temp_item_value(items, 'Freezer')),
|
||||
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items',
|
||||
name='Fridge temperature', icon='mdi:thermometer',
|
||||
icon='mdi:thermometer',
|
||||
device_class='temperature', state_class='measurement',
|
||||
unit_fn=lambda rep: _temp_item_unit(
|
||||
rep.get('x.com.samsung.da.items'), 'Fridge'),
|
||||
@@ -665,11 +670,11 @@ ICEMAKER_STATUS_FALLBACK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SwitchDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker',
|
||||
name='Ice maker', icon='mdi:cube-outline',
|
||||
icon='mdi:cube-outline',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['icemaker', 'status', 'vs', '0'],
|
||||
{'x.com.samsung.da.iceMaker': 'On' if p else 'Off'})),
|
||||
{'x.com.samsung.da.iceMaker': 'On' if p == 'On' else 'Off'})),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -695,17 +700,17 @@ REFRIGERATION_FALLBACK = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='defrost_active', field='defrost',
|
||||
name='Defrost active', icon='mdi:snowflake-melt',
|
||||
icon='mdi:snowflake-melt',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources),
|
||||
BinarySensorDesc(key='rapid_fridge', field='rapidCool',
|
||||
name='Rapid fridge', icon='mdi:fridge-industrial',
|
||||
icon='mdi:fridge-industrial',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||
BinarySensorDesc(key='rapid_freezing', field='rapidFreeze',
|
||||
name='Rapid freezing', icon='mdi:snowflake',
|
||||
icon='mdi:snowflake',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: bool(v),
|
||||
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
|
||||
|
||||
@@ -21,6 +21,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
"""
|
||||
from datetime import time as dt_time
|
||||
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
|
||||
@@ -60,30 +61,30 @@ DOOR_LED = Capability(
|
||||
href='/doorled/light/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='led_brightness', field='setBrightness',
|
||||
name='Door LED brightness', icon='mdi:brightness-6',
|
||||
icon='mdi:brightness-6',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS, write_fn=_led_brightness_write),
|
||||
SwitchDesc(key='led_night_light', field='setNightLight',
|
||||
name='Door LED night light', icon='mdi:weather-night',
|
||||
icon='mdi:weather-night',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v == 'On',
|
||||
write_fn=_led_night_write),
|
||||
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
|
||||
name='Door LED night brightness', icon='mdi:brightness-4',
|
||||
icon='mdi:brightness-4',
|
||||
entity_category='config',
|
||||
options=_LED_LEVELS,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['doorled', 'light', 'vs', '0'],
|
||||
{'setNightLightBrightness': p})),
|
||||
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
|
||||
name='Door LED night start', icon='mdi:clock-start',
|
||||
icon='mdi:clock-start',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['doorled', 'light', 'vs', '0'],
|
||||
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
|
||||
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
|
||||
name='Door LED night end', icon='mdi:clock-end',
|
||||
icon='mdi:clock-end',
|
||||
entity_category='config',
|
||||
value_fn=_parse_hm,
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -96,7 +97,7 @@ SOUND_MODE = Capability(
|
||||
href='/settings/sound/mode/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='sound_mode', field='mode',
|
||||
name='Sound mode', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options=_SOUND_MODES, write_fn=_sound_mode_write),
|
||||
),
|
||||
@@ -106,7 +107,7 @@ SOUND_VOLUME = Capability(
|
||||
href='/settings/sound/volume/vs/0',
|
||||
entities=(
|
||||
NumberDesc(key='sound_volume', field='level',
|
||||
name='Sound volume', icon='mdi:volume-medium',
|
||||
icon='mdi:volume-medium',
|
||||
entity_category='config',
|
||||
native_min=0, native_max=15, step=5,
|
||||
value_fn=lambda v: int(v) if v is not None else None,
|
||||
@@ -127,13 +128,13 @@ BUZZER_SOUND = Capability(
|
||||
href='/buzzersound/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
|
||||
name='Buzzer sound', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
options_field='supportedBuzzerSound',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
|
||||
SelectDesc(key='finish_sound', field='setFinishSound',
|
||||
name='Finish sound', icon='mdi:bell-ring',
|
||||
icon='mdi:bell-ring',
|
||||
entity_category='config',
|
||||
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
|
||||
options_field='supportedFinishSound',
|
||||
@@ -146,9 +147,13 @@ BUZZER_SOUND = Capability(
|
||||
# Cycle selection over /course/vs/0.
|
||||
#
|
||||
# The selected course and every other user-tunable option ride in the
|
||||
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens;
|
||||
# a write is a read-modify-write of that whole array (cycle_write). The set of
|
||||
# *selectable* courses is not hardcoded -- it's read live from
|
||||
# x.com.samsung.da.options array on /course/vs/0 as `<Prefix>_<value>` tokens.
|
||||
# Confirmed on real hardware (issue #54): a write only needs to carry the one
|
||||
# changed token -- `{'x.com.samsung.da.options': ['SoftenerLevelCtrl_2']}` --
|
||||
# the device matches by prefix, evicts the stale token, and merges the result
|
||||
# into the array itself. No read-modify-write of the whole array needed (see
|
||||
# option_write). The set of *selectable* courses is not hardcoded -- it's read
|
||||
# live from
|
||||
# x.com.samsung.da.editCourseList on /wm/editcourse/vs/0 (cycle_options), so we
|
||||
# never show a course a given model doesn't have or hide one it does. Course
|
||||
# codes are uppercase hex; display names live in translations under
|
||||
@@ -157,6 +162,11 @@ BUZZER_SOUND = Capability(
|
||||
# washer.py's course comment has the byte-level evidence for why the options[]
|
||||
# MostUsed_* entry is *not* a trustworthy second source.
|
||||
#
|
||||
# Some boards populate /wm/editcourse/vs/0 without ever filling in
|
||||
# editCourseList itself (issue #1) -- cycle_options() falls back to deriving
|
||||
# the same list from /course/vs/0's own supportedOptions in that case; see
|
||||
# _course_codes_from_supported_options for the byte-level evidence.
|
||||
#
|
||||
# Shared verbatim by washer, dishwasher, and dryer -- all DA_WM_-family boards
|
||||
# expose the same /course/vs/0 options contract.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -176,7 +186,10 @@ def parse_edit_course_list(raw):
|
||||
|
||||
def cycle_options(resources):
|
||||
rep = resources.get('/wm/editcourse/vs/0') or {}
|
||||
return parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
|
||||
if codes:
|
||||
return codes
|
||||
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
|
||||
|
||||
|
||||
def option_value(options, prefix):
|
||||
@@ -187,29 +200,131 @@ def option_value(options, prefix):
|
||||
return None
|
||||
|
||||
|
||||
def replace_in_options(options, prefix, new_value):
|
||||
return [f"{prefix}_{new_value}" if isinstance(o, str) and o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
def _course_codes_from_supported_options(course_rep):
|
||||
"""Fallback for an empty/missing editCourseList: derive the selectable
|
||||
course list from /course/vs/0's own x.com.samsung.da.supportedOptions
|
||||
instead (issue #1: some DA_WM_TP1/TP2-class boards populate the
|
||||
/wm/editcourse/vs/0 href but never fill in editCourseList itself).
|
||||
|
||||
supportedOptions is a 1-hex-nibble header followed by one fixed-width
|
||||
record per selectable course, self-indexed rather than positional --
|
||||
the first byte of every record is that course's own hex code, just in
|
||||
the firmware's own internal order, not editCourseList's. Confirmed
|
||||
against six independent real-world washer/dryer/dishwasher dumps: every
|
||||
one divides evenly into `header + N * K bytes` with fully unique first
|
||||
bytes across all N records, at the record's true byte width. (What the
|
||||
rest of each record encodes is still unconfirmed -- this only uses the
|
||||
course-code byte.)
|
||||
|
||||
Two guards, deliberately conservative rather than guessing further: the
|
||||
derived codes must (a) all be distinct -- a real course table, not
|
||||
noise -- and (b) include whatever course is currently selected
|
||||
(x.com.samsung.da.options' Course_<code> token), which must always be a
|
||||
member of its own device's valid list. If no split satisfies both, this
|
||||
returns [] rather than guess.
|
||||
|
||||
Among splits that satisfy both, the *smallest* passing K wins, rather
|
||||
than requiring a single unambiguous one -- more than one K reliably
|
||||
does pass on real data (e.g. the shipped dishwasher fixture: true
|
||||
K=7 passes, but so do 10, 14, and 35, none of which are multiples of
|
||||
7 -- position 0 always lands on the same real course code regardless
|
||||
of K, which is enough on its own to satisfy the current-course guard
|
||||
for several unrelated splits). Smallest-K-wins is a heuristic, not a
|
||||
proof: it matches the confirmed answer on every one of six independent
|
||||
real-world dumps this was checked against, but a coincidentally
|
||||
unique, current-course-inclusive *smaller* K is not mathematically
|
||||
impossible on some future device, and would be picked silently. Not
|
||||
guarded against further here, since course tables are typically large
|
||||
enough (double digits) that colliding by chance on both checks is
|
||||
unlikely, and no device seen so far actually needs it.
|
||||
"""
|
||||
raw = course_rep.get('x.com.samsung.da.supportedOptions')
|
||||
hexstr = raw[0] if isinstance(raw, list) and raw else raw
|
||||
if not isinstance(hexstr, str) or len(hexstr) < 3:
|
||||
return []
|
||||
body = hexstr[1:]
|
||||
if len(body) % 2:
|
||||
return []
|
||||
total_bytes = len(body) // 2
|
||||
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
|
||||
for k in range(1, total_bytes + 1):
|
||||
if total_bytes % k:
|
||||
continue
|
||||
n = total_bytes // k
|
||||
if n < 2:
|
||||
continue
|
||||
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
|
||||
if len(set(firsts)) != n:
|
||||
continue
|
||||
if current is not None and current not in firsts:
|
||||
continue
|
||||
return firsts
|
||||
return []
|
||||
|
||||
|
||||
def option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write -- see the module comment
|
||||
above cycle_options for why this doesn't read/rewrite the whole array."""
|
||||
return [f'{prefix}_{new_value}']
|
||||
|
||||
|
||||
def cycle_write(p, rep, href=None):
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': replace_in_options(opts, 'Course', p),
|
||||
'x.com.samsung.da.options': option_write('Course', p),
|
||||
}
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon):
|
||||
def _table_id(resources, table_href):
|
||||
rep = resources.get(table_href) or {}
|
||||
return rep.get('x.com.samsung.da.st.courseTable')
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=None):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
The caller supplies the family's translation key (washer_cycle /
|
||||
dishwasher_cycle / dryer_cycle) and icon; the option list, current value,
|
||||
and write path are all shared.
|
||||
The option list, current value, and write path are all shared across
|
||||
washer/dryer/dishwasher; only the translation is family- (and, for
|
||||
washer/dryer, board-) specific.
|
||||
|
||||
table_href (washer/dryer only -- see washer.py/dryer.py's call sites)
|
||||
suffixes translation_key with the device's own course-table id, read
|
||||
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
|
||||
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
|
||||
'washer_cycle_table_02'). An absent or unrecognized table id gets the
|
||||
name-only ``cycle`` translation key while the raw course code remains
|
||||
visible and writable.
|
||||
|
||||
This matters because course codes are NOT guaranteed consistent across
|
||||
board generations sharing the same /course/vs/0 contract: every code in
|
||||
washer_cycle_table_02 was confirmed against Table_02-reporting devices
|
||||
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
|
||||
Table_00 instead, so the same hex code could mean a different course
|
||||
there for all we've verified. So a table-specific key is used only when
|
||||
the shipped catalog actually has one; any other table (Table_00 today,
|
||||
whatever ships next) falls back to the name-only ``cycle`` key, which
|
||||
shows the raw course code rather than a label borrowed from another
|
||||
board generation. Translating a new table is therefore a
|
||||
translations-only change -- add the ``<family>_cycle_<table>`` entry and
|
||||
this resolver picks it up.
|
||||
|
||||
Left at its default for dishwasher, which has no equivalent table-id
|
||||
resource in any dump seen and no evidence its course codes vary by
|
||||
table the way washer/dryer's do -- there's nothing to build a
|
||||
table-specific key from.
|
||||
"""
|
||||
key = translation_key
|
||||
if table_href is not None:
|
||||
def key(resources):
|
||||
table = _table_id(resources, table_href)
|
||||
if not isinstance(table, str) or not table:
|
||||
return 'cycle'
|
||||
candidate = f'{translation_key}_{table.lower()}'
|
||||
return candidate if has_entity_translation('select', candidate) else 'cycle'
|
||||
|
||||
return SelectDesc(
|
||||
key='cycle', name='Cycle', icon=icon, translation_key=translation_key,
|
||||
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'),
|
||||
@@ -231,11 +346,10 @@ def bool_option_write(prefix):
|
||||
def write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': replace_in_options(opts, prefix, p),
|
||||
'x.com.samsung.da.options': option_write(prefix, p),
|
||||
}
|
||||
return write
|
||||
|
||||
@@ -249,8 +363,8 @@ def bool_option_exists(prefix):
|
||||
rep.get('x.com.samsung.da.options'), prefix) is not None
|
||||
|
||||
|
||||
def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
|
||||
gate_on_presence=False, validate_fn=None):
|
||||
def bool_option_switch(key, icon, prefix, *, entity_category=None,
|
||||
gate_on_presence=False, validate_fn=None):
|
||||
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
|
||||
|
||||
gate_on_presence self-gates the entity off on models that never report
|
||||
@@ -262,7 +376,7 @@ def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
|
||||
building one, if needed, is the caller's job.
|
||||
"""
|
||||
return SwitchDesc(
|
||||
key=key, name=name, icon=icon, entity_category=entity_category,
|
||||
key=key, icon=icon, entity_category=entity_category,
|
||||
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
|
||||
rep_fn=bool_option_value(prefix),
|
||||
write_fn=bool_option_write(prefix),
|
||||
@@ -285,7 +399,6 @@ JOB_BEGINNING_STATUS = Capability(
|
||||
entities=(
|
||||
SensorDesc(key='job_beginning_status',
|
||||
field='x.com.samsung.da.currentStatus',
|
||||
name='Job beginning status',
|
||||
entity_category='diagnostic'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -70,12 +70,21 @@ def _finish_time(remaining_str):
|
||||
return None
|
||||
|
||||
|
||||
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
|
||||
# rather than keeping its own copy, since both wrote the identical
|
||||
# state='Ready' RMW.
|
||||
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
|
||||
icon='mdi:stop',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p}))
|
||||
|
||||
OPERATIONAL_STATE = Capability(
|
||||
href='/operational/state/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
name='Machine state', device_class='enum',
|
||||
device_class='enum',
|
||||
options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
# cycle_active is a bool derived from machine_state; used by the
|
||||
@@ -83,19 +92,22 @@ OPERATIONAL_STATE = Capability(
|
||||
# Harmless for non-oven appliances — just an extra bool in state.
|
||||
# Samsung firmware keeps state='Run' after progress reaches 'Finish',
|
||||
# so we also gate on progress to avoid a stuck 'Running' indication.
|
||||
# Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
|
||||
# start/pause/stop buttons below) is shared across the dryer/
|
||||
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
|
||||
# vocabulary that doesn't fit an oven's bake/roast/etc.
|
||||
BinarySensorDesc(key='cycle_active', device_class='running',
|
||||
name='Cycle active',
|
||||
rep_fn=lambda rep: (
|
||||
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
|
||||
and rep.get('x.com.samsung.da.progress') != 'Finish'
|
||||
)),
|
||||
SensorDesc(key='progress', name='Progress', icon='mdi:progress-wrench',
|
||||
SensorDesc(key='progress', icon='mdi:progress-wrench',
|
||||
rep_fn=lambda rep: (
|
||||
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _progress(rep.get('x.com.samsung.da.progress'))
|
||||
)),
|
||||
SensorDesc(key='progress_percentage',
|
||||
name='Progress percent', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
rep_fn=lambda rep: (
|
||||
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
else _int(rep.get('x.com.samsung.da.progressPercentage'))
|
||||
@@ -104,13 +116,12 @@ OPERATIONAL_STATE = Capability(
|
||||
# firmware leaves a stale remainingTime after a cycle ends, and
|
||||
# freezes it at '00:01:00' when progress reaches 'Finish'.
|
||||
SensorDesc(key='finish_time', device_class='timestamp',
|
||||
name='Estimated finish',
|
||||
rep_fn=lambda rep: (
|
||||
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
|
||||
or rep.get('x.com.samsung.da.progress') == 'Finish'
|
||||
else _finish_time(rep.get('x.com.samsung.da.remainingTime'))
|
||||
)),
|
||||
NumberDesc(key='delay_start_hours', name='Delay start', icon='mdi:timer-plus-outline',
|
||||
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
|
||||
device_class='duration', unit='h',
|
||||
native_min=0, native_max=24, step=1,
|
||||
rep_fn=lambda rep: _delay_hours(
|
||||
@@ -119,20 +130,16 @@ OPERATIONAL_STATE = Capability(
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{_delay_field(rep): _format_delay(p)})),
|
||||
ButtonDesc(key='start', field='', name='Start cycle', payload='Run',
|
||||
ButtonDesc(key='start', field='', payload='Run',
|
||||
icon='mdi:play',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
ButtonDesc(key='pause', field='', name='Pause cycle', payload='Pause',
|
||||
ButtonDesc(key='pause', field='', payload='Pause',
|
||||
icon='mdi:pause',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
ButtonDesc(key='stop', field='', name='Stop cycle', payload='Ready',
|
||||
icon='mdi:stop',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['operational', 'state', 'vs', '0'],
|
||||
{'x.com.samsung.da.state': p})),
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -26,10 +26,10 @@ from datetime import datetime, timezone, timedelta
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, NumberDesc, SelectDesc, SensorDesc,
|
||||
SwitchDesc,
|
||||
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
)
|
||||
from .common import normalize_temp_unit
|
||||
from .operational import STOP_BUTTON
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -122,10 +122,17 @@ def _option_value(options, prefix):
|
||||
return None
|
||||
|
||||
|
||||
def _replace_in_options(options, prefix, new_value):
|
||||
"""Return a new options list with the `<prefix>_*` slot replaced."""
|
||||
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
|
||||
for o in options]
|
||||
def _option_write(prefix, new_value):
|
||||
"""A one-token x.com.samsung.da.options write, mirroring
|
||||
laundry.option_write. NOT independently confirmed on an oven -- issue
|
||||
#54 only confirmed prefix-merge-on-write for a washer's /course/vs/0.
|
||||
This extrapolates that same vendor field/contract to the oven's
|
||||
/mode/vs/0, on the assumption the firmware handles the array the same
|
||||
way there. If that assumption is wrong for some oven, a device that
|
||||
replaces the field outright instead of merging would drop every other
|
||||
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
|
||||
real device report surfaces that."""
|
||||
return [f'{prefix}_{new_value}']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -166,12 +173,6 @@ def _cook_time_write(p, rep, href=None):
|
||||
}
|
||||
|
||||
|
||||
def _stop_write(p, rep, href=None):
|
||||
return ['operational', 'state', 'vs', '0'], {
|
||||
'x.com.samsung.da.state': 'Ready',
|
||||
}
|
||||
|
||||
|
||||
def _oven_mode_write(p, rep, href=None):
|
||||
if p not in _OVEN_MODES:
|
||||
return None
|
||||
@@ -181,47 +182,41 @@ def _oven_mode_write(p, rep, href=None):
|
||||
def _lamp_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'UpperLamp', p),
|
||||
'x.com.samsung.da.options': _option_write('UpperLamp', p),
|
||||
}
|
||||
|
||||
|
||||
def _sound_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
|
||||
'x.com.samsung.da.options': _option_write('Sound', p),
|
||||
}
|
||||
|
||||
|
||||
def _fastpreheat_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _replace_in_options(opts, 'fastpreheat', p),
|
||||
'x.com.samsung.da.options': _option_write('fastpreheat', p),
|
||||
}
|
||||
|
||||
|
||||
def _naturalsteam_write(p, rep, href=None):
|
||||
if p not in ('On', 'Off'):
|
||||
return None
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
if not any(o.startswith('NaturalSteam_') for o in opts):
|
||||
opts = opts + [f'NaturalSteam_{p}']
|
||||
else:
|
||||
opts = _replace_in_options(opts, 'NaturalSteam', p)
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.options': opts}
|
||||
return ['mode', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': _option_write('NaturalSteam', p),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -233,29 +228,28 @@ OVEN_OPERATIONAL_STATE = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
|
||||
name='Machine state', icon='mdi:stove',
|
||||
icon='mdi:stove',
|
||||
device_class='enum', options=('idle', 'active', 'pause'),
|
||||
translation_key='machine_state', value_fn=_to_ocf),
|
||||
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
|
||||
name='Cycle active', device_class='running',
|
||||
device_class='running',
|
||||
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
|
||||
SensorDesc(key='progress_percentage',
|
||||
field='x.com.samsung.da.progressPercentage',
|
||||
name='Progress percent', unit='%', state_class='measurement',
|
||||
unit='%', state_class='measurement',
|
||||
value_fn=_int),
|
||||
SensorDesc(key='operation_time_minutes',
|
||||
field='x.com.samsung.da.operationTime',
|
||||
name='Operation time (minutes)', unit='min',
|
||||
unit='min',
|
||||
state_class='measurement', value_fn=_op_minutes),
|
||||
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
|
||||
name='Estimated finish', device_class='timestamp',
|
||||
device_class='timestamp',
|
||||
value_fn=_finish_time),
|
||||
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
|
||||
name='Cook time', unit='min', native_min=0, native_max=1439,
|
||||
unit='min', native_min=0, native_max=1439,
|
||||
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
|
||||
write_fn=_cook_time_write),
|
||||
ButtonDesc(key='stop', field='', name='Stop cycle', icon='mdi:stop',
|
||||
payload='Stop', write_fn=_stop_write),
|
||||
STOP_BUTTON,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -264,7 +258,7 @@ OVEN_CAVITY = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
|
||||
name='Cavity state'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -297,7 +291,7 @@ OVEN_SETPOINT = Capability(
|
||||
entities=(
|
||||
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
|
||||
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
|
||||
name='Setpoint', device_class='temperature', unit_fn=_oven_temp_unit,
|
||||
device_class='temperature', unit_fn=_oven_temp_unit,
|
||||
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
|
||||
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
|
||||
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
|
||||
@@ -307,7 +301,7 @@ OVEN_SETPOINT = Capability(
|
||||
(items[0].get('x.com.samsung.da.desired') if items else None)),
|
||||
write_fn=_oven_setpoint_write),
|
||||
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
|
||||
name='Temperature', device_class='temperature',
|
||||
device_class='temperature',
|
||||
state_class='measurement', unit_fn=_oven_temp_unit,
|
||||
value_fn=lambda items: _int(
|
||||
(items[0].get('x.com.samsung.da.current') if items else None))),
|
||||
@@ -319,7 +313,7 @@ OVEN_DOOR = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
|
||||
name='Door', device_class='door',
|
||||
device_class='door',
|
||||
value_fn=lambda items: (
|
||||
items[0].get('x.com.samsung.da.openState') == 'Open'
|
||||
if items else None)),
|
||||
@@ -331,7 +325,7 @@ OVEN_CONNECTED = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
|
||||
name='Cloud connected', device_class='connectivity',
|
||||
device_class='connectivity',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda v: v == 'On'),
|
||||
),
|
||||
@@ -350,25 +344,25 @@ OVEN_MODE = Capability(
|
||||
entities=(
|
||||
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
|
||||
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
|
||||
name='Cooking mode', icon='mdi:tune',
|
||||
icon='mdi:tune',
|
||||
options=_OVEN_MODES,
|
||||
value_fn=lambda v: v[0] if v else None,
|
||||
write_fn=_oven_mode_write),
|
||||
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
|
||||
name='Lamp', icon='mdi:track-light',
|
||||
icon='mdi:track-light',
|
||||
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
|
||||
write_fn=_lamp_write),
|
||||
SwitchDesc(key='sound', field='x.com.samsung.da.options',
|
||||
name='Sound', icon='mdi:volume-high',
|
||||
icon='mdi:volume-high',
|
||||
entity_category='config',
|
||||
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
|
||||
write_fn=_sound_write),
|
||||
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
|
||||
name='Fast preheat', icon='mdi:fire',
|
||||
icon='mdi:fire',
|
||||
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
|
||||
write_fn=_fastpreheat_write),
|
||||
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
|
||||
name='Natural steam', icon='mdi:kettle-steam',
|
||||
icon='mdi:kettle-steam',
|
||||
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
|
||||
write_fn=_naturalsteam_write),
|
||||
),
|
||||
|
||||
@@ -81,17 +81,23 @@ def _burner_entities(i):
|
||||
n = i + 1
|
||||
return (
|
||||
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
|
||||
name=f'Burner {n} power level', icon='mdi:knob',
|
||||
icon='mdi:knob',
|
||||
translation_key='range_burner_power_level',
|
||||
translation_placeholders={'number': str(n)},
|
||||
options=_power_level_options,
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'powerLevel'),
|
||||
write_fn=_burner_power_level_write(i)),
|
||||
SensorDesc(key=f'burner_{i}_state', field='burnerList',
|
||||
name=f'Burner {n} state', icon='mdi:stove',
|
||||
icon='mdi:stove',
|
||||
translation_key='burner_state',
|
||||
translation_placeholders={'number': str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_field_fn(i, 'operationState')),
|
||||
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
|
||||
name=f'Burner {n} hot surface', device_class='heat',
|
||||
device_class='heat',
|
||||
translation_key='burner_hot_surface',
|
||||
translation_placeholders={'number': str(n)},
|
||||
exists_fn=exists,
|
||||
value_fn=_burner_hot_surface_fn(i)),
|
||||
)
|
||||
@@ -102,7 +108,7 @@ COOKTOP_STATUS = Capability(
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(key='cooktop_state', field='operationState',
|
||||
name='Cooktop state', icon='mdi:pot-steam'),
|
||||
icon='mdi:pot-steam'),
|
||||
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
|
||||
),
|
||||
)
|
||||
@@ -123,7 +129,6 @@ COOKTOP_SAFETY = Capability(
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
|
||||
name='Hot surface auto-shutoff enabled',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: (v or {}).get('state') == 'on'),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Capabilities for Samsung AHD-WW-TP1-22 range hoods.
|
||||
|
||||
The verified device exposes its fan, two-level work lamp, washable-filter
|
||||
status, and particulate sensors as distinct local OCF resources. Fan power
|
||||
and speed are combined into one HA fan entity by ``fan.py``; lamp power and
|
||||
brightness remain separate controls because the device advertises them as two
|
||||
independent fields.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc,
|
||||
FanDesc,
|
||||
SelectDesc,
|
||||
SensorDesc,
|
||||
SwitchDesc,
|
||||
)
|
||||
from .common import int_or_none, sensor_item_value
|
||||
|
||||
|
||||
def _timestamp(value):
|
||||
try:
|
||||
return datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
except (TypeError, ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _active_alarm_codes(items):
|
||||
"""Discard the hood firmware's retained/deleted ``ErrorCode_OFF`` row.
|
||||
|
||||
Unlike ``common._alarm_codes``, the hood retains a deleted alarm row in
|
||||
its live representation, so this family-specific helper also checks state.
|
||||
"""
|
||||
codes = []
|
||||
for item in items or ():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
|
||||
continue
|
||||
code = item.get('x.com.samsung.da.code')
|
||||
if code and str(code).lower() != 'errorcode_off':
|
||||
codes.append(code)
|
||||
return ', '.join(codes) if codes else 'none'
|
||||
|
||||
|
||||
HOOD_ALARMS = Capability(
|
||||
href='/alarms/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='alarm_code',
|
||||
field='x.com.samsung.da.items',
|
||||
icon='mdi:alert',
|
||||
entity_category='diagnostic',
|
||||
value_fn=_active_alarm_codes,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _hood_fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
power_href = args[0] if args else '/power/0'
|
||||
if power_href == '/power/0':
|
||||
return ['power', '0'], {'value': bool(value)}
|
||||
if power_href == '/power/vs/0':
|
||||
return ['power', 'vs', '0'], {
|
||||
'x.com.samsung.da.power': 'On' if value else 'Off',
|
||||
}
|
||||
return None
|
||||
if kind == 'speed':
|
||||
value = str(value)
|
||||
supported = [
|
||||
str(code)
|
||||
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
|
||||
]
|
||||
if value not in supported:
|
||||
return None
|
||||
return ['hood', 'fanspeed', 'vs', '0'], {
|
||||
'x.com.samsung.da.hood.fanSpeed': value,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
HOOD_FAN = Capability(
|
||||
href='/hood/fanspeed/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
FanDesc(
|
||||
key='fan',
|
||||
field='x.com.samsung.da.hood.fanSpeed',
|
||||
write_fn=_hood_fan_write,
|
||||
),
|
||||
BinarySensorDesc(
|
||||
key='automatic_operation',
|
||||
field='x.com.samsung.da.hood.autoOperation',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _lamp_level_write(value, rep, href=None):
|
||||
code = str(value)
|
||||
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
|
||||
if code not in supported:
|
||||
return None
|
||||
return ['hood', 'lamp', 'vs', '0'], {
|
||||
'x.com.samsung.lamp.current': code,
|
||||
}
|
||||
|
||||
|
||||
HOOD_LAMP = Capability(
|
||||
href='/hood/lamp/vs/0',
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
SwitchDesc(
|
||||
key='lamp',
|
||||
field='x.com.samsung.lamp.power',
|
||||
icon='mdi:range-hood',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
write_fn=lambda payload, rep, href=None: (
|
||||
['hood', 'lamp', 'vs', '0'],
|
||||
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
|
||||
),
|
||||
),
|
||||
SelectDesc(
|
||||
key='lamp_brightness',
|
||||
field='x.com.samsung.lamp.current',
|
||||
icon='mdi:brightness-6',
|
||||
translation_key='range_hood_lamp_brightness',
|
||||
options_field='x.com.samsung.lamp.range',
|
||||
write_fn=_lamp_level_write,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
HOOD_FILTER = Capability(
|
||||
href='/filter/hoodfilter/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='hood_filter_usage',
|
||||
field='x.com.samsung.da.filterUsage',
|
||||
unit='%',
|
||||
state_class='measurement',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
SensorDesc(
|
||||
key='hood_filter_status',
|
||||
field='x.com.samsung.da.filterStatus',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
device_class='enum',
|
||||
options=('normal', 'wash', 'replace'),
|
||||
translation_key='filter_status',
|
||||
value_fn=lambda value: (
|
||||
value.lower() if isinstance(value, str) else value
|
||||
),
|
||||
),
|
||||
SensorDesc(
|
||||
key='hood_filter_capacity',
|
||||
field='x.com.samsung.da.filterCapacity',
|
||||
unit='h',
|
||||
icon='mdi:timer-outline',
|
||||
entity_category='diagnostic',
|
||||
enabled_default=False,
|
||||
value_fn=int_or_none,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AIR_QUALITY = Capability(
|
||||
href='/sensors/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='clean_level',
|
||||
field='x.com.samsung.da.items',
|
||||
icon='mdi:air-filter',
|
||||
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'Dust'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
|
||||
),
|
||||
SensorDesc(
|
||||
key='super_fine_dust',
|
||||
field='x.com.samsung.da.items',
|
||||
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AIR_LEVEL_CHECK = Capability(
|
||||
href='/airlevelcheck/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
BinarySensorDesc(
|
||||
key='periodic_air_sensing',
|
||||
field='x.com.samsung.da.periodicSensingActivationState',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
SensorDesc(
|
||||
key='air_sensing_state',
|
||||
field='x.com.samsung.da.sensingState',
|
||||
icon='mdi:radar',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
SensorDesc(
|
||||
key='last_air_sensing_time',
|
||||
field='x.com.samsung.da.lastSensingTime',
|
||||
device_class='timestamp',
|
||||
entity_category='diagnostic',
|
||||
value_fn=_timestamp,
|
||||
),
|
||||
SensorDesc(
|
||||
key='last_air_sensing_level',
|
||||
field='x.com.samsung.da.lastSensingLevel',
|
||||
icon='mdi:air-filter',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
SensorDesc(
|
||||
key='automatic_ventilation_state',
|
||||
field='x.com.samsung.da.autoExeState',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
AUTO_VENTILATION = Capability(
|
||||
href='/autoventilation/vs/0',
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SensorDesc(
|
||||
key='auto_ventilation_action',
|
||||
field='action',
|
||||
icon='mdi:fan-auto',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Resource plumbing and opaque feature-negotiation fields that are specific to
|
||||
# this family. Bare capabilities mark them covered without creating entities.
|
||||
COVERAGE = [
|
||||
Capability(href=href)
|
||||
for href in (
|
||||
'/power/0',
|
||||
'/power/vs/0',
|
||||
'/mode/vs/0',
|
||||
'/personality/presence/vs/0',
|
||||
'/availablecontrolsets/vs/0',
|
||||
'/da/softreset/vs/0',
|
||||
)
|
||||
]
|
||||
@@ -19,12 +19,12 @@ from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
|
||||
from .laundry import (
|
||||
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
|
||||
replace_in_options,
|
||||
option_write,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Course_XX hex codes. 23 of the codes named in strings.json/translations
|
||||
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
||||
# Course_XX hex codes. 23 of the codes named in translations/en.json
|
||||
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
|
||||
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
||||
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
||||
# positionally against a Slovak-UI user's screenshots of their app's course
|
||||
@@ -75,19 +75,19 @@ WASHER_SETTINGS = Capability(
|
||||
href='/washer/vs/0',
|
||||
entities=(
|
||||
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
|
||||
name='Wash temperature', icon='mdi:thermometer-water',
|
||||
icon='mdi:thermometer-water',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedWaterTemperature',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
|
||||
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
|
||||
name='Spin speed', icon='mdi:sync',
|
||||
icon='mdi:sync',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedSpinLevel',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
|
||||
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
|
||||
name='Rinse cycles', icon='mdi:water-sync',
|
||||
icon='mdi:water-sync',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedRinseCycles',
|
||||
write_fn=lambda p, rep, href=None: (
|
||||
@@ -97,7 +97,7 @@ WASHER_SETTINGS = Capability(
|
||||
# #22. Self-gates off on plain washers, which never report
|
||||
# supportedDryLevel.
|
||||
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
|
||||
name='Dry level', icon='mdi:tumble-dryer',
|
||||
icon='mdi:tumble-dryer',
|
||||
entity_category='config',
|
||||
translation_key='washer_dry_level',
|
||||
options_field='x.com.samsung.da.supportedDryLevel',
|
||||
@@ -157,8 +157,8 @@ def _drum_clean_last_cleaned(rep):
|
||||
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
||||
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
||||
#
|
||||
# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_
|
||||
# water_hardness/washer_softener_concentration in strings.json) is an
|
||||
# Label mapping (entity.select.{detergent,softener}_quantity /
|
||||
# detergent_water_hardness / softener_concentration in translations/en.json) is an
|
||||
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
||||
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
||||
# on-screen equivalent -- the app's Quantité picker only offers
|
||||
@@ -215,8 +215,7 @@ def _dosing_level(prefix):
|
||||
|
||||
def _level_write(prefix):
|
||||
def write(p, rep, href=None):
|
||||
opts = list(rep.get('x.com.samsung.da.options') or [])
|
||||
if not opts:
|
||||
if not rep.get('x.com.samsung.da.options'):
|
||||
return None
|
||||
# `p` is the zero-padded supported code the UI selected (e.g. '03');
|
||||
# the device stores the level un-padded (e.g. '3'), matching how it
|
||||
@@ -226,7 +225,7 @@ def _level_write(prefix):
|
||||
except (TypeError, ValueError):
|
||||
native = p
|
||||
return ['course', 'vs', '0'], {
|
||||
'x.com.samsung.da.options': replace_in_options(opts, prefix, native),
|
||||
'x.com.samsung.da.options': option_write(prefix, native),
|
||||
}
|
||||
return write
|
||||
|
||||
@@ -260,7 +259,7 @@ def _dosing_low(prefix):
|
||||
# auto-release-dry toggles -- only this per-course gating is washer-only, so
|
||||
# it stays here rather than in laundry.py (see laundry.bool_option_switch's
|
||||
# docstring: it takes a prebuilt validate_fn and has no opinion on it).
|
||||
def _bool_option_switch(key, name, icon, prefix, availability_field):
|
||||
def _bool_option_switch(key, icon, prefix, availability_field):
|
||||
def validate(p, rep, resources):
|
||||
"""Reject turning on when the selected course's byte in
|
||||
`availability_field` isn't 'F0'. Turning off is never blocked. Falls
|
||||
@@ -282,75 +281,74 @@ def _bool_option_switch(key, name, icon, prefix, availability_field):
|
||||
if len(pairs) != len(courses):
|
||||
return None
|
||||
if pairs[courses.index(current)] != 'F0':
|
||||
return f"{name} isn't available on the selected cycle."
|
||||
return f"{key}_unavailable_for_cycle"
|
||||
return None
|
||||
|
||||
return bool_option_switch(
|
||||
key, name, icon, prefix,
|
||||
key, icon, prefix,
|
||||
entity_category='config', gate_on_presence=True, validate_fn=validate)
|
||||
|
||||
|
||||
WASHER_COURSE = Capability(
|
||||
href='/course/vs/0',
|
||||
entities=(
|
||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine'),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in',
|
||||
icon='mdi:washing-machine-alert', unit='cycles',
|
||||
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
|
||||
table_href='/st/washercourse/vs/0'),
|
||||
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
|
||||
icon='mdi:washing-machine-alert',
|
||||
state_class='measurement',
|
||||
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
|
||||
rep_fn=_drum_clean_cycles_remaining),
|
||||
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned',
|
||||
icon='mdi:calendar-clock', device_class='timestamp',
|
||||
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
|
||||
icon='mdi:calendar-clock',
|
||||
entity_category='diagnostic',
|
||||
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
|
||||
rep_fn=_drum_clean_last_cleaned),
|
||||
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water',
|
||||
translation_key='washer_dosing_quantity',
|
||||
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
|
||||
translation_key='detergent_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevelCtrl'),
|
||||
write_fn=_level_write('DetergentLevelCtrl')),
|
||||
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
|
||||
icon='mdi:water-opacity',
|
||||
translation_key='washer_detergent_water_hardness',
|
||||
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
|
||||
translation_key='detergent_water_hardness',
|
||||
entity_category='config',
|
||||
options=_level_options('DetergentLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('DetergentLevel2Ctrl')(resources)),
|
||||
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
|
||||
write_fn=_level_write('DetergentLevel2Ctrl')),
|
||||
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
|
||||
translation_key='washer_dosing_quantity',
|
||||
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
|
||||
translation_key='softener_quantity',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevelCtrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevelCtrl')(resources)),
|
||||
rep_fn=_dosing_level('SoftenerLevelCtrl'),
|
||||
write_fn=_level_write('SoftenerLevelCtrl')),
|
||||
SelectDesc(key='softener_concentration', name='Softener concentration',
|
||||
icon='mdi:flask-plus-outline',
|
||||
translation_key='washer_softener_concentration',
|
||||
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
|
||||
translation_key='softener_concentration',
|
||||
entity_category='config',
|
||||
options=_level_options('SoftenerLevel2Ctrl'),
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
_level_options('SoftenerLevel2Ctrl')(resources)),
|
||||
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
|
||||
write_fn=_level_write('SoftenerLevel2Ctrl')),
|
||||
BinarySensorDesc(key='detergent_low', name='Detergent low',
|
||||
icon='mdi:alert-circle-outline', device_class='problem',
|
||||
BinarySensorDesc(key='detergent_low', device_class='problem',
|
||||
icon='mdi:alert-circle-outline',
|
||||
exists_fn=bool_option_exists('DetergentAlarm'),
|
||||
rep_fn=_dosing_low('DetergentAlarm')),
|
||||
BinarySensorDesc(key='softener_low', name='Softener low',
|
||||
icon='mdi:alert-circle-outline', device_class='problem',
|
||||
BinarySensorDesc(key='softener_low', device_class='problem',
|
||||
icon='mdi:alert-circle-outline',
|
||||
exists_fn=bool_option_exists('SoftenerAlarm'),
|
||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||
_bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble',
|
||||
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
|
||||
'BubbleSoak', 'BubbleSoakSet'),
|
||||
_bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine',
|
||||
_bool_option_switch('pre_wash', 'mdi:washing-machine',
|
||||
'PreWashSetting', 'PreWashAvailableSet'),
|
||||
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine',
|
||||
_bool_option_switch('intensive', 'mdi:washing-machine',
|
||||
'IntensiveSetting', 'IntensiveAvailableSet'),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ class BoundEntity:
|
||||
|
||||
def _snake_to_title(s: str) -> str:
|
||||
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
|
||||
_derive_name, which applies the same transform to a state key."""
|
||||
_derive_name, which applies the same transform to an href-derived key."""
|
||||
return s.replace('_', ' ').title()
|
||||
|
||||
|
||||
|
||||
@@ -8,14 +8,14 @@ where a description declares one.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
|
||||
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
|
||||
# (payload, rep, resources) -> a human-readable rejection message, or None to
|
||||
# allow the write. resources is the coordinator's full href->rep snapshot, for
|
||||
# the same cross-resource lookups exists_fn needs (e.g. reading a sibling
|
||||
# href's live option list).
|
||||
# (payload, rep, resources) -> a translation key, or None to allow the
|
||||
# write. resources is the coordinator's full href->rep snapshot, for the same
|
||||
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
|
||||
# option list).
|
||||
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
|
||||
|
||||
|
||||
@@ -27,8 +27,18 @@ def _identity(v: Any) -> Any:
|
||||
class SamsungEntityDescription:
|
||||
key: str
|
||||
field: str = ''
|
||||
name: Optional[str] = None
|
||||
translation_key: Optional[str] = None
|
||||
# Defaults to `key`: entity names and states live in translations/, never
|
||||
# here, so a descriptor only sets this to share one catalog entry across
|
||||
# several descriptors, or to point at a differently-named one.
|
||||
translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
|
||||
# callable form receives the coordinator's full href->rep resource
|
||||
# snapshot and returns the key to use -- for a descriptor shared across
|
||||
# board generations whose state-code meaning isn't guaranteed consistent
|
||||
# between them; see laundry.cycle_select's table-id-gated resolver.
|
||||
translation_placeholders: Optional[Mapping[str, str]] = None
|
||||
# Dynamic resources such as fridge compartments and ice makers use a
|
||||
# device-provided or href-derived instance label inside a translated name.
|
||||
use_instance_name: bool = False
|
||||
icon: Optional[str] = None
|
||||
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
|
||||
enabled_default: bool = True
|
||||
@@ -111,6 +121,13 @@ class ClimateDesc(SamsungEntityDescription):
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FanDesc(SamsungEntityDescription):
|
||||
# Composite fan entity: reads power from /power/0 and speed/support data
|
||||
# from its bound href. Payloads are (kind, value), like ClimateDesc.
|
||||
write_fn: WriteFn = None
|
||||
|
||||
|
||||
PLATFORM_OF: dict[type, str] = {
|
||||
SensorDesc: 'sensor',
|
||||
BinarySensorDesc: 'binary_sensor',
|
||||
@@ -120,4 +137,5 @@ PLATFORM_OF: dict[type, str] = {
|
||||
NumberDesc: 'number',
|
||||
TimeDesc: 'time',
|
||||
ClimateDesc: 'climate',
|
||||
FanDesc: 'fan',
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -10,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import SelectDesc
|
||||
|
||||
from .catalog import translated_states
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
@@ -31,16 +33,37 @@ async def async_setup_entry(
|
||||
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
|
||||
|
||||
|
||||
def _display(value, desc: SelectDesc):
|
||||
def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
"""Return the catalog state `value` normalizes to, else None.
|
||||
|
||||
Samsung reports options in whatever casing the resource uses
|
||||
('Rinse_Hold', 'SpTtypeBeerDrinks', '1b'); Home Assistant looks state
|
||||
translations up by a lowercase key. Only values the catalog actually
|
||||
knows are normalized -- an unrecognized (or future) vendor value keeps
|
||||
its own readable form rather than becoming an untranslatable slug.
|
||||
"""
|
||||
direct = value.lower().replace(' ', '_')
|
||||
if direct in known:
|
||||
return direct
|
||||
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
|
||||
return snake if snake in known else None
|
||||
|
||||
|
||||
def _display(value, translation_key: Optional[str]):
|
||||
"""Turn a raw device option/state value into what's shown in the UI.
|
||||
|
||||
An entity with a translation_key looks its state up in strings.json,
|
||||
and hassfest requires those keys to be lowercase -- so those values
|
||||
`translation_key` is the entity's already-resolved key (SelectDesc.
|
||||
translation_key can itself be a callable -- see entities.py -- so
|
||||
callers pass the resolved value, e.g. self.translation_key, not
|
||||
the raw descriptor field).
|
||||
|
||||
An entity with a translation_key looks its state up in the shipped
|
||||
translation catalog, whose state keys are lowercase -- so those values
|
||||
must be lowercased exactly to match, and the device still expects
|
||||
that same raw casing back on write (callers map the displayed value
|
||||
back to raw via _raw_options()).
|
||||
|
||||
Everything else has no strings.json lookup, so there's no reason to
|
||||
Everything else has no catalog lookup, so there's no reason to
|
||||
destroy the device's own casing. Only two cosmetic fixups apply: a
|
||||
fully lowercase device-native token (e.g. "voice") is title-cased,
|
||||
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
|
||||
@@ -50,8 +73,16 @@ def _display(value, desc: SelectDesc):
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if desc.translation_key:
|
||||
return value.lower()
|
||||
if translation_key:
|
||||
known = translated_states('select', translation_key)
|
||||
if not known:
|
||||
# No state table for this key: either the entity isn't translated
|
||||
# at all, or its name is translated but its options deliberately
|
||||
# aren't (an unrecognized course table, say). Either way the
|
||||
# opaque device value is the best thing to show.
|
||||
return value
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
if value.islower():
|
||||
return value.replace('_', ' ').title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(' ', value)
|
||||
@@ -63,7 +94,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
super().__init__(coordinator, bound)
|
||||
desc: SelectDesc = bound.desc
|
||||
if not desc.options_field and not callable(desc.options):
|
||||
self._attr_options = [_display(o, desc) for o in desc.options]
|
||||
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
|
||||
|
||||
def _raw_options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
@@ -83,17 +114,17 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
|
||||
def options(self) -> list[str]:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
if desc.options_field or callable(desc.options):
|
||||
return [_display(o, desc) for o in self._raw_options()]
|
||||
return [_display(o, self.translation_key) for o in self._raw_options()]
|
||||
return self._attr_options
|
||||
|
||||
@property
|
||||
def current_option(self):
|
||||
raw = (self.coordinator.data or {}).get(self._state_key)
|
||||
return _display(raw, self._bound.desc)
|
||||
return _display(raw, self.translation_key)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
desc: SelectDesc = self._bound.desc
|
||||
raw = next(
|
||||
(o for o in self._raw_options() if _display(o, desc) == option), option
|
||||
(o for o in self._raw_options() if _display(o, self.translation_key) == option),
|
||||
option,
|
||||
)
|
||||
await self.coordinator.async_send_command(self._bound, raw)
|
||||
|
||||
@@ -61,7 +61,6 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
|
||||
Disabled by default — it's for troubleshooting, not everyday use."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = 'Connection mode'
|
||||
_attr_translation_key = 'connection_mode'
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = False
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"select": {
|
||||
"door_alert": {
|
||||
"state": {
|
||||
"1": "Alarm 1",
|
||||
"2": "Alarm 2",
|
||||
"3": "Alarm 3",
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"brightness_level": {
|
||||
"state": {
|
||||
"33": "Low",
|
||||
"66": "Medium",
|
||||
"100": "High"
|
||||
}
|
||||
},
|
||||
"ice_type": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"whiskey_iceball_3": "3 Balls/Day",
|
||||
"whiskey_iceball_6": "6 Balls/Day",
|
||||
"whiskey_iceball_9": "9 Balls/Day"
|
||||
}
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"state": {
|
||||
"sp_ttype_beer_drinks": "Beverage",
|
||||
"sp_ttype_wine_dessert": "Wine and Dessert"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
"state": {
|
||||
"fdr_wine": "Wine",
|
||||
"fdr_deli": "Deli",
|
||||
"fdr_drinks": "Drinks"
|
||||
}
|
||||
},
|
||||
"flex_zone_mode": {
|
||||
"state": {
|
||||
"cv_ttype_rf9000a_freeze": "Freeze",
|
||||
"cv_ttype_rf9000a_softfreeze": "Soft Freeze",
|
||||
"cv_ttype_rf9000a_meat_fish": "Meat/Fish",
|
||||
"cv_ttype_rf9000a_fruit_veggies": "Fruit & Veggies",
|
||||
"cv_ttype_rf9000a_beverage": "Beverage",
|
||||
"cv_fdr_wine": "Wine",
|
||||
"cv_fdr_deli": "Deli",
|
||||
"cv_fdr_beverage": "Beverage",
|
||||
"cv_fdr_meat": "Meat",
|
||||
"cv_fdr_soft_freezer": "Soft Freezer"
|
||||
}
|
||||
},
|
||||
"dishwasher_cycle": {
|
||||
"state": {
|
||||
"0e": "AI Wash",
|
||||
"07": "Pre blast",
|
||||
"90": "Self clean",
|
||||
"86": "Normal",
|
||||
"83": "Express 60",
|
||||
"84": "Heavy",
|
||||
"8d": "Pots and pans",
|
||||
"80": "Delicate",
|
||||
"8e": "Plastic",
|
||||
"8f": "Baby Care"
|
||||
}
|
||||
},
|
||||
"washer_cycle": {
|
||||
"state": {
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
"21": "Colours",
|
||||
"1b": "Cotton",
|
||||
"1e": "15' Quick Wash",
|
||||
"29": "Drum Clean+",
|
||||
"24": "Towels",
|
||||
"33": "Bedding",
|
||||
"28": "Drain/Spin",
|
||||
"26": "Delicates",
|
||||
"27": "Rinse+Spin",
|
||||
"22": "Wool",
|
||||
"20": "Hygiene Steam",
|
||||
"23": "Outdoor",
|
||||
"25": "Synthetics",
|
||||
"32": "Shirts",
|
||||
"2f": "Activewear",
|
||||
"2e": "Baby Care",
|
||||
"30": "Cloudy Day",
|
||||
"66": "Denim",
|
||||
"2d": "Silent Wash",
|
||||
"8f": "Intense Cold",
|
||||
"96": "Less Microfiber",
|
||||
"36": "Wash+Dry",
|
||||
"37": "Air Wash",
|
||||
"38": "Cotton Dry",
|
||||
"39": "Synthetics Dry",
|
||||
"1f": "Intense Cold"
|
||||
}
|
||||
},
|
||||
"dryer_cycle": {
|
||||
"state": {
|
||||
"16": "Cotton",
|
||||
"18": "Synthetics",
|
||||
"19": "Delicates",
|
||||
"1a": "Wool",
|
||||
"1b": "Bedding",
|
||||
"1c": "Shirts",
|
||||
"1d": "Towels",
|
||||
"1e": "Outdoor",
|
||||
"1f": "Mixed Load",
|
||||
"20": "Iron Dry",
|
||||
"23": "Quick Dry 35",
|
||||
"24": "Cool Air",
|
||||
"25": "Warm Air",
|
||||
"27": "Time Dry"
|
||||
}
|
||||
},
|
||||
"washer_dosing_quantity": {
|
||||
"state": {
|
||||
"00": "None",
|
||||
"01": "Low",
|
||||
"02": "Medium",
|
||||
"03": "High"
|
||||
}
|
||||
},
|
||||
"washer_detergent_water_hardness": {
|
||||
"state": {
|
||||
"01": "Soft",
|
||||
"02": "Medium",
|
||||
"03": "Hard"
|
||||
}
|
||||
},
|
||||
"washer_softener_concentration": {
|
||||
"state": {
|
||||
"01": "1x",
|
||||
"02": "2x",
|
||||
"03": "3x"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
"state": {
|
||||
"none": "Off",
|
||||
"cupboard": "Cupboard",
|
||||
"30": "30 min",
|
||||
"60": "1 hr",
|
||||
"90": "1 hr 30",
|
||||
"120": "2 hr",
|
||||
"180": "3 hr",
|
||||
"240": "4 hr"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ice_making_status": {
|
||||
"state": {
|
||||
"icestatus_stop": "Idle",
|
||||
"icestatus_run": "Making ice"
|
||||
}
|
||||
},
|
||||
"connection_mode": {
|
||||
"state": {
|
||||
"observe": "Push (observe)",
|
||||
"poll": "Polling"
|
||||
}
|
||||
},
|
||||
"machine_state": {
|
||||
"state": {
|
||||
"idle": "Idle",
|
||||
"active": "Active",
|
||||
"pause": "Paused"
|
||||
}
|
||||
},
|
||||
"air_filter_status": {
|
||||
"state": {
|
||||
"normal": "Normal",
|
||||
"wash": "Wash",
|
||||
"replace": "Replace"
|
||||
}
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"airconditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"turbo": "Turbo"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"off": "Fixed",
|
||||
"both": "All directions",
|
||||
"vertical": "Up and down"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"none": "Off",
|
||||
"sleep": "Sleep",
|
||||
"quiet": "Quiet",
|
||||
"smart": "Smart",
|
||||
"speed": "Speed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Add Samsung Appliance",
|
||||
"description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates. {reuse_note}",
|
||||
"data": {
|
||||
"host": "IP Address",
|
||||
"ca_cert_pem": "CA Certificate (PEM)",
|
||||
"ca_key_pem": "CA Private Key (PEM)"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).",
|
||||
"ca_cert_pem": "The PEM-encoded AC14K_M CA certificate chain. Paste the full contents of your fullchain PEM including all BEGIN/END CERTIFICATE blocks.",
|
||||
"ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer."
|
||||
}
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"title": "Appliance type not recognized",
|
||||
"description": "This appliance reported oneUiVersion \"{one_ui_version}\", which isn't a recognized type. It'll still be added, but only with common capabilities (power, alarms, etc. where present) rather than the full set for its family. You can help add full support afterward by downloading diagnostics for this device (Settings > Devices & Services > this device > the menu > Download diagnostics) and filing them in a new issue. Submit to add it anyway."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to the device. Verify the IP address is reachable and the CA credentials are correct.",
|
||||
"invalid_ca": "The CA certificate or private key could not be loaded. Verify the PEM contents are correct and the key matches the certificate.",
|
||||
"unknown": "Unexpected error. Check the Home Assistant logs for details."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "This device is already configured."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"device_gap": {
|
||||
"title": "Incomplete capability coverage for {device_name}",
|
||||
"description": "This device is missing full capability coverage. Either its appliance type wasn't recognized, or some of the resources it exposes aren't modeled yet. It'll keep working with whatever is already supported. You can help expand support by going to Settings > Devices & Services > {device_name} > the menu (top right) > Download diagnostics, then filing it with the linked issue template."
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,835 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"any_burner_active": {
|
||||
"name": "Een brander actief"
|
||||
},
|
||||
"automatic_operation": {
|
||||
"name": "Automatische werking"
|
||||
},
|
||||
"burner_hot_surface": {
|
||||
"name": "Brander {number} heet oppervlak"
|
||||
},
|
||||
"cloud_connected": {
|
||||
"name": "Verbonden met cloud"
|
||||
},
|
||||
"cooktop_safety_shutoff_enabled": {
|
||||
"name": "Automatische uitschakeling heet oppervlak ingeschakeld"
|
||||
},
|
||||
"current_limit_enabled": {
|
||||
"name": "Stroombegrenzing ingeschakeld"
|
||||
},
|
||||
"cycle_active": {
|
||||
"name": "Actief"
|
||||
},
|
||||
"defrost_active": {
|
||||
"name": "Ontdooien actief"
|
||||
},
|
||||
"detergent_low": {
|
||||
"name": "Wasmiddel bijna op"
|
||||
},
|
||||
"device_active": {
|
||||
"name": "Apparaat actief"
|
||||
},
|
||||
"door_open": {
|
||||
"name": "Deur"
|
||||
},
|
||||
"firmware_update": {
|
||||
"name": "Firmware-update beschikbaar"
|
||||
},
|
||||
"instance_open": {
|
||||
"name": "{instance_name} open"
|
||||
},
|
||||
"paired_hood_connected": {
|
||||
"name": "Gekoppelde afzuigkap verbonden"
|
||||
},
|
||||
"paired_hood_light": {
|
||||
"name": "Verlichting gekoppelde afzuigkap"
|
||||
},
|
||||
"paired_hood_power": {
|
||||
"name": "Gekoppelde afzuigkap actief"
|
||||
},
|
||||
"periodic_air_sensing": {
|
||||
"name": "Periodieke luchtmeting"
|
||||
},
|
||||
"power_state": {
|
||||
"name": "Voedingsstatus"
|
||||
},
|
||||
"rapid_freezing": {
|
||||
"name": "Snelvriezen"
|
||||
},
|
||||
"rapid_fridge": {
|
||||
"name": "Snelkoelen"
|
||||
},
|
||||
"remote_control": {
|
||||
"name": "Smart Control"
|
||||
},
|
||||
"softener_low": {
|
||||
"name": "Wasverzachter bijna op"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"diagnosis_start": {
|
||||
"name": "Diagnose starten"
|
||||
},
|
||||
"pause": {
|
||||
"name": "Pauzeren"
|
||||
},
|
||||
"selfcheck_start": {
|
||||
"name": "Zelfcontrole starten"
|
||||
},
|
||||
"start": {
|
||||
"name": "Starten"
|
||||
},
|
||||
"stop": {
|
||||
"name": "Stoppen"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"airconditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"turbo": "Turbo"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"quiet": "Stil",
|
||||
"smart": "Slim",
|
||||
"speed": "Snel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"cook_time": {
|
||||
"name": "Bereidingstijd"
|
||||
},
|
||||
"delay_start_hours": {
|
||||
"name": "Uitgestelde start"
|
||||
},
|
||||
"instance_setpoint": {
|
||||
"name": "Instelpunt {instance_name}"
|
||||
},
|
||||
"oven_setpoint": {
|
||||
"name": "Instelpunt"
|
||||
},
|
||||
"sound_volume": {
|
||||
"name": "Geluidsvolume"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"ai_energy_level": {
|
||||
"name": "Niveau AI Energy Mode"
|
||||
},
|
||||
"beverage_zone_mode": {
|
||||
"name": "Modus drankenzone",
|
||||
"state": {
|
||||
"sp_ttype_beer_drinks": "Dranken",
|
||||
"sp_ttype_wine_dessert": "Wijn en dessert"
|
||||
}
|
||||
},
|
||||
"brightness_level": {
|
||||
"name": "Nachthelderheid",
|
||||
"state": {
|
||||
"33": "Laag",
|
||||
"66": "Gemiddeld",
|
||||
"100": "Hoog"
|
||||
}
|
||||
},
|
||||
"day_brightness": {
|
||||
"name": "Helderheid kast",
|
||||
"state": {
|
||||
"33": "Laag",
|
||||
"66": "Gemiddeld",
|
||||
"100": "Hoog"
|
||||
}
|
||||
},
|
||||
"buzzer_sound": {
|
||||
"name": "Zoemergeluid",
|
||||
"state": {
|
||||
"off": "Uit",
|
||||
"on": "Aan"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"name": "Programma"
|
||||
},
|
||||
"detergent_quantity": {
|
||||
"name": "Hoeveelheid wasmiddel",
|
||||
"state": {
|
||||
"00": "Geen",
|
||||
"01": "Laag",
|
||||
"02": "Gemiddeld",
|
||||
"03": "Hoog"
|
||||
}
|
||||
},
|
||||
"detergent_water_hardness": {
|
||||
"name": "Waterhardheid voor wasmiddel",
|
||||
"state": {
|
||||
"01": "Zacht",
|
||||
"02": "Gemiddeld",
|
||||
"03": "Hard"
|
||||
}
|
||||
},
|
||||
"dishwasher_cycle": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"80": "Delicaat",
|
||||
"83": "Express 60",
|
||||
"84": "Intensief",
|
||||
"86": "Normaal",
|
||||
"90": "Zelfreiniging",
|
||||
"0e": "AI Wash",
|
||||
"07": "Voorspoelen",
|
||||
"8d": "Potten en pannen",
|
||||
"8e": "Kunststof",
|
||||
"8f": "Babyverzorging"
|
||||
}
|
||||
},
|
||||
"door_alert": {
|
||||
"name": "Deuralarm",
|
||||
"state": {
|
||||
"1": "Alarm 1",
|
||||
"2": "Alarm 2",
|
||||
"3": "Alarm 3",
|
||||
"4": "Alarm 4"
|
||||
}
|
||||
},
|
||||
"dryer_cycle_table_03": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"16": "Katoen",
|
||||
"18": "Synthetisch",
|
||||
"19": "Fijne was",
|
||||
"20": "Strijkdroog",
|
||||
"23": "Snel drogen 35'",
|
||||
"24": "Koude lucht",
|
||||
"25": "Warme lucht",
|
||||
"27": "Tijdprogramma",
|
||||
"1a": "Wol",
|
||||
"1b": "Beddengoed",
|
||||
"1c": "Overhemden",
|
||||
"1d": "Handdoeken",
|
||||
"1e": "Outdoor",
|
||||
"1f": "Gemengde was"
|
||||
}
|
||||
},
|
||||
"finish_sound": {
|
||||
"name": "Eindgeluid",
|
||||
"state": {
|
||||
"off": "Uit",
|
||||
"on": "Aan"
|
||||
}
|
||||
},
|
||||
"flex_zone_mode": {
|
||||
"name": "Modus FlexZone",
|
||||
"state": {
|
||||
"cv_ttype_rf9000a_freeze": "Vriezen",
|
||||
"cv_ttype_rf9000a_softfreeze": "Zachtvriezen",
|
||||
"cv_ttype_rf9000a_meat_fish": "Vlees/vis",
|
||||
"cv_ttype_rf9000a_fruit_veggies": "Fruit en groenten",
|
||||
"cv_ttype_rf9000a_beverage": "Dranken",
|
||||
"cv_fdr_wine": "Wijn",
|
||||
"cv_fdr_deli": "Delicatessen",
|
||||
"cv_fdr_beverage": "Dranken",
|
||||
"cv_fdr_meat": "Vlees",
|
||||
"cv_fdr_soft_freezer": "Zachtvriezer"
|
||||
}
|
||||
},
|
||||
"heated_dry": {
|
||||
"name": "Smart Dry",
|
||||
"state": {
|
||||
"off": "Uit",
|
||||
"low": "Laag",
|
||||
"high": "Hoog",
|
||||
"extra_high": "Extra hoog"
|
||||
}
|
||||
},
|
||||
"ice_type": {
|
||||
"name": "Type {instance_name}",
|
||||
"state": {
|
||||
"off": "Uit",
|
||||
"whiskey_iceball_3": "3 ijsballen per dag",
|
||||
"whiskey_iceball_6": "6 ijsballen per dag",
|
||||
"whiskey_iceball_9": "9 ijsballen per dag"
|
||||
}
|
||||
},
|
||||
"led_brightness": {
|
||||
"name": "Helderheid deur-LED",
|
||||
"state": {
|
||||
"low": "Laag",
|
||||
"high": "Hoog"
|
||||
}
|
||||
},
|
||||
"led_night_brightness": {
|
||||
"name": "Nachtelijke helderheid deur-LED",
|
||||
"state": {
|
||||
"low": "Laag",
|
||||
"high": "Hoog"
|
||||
}
|
||||
},
|
||||
"oven_mode": {
|
||||
"name": "Bereidingsmodus",
|
||||
"state": {
|
||||
"no_operation": "Niet actief",
|
||||
"bake": "Bakken",
|
||||
"broil": "Grillen",
|
||||
"convection": "Hetelucht",
|
||||
"convection_bake": "Bakken met hetelucht",
|
||||
"convection_broil": "Grillen met hetelucht",
|
||||
"frozen_pizza_plus": "Frozen Pizza+",
|
||||
"slow_cook": "Langzaam garen",
|
||||
"plate_warm": "Borden warmhouden",
|
||||
"air_fry": "Airfryen"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
"name": "Modus voorraadzone",
|
||||
"state": {
|
||||
"fdr_wine": "Wijn",
|
||||
"fdr_deli": "Delicatessen",
|
||||
"fdr_drinks": "Dranken"
|
||||
}
|
||||
},
|
||||
"range_burner_power_level": {
|
||||
"name": "Vermogensniveau brander {number}",
|
||||
"state": {
|
||||
"0": "Uit",
|
||||
"boost": "Boost",
|
||||
"simmer": "Sudderen"
|
||||
}
|
||||
},
|
||||
"range_hood_lamp_brightness": {
|
||||
"name": "Helderheid lamp",
|
||||
"state": {
|
||||
"1": "Niveau 1",
|
||||
"2": "Niveau 2"
|
||||
}
|
||||
},
|
||||
"rinse_cycles": {
|
||||
"name": "Spoelcycli"
|
||||
},
|
||||
"softener_concentration": {
|
||||
"name": "Concentratie wasverzachter",
|
||||
"state": {
|
||||
"01": "1x",
|
||||
"02": "2x",
|
||||
"03": "3x"
|
||||
}
|
||||
},
|
||||
"softener_quantity": {
|
||||
"name": "Hoeveelheid wasverzachter",
|
||||
"state": {
|
||||
"00": "Geen",
|
||||
"01": "Laag",
|
||||
"02": "Gemiddeld",
|
||||
"03": "Hoog"
|
||||
}
|
||||
},
|
||||
"sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
"voice": "Spraak",
|
||||
"tone": "Toon",
|
||||
"mute": "Dempen"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"name": "Centrifugesnelheid",
|
||||
"state": {
|
||||
"rinse_hold": "Spoelstop",
|
||||
"no_spin": "Niet centrifugeren",
|
||||
"low": "Laag",
|
||||
"medium": "Gemiddeld",
|
||||
"high": "Hoog"
|
||||
}
|
||||
},
|
||||
"wash_temperature": {
|
||||
"name": "Wastemperatuur",
|
||||
"state": {
|
||||
"none": "Geen",
|
||||
"cold": "Koud",
|
||||
"cool": "Koel",
|
||||
"warm": "Warm",
|
||||
"hot": "Heet",
|
||||
"extra_hot": "Extra heet"
|
||||
}
|
||||
},
|
||||
"washer_cycle_table_02": {
|
||||
"name": "Programma",
|
||||
"state": {
|
||||
"20": "Hygiënisch stomen",
|
||||
"21": "Bonte was",
|
||||
"22": "Wol",
|
||||
"23": "Outdoor",
|
||||
"24": "Handdoeken",
|
||||
"25": "Synthetisch",
|
||||
"26": "Fijne was",
|
||||
"27": "Spoelen+centrifugeren",
|
||||
"28": "Afpompen/centrifugeren",
|
||||
"29": "Drum Clean+",
|
||||
"30": "Bewolkte dag",
|
||||
"32": "Overhemden",
|
||||
"33": "Beddengoed",
|
||||
"36": "Wassen+drogen",
|
||||
"37": "Air Wash",
|
||||
"38": "Katoen drogen",
|
||||
"39": "Synthetisch drogen",
|
||||
"66": "Spijkergoed",
|
||||
"96": "Minder microvezels",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
"1b": "Katoen",
|
||||
"1e": "15' Quick Wash",
|
||||
"2f": "Sportkleding",
|
||||
"2e": "Babyverzorging",
|
||||
"2d": "Stille was",
|
||||
"8f": "Intensief koud",
|
||||
"1f": "Intensief koud"
|
||||
}
|
||||
},
|
||||
"washer_dry_level": {
|
||||
"name": "Droogniveau",
|
||||
"state": {
|
||||
"30": "30 min",
|
||||
"60": "1 uur",
|
||||
"90": "1 uur 30 min",
|
||||
"120": "2 uur",
|
||||
"180": "3 uur",
|
||||
"240": "4 uur",
|
||||
"none": "Uit",
|
||||
"cupboard": "Kastdroog"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"air_filter_usage": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
"air_sensing_state": {
|
||||
"name": "Status luchtmeting"
|
||||
},
|
||||
"alarm_code": {
|
||||
"name": "Alarmcode"
|
||||
},
|
||||
"auto_ventilation_action": {
|
||||
"name": "Actie automatische ventilatie"
|
||||
},
|
||||
"automatic_ventilation_state": {
|
||||
"name": "Status automatische ventilatie"
|
||||
},
|
||||
"burner_state": {
|
||||
"name": "Status brander {number}"
|
||||
},
|
||||
"clean_level": {
|
||||
"name": "Reinigingsniveau"
|
||||
},
|
||||
"connection_mode": {
|
||||
"name": "Verbindingsmodus",
|
||||
"state": {
|
||||
"observe": "Push (observeren)",
|
||||
"poll": "Polling"
|
||||
}
|
||||
},
|
||||
"cooktop_state": {
|
||||
"name": "Status kookplaat"
|
||||
},
|
||||
"current_limit_level": {
|
||||
"name": "Niveau stroombegrenzing"
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatuur"
|
||||
},
|
||||
"diagnosis": {
|
||||
"name": "Diagnose"
|
||||
},
|
||||
"diagnosis_status": {
|
||||
"name": "Diagnosestatus"
|
||||
},
|
||||
"drum_clean_cycles_remaining": {
|
||||
"name": "Trommelreiniging over"
|
||||
},
|
||||
"drum_clean_last_cleaned": {
|
||||
"name": "Trommel laatst gereinigd"
|
||||
},
|
||||
"dry_level": {
|
||||
"name": "Droogniveau"
|
||||
},
|
||||
"dry_time": {
|
||||
"name": "Droogtijd"
|
||||
},
|
||||
"dryer_type": {
|
||||
"name": "Type droger"
|
||||
},
|
||||
"dust": {
|
||||
"name": "Stof"
|
||||
},
|
||||
"energy_kwh": {
|
||||
"name": "Energie"
|
||||
},
|
||||
"energy_last_month_kwh": {
|
||||
"name": "Energie (vorige maand)"
|
||||
},
|
||||
"energy_saved_kwh": {
|
||||
"name": "Bespaarde energie"
|
||||
},
|
||||
"energy_this_month_kwh": {
|
||||
"name": "Energie (deze maand)"
|
||||
},
|
||||
"fan_direction": {
|
||||
"name": "Ventilatorrichting"
|
||||
},
|
||||
"fan_speed_level": {
|
||||
"name": "Ventilatorniveau"
|
||||
},
|
||||
"filter_progress": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
"filter_usage": {
|
||||
"name": "Filtergebruik"
|
||||
},
|
||||
"fine_dust": {
|
||||
"name": "Fijnstof"
|
||||
},
|
||||
"finish_time": {
|
||||
"name": "Geschatte eindtijd"
|
||||
},
|
||||
"freezer_temperature": {
|
||||
"name": "Vriezertemperatuur"
|
||||
},
|
||||
"fridge_temperature": {
|
||||
"name": "Koelkasttemperatuur"
|
||||
},
|
||||
"hood_filter_capacity": {
|
||||
"name": "Filtercapaciteit"
|
||||
},
|
||||
"hood_filter_usage": {
|
||||
"name": "Filterverbruik"
|
||||
},
|
||||
"instance_temperature": {
|
||||
"name": "Temperatuur {instance_name}"
|
||||
},
|
||||
"job_beginning_status": {
|
||||
"name": "Status programmastart"
|
||||
},
|
||||
"last_air_sensing_level": {
|
||||
"name": "Laatste luchtmeetniveau"
|
||||
},
|
||||
"last_air_sensing_time": {
|
||||
"name": "Tijdstip laatste luchtmeting"
|
||||
},
|
||||
"main_timer_current": {
|
||||
"name": "Huidige timerwaarde"
|
||||
},
|
||||
"main_timer_state": {
|
||||
"name": "Timerstatus"
|
||||
},
|
||||
"odor": {
|
||||
"name": "Geur"
|
||||
},
|
||||
"operating_mode": {
|
||||
"name": "Werkingsmodus"
|
||||
},
|
||||
"operation_origin": {
|
||||
"name": "Bron laatste bediening"
|
||||
},
|
||||
"operation_time_minutes": {
|
||||
"name": "Werkingsduur"
|
||||
},
|
||||
"oven_state": {
|
||||
"name": "Status ovenruimte"
|
||||
},
|
||||
"paired_hood_fan_speed": {
|
||||
"name": "Ventilatorsnelheid gekoppelde afzuigkap"
|
||||
},
|
||||
"paired_hood_firmware": {
|
||||
"name": "Firmware gekoppelde afzuigkap"
|
||||
},
|
||||
"paired_hood_model": {
|
||||
"name": "Model gekoppelde afzuigkap"
|
||||
},
|
||||
"power_energy_kwh": {
|
||||
"name": "Energieverbruik"
|
||||
},
|
||||
"power_watts": {
|
||||
"name": "Vermogen"
|
||||
},
|
||||
"progress": {
|
||||
"name": "Voortgang"
|
||||
},
|
||||
"progress_percentage": {
|
||||
"name": "Voortgangspercentage"
|
||||
},
|
||||
"selfcheck_error": {
|
||||
"name": "Fout zelfcontrole"
|
||||
},
|
||||
"selfcheck_result": {
|
||||
"name": "Resultaat zelfcontrole"
|
||||
},
|
||||
"selfcheck_status": {
|
||||
"name": "Status zelfcontrole"
|
||||
},
|
||||
"super_fine_dust": {
|
||||
"name": "Ultrafijnstof"
|
||||
},
|
||||
"water_liters": {
|
||||
"name": "Waterverbruik"
|
||||
},
|
||||
"machine_state": {
|
||||
"name": "Machinestatus",
|
||||
"state": {
|
||||
"idle": "Inactief",
|
||||
"active": "Actief",
|
||||
"pause": "Gepauzeerd"
|
||||
}
|
||||
},
|
||||
"filter_status": {
|
||||
"name": "Filterstatus",
|
||||
"state": {
|
||||
"normal": "Normaal",
|
||||
"wash": "Reinigen",
|
||||
"replace": "Vervangen"
|
||||
}
|
||||
},
|
||||
"ice_making_status": {
|
||||
"name": "{instance_name} ijsmaakstatus",
|
||||
"state": {
|
||||
"icestatus_stop": "Inactief",
|
||||
"icestatus_run": "IJs maken"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"ai_energy_level": {
|
||||
"name": "AI Energy Mode"
|
||||
},
|
||||
"air_purify": {
|
||||
"name": "Luchtzuivering"
|
||||
},
|
||||
"auto_clean": {
|
||||
"name": "Automatisch reinigen"
|
||||
},
|
||||
"auto_door_opener": {
|
||||
"name": "Automatische deuropener"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Auto Release Dry"
|
||||
},
|
||||
"autofill": {
|
||||
"name": "Automatisch gevulde waterkan"
|
||||
},
|
||||
"bubble_soak": {
|
||||
"name": "Bubble Soak"
|
||||
},
|
||||
"cabinet_light_dim": {
|
||||
"name": "Geleidelijk feller"
|
||||
},
|
||||
"cabinet_light_switch": {
|
||||
"name": "Kastverlichting"
|
||||
},
|
||||
"child_lock": {
|
||||
"name": "Kinderslot"
|
||||
},
|
||||
"defrost_delay": {
|
||||
"name": "Ontdooien uitstellen"
|
||||
},
|
||||
"display_light": {
|
||||
"name": "Displayverlichting"
|
||||
},
|
||||
"fast_preheat": {
|
||||
"name": "Snel voorverwarmen"
|
||||
},
|
||||
"fridge_sound": {
|
||||
"name": "Geluid"
|
||||
},
|
||||
"ice_maker_enabled": {
|
||||
"name": "IJsmaker"
|
||||
},
|
||||
"ice_night_mode": {
|
||||
"name": "Stille nachtmodus ijsmaker"
|
||||
},
|
||||
"instance_enabled": {
|
||||
"name": "{instance_name} ingeschakeld"
|
||||
},
|
||||
"intensive": {
|
||||
"name": "Intensive"
|
||||
},
|
||||
"lamp": {
|
||||
"name": "Lamp"
|
||||
},
|
||||
"led_night_light": {
|
||||
"name": "Nachtverlichting deur-LED"
|
||||
},
|
||||
"mute_once": {
|
||||
"name": "Eenmalig dempen"
|
||||
},
|
||||
"natural_steam": {
|
||||
"name": "Natuurlijke stoom"
|
||||
},
|
||||
"power_switch": {
|
||||
"name": "Voeding"
|
||||
},
|
||||
"pre_wash": {
|
||||
"name": "Voorwas"
|
||||
},
|
||||
"rapid_freezing": {
|
||||
"name": "Snelvriezen"
|
||||
},
|
||||
"rapid_fridge": {
|
||||
"name": "Snelkoelen"
|
||||
},
|
||||
"sabbath_mode": {
|
||||
"name": "Sabbatmodus"
|
||||
},
|
||||
"sanitize": {
|
||||
"name": "Hygiëne"
|
||||
},
|
||||
"sound": {
|
||||
"name": "Geluid"
|
||||
},
|
||||
"storm_wash": {
|
||||
"name": "Storm Wash+"
|
||||
},
|
||||
"welcome_lighting": {
|
||||
"name": "Welkomstverlichting"
|
||||
},
|
||||
"wrinkle_prevent": {
|
||||
"name": "Kreukpreventie"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"led_night_end": {
|
||||
"name": "Einde nachtverlichting deur-LED"
|
||||
},
|
||||
"led_night_start": {
|
||||
"name": "Start nachtverlichting deur-LED"
|
||||
},
|
||||
"night_end": {
|
||||
"name": "Einde nachtverlichting"
|
||||
},
|
||||
"night_start": {
|
||||
"name": "Start nachtverlichting"
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Samsung-apparaat toevoegen",
|
||||
"description": "Voer het IP-adres van je Samsung-apparaat en de AC14K_M-CA-inloggegevens in waarmee apparaatcertificaten worden aangemaakt.",
|
||||
"data": {
|
||||
"host": "IP-adres",
|
||||
"ca_cert_pem": "CA-certificaat (PEM)",
|
||||
"ca_key_pem": "Privésleutel van CA (PEM)"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Het lokale IP-adres van je Samsung-apparaat (bijvoorbeeld 192.168.1.50).",
|
||||
"ca_cert_pem": "De PEM-gecodeerde AC14K_M-CA-certificaatketen. Plak de volledige inhoud van je fullchain-PEM, inclusief alle blokken BEGIN CERTIFICATE en END CERTIFICATE.",
|
||||
"ca_key_pem": "De PEM-gecodeerde AC14K_M-privésleutel. Plak de volledige inhoud, inclusief de kop- en voettekst BEGIN PRIVATE KEY en END PRIVATE KEY."
|
||||
}
|
||||
},
|
||||
"user_reuse": {
|
||||
"title": "Samsung-apparaat toevoegen",
|
||||
"description": "Voer het IP-adres van je Samsung-apparaat in. De CA-inloggegevens van een bestaand LocalThings-apparaat worden hergebruikt.",
|
||||
"data": {
|
||||
"host": "IP-adres"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Het lokale IP-adres van je Samsung-apparaat (bijvoorbeeld 192.168.1.50)."
|
||||
}
|
||||
},
|
||||
"confirm_unknown_type": {
|
||||
"title": "Apparaattype niet herkend",
|
||||
"description": "Dit apparaat meldt oneUiVersion \"{one_ui_version}\", wat niet als apparaattype wordt herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
||||
},
|
||||
"confirm_unknown_type_no_version": {
|
||||
"title": "Apparaattype niet herkend",
|
||||
"description": "Dit apparaat meldt geen oneUiVersion en het apparaattype kon niet worden herkend. Het apparaat wordt toch toegevoegd, maar alleen met algemene mogelijkheden (zoals voeding en alarmen, voor zover aanwezig), in plaats van alle mogelijkheden voor deze apparaatfamilie. Je kunt daarna helpen volledige ondersteuning toe te voegen door diagnostische gegevens voor dit apparaat te downloaden (Instellingen > Apparaten & diensten > dit apparaat > het menu > Diagnostische gegevens downloaden) en deze bij een nieuw issue te voegen. Kies Verzenden om het apparaat toch toe te voegen."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Kan geen verbinding maken met het apparaat. Controleer of het IP-adres bereikbaar is en de CA-inloggegevens juist zijn.",
|
||||
"invalid_ca": "Het CA-certificaat of de privésleutel kon niet worden geladen. Controleer of de PEM-inhoud juist is en of de sleutel bij het certificaat hoort.",
|
||||
"unknown": "Onverwachte fout. Raadpleeg de Home Assistant-logboeken voor meer informatie."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Apparaat is al geconfigureerd"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "LocalThings-opties",
|
||||
"menu_options": {
|
||||
"settings": "Schrijfinstellingen voor afstandsbediening",
|
||||
"debug_write": "Foutopsporing: naar een resource schrijven"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Schrijfinstellingen voor afstandsbediening",
|
||||
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.",
|
||||
"data": {
|
||||
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld"
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"data": {
|
||||
"href": "Href van resource"
|
||||
}
|
||||
},
|
||||
"debug_edit": {
|
||||
"title": "Schrijfbewerking voor foutopsporing: {href}",
|
||||
"description": "Huidige waarde van `{href}`:\n```\n{current_value}\n```\nVoer hieronder ALLEEN de velden in die je wilt wijzigen. Wat je invoert, wordt ongewijzigd naar het apparaat verzonden (als gedeeltelijke update). Het wordt NIET samengevoegd met de hierboven getoonde velden, dus plak niet de volledige waarde terug. Let op de typen: een numerieke tekenreeks zoals \"1\" moet tussen aanhalingstekens blijven staan. Een 1 zonder aanhalingstekens wordt als integer verzonden, wat sommige apparaten weigeren.",
|
||||
"data": {
|
||||
"payload": "Te schrijven payload"
|
||||
}
|
||||
},
|
||||
"debug_result": {
|
||||
"title": "Resultaat van schrijfbewerking voor foutopsporing",
|
||||
"description": "Het apparaat heeft CoAP-code {code} geretourneerd. Een code in de 2.xx-klasse betekent dat de schrijfbewerking is geaccepteerd; 4.xx/5.xx betekent dat deze is geweigerd. De resource heeft nu de volgende waarde:\n```\n{new_value}\n```\nAls de waarde ongewijzigd is, heeft het apparaat de schrijfbewerking waarschijnlijk genegeerd. Kies wat je hierna wilt doen:",
|
||||
"menu_options": {
|
||||
"debug_write": "Naar een andere resource schrijven",
|
||||
"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."
|
||||
},
|
||||
"abort": {
|
||||
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"remote_control_disabled": {
|
||||
"message": "Afstandsbediening is uitgeschakeld op dit apparaat. Raadpleeg de handleiding van je apparaat om afstandsbediening in te schakelen voordat Home Assistant het kan bedienen."
|
||||
},
|
||||
"debug_payload_empty": {
|
||||
"message": "De payload voor de schrijfbewerking voor foutopsporing moet een niet-leeg object zijn."
|
||||
},
|
||||
"resource_href_required": {
|
||||
"message": "Een resource-href is vereist."
|
||||
},
|
||||
"bubble_soak_unavailable_for_cycle": {
|
||||
"message": "Bubble Soak is niet beschikbaar voor het geselecteerde programma."
|
||||
},
|
||||
"pre_wash_unavailable_for_cycle": {
|
||||
"message": "Voorwas is niet beschikbaar voor het geselecteerde programma."
|
||||
},
|
||||
"intensive_unavailable_for_cycle": {
|
||||
"message": "Intensief is niet beschikbaar voor het geselecteerde programma."
|
||||
}
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/airflow/0",
|
||||
"rep": {
|
||||
"speed": 0,
|
||||
"direction": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airflow/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.speedLevel": "0",
|
||||
"x.com.samsung.da.direction": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-23T15:56:55",
|
||||
"x.com.samsung.da.state": "Created"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/consumable/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.state": "100",
|
||||
"x.com.samsung.da.name": "FilterProgress"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/devicespecificinfo/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.deviceActive": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "ARTIK051_TVTL_18K|10193941|7000023C001111C40100000000000000",
|
||||
"x.com.samsung.da.description": "ARTIK051_TVTL_18K",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02059A230513",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "19071703,19071706",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": [
|
||||
"Comode_Off",
|
||||
"Blooming_0",
|
||||
"Light_On",
|
||||
"OptionCode_60282"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/0",
|
||||
"rep": {
|
||||
"value": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": [
|
||||
"11",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"9",
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Sensor for Odor",
|
||||
"x.com.samsung.da.type": "Odor",
|
||||
"x.com.samsung.da.value": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": [
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||
"x.com.samsung.da.type": "SuperFineDust",
|
||||
"x.com.samsung.da.value": [
|
||||
"5",
|
||||
"0"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/aisleep/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.displayNightMode": "Off",
|
||||
"x.com.samsung.da.elapsedTime": "0",
|
||||
"x.com.samsung.da.requestFeedback": "Off",
|
||||
"x.com.samsung.da.resultFeedback": "0",
|
||||
"x.com.samsung.da.statusFeedback": "Idle",
|
||||
"x.com.samsung.da.sleepTime": "14002200",
|
||||
"x.com.samsung.da.sleepMode": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-23T05:13:21",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "FilterAlarm_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-23T05:13:21",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "000100B4012C0000120000000000",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "4131000000",
|
||||
"x.com.samsung.da.airconOptionList": [
|
||||
"DR",
|
||||
"SingleCommand_1.0",
|
||||
"AI_2.0"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.drlcLevel": "0",
|
||||
"x.com.samsung.da.durationminutes": "0",
|
||||
"x.com.samsung.da.start": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "153.000000",
|
||||
"x.com.samsung.da.cumulativePower": "239209",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "7201",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePowerType": "total",
|
||||
"x.com.samsung.da.saveLocation": "/mnt/usage.db"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/filter/airdustfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "85",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "normal",
|
||||
"x.com.samsung.da.filterCapacity": "1000",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": [
|
||||
"replaceable",
|
||||
"washable"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/humidity/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.humidity": "0.000000",
|
||||
"x.com.samsung.da.fivepercentHumidity": "83"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "A-CAWW-TP2-20-COMMON|10241941|600301170016110B40000F2000F2AD00",
|
||||
"x.com.samsung.da.description": "A-CAWW-TP2-20-COMMON",
|
||||
"x.com.samsung.da.serialNum": "**REDACTED**",
|
||||
"x.com.samsung.da.otnDUID": "**REDACTED**",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagLogType": [
|
||||
"errCode",
|
||||
"dump"
|
||||
],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "023",
|
||||
"x.com.samsung.da.diagMinVersion": "1.0",
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02274A260512",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "02419A25051901,FFFFFFFFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Outdoor",
|
||||
"x.com.samsung.da.number": "FFFFFFFFFFFFFF,FFFFFFFFFFFFFF"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/keepnormalstate/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.keepnormal": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/convenient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Off",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Off",
|
||||
"Nano",
|
||||
"LongWind",
|
||||
"Speed",
|
||||
"Sleep",
|
||||
"NanoSleep"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.workingMode": "Auto",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Auto",
|
||||
"Cool",
|
||||
"Dry",
|
||||
"Fan",
|
||||
"AIComfort"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"Auto"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"Sleep_0",
|
||||
"ArtificialWorking_Off",
|
||||
"ComfortAICooling_Off",
|
||||
"AiTempChanged_Off",
|
||||
"AiTemp_240",
|
||||
"OutdoorTemp_82",
|
||||
"Light_Off",
|
||||
"Volume_100",
|
||||
"KeyInputPermit_On",
|
||||
"ModePermit_NoLimit",
|
||||
"UpdateAllow_NotAllowed",
|
||||
"DurationOn_419",
|
||||
"WelcomeCoolingState_Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/autoclean/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.status": "Stop",
|
||||
"x.com.samsung.da.settingStatus": "On",
|
||||
"x.com.samsung.da.progress": "0",
|
||||
"x.com.samsung.da.supportedStatus": [
|
||||
"Start",
|
||||
"Stop"
|
||||
],
|
||||
"x.com.samsung.da.supportedSettingStatus": [
|
||||
"On",
|
||||
"Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/option/muteonce/vs/0",
|
||||
"rep": {
|
||||
"muteonce": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"x.com.samsung.da.newVersionNo": "00000000",
|
||||
"x.com.samsung.da.currentVersionInfo": "00000000",
|
||||
"otnStatus": "None",
|
||||
"flashingProgress": "",
|
||||
"otnTarget": "main",
|
||||
"otnCompleteDate": "noHistory",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "A-CAWW-TP2-20-COMMON",
|
||||
"versions": [
|
||||
"14260512"
|
||||
],
|
||||
"visVersion": "260512"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "040010241941FFFFFFFF",
|
||||
"versions": [
|
||||
"25051901",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "250519"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "0400FFFFFFFFFFFFFFFF",
|
||||
"versions": [
|
||||
"FFFFFFFF",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "true",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/reserverulesets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C",
|
||||
"x.com.samsung.da.id": "CAC",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/sac/installationinfo/vs/0",
|
||||
"rep": {
|
||||
"installationOptions": [
|
||||
{
|
||||
"id": "0",
|
||||
"option": "17444980C80171700010"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"option": "28010000000000000100"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"option": "50000000000000000000"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"option": "30000000000000000000"
|
||||
}
|
||||
],
|
||||
"installationDeviceNum": [
|
||||
{
|
||||
"id": "0",
|
||||
"number": "01"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"number": "04"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"number": "00"
|
||||
}
|
||||
],
|
||||
"outdoorInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": "01900A230418",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": "000000000000",
|
||||
"serial": "**REDACTED**"
|
||||
}
|
||||
],
|
||||
"mcuInfo": [
|
||||
{
|
||||
"id": "0",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"info": ""
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"info": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperature/control/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.increment": "0.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/temperatures/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Temperature",
|
||||
"x.com.samsung.da.desired": "27.0",
|
||||
"x.com.samsung.da.current": "29.0",
|
||||
"x.com.samsung.da.maximum": "30",
|
||||
"x.com.samsung.da.minimum": "18",
|
||||
"x.com.samsung.da.increment": "0.5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Asia/Seoul",
|
||||
"offset": "+09:00",
|
||||
"DST": "OFF"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/direction/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "Fix",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Up_And_Low",
|
||||
"Fix",
|
||||
"Left_And_Right",
|
||||
"All"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wind/strength/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modes": "0",
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3"
|
||||
],
|
||||
"x.com.samsung.da.modesName": [
|
||||
"Auto",
|
||||
"Low",
|
||||
"Mid",
|
||||
"High"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+73
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"rt": [
|
||||
"x.com.samsung.da.mode"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NA9300K-/AA1",
|
||||
"MainTimerCurrent_0",
|
||||
"MainTimerSet_0",
|
||||
"MainTimerState_Ready",
|
||||
"OperationState0_Ready",
|
||||
"OperationState1_Ready",
|
||||
"OperationState3_Ready",
|
||||
"OperationState4_Ready",
|
||||
"OperationState5_Ready"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/bluetooth/hood/status/vs/0",
|
||||
"rep": {
|
||||
"connectionState": "disconnected",
|
||||
"micomModelId": "",
|
||||
"firmwareVersion": "",
|
||||
"power": "off",
|
||||
"fanSpeed": 0,
|
||||
"lampState": "off",
|
||||
"timer": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"clean_level",
|
||||
"device_active",
|
||||
"diagnosis_status",
|
||||
"display_light",
|
||||
"dust",
|
||||
"energy_kwh",
|
||||
"energy_last_month_kwh",
|
||||
"energy_saved_kwh",
|
||||
"energy_this_month_kwh",
|
||||
"fan_direction",
|
||||
"fan_speed_level",
|
||||
"filter_progress",
|
||||
"fine_dust",
|
||||
"odor",
|
||||
"operating_mode",
|
||||
"power_energy_kwh",
|
||||
"power_switch",
|
||||
"power_watts",
|
||||
"super_fine_dust"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_filter_status",
|
||||
"air_filter_usage",
|
||||
"alarm_code",
|
||||
"auto_clean",
|
||||
"climate",
|
||||
"diagnosis_status",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"firmware_update",
|
||||
"mute_once",
|
||||
"power_watts"
|
||||
]
|
||||
}
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"any_burner_active",
|
||||
"burner_0_state",
|
||||
"burner_1_state",
|
||||
"burner_3_state",
|
||||
"burner_4_state",
|
||||
"burner_5_state",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"current_temp_c",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"fast_preheat",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"natural_steam",
|
||||
"operation_time_minutes",
|
||||
"oven_mode",
|
||||
"oven_setpoint",
|
||||
"oven_state",
|
||||
"power_switch",
|
||||
"progress_percentage",
|
||||
"remote_control",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"air_sensing_state",
|
||||
"alarm_code",
|
||||
"auto_ventilation_action",
|
||||
"automatic_operation",
|
||||
"automatic_ventilation_state",
|
||||
"clean_level",
|
||||
"energy_kwh",
|
||||
"energy_saved_kwh",
|
||||
"fan",
|
||||
"firmware_update",
|
||||
"hood_filter_capacity",
|
||||
"hood_filter_status",
|
||||
"hood_filter_usage",
|
||||
"lamp",
|
||||
"lamp_brightness",
|
||||
"last_air_sensing_level",
|
||||
"last_air_sensing_time",
|
||||
"periodic_air_sensing",
|
||||
"dust",
|
||||
"fine_dust",
|
||||
"super_fine_dust"
|
||||
]
|
||||
}
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"child_lock",
|
||||
"cycle",
|
||||
"cycle_active",
|
||||
"delay_start_hours",
|
||||
"detergent_low",
|
||||
|
||||
Vendored
+289
@@ -0,0 +1,289 @@
|
||||
{
|
||||
"meta": {
|
||||
"model": "TP1X_DA-KS-OVEN-0107X (NV7000BS/ET5)",
|
||||
"device_type": "oven",
|
||||
"source": "issue #55 diagnostics (scrubbed)",
|
||||
"note": "Wall oven reports no oneUiVersion; resolved via the '-OVEN-' modelNum token fallback in for_device_by_model, mirroring the '-RANGE-' fallback added for issue #44."
|
||||
},
|
||||
"device0": [
|
||||
{
|
||||
"rt": [
|
||||
"x.com.samsung.devcol",
|
||||
"oic.wk.col"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.ll",
|
||||
"oic.if.b"
|
||||
]
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"flashingProgress": "",
|
||||
"otnStatus": "None",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AKS-WW-TP1-22-OVEN-1",
|
||||
"versions": [
|
||||
"40250221"
|
||||
],
|
||||
"visVersion": "250221"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "07134046004140472641",
|
||||
"versions": [
|
||||
"23011700",
|
||||
"23122000"
|
||||
],
|
||||
"visVersion": "231220"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "071360127441FFFFFFFF",
|
||||
"versions": [
|
||||
"21123000",
|
||||
"FFFFFFFF"
|
||||
],
|
||||
"visVersion": "211230"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.doors"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000",
|
||||
"x.com.samsung.da.description": "NV7000BS/ET5",
|
||||
"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": "25022100",
|
||||
"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": "04600A23011700, 04726A23122000",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "01274A21123000",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"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": "KV1",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"Autocook",
|
||||
"Convection",
|
||||
"TopHeatPluseConvection",
|
||||
"Conventional",
|
||||
"LargeGrill",
|
||||
"SmallGrill",
|
||||
"BottomHeatPluseConvection",
|
||||
"PlateWarm",
|
||||
"KeepWarm",
|
||||
"Bottom",
|
||||
"EcoConvection",
|
||||
"FanGrill",
|
||||
"Defrost",
|
||||
"SteamClean"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"NoOperation"
|
||||
],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_NV7000BS/ET5",
|
||||
"UpperTimerCurrent_0",
|
||||
"UpperTimerSet_0",
|
||||
"UpperTimerState_Ready",
|
||||
"UpperLamp_Off",
|
||||
"Sound_On"
|
||||
],
|
||||
"x.com.samsung.da.defaultMode": "Convection",
|
||||
"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": "0",
|
||||
"rt": [
|
||||
"x.com.samsung.da.operation"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"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-07-23T15:26:07"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.recipe": "00000000000000",
|
||||
"rt": [
|
||||
"x.com.samsung.da.oven"
|
||||
],
|
||||
"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": "5",
|
||||
"x.com.samsung.da.unit": "Celsius"
|
||||
}
|
||||
],
|
||||
"rt": [
|
||||
"x.com.samsung.da.temperatures"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.a"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "Europe/Rome",
|
||||
"offset": "+02:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.power": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/remotectrl/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.remoteControlEnabled": "true",
|
||||
"rt": [
|
||||
"x.com.samsung.da.configuration"
|
||||
],
|
||||
"if": [
|
||||
"oic.if.baseline",
|
||||
"oic.if.s"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
|
||||
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
|
||||
},
|
||||
{
|
||||
"href": "/personality/presence/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "",
|
||||
"x.com.samsung.da.deviceId": "TEST-DEVICE-ID",
|
||||
"x.com.samsung.da.value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.longnotisubscription": "false",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/airlevelcheck/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.periodicSensingActivationState": "Off",
|
||||
"x.com.samsung.da.periodicSensingInterval": "3600",
|
||||
"x.com.samsung.da.startSensingOnce": "Off",
|
||||
"x.com.samsung.da.sensingState": "NonProcessing",
|
||||
"x.com.samsung.da.lastSensingTime": "1784651378",
|
||||
"x.com.samsung.da.lastSensingLevel": "Kr2",
|
||||
"x.com.samsung.da.autoExeState": "Sensing",
|
||||
"x.com.samsung.da.supportedAutoExeState": ["Airpurify", "Alarm"],
|
||||
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
|
||||
"x.com.samsung.da.periodicSensingSkipTime": "00000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "ErrorCode_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-21T20:18:51",
|
||||
"x.com.samsung.da.state": "Deleted"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativePower": "165620",
|
||||
"x.com.samsung.da.cumulativeSavedPower": "0",
|
||||
"x.com.samsung.da.cumulativeDate": "1784647130",
|
||||
"x.com.samsung.da.cumulativeDateUTC": "1784665130",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
},
|
||||
{"href": "/energy/consumption/0", "rep": {}},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.options": ["OptionCode_19322", "UpdateAllow_NotAllowed"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/power/vs/0",
|
||||
"rep": {"x.com.samsung.da.power": "Off"}
|
||||
},
|
||||
{"href": "/power/0", "rep": {"value": false}},
|
||||
{
|
||||
"href": "/sensors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Sensor for CleanLevel",
|
||||
"x.com.samsung.da.type": "CleanLevel",
|
||||
"x.com.samsung.da.value": ["2"]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Sensor for Dust",
|
||||
"x.com.samsung.da.type": "Dust",
|
||||
"x.com.samsung.da.value": ["18", "1"]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "3",
|
||||
"x.com.samsung.da.description": "Sensor for FineDust",
|
||||
"x.com.samsung.da.type": "FineDust",
|
||||
"x.com.samsung.da.value": ["14", "1"]
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Sensor for SuperFineDust",
|
||||
"x.com.samsung.da.type": "SuperFineDust",
|
||||
"x.com.samsung.da.value": ["9", "1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000",
|
||||
"x.com.samsung.da.description": "AHD-WW-TP1-22-COMMON",
|
||||
"x.com.samsung.da.serialNum": "TEST-HOOD-SERIAL",
|
||||
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
|
||||
"x.com.samsung.da.diagSetupid": "KH0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {"x.com.samsung.timeoffset": "-05:00"}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.region": "0000000000",
|
||||
"x.com.samsung.da.airconOptionList": ["DR"],
|
||||
"x.com.samsung.da.countryCode": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/drlc/0",
|
||||
"rep": {
|
||||
"DRLevel": 0,
|
||||
"start": "1970-01-01T00:00:00Z",
|
||||
"durationminutes": 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": "1970-01-01T00:00:00Z",
|
||||
"x.com.samsung.da.override": "Off",
|
||||
"x.com.samsung.da.realSaving": "Off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/availablecontrolsets/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.sets": "0000000000012000000000000000",
|
||||
"x.com.samsung.da.id": "HOOD",
|
||||
"x.com.samsung.da.version": "1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/da/softreset/vs/0",
|
||||
"rep": {"x.com.samsung.da.softwarereset": "false"}
|
||||
},
|
||||
{
|
||||
"href": "/filter/hoodfilter/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.filterUsage": "100",
|
||||
"x.com.samsung.da.filterUsageResolution": "1",
|
||||
"x.com.samsung.da.filterStatus": "wash",
|
||||
"x.com.samsung.da.filterCapacity": "60",
|
||||
"x.com.samsung.da.filterCapacityUnit": "Hour",
|
||||
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hood/fanspeed/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.hood.autoOperation": "Off",
|
||||
"x.com.samsung.da.hood.fanSpeed": "14",
|
||||
"x.com.samsung.da.hood.supportedFanSpeed": ["14", "15", "16", "17"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hood/lamp/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.lamp.power": "Off",
|
||||
"x.com.samsung.lamp.current": "2",
|
||||
"x.com.samsung.lamp.range": ["1", "2"]
|
||||
}
|
||||
},
|
||||
{"href": "/autoventilation/vs/0", "rep": {"action": "Start"}},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,8 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM, CONF_HOST, CONF_PORT, DOMAIN,
|
||||
CONF_BYPASS_REMOTE_CONTROL, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
|
||||
CONF_HOST, CONF_PORT, DOMAIN,
|
||||
)
|
||||
|
||||
from .conftest import (
|
||||
@@ -37,6 +38,7 @@ async def test_form_second_device_reuses_creds(hass: HomeAssistant) -> None:
|
||||
DOMAIN, context={'source': 'user'}
|
||||
)
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'user_reuse'
|
||||
assert CONF_CA_CERT_PEM not in result['data_schema'].schema
|
||||
assert CONF_CA_KEY_PEM not in result['data_schema'].schema
|
||||
|
||||
@@ -218,6 +220,39 @@ async def test_unknown_type_shows_confirmation_step(
|
||||
assert result['data'][CONF_HOST] == MOCK_HOST
|
||||
|
||||
|
||||
async def test_unknown_type_without_version_uses_localized_step(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""No English placeholder sentinel leaks into a translated description."""
|
||||
probe_result = {
|
||||
'port': MOCK_PORT,
|
||||
'serial': MOCK_SERIAL,
|
||||
'leaf_cert_pem': 'leaf cert',
|
||||
'leaf_key_pem': 'leaf key',
|
||||
'one_ui_version': '',
|
||||
'device_type_recognized': False,
|
||||
}
|
||||
with patch(
|
||||
'custom_components.localthings.config_flow._probe_and_validate',
|
||||
return_value=probe_result,
|
||||
):
|
||||
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: MOCK_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
|
||||
},
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'confirm_unknown_type_no_version'
|
||||
assert not result.get('description_placeholders')
|
||||
|
||||
|
||||
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
|
||||
"""Second add of same serial: flow aborts.
|
||||
|
||||
@@ -276,3 +311,200 @@ def test_probe_marks_washer_as_recognized(monkeypatch):
|
||||
) is not None
|
||||
)
|
||||
assert recognized is True
|
||||
|
||||
|
||||
async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
|
||||
"""The options flow's entry point is now a menu (issue #54's debug
|
||||
panel lives alongside the remote-control settings), not the settings
|
||||
form directly."""
|
||||
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)
|
||||
|
||||
assert result['type'] == FlowResultType.MENU
|
||||
assert result['step_id'] == 'init'
|
||||
assert set(result['menu_options']) == {'settings', 'debug_write'}
|
||||
|
||||
|
||||
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
|
||||
"""The bypass defaults to False, so devices that never touch this
|
||||
option see no change in the remote-control write block."""
|
||||
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'}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'settings'
|
||||
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is False
|
||||
|
||||
|
||||
async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
|
||||
"""Submitting the form with the toggle on stores it in entry.options,
|
||||
where coordinator.async_send_command reads it (issue #54)."""
|
||||
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: True}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
|
||||
|
||||
|
||||
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."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
|
||||
options={CONF_BYPASS_REMOTE_CONTROL: True},
|
||||
)
|
||||
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'}
|
||||
)
|
||||
|
||||
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is True
|
||||
|
||||
|
||||
async def test_options_flow_debug_write_shows_hrefs_from_coordinator(
|
||||
hass: HomeAssistant, mock_coordinator_session,
|
||||
) -> None:
|
||||
"""The debug panel's href dropdown is populated from the live
|
||||
coordinator's cached resources, not a static list."""
|
||||
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)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
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': 'debug_write'}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'debug_write'
|
||||
|
||||
|
||||
async def test_options_flow_debug_write_aborts_when_device_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""No coordinator in hass.data (device never finished loading) means
|
||||
the debug panel has nothing to write to or read from."""
|
||||
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': 'debug_write'}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.ABORT
|
||||
assert result['reason'] == 'not_loaded'
|
||||
|
||||
|
||||
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."""
|
||||
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)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
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': 'debug_write'}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'href': '/washer/vs/0'},
|
||||
)
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'debug_edit'
|
||||
|
||||
with patch(
|
||||
'custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write',
|
||||
return_value=(0x44, {'a': 1}),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'payload': {'a': 1}},
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.MENU
|
||||
assert result['step_id'] == 'debug_result'
|
||||
assert result['description_placeholders']['code'] == '2.04 (0x44)'
|
||||
|
||||
|
||||
async def test_options_flow_debug_edit_rejects_empty_payload(
|
||||
hass: HomeAssistant, mock_coordinator_session,
|
||||
) -> None:
|
||||
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)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
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': 'debug_write'}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'href': '/washer/vs/0'},
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'payload': {}},
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.FORM
|
||||
assert result['step_id'] == 'debug_edit'
|
||||
assert result['errors'] == {'payload': 'empty_payload'}
|
||||
|
||||
|
||||
async def test_options_flow_finish_preserves_existing_options(
|
||||
hass: HomeAssistant, mock_coordinator_session,
|
||||
) -> None:
|
||||
"""Finishing from the debug-result menu must not clobber a previously
|
||||
saved remote-control bypass setting."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
|
||||
options={CONF_BYPASS_REMOTE_CONTROL: True},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
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': 'debug_write'}
|
||||
)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'href': '/washer/vs/0'},
|
||||
)
|
||||
with patch(
|
||||
'custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write',
|
||||
return_value=(0x44, {'a': 1}),
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'payload': {'a': 1}},
|
||||
)
|
||||
assert result['type'] == FlowResultType.MENU
|
||||
assert result['step_id'] == 'debug_result'
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result['flow_id'], user_input={'next_step_id': 'finish'}
|
||||
)
|
||||
|
||||
assert result['type'] == FlowResultType.CREATE_ENTRY
|
||||
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
|
||||
CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities.common import (
|
||||
@@ -136,6 +136,31 @@ def test_run_discovery_detects_washer_via_model_fallback(
|
||||
assert coordinator.device_type_name == 'washer'
|
||||
|
||||
|
||||
def test_run_discovery_detects_cooktop_via_resource_signature(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
"""NA9300K omits oneUiVersion and information but has burner options."""
|
||||
from tests.conftest import _load_device
|
||||
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(_load_device('cooktop'))
|
||||
|
||||
assert coordinator.device_type_name == 'cooktop'
|
||||
assert coordinator._unbound_hrefs == []
|
||||
|
||||
|
||||
def test_run_discovery_detects_range_hood_via_model(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
from tests.conftest import _load_device
|
||||
|
||||
coordinator = LocalThingsCoordinator(hass, mock_entry)
|
||||
coordinator._run_discovery(_load_device('range_hood'))
|
||||
|
||||
assert coordinator.device_type_name == 'range_hood'
|
||||
assert coordinator._unbound_hrefs == []
|
||||
|
||||
|
||||
def test_update_coverage_gap_issue_creates_issue_for_unknown_type(
|
||||
hass: HomeAssistant, mock_entry
|
||||
) -> None:
|
||||
@@ -618,7 +643,13 @@ async def test_write_marks_href_pending_before_post(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""async_send_command must call mark_write_pending before POSTing so a
|
||||
slow-to-settle device can't immediately revert the optimistic write."""
|
||||
slow-to-settle device can't immediately revert the optimistic write --
|
||||
and it must guard the write's actual target href (write_fn's own
|
||||
path_segs), not bound_entity.href. Those differ for a composite entity
|
||||
like the AC's ClimateDesc (bound to /mode/vs/0, but writing power/temp/
|
||||
fan/swing/preset to their own sibling hrefs) -- guarding the bound href
|
||||
protected the wrong resource and left the one the entity actually
|
||||
displays from unprotected (issues #17/#53)."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
@@ -637,17 +668,23 @@ async def test_write_marks_href_pending_before_post(
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
|
||||
assert coordinator._observe._settle_until.get('/some/path') is not None
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is None
|
||||
|
||||
|
||||
async def test_send_command_applies_write_optimistically_before_settling(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""The cache must reflect a write immediately, and stay put against a
|
||||
stale echo for the rest of the settle window -- otherwise
|
||||
mark_write_pending has nothing to protect and just delays the real
|
||||
device confirmation instead, which reads to a user as the write being
|
||||
silently reverted (issue #27)."""
|
||||
"""The cache must reflect a write immediately at the write's actual
|
||||
target href (write_fn's path_segs) -- not bound_entity.href, which
|
||||
differs for a composite entity like the AC's ClimateDesc -- and stay put
|
||||
against a stale echo for the rest of the settle window there.
|
||||
|
||||
Applying the optimistic value to bound_entity.href instead is exactly
|
||||
how issues #17/#53 survived the original optimistic-apply fix (issue
|
||||
#27): the bound href got a harmless (if nonsensical) optimistic merge,
|
||||
while the resource the entity actually reads from for its displayed
|
||||
state never got one and stayed stale until the next real read of it."""
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
@@ -666,18 +703,150 @@ async def test_send_command_applies_write_optimistically_before_settling(
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
# The optimistic value is visible right away, without waiting for a
|
||||
# device response.
|
||||
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||
# The optimistic value is visible right away at the write's real
|
||||
# target, not the bound href.
|
||||
assert coordinator._cache.get('/some/path') == {'value': 5}
|
||||
assert coordinator._cache.get('/test/vs/0') != {'value': 5}
|
||||
|
||||
# A stale update racing in behind the write (e.g. a poll/notify that
|
||||
# was already in flight before the PUT) must not clobber it while the
|
||||
# settle window is open.
|
||||
applied = coordinator._observe.apply('/some/path', {'value': 0}, source='observe')
|
||||
assert applied is False
|
||||
assert coordinator._cache.get('/some/path') == {'value': 5}
|
||||
|
||||
|
||||
async def test_climate_power_write_applies_to_its_own_href_not_bound_href(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Regression for issues #17/#53, using the real AC capability. The
|
||||
composite climate entity binds /mode/vs/0 (airconditioner.CLIMATE.href),
|
||||
but a power command's write_fn (airconditioner._climate_write) targets
|
||||
the sibling /power/0 -- the href climate.py's `_is_on()` actually reads.
|
||||
The optimistic value and settle guard must land there, not on the bound
|
||||
/mode/vs/0, or the entity never sees the write and shows stale state
|
||||
until the next unrelated read of /power/0."""
|
||||
from custom_components.localthings.registry.capabilities import airconditioner
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
|
||||
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]
|
||||
|
||||
bound = BoundEntity(
|
||||
href=airconditioner.CLIMATE.href,
|
||||
capability=coordinator.bound[0].capability,
|
||||
desc=airconditioner.CLIMATE.entities[0],
|
||||
)
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
|
||||
assert coordinator._cache.get('/power/0') == {'value': True}
|
||||
assert coordinator._cache.get(airconditioner.CLIMATE.href) != {'value': True}
|
||||
assert coordinator._observe._settle_until.get('/power/0') is not None
|
||||
|
||||
|
||||
async def test_send_command_survives_stale_confirm_poll(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources,
|
||||
) -> None:
|
||||
"""The settle window must outlast the confirm poll async_send_command
|
||||
triggers via async_request_refresh(), not just DEFAULT_SETTLE_S's fixed
|
||||
few seconds (issue #9). Some devices settle a write internally slower
|
||||
than that refresh's own round trip -- issue #9's washer packs cycle/
|
||||
detergent/softener selection into /course/vs/0's shared options[]
|
||||
array, which visibly takes a few seconds of validation/dispenser
|
||||
movement to catch up, unlike /washer/vs/0's plain temperature/spin
|
||||
fields on the same device, which confirm instantly. A confirm poll that
|
||||
lands before the device has caught up is stale, and a settle window
|
||||
sized only to the fixed default (rather than the PUT+poll round trip)
|
||||
expires before that poll even returns, so the stale read lands
|
||||
unprotected and reverts the optimistic value -- self-correcting again
|
||||
only once a later poll finally sees the real change. That reads to a
|
||||
user as the write reverting, then reapplying itself, a few seconds
|
||||
later."""
|
||||
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):
|
||||
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 _stale_confirm_poll():
|
||||
# Stand-in for the write's own confirming /device/0 poll racing
|
||||
# against a stale read for the same href -- the device's internal
|
||||
# state hasn't caught up to the write yet, even though it will a
|
||||
# little later.
|
||||
coordinator._observe.apply('/test/vs/0', {'value': 0}, source='poll')
|
||||
return fridge_resources
|
||||
|
||||
with (
|
||||
patch.object(fake, 'subscribe'),
|
||||
patch.object(LocalThingsCoordinator, '_poll_once', side_effect=_stale_confirm_poll),
|
||||
):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
# The optimistic value is visible right away and survived the stale
|
||||
# confirm-poll race triggered by the refresh above.
|
||||
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||
|
||||
# The settle window is still open afterward -- sized to outlast the
|
||||
# whole PUT + confirm-poll round trip, not released the moment that
|
||||
# round trip happens to finish.
|
||||
applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe')
|
||||
assert applied is False
|
||||
assert coordinator._cache.get('/test/vs/0') == {'value': 5}
|
||||
|
||||
|
||||
async def test_second_write_to_same_href_lands_during_first_writes_settle_window(
|
||||
hass: HomeAssistant, mock_entry, mock_coordinator_observe_session,
|
||||
) -> None:
|
||||
"""Regression for issue #9: /course/vs/0 backs several independent
|
||||
washer selects (cycle, detergent quantity, softener quantity, ...)
|
||||
sharing one href. The settle window is now sized to tens of seconds
|
||||
(see test_send_command_survives_stale_confirm_poll), which makes a
|
||||
second, different write to that href landing while the first write's
|
||||
window is still open a routine occurrence -- e.g. a user picking a
|
||||
cycle and then adjusting detergent quantity within the same minute --
|
||||
not a rare edge case. The second write's own optimistic value must
|
||||
still show up immediately; a guard meant to protect optimistic writes
|
||||
from stale device echoes must not itself suppress a later one."""
|
||||
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]
|
||||
|
||||
desc_a = NumberDesc(key='cycle', field='cycle',
|
||||
write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'cycle': p}))
|
||||
desc_b = NumberDesc(key='detergent', field='detergent',
|
||||
write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'detergent': p}))
|
||||
bound_a = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_a)
|
||||
bound_b = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_b)
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound_a, 'Eco')
|
||||
assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco'}
|
||||
|
||||
# Still well within the first write's settle window.
|
||||
await coordinator.async_send_command(bound_b, 'High')
|
||||
|
||||
assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco', 'detergent': 'High'}
|
||||
|
||||
|
||||
class TestRemoteControlEnabled:
|
||||
"""remote_control_enabled (registry/capabilities/common.py) is the
|
||||
single source of truth for the /remotectrl on/off signal, shared by
|
||||
@@ -745,9 +914,12 @@ async def test_send_command_blocked_when_remote_control_disabled(
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = _post
|
||||
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
|
||||
with pytest.raises(ServiceValidationError) as exc_info:
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert exc_info.value.translation_domain == DOMAIN
|
||||
assert exc_info.value.translation_key == 'remote_control_disabled'
|
||||
|
||||
assert posted is False
|
||||
|
||||
|
||||
@@ -778,7 +950,7 @@ async def test_send_command_allowed_when_remote_control_enabled(
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator._observe._settle_until.get('/test/vs/0') is not None
|
||||
assert coordinator._cache.get('/some/path') == {'value': 5}
|
||||
|
||||
|
||||
async def test_send_command_remote_control_check_precedes_validate_fn(
|
||||
@@ -808,5 +980,47 @@ async def test_send_command_remote_control_check_precedes_validate_fn(
|
||||
desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn)
|
||||
bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc)
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Remote control is turned off"):
|
||||
with pytest.raises(ServiceValidationError) as exc_info:
|
||||
await coordinator.async_send_command(bound, 'On')
|
||||
assert exc_info.value.translation_key == 'remote_control_disabled'
|
||||
|
||||
|
||||
async def test_send_command_bypasses_remote_control_when_option_enabled(
|
||||
hass: HomeAssistant, mock_coordinator_observe_session
|
||||
) -> None:
|
||||
"""Issue #54: some devices accept certain writes even while reporting
|
||||
remote control off, so a user who's confirmed that for their device can
|
||||
opt it out of the block entirely via the options flow
|
||||
(CONF_BYPASS_REMOTE_CONTROL, entry.options -- not entry.data)."""
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
from custom_components.localthings.registry.entities import NumberDesc
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
|
||||
options={CONF_BYPASS_REMOTE_CONTROL: True},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
fake = mock_coordinator_observe_session
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator._cache.apply_rep(
|
||||
'/remotectrl/vs/0',
|
||||
{'x.com.samsung.da.remoteControlEnabled': 'false'},
|
||||
source='test',
|
||||
)
|
||||
|
||||
def _write_fn(payload, rep, href):
|
||||
return (['some', 'path'], {'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)
|
||||
|
||||
with patch.object(fake, 'subscribe'):
|
||||
fake.post = lambda *a, **k: (0x44, b'')
|
||||
await coordinator.async_send_command(bound, 5)
|
||||
|
||||
assert coordinator._cache.get('/some/path') == {'value': 5}
|
||||
|
||||
@@ -67,6 +67,30 @@ def test_apply_drops_update_during_settle_window():
|
||||
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
|
||||
|
||||
|
||||
def test_apply_optimistic_bypasses_an_in_progress_settle_window():
|
||||
"""Regression for issue #9: /course/vs/0 backs several independent
|
||||
washer selects (cycle, detergent quantity, softener quantity, ...).
|
||||
Picking a second one while the first's settle window is still open
|
||||
(now sized to tens of seconds -- see coordinator._POST_TIMEOUT_S/
|
||||
_POLL_TIMEOUT_S) is a normal sequence, not a stale echo of the first
|
||||
write, and must land in the cache immediately -- not get silently
|
||||
dropped by a guard that exists to protect optimistic writes, not
|
||||
suppress them."""
|
||||
mgr = _manager()
|
||||
mgr.cache.apply_rep('/course/vs/0', {'Course': '1C', 'Detergent': '1'}, source='seed')
|
||||
mgr.mark_write_pending('/course/vs/0', settle_s=30.0)
|
||||
|
||||
result = mgr.apply('/course/vs/0', {'Detergent': '2'}, source='optimistic')
|
||||
|
||||
assert result is True
|
||||
assert mgr.cache.get('/course/vs/0') == {'Course': '1C', 'Detergent': '2'}
|
||||
|
||||
# A poll/sweep/observe update racing in right behind it is still
|
||||
# gated -- the second write's own guard (re-armed by mark_write_pending,
|
||||
# not exercised directly here) is what protects it going forward.
|
||||
assert mgr.apply('/course/vs/0', {'Detergent': '1'}, source='poll') is False
|
||||
|
||||
|
||||
def test_apply_accepts_update_after_settle_window_elapses():
|
||||
mgr = _manager()
|
||||
mgr.mark_write_pending('/oven/vs/0', settle_s=0.05)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for the ARTIK051_TVTL_18K air-purifier profile (issue #56)."""
|
||||
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 air_purifier
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _purifier():
|
||||
resources = _load_device('air_purifier')
|
||||
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 = _purifier()
|
||||
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_model_resolves_to_air_purifier_registry():
|
||||
reg, _ = _purifier()
|
||||
assert reg is not None
|
||||
assert reg.name == 'air_purifier'
|
||||
|
||||
|
||||
def test_no_unbound_hrefs():
|
||||
"""Every resource in the issue #56 dump binds or is ignored -- clears the
|
||||
coverage-gap repair."""
|
||||
reg, resources = _purifier()
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_expected_entities_present():
|
||||
state = _state()
|
||||
for key in (
|
||||
'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
|
||||
'odor', 'clean_level', 'filter_progress', 'device_active',
|
||||
'diagnosis_status', 'fan_speed_level', 'fan_direction',
|
||||
'display_light', 'operating_mode',
|
||||
):
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_air_quality_sensor_values():
|
||||
"""Dust/FineDust/SuperFineDust/Odor/CleanLevel read index 0 of each
|
||||
items[] entry's value list (the raw measurement, per common.sensor_item_value)."""
|
||||
state = _state()
|
||||
assert state['dust'] == 11
|
||||
assert state['fine_dust'] == 9
|
||||
assert state['super_fine_dust'] == 5
|
||||
assert state['odor'] == 0
|
||||
assert state['clean_level'] == 0
|
||||
|
||||
|
||||
def test_filter_progress_reads_named_consumable_item():
|
||||
"""FilterProgress is confirmed (issue #56) to count up as the filter
|
||||
wears -- 100 means fully used and needs replacing, not "brand new"."""
|
||||
assert _state()['filter_progress'] == 100
|
||||
|
||||
|
||||
def test_diagnosis_reuses_dishwasher_capability():
|
||||
"""/diagnosis/vs/0 has the identical field/write contract as
|
||||
dishwasher.DIAGNOSIS, so the by_type registry reuses it directly."""
|
||||
from custom_components.localthings.registry.capabilities import dishwasher
|
||||
reg, _ = _purifier()
|
||||
assert dishwasher.DIAGNOSIS in reg.capabilities['/diagnosis/vs/0']
|
||||
|
||||
|
||||
def test_light_switch_write_contract():
|
||||
"""The display-light switch writes only the changed 'Light_*' token
|
||||
(via laundry.option_write) -- confirmed on real hardware (issue #54)
|
||||
that the device merges by prefix itself, so no read-modify-write of the
|
||||
whole packed /mode/vs/0 options list is needed."""
|
||||
desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light')
|
||||
rep = {'x.com.samsung.da.options': [
|
||||
'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282',
|
||||
]}
|
||||
assert desc.rep_fn(rep) is True
|
||||
assert desc.write_fn('Off', rep) == (
|
||||
['mode', 'vs', '0'],
|
||||
{'x.com.samsung.da.options': ['Light_Off']},
|
||||
)
|
||||
|
||||
|
||||
def test_operating_mode_is_a_read_only_diagnostic():
|
||||
"""Comode_* surfaces as a raw diagnostic sensor rather than a select/
|
||||
control -- issue #56's five running-state dumps confirmed it reads 'Off'
|
||||
regardless of the device's actual fan setting, ruling out the original
|
||||
guess that it was the fan-speed selector; its real purpose is still
|
||||
unconfirmed (see the air_purifier.py module docstring)."""
|
||||
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
|
||||
rep = {'x.com.samsung.da.options': ['Comode_Off']}
|
||||
assert operating_mode.rep_fn(rep) == 'Off'
|
||||
assert not hasattr(operating_mode, 'write_fn')
|
||||
|
||||
|
||||
def test_blooming_not_modeled():
|
||||
"""Confirmed (issue #56) to have no corresponding SmartThings app
|
||||
setting -- dropped entirely rather than kept as an unexplained
|
||||
diagnostic."""
|
||||
assert not any(e.key == 'blooming_level' for e in air_purifier.MODE.entities)
|
||||
|
||||
|
||||
def test_airflow_vs_fallback_only_binds_without_generic():
|
||||
"""/airflow/vs/0 is a match_fn fallback -- it must not bind when the
|
||||
OCF-standard /airflow/0 is also present (both are on every dump seen)."""
|
||||
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn(
|
||||
{}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}},
|
||||
) is False
|
||||
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True
|
||||
@@ -215,6 +215,30 @@ def test_tp1x_rac_expected_state_keys_present():
|
||||
assert key in state, key
|
||||
|
||||
|
||||
def test_caww_tp2_model_resolves_via_model_fallback():
|
||||
"""A-CAWW-TP2-20-COMMON (issue #52, System AC) reports no oneUiVersion
|
||||
and no '_RAC_'/'_PRAC_' token -- resolved via the '-CAWW-' modelNum
|
||||
fallback added for this device."""
|
||||
reg, _ = _resolve('airconditioner_caww_tp2')
|
||||
assert reg is not None and reg.name == 'airconditioner'
|
||||
|
||||
|
||||
def test_caww_tp2_no_unbound_hrefs():
|
||||
"""Every resource in the issue #52 dump binds or is ignored -- clears
|
||||
the coverage-gap repair. Only new href beyond the existing RAC/PRAC
|
||||
surface is /sac/installationinfo/vs/0 (opaque SAC installation topology,
|
||||
ignored)."""
|
||||
reg, resources = _resolve('airconditioner_caww_tp2')
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_caww_tp2_sac_installationinfo_is_ignored():
|
||||
ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE}
|
||||
assert '/sac/installationinfo/vs/0' in ignored_hrefs
|
||||
|
||||
|
||||
def test_mute_once_write_target():
|
||||
write = airconditioner.MUTE_ONCE.entities[0].write_fn
|
||||
assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'})
|
||||
|
||||
@@ -41,6 +41,16 @@ class TestForDevice:
|
||||
assert isinstance(registry, DeviceRegistry)
|
||||
assert registry.name == 'refrigerator'
|
||||
|
||||
def test_for_device_returns_cooktop_registry(self):
|
||||
registry = for_device('7.0 Cooktop')
|
||||
assert registry is not None
|
||||
assert registry.name == 'cooktop'
|
||||
|
||||
def test_for_device_returns_range_hood_registry(self):
|
||||
registry = for_device('7.0 Range Hood')
|
||||
assert registry is not None
|
||||
assert registry.name == 'range_hood'
|
||||
|
||||
|
||||
class TestDeviceRegistries:
|
||||
"""Tests for device registries themselves."""
|
||||
@@ -180,6 +190,25 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'airconditioner'
|
||||
|
||||
def test_cooktop_via_legacy_model_description(self):
|
||||
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'ARTIK051_GB_CT_001|40424141|50000204001211000200000000000000',
|
||||
'ARTIK051_GLOBAL_COOKTOP',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'cooktop'
|
||||
|
||||
def test_range_hood_via_ahd_model(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000',
|
||||
'AHD-WW-TP1-22-COMMON',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'range_hood'
|
||||
|
||||
def test_washer_wf_prefix(self):
|
||||
"""US front-load washers use the WF consumer-model prefix."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
@@ -200,6 +229,18 @@ class TestForDeviceByModel:
|
||||
assert reg is not None
|
||||
assert reg.name == 'washer'
|
||||
|
||||
def test_oven_via_oven_token(self):
|
||||
"""Issue #55: a wall oven (NV7000BS/ET5) reports no oneUiVersion and
|
||||
an unrecognized consumer token ('NV'); it falls back to the '-OVEN-'
|
||||
token in modelNum, mirroring the '-RANGE-' fallback for issue #44."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model(
|
||||
'TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000',
|
||||
'NV7000BS/ET5',
|
||||
)
|
||||
assert reg is not None
|
||||
assert reg.name == 'oven'
|
||||
|
||||
def test_unknown_model_returns_none(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD')
|
||||
@@ -208,3 +249,38 @@ class TestForDeviceByModel:
|
||||
def test_empty_inputs_return_none(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
assert for_device_by_model('', '') is None
|
||||
|
||||
|
||||
class TestForDeviceByResources:
|
||||
def test_na9300k_without_one_ui_or_information_is_cooktop(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
from tests.conftest import _load_device
|
||||
|
||||
reg = for_device_by_resources(_load_device('cooktop'))
|
||||
|
||||
assert reg is not None
|
||||
assert reg.name == 'cooktop'
|
||||
|
||||
def test_unrelated_mode_options_are_not_cooktop(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
|
||||
resources = {
|
||||
'/mode/vs/0': {
|
||||
'x.com.samsung.da.options': [
|
||||
'DeviceType_SOME_OVEN',
|
||||
'UpperLamp_Off',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert for_device_by_resources(resources) is None
|
||||
|
||||
def test_hood_resource_signature(self):
|
||||
from custom_components.localthings.registry.by_type import for_device_by_resources
|
||||
resources = {
|
||||
'/hood/fanspeed/vs/0': {},
|
||||
'/hood/lamp/vs/0': {},
|
||||
}
|
||||
reg = for_device_by_resources(resources)
|
||||
assert reg is not None
|
||||
assert reg.name == 'range_hood'
|
||||
|
||||
@@ -26,6 +26,37 @@ def test_common_caps_discover_on_dishwasher(dishwasher_resources):
|
||||
assert 'power_switch' in keys
|
||||
|
||||
|
||||
class TestMergeOptionsField:
|
||||
"""merge_options_field() is the read side of issue #54's finding: a
|
||||
write only needs to carry the changed token, so the coordinator uses
|
||||
this to keep its optimistic cache entry complete (every sibling option
|
||||
still present) without waiting on a real poll."""
|
||||
|
||||
def test_replaces_matching_prefix(self):
|
||||
cached = ['DeviceType_0167', 'Course_16', 'GMT_04']
|
||||
assert common.merge_options_field(cached, ['Course_1D']) == [
|
||||
'DeviceType_0167', 'Course_1D', 'GMT_04',
|
||||
]
|
||||
|
||||
def test_appends_when_prefix_absent(self):
|
||||
cached = ['DeviceType_0167']
|
||||
assert common.merge_options_field(cached, ['NaturalSteam_On']) == [
|
||||
'DeviceType_0167', 'NaturalSteam_On',
|
||||
]
|
||||
|
||||
def test_merges_multiple_tokens_independently(self):
|
||||
cached = ['DetergentLevelCtrl_1', 'SoftenerLevelCtrl_0', 'GMT_04']
|
||||
merged = common.merge_options_field(cached, ['SoftenerLevelCtrl_2'])
|
||||
assert merged == ['DetergentLevelCtrl_1', 'SoftenerLevelCtrl_2', 'GMT_04']
|
||||
|
||||
def test_handles_missing_cache(self):
|
||||
assert common.merge_options_field(None, ['Course_1D']) == ['Course_1D']
|
||||
|
||||
def test_ignores_malformed_tokens(self):
|
||||
cached = ['Course_16']
|
||||
assert common.merge_options_field(cached, ['nounderscore']) == ['Course_16']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OCF-native / vendor '-vs' fallback pairs (power, kids-lock, remote control).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -228,7 +259,7 @@ class TestAiEnergyLevelSelect:
|
||||
|
||||
def test_no_translation_key(self):
|
||||
"""aiLevel's values are plain digits that render fine untranslated
|
||||
(select.py's _display()) -- no strings.json entry to maintain
|
||||
(select.py's _display()) -- no catalog entry to maintain
|
||||
against an unknown number of future levels."""
|
||||
desc = self._desc()
|
||||
assert desc.translation_key is None
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for the read-only cooktop capability profile."""
|
||||
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import cooktop
|
||||
from custom_components.localthings.registry.capabilities.cooktop import (
|
||||
COOKTOP_MODE,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _state(resources):
|
||||
bound = discover(
|
||||
resources,
|
||||
cooktop.REGISTRY.capabilities,
|
||||
cooktop.REGISTRY.pattern_capabilities,
|
||||
)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_real_cooktop_fixture_has_expected_idle_state():
|
||||
state = _state(_load_device('cooktop'))
|
||||
|
||||
assert state['power_state'] is True
|
||||
assert state['any_burner_active'] is False
|
||||
assert state['burner_0_state'] == 'Ready'
|
||||
assert state['burner_5_state'] == 'Ready'
|
||||
assert state['main_timer_state'] == 'Ready'
|
||||
assert state['main_timer_current'] == 0
|
||||
assert state['cloud_connected'] is True
|
||||
assert state['paired_hood_connected'] is False
|
||||
assert state['paired_hood_power'] is False
|
||||
assert state['paired_hood_fan_speed'] == 0
|
||||
assert state['paired_hood_light'] is False
|
||||
|
||||
|
||||
def test_run_operation_marks_any_burner_active():
|
||||
resources = _load_device('cooktop')
|
||||
options = resources['/mode/vs/0']['x.com.samsung.da.options']
|
||||
options[options.index('OperationState3_Ready')] = 'OperationState3_Run'
|
||||
|
||||
state = _state(resources)
|
||||
|
||||
assert state['burner_3_state'] == 'Run'
|
||||
assert state['any_burner_active'] is True
|
||||
|
||||
|
||||
def test_cooktop_profile_has_no_write_functions():
|
||||
"""A local integration must never expose unverified remote ignition."""
|
||||
descriptions = [
|
||||
desc
|
||||
for capabilities in cooktop.REGISTRY.capabilities.values()
|
||||
for capability in capabilities
|
||||
for desc in capability.entities
|
||||
]
|
||||
|
||||
assert descriptions
|
||||
assert all(not hasattr(desc, 'write_fn') or desc.write_fn is None
|
||||
for desc in descriptions)
|
||||
|
||||
|
||||
def test_static_slot_superset_has_headroom_for_other_layouts():
|
||||
keys = {desc.key for desc in COOKTOP_MODE.entities}
|
||||
|
||||
assert {f'burner_{slot}_state' for slot in range(8)} <= keys
|
||||
|
||||
|
||||
def test_variant_slot_outside_na9300k_layout_is_discovered():
|
||||
resources = _load_device('cooktop')
|
||||
resources['/mode/vs/0']['x.com.samsung.da.options'].append(
|
||||
'OperationState7_Ready',
|
||||
)
|
||||
|
||||
state = _state(resources)
|
||||
|
||||
assert state['burner_7_state'] == 'Ready'
|
||||
assert 'burner_2_state' not in state
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for LocalThingsCoordinator.async_raw_write -- the debug-only
|
||||
arbitrary-href write primitive backing the options-flow debug panel
|
||||
(issue #54). It deliberately bypasses the remote-control block and every
|
||||
write_fn/validate_fn, so this only exercises the primitive itself: it
|
||||
POSTs exactly the caller's body, reads the href back, and validates its
|
||||
inputs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
|
||||
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-----',
|
||||
}
|
||||
|
||||
|
||||
class _FakeRawWriteSession:
|
||||
"""Stand-in for DtlsCoapSession: records every POST verbatim and
|
||||
answers the follow-up GET with a canned representation -- no real
|
||||
DTLS/network involved."""
|
||||
|
||||
def __init__(self, post_code: int = 0x44, get_rep: dict | None = None):
|
||||
self.post_calls: list[tuple[list[str], bytes]] = []
|
||||
self.get_calls: list[list[str]] = []
|
||||
self._post_code = post_code
|
||||
self._get_rep = {} if get_rep is None else get_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))
|
||||
return 0x45, cbor2.dumps(self._get_rep)
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_RAWWRITE-TEST',
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
coord = LocalThingsCoordinator(hass, entry)
|
||||
# async_raw_write kicks a full 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 the reference test setup does.
|
||||
coord.async_request_refresh = AsyncMock()
|
||||
return coord
|
||||
|
||||
|
||||
async def test_raw_write_splits_href_and_posts_exact_body(coordinator) -> None:
|
||||
fake = _FakeRawWriteSession(post_code=0x44, get_rep={'x.field': 'after'})
|
||||
coordinator._session = fake
|
||||
|
||||
body = {'x.com.samsung.da.field': 'value', 'n': 1}
|
||||
code, new_rep = await coordinator.async_raw_write('/course/vs/0', body)
|
||||
|
||||
assert len(fake.post_calls) == 1
|
||||
posted_path, posted_bytes = fake.post_calls[0]
|
||||
assert posted_path == ['course', 'vs', '0']
|
||||
assert cbor2.loads(posted_bytes) == body
|
||||
|
||||
assert code == 0x44
|
||||
assert new_rep == {'x.field': 'after'}
|
||||
coordinator.async_request_refresh.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_raw_write_reads_href_back_for_ground_truth(coordinator) -> None:
|
||||
fake = _FakeRawWriteSession(post_code=0x45, get_rep={'value': 'confirmed'})
|
||||
coordinator._session = fake
|
||||
|
||||
code, new_rep = await coordinator.async_raw_write('/washer/vs/0', {'a': 1})
|
||||
|
||||
assert fake.get_calls == [['washer', 'vs', '0']]
|
||||
assert code == 0x45
|
||||
assert new_rep == {'value': 'confirmed'}
|
||||
|
||||
|
||||
async def test_raw_write_returns_coap_code_from_post_not_get(coordinator) -> None:
|
||||
"""The returned code reflects the write's own response, even though a
|
||||
follow-up GET (which could carry a different code in principle) runs
|
||||
right after it."""
|
||||
fake = _FakeRawWriteSession(post_code=0x80, get_rep={'value': 1})
|
||||
coordinator._session = fake
|
||||
|
||||
code, _ = await coordinator.async_raw_write('/test/vs/0', {'a': 1})
|
||||
|
||||
assert code == 0x80
|
||||
|
||||
|
||||
async def test_raw_write_rejects_empty_dict(coordinator) -> None:
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('/washer/vs/0', {})
|
||||
|
||||
|
||||
async def test_raw_write_rejects_non_dict_payload(coordinator) -> None:
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('/washer/vs/0', ['not', 'a', 'dict']) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_raw_write_rejects_empty_href(coordinator) -> None:
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('', {'a': 1})
|
||||
|
||||
|
||||
async def test_raw_write_rejects_root_href(coordinator) -> None:
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('/', {'a': 1})
|
||||
|
||||
|
||||
async def test_raw_write_validation_errors_do_not_touch_the_session(coordinator) -> None:
|
||||
"""A rejected call must fail before ever reaching the network -- no
|
||||
session needs to be connected at all for validation to run."""
|
||||
assert coordinator._session is None
|
||||
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await coordinator.async_raw_write('/washer/vs/0', {})
|
||||
|
||||
assert coordinator._session is None
|
||||
coordinator.async_request_refresh.assert_not_awaited()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Tests for LocalThingsCoordinator.async_send_command's optimistic-apply
|
||||
step, specifically for x.com.samsung.da.options[] writes (issue #54).
|
||||
|
||||
The write itself only needs to carry the single changed token -- confirmed
|
||||
on real hardware, the device merges by prefix and evicts the stale token
|
||||
itself. But observe.ObserveManager.apply() does a shallow {**cached, **rep}
|
||||
field merge, so handing it that same minimal single-token body would
|
||||
overwrite the *whole* cached options[] field, wiping every sibling option
|
||||
(other courses/levels/toggles packed into the same array) until the next
|
||||
real poll lands. async_send_command must pre-merge the token into the
|
||||
cached array itself before applying it optimistically, while still POSTing
|
||||
only the minimal body over the wire.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import cbor2
|
||||
import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities import laundry
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
|
||||
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-----',
|
||||
}
|
||||
|
||||
|
||||
class _FakeSendSession:
|
||||
def __init__(self):
|
||||
self.post_calls: list[tuple[list[str], bytes]] = []
|
||||
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
self.post_calls.append((list(path_segs), payload))
|
||||
return 0x44, b''
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SENDCMD-TEST',
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
coord = LocalThingsCoordinator(hass, entry)
|
||||
coord.async_request_refresh = AsyncMock()
|
||||
coord._session = _FakeSendSession()
|
||||
return coord
|
||||
|
||||
|
||||
async def test_options_write_posts_only_the_changed_token(coordinator) -> None:
|
||||
href = '/course/vs/0'
|
||||
coordinator._observe.apply(href, {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
|
||||
}, source='poll')
|
||||
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
bound = BoundEntity(href=href, capability=None, desc=desc)
|
||||
|
||||
await coordinator.async_send_command(bound, '1D')
|
||||
|
||||
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
||||
assert posted_path == ['course', 'vs', '0']
|
||||
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.options': ['Course_1D']}
|
||||
|
||||
|
||||
async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator) -> None:
|
||||
"""The regression this guards: applying the minimal wire body straight
|
||||
to the cache would wipe DeviceType_0167/GMT_04 until the next poll."""
|
||||
href = '/course/vs/0'
|
||||
coordinator._observe.apply(href, {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
|
||||
}, source='poll')
|
||||
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
bound = BoundEntity(href=href, capability=None, desc=desc)
|
||||
|
||||
await coordinator.async_send_command(bound, '1D')
|
||||
|
||||
cached = coordinator._cache.get(href)
|
||||
assert cached['x.com.samsung.da.options'] == [
|
||||
'DeviceType_0167', 'Course_1D', 'GMT_04',
|
||||
]
|
||||
@@ -28,9 +28,7 @@ class TestCycleOptions:
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_0001', 'Course_0E', 'GMT_04']}
|
||||
path, body = desc.write_fn('90', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert body == {
|
||||
'x.com.samsung.da.options': ['DeviceType_0001', 'Course_90', 'GMT_04'],
|
||||
}
|
||||
assert body == {'x.com.samsung.da.options': ['Course_90']}
|
||||
|
||||
|
||||
class TestDishwasherOptions:
|
||||
|
||||
@@ -59,11 +59,17 @@ def test_power_watts_gated_for_dead_sentinel():
|
||||
|
||||
|
||||
def test_course_bound_to_shared_course_vs_0():
|
||||
"""Dryer course uses the shared /course/vs/0 cycle select with dryer_cycle
|
||||
translations, consistent with washer/dishwasher."""
|
||||
"""Dryer course uses the shared /course/vs/0 cycle select, with the
|
||||
translation key built from the device's own course table (see
|
||||
laundry.cycle_select) -- confirmed dryers report Table_03, matching
|
||||
the shipped dryer_cycle_table_03 translations, consistent with
|
||||
washer/dishwasher."""
|
||||
assert dryer.DRYER_COURSE.href == '/course/vs/0'
|
||||
desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle')
|
||||
assert desc.translation_key == 'dryer_cycle'
|
||||
assert callable(desc.translation_key)
|
||||
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
|
||||
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
|
||||
assert desc.rep_fn(rep) == '16'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from custom_components.localthings.registry.entities import (
|
||||
SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc,
|
||||
NumberDesc, TimeDesc, ClimateDesc, PLATFORM_OF,
|
||||
NumberDesc, TimeDesc, ClimateDesc, FanDesc, PLATFORM_OF,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ def test_platform_mapping_covers_all_subclasses():
|
||||
assert PLATFORM_OF[NumberDesc] == 'number'
|
||||
assert PLATFORM_OF[TimeDesc] == 'time'
|
||||
assert PLATFORM_OF[ClimateDesc] == 'climate'
|
||||
assert PLATFORM_OF[FanDesc] == 'fan'
|
||||
|
||||
|
||||
def test_select_carries_options_and_write_fn():
|
||||
|
||||
+23
-17
@@ -1,7 +1,4 @@
|
||||
"""Tests for LocalThingsEntity's display-name derivation
|
||||
(custom_components/localthings/entity.py) -- the explicit-name,
|
||||
device-given-instance-name, and href-derived fallbacks.
|
||||
"""
|
||||
"""Tests for translated entity naming and dynamic instance placeholders."""
|
||||
from custom_components.localthings.entity import LocalThingsEntity
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.discovery import BoundEntity
|
||||
@@ -23,23 +20,32 @@ def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_
|
||||
return LocalThingsEntity(_FakeCoordinator(), bound)
|
||||
|
||||
|
||||
def test_explicit_name_wins_over_everything():
|
||||
desc = BinarySensorDesc(key='enabled', name='Explicit Name')
|
||||
entity = _make_entity(desc, instance_name='Cubed Ice')
|
||||
assert entity._attr_name == 'Explicit Name'
|
||||
def test_descriptor_key_is_the_default_translation_key():
|
||||
"""A descriptor names itself through the catalog, keyed by its own key.
|
||||
|
||||
|
||||
def test_instance_name_prefixes_the_derived_suffix():
|
||||
"""Issue #27: an ice maker's device-given name ("Cubed Ice") replaces
|
||||
the href-derived instance label ("Icemaker One") as the name prefix,
|
||||
keeping the same entity-specific suffix."""
|
||||
Nothing sets _attr_name: that would take precedence over HA's
|
||||
translation catalog and make the entity untranslatable.
|
||||
"""
|
||||
desc = BinarySensorDesc(key='enabled')
|
||||
entity = _make_entity(desc, instance_name='Cubed Ice')
|
||||
assert entity.translation_key == 'enabled'
|
||||
assert not hasattr(entity, '_attr_name')
|
||||
|
||||
|
||||
def test_device_instance_name_becomes_translation_placeholder():
|
||||
desc = BinarySensorDesc(
|
||||
key='enabled', translation_key='instance_enabled', use_instance_name=True
|
||||
)
|
||||
entity = _make_entity(desc, key_override='icemaker_one_enabled',
|
||||
instance_name='Cubed Ice')
|
||||
assert entity._attr_name == 'Cubed Ice Enabled'
|
||||
assert entity.translation_key == 'instance_enabled'
|
||||
assert entity.translation_placeholders == {'instance_name': 'Cubed Ice'}
|
||||
assert not hasattr(entity, '_attr_name')
|
||||
|
||||
|
||||
def test_no_instance_name_falls_back_to_derived_state_key():
|
||||
desc = BinarySensorDesc(key='enabled')
|
||||
def test_href_instance_name_becomes_translation_placeholder():
|
||||
desc = BinarySensorDesc(
|
||||
key='enabled', translation_key='instance_enabled', use_instance_name=True
|
||||
)
|
||||
entity = _make_entity(desc, key_override='icemaker_one_enabled')
|
||||
assert entity._attr_name == 'Icemaker One Enabled'
|
||||
assert entity.translation_placeholders == {'instance_name': 'Icemaker One'}
|
||||
|
||||
@@ -258,3 +258,39 @@ class TestRefrigeratorAiEnergyLevelFixtureCoverage:
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['ai_energy_level'] == '1'
|
||||
|
||||
|
||||
class TestSwitchOffIsNotInverted:
|
||||
"""Regression: several fridge SwitchDesc write_fns used ``'On' if p else 'Off'``.
|
||||
|
||||
The switch platform passes the literal string ``'Off'`` on turn-off (and
|
||||
``'On'`` on turn-on). ``'Off'`` is a non-empty, truthy string, so
|
||||
``'On' if p else 'Off'`` always evaluated to ``'On'`` — turning the switch
|
||||
OFF silently re-sent ``On`` and the switch could never be turned off. The
|
||||
guard must compare ``p == 'On'`` (as the other capability files already do).
|
||||
"""
|
||||
|
||||
# (switch descriptor, payload key it writes)
|
||||
CASES = [
|
||||
(fridge.ICEMAKER_NIGHTTIME.entities[0], 'ice.night.status'),
|
||||
(fridge.STATUS_LOCK.entities[0], 'x.com.samsung.da.ado.devicecontrol'),
|
||||
(fridge.STATUS_LOCK.entities[1], 'x.com.samsung.da.device.sound'),
|
||||
(fridge.DEFROST_DELAY.entities[0], 'x.com.samsung.da.delayDefrost'),
|
||||
(fridge.WELCOME_LIGHTING.entities[0], 'status'),
|
||||
(fridge.CABINET_LIGHT.entities[1], 'light.dimming.status'),
|
||||
(fridge.ICEMAKER_STATUS_FALLBACK.entities[0], 'x.com.samsung.da.iceMaker'),
|
||||
]
|
||||
|
||||
def test_turning_off_sends_off(self):
|
||||
for desc, key in self.CASES:
|
||||
_segs, payload = desc.write_fn('Off', {})
|
||||
assert payload[key] == 'Off', (
|
||||
f"{desc.key}: OFF must send 'Off', got {payload[key]!r} (inverted)"
|
||||
)
|
||||
|
||||
def test_turning_on_sends_on(self):
|
||||
for desc, key in self.CASES:
|
||||
_segs, payload = desc.write_fn('On', {})
|
||||
assert payload[key] == 'On', (
|
||||
f"{desc.key}: ON must send 'On', got {payload[key]!r}"
|
||||
)
|
||||
|
||||
@@ -7,7 +7,9 @@ GOLDEN = Path(__file__).parent / 'fixtures' / 'golden'
|
||||
|
||||
|
||||
def _new_state_keys(name, resources):
|
||||
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
|
||||
from custom_components.localthings.registry.by_type import (
|
||||
for_device, for_device_by_model, for_device_by_resources,
|
||||
)
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
otn = resources.get('/otninformation/vs/0', {})
|
||||
@@ -19,6 +21,8 @@ def _new_state_keys(name, resources):
|
||||
info.get('x.com.samsung.da.modelNum', ''),
|
||||
info.get('x.com.samsung.da.description', ''),
|
||||
)
|
||||
if reg is None:
|
||||
reg = for_device_by_resources(resources)
|
||||
if reg is None:
|
||||
from custom_components.localthings.registry.registry import CAPABILITIES
|
||||
caps, pats = CAPABILITIES, []
|
||||
@@ -81,6 +85,18 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_cooktop():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('cooktop')
|
||||
golden = json.loads((GOLDEN / 'cooktop.json').read_text())
|
||||
state_keys = _new_state_keys('cooktop', 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_tp1x_ref_21k_us():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('refrigerator_tp1x_ref_21k_us')
|
||||
@@ -95,6 +111,18 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_range_hood():
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('range_hood')
|
||||
golden = json.loads((GOLDEN / 'range_hood.json').read_text())
|
||||
state_keys = _new_state_keys('range_hood', 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_washer_flexwash():
|
||||
"""FlexWash twin washers (WV-prefix consumer model, e.g. WV55M9600AW)
|
||||
report no oneUiVersion and previously fell through for_device_by_model's
|
||||
@@ -185,6 +213,24 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_rac_20k():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_caww_tp2():
|
||||
"""A-CAWW-TP2-20-COMMON (issue #52, System AC / multi-indoor-unit
|
||||
commercial install) reports no oneUiVersion and no '_RAC_'/'_PRAC_'
|
||||
token; resolved via the '-CAWW-' modelNum fallback in
|
||||
for_device_by_model. Otherwise binds cleanly against the existing
|
||||
airconditioner registry -- same TP1X/TP2X-class resource surface, plus
|
||||
one SAC-only installation-topology resource (ignored)."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('airconditioner_caww_tp2')
|
||||
golden = json.loads((GOLDEN / 'airconditioner_caww_tp2.json').read_text())
|
||||
state_keys = _new_state_keys('airconditioner_caww_tp2', 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_tp1x_rac():
|
||||
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display
|
||||
light, self-check, mute-once, and a current-limit setting."""
|
||||
@@ -216,6 +262,38 @@ def test_registry_reproduces_golden_state_keys_for_range():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_air_purifier():
|
||||
"""ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via
|
||||
the '_TVTL_' modelNum token fallback in for_device_by_model."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('air_purifier')
|
||||
golden = json.loads((GOLDEN / 'air_purifier.json').read_text())
|
||||
state_keys = _new_state_keys('air_purifier', 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_oven():
|
||||
"""Wall oven (model TP1X_DA-KS-OVEN-0107X, issue #55) -- reports no
|
||||
oneUiVersion; resolved via the '-OVEN-' modelNum token fallback in
|
||||
for_device_by_model, mirroring the '-RANGE-' fallback added for
|
||||
issue #44. Before that fallback existed the device type came back
|
||||
'unknown' and every href fell through to the global CAPABILITIES
|
||||
registry instead of the oven family's own."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('oven')
|
||||
golden = json.loads((GOLDEN / 'oven.json').read_text())
|
||||
state_keys = _new_state_keys('oven', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_resources_from_batch_preferred_over_flat():
|
||||
from tests.conftest import _resources_from_dump
|
||||
dump = {
|
||||
|
||||
@@ -35,6 +35,103 @@ class TestCourseHelpers:
|
||||
assert laundry.option_value(opts, 'Missing') is None
|
||||
|
||||
|
||||
class TestCourseCodesFromSupportedOptions:
|
||||
"""cycle_options()'s fallback for boards that populate
|
||||
/wm/editcourse/vs/0 without ever filling in editCourseList itself
|
||||
(issue #1) -- derives the course list from /course/vs/0's own
|
||||
supportedOptions instead."""
|
||||
|
||||
# Real dump from issue #1 (DA_WM_TP1_21_COMMON, WW5000C): a 1-hex-nibble
|
||||
# header followed by 14 self-indexed 7-byte-per-course records. '1C' (the
|
||||
# first record) is confirmed as "Eco 40-60" both by this device's own
|
||||
# currently-selected course matching the SmartThings app screenshot's
|
||||
# checked item, and by six other independent devices' already-shipped
|
||||
# translations agreeing on the same code -> name mapping.
|
||||
_REAL_SUPPORTED_OPTIONS = (
|
||||
'31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F'
|
||||
'088000913FA67F7485209204A5208780009000A00006841E930FA30F'
|
||||
'7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F'
|
||||
'34841E923FA67FA0811E933FA33F'
|
||||
)
|
||||
|
||||
def test_derives_codes_when_edit_course_list_is_empty(self):
|
||||
resources = {
|
||||
'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': ''},
|
||||
'/course/vs/0': {
|
||||
'x.com.samsung.da.options': ['Course_1C'],
|
||||
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
|
||||
},
|
||||
}
|
||||
assert laundry.cycle_options(resources) == [
|
||||
'1C', '1B', '25', '20', '08', '74', '87', '06',
|
||||
'7F', '65', '8F', '96', '34', 'A0',
|
||||
]
|
||||
|
||||
def test_edit_course_list_still_takes_priority(self):
|
||||
"""A live editCourseList wins even with supportedOptions present --
|
||||
no reason to prefer a derived list over the authoritative one."""
|
||||
resources = {
|
||||
'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_651C'},
|
||||
'/course/vs/0': {
|
||||
'x.com.samsung.da.options': ['Course_1C'],
|
||||
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
|
||||
},
|
||||
}
|
||||
assert laundry.cycle_options(resources) == ['65', '1C']
|
||||
|
||||
def test_rejects_a_table_missing_the_current_course(self):
|
||||
"""The device's own currently-selected course must be a member of
|
||||
its derived list -- a mismatch means the guess is wrong, not that
|
||||
the device selected something outside its own supported set."""
|
||||
resources = {
|
||||
'/course/vs/0': {
|
||||
'x.com.samsung.da.options': ['Course_FF'],
|
||||
'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS],
|
||||
},
|
||||
}
|
||||
assert laundry.cycle_options(resources) == []
|
||||
|
||||
def test_smallest_passing_split_wins_over_its_own_multiples(self):
|
||||
"""K=2 and K=4 both trivially re-pass the same two checks here --
|
||||
each is just a sparser sampling of the true, smaller K=1 table (its
|
||||
first bytes are a subset of K=1's, so uniqueness and "contains the
|
||||
current course" carry over for free) -- but K=1 is the real, most
|
||||
specific table and must be the one returned."""
|
||||
resources = {
|
||||
'/course/vs/0': {
|
||||
'x.com.samsung.da.options': ['Course_AA'],
|
||||
'x.com.samsung.da.supportedOptions': ['0AABBCCDD'],
|
||||
},
|
||||
}
|
||||
assert laundry.cycle_options(resources) == ['AA', 'BB', 'CC', 'DD']
|
||||
|
||||
def test_empty_without_supported_options_or_course_href(self):
|
||||
assert laundry.cycle_options({}) == []
|
||||
assert laundry.cycle_options({'/course/vs/0': {}}) == []
|
||||
|
||||
def test_smallest_wins_even_when_a_larger_pass_is_not_a_multiple(self):
|
||||
"""Real dishwasher dump (K=7, 10 courses): K=10, 14, and 35 also
|
||||
pass both checks here, and none of them are multiples of 7 --
|
||||
position 0 lands on the same real course code ('0e') regardless of
|
||||
K, which alone satisfies the current-course guard for several
|
||||
unrelated splits. Smallest-K-wins is a heuristic that matches every
|
||||
real dump checked so far, not a proven guarantee -- see
|
||||
_course_codes_from_supported_options's docstring."""
|
||||
resources = {
|
||||
'/course/vs/0': {
|
||||
'x.com.samsung.da.options': ['Course_0E'],
|
||||
'x.com.samsung.da.supportedOptions': [
|
||||
'30E5434B102D102835034B002D002845034B002D002805034B000D000'
|
||||
'865034B002D002075000B000D000905000B000D0008D5034B002D0028'
|
||||
'E5034B000D0008F5034B000D000'
|
||||
],
|
||||
},
|
||||
}
|
||||
assert laundry.cycle_options(resources) == [
|
||||
'0E', '83', '84', '80', '86', '07', '90', '8D', '8E', '8F',
|
||||
]
|
||||
|
||||
|
||||
class TestCycleSelect:
|
||||
def test_builds_labelled_cycle_select(self):
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer')
|
||||
@@ -56,20 +153,63 @@ class TestCycleSelect:
|
||||
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_16'}}
|
||||
assert desc.exists_fn({}, live) is True
|
||||
|
||||
def test_cycle_write_rmw_on_options(self):
|
||||
def test_cycle_write_is_single_token(self):
|
||||
"""Confirmed on real hardware (issue #54): the device merges a
|
||||
single-token options[] write by prefix itself, so the write only
|
||||
needs to carry the changed token, not the whole rewritten array."""
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
|
||||
path, body = desc.write_fn('1D', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert body == {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
|
||||
}
|
||||
assert body == {'x.com.samsung.da.options': ['Course_1D']}
|
||||
|
||||
def test_cycle_write_noop_without_options(self):
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
assert desc.write_fn('1D', {}) is None
|
||||
|
||||
|
||||
class TestCycleSelectTableGating:
|
||||
"""translation_key becomes a resolver, not a plain string, once
|
||||
table_href is given -- washer/dryer's real call sites (issue: course
|
||||
codes aren't guaranteed consistent across board generations sharing
|
||||
the same /course/vs/0 contract; FlexWash's older board reports a
|
||||
different course table than every device the shipped translations
|
||||
were confirmed against). The resolved key is built from a reported
|
||||
table id. Devices without one use the generic translated Cycle entity
|
||||
name while preserving raw option codes as a safe fallback."""
|
||||
|
||||
def _desc(self):
|
||||
return laundry.cycle_select(
|
||||
translation_key='washer_cycle', icon='x',
|
||||
table_href='/st/washercourse/vs/0',
|
||||
)
|
||||
|
||||
def test_static_string_when_no_table_href_given(self):
|
||||
"""dishwasher's call site -- no equivalent table-id resource in any
|
||||
dump seen, no evidence of the same cross-board risk -- keeps the
|
||||
plain static key unconditionally."""
|
||||
desc = laundry.cycle_select(translation_key='dishwasher_cycle', icon='x')
|
||||
assert desc.translation_key == 'dishwasher_cycle'
|
||||
|
||||
def test_resolved_key_is_built_from_the_reported_table(self):
|
||||
desc = self._desc()
|
||||
resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
|
||||
assert callable(desc.translation_key)
|
||||
assert desc.translation_key(resources) == 'washer_cycle_table_02'
|
||||
|
||||
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'}}
|
||||
assert desc.translation_key(resources) == 'cycle'
|
||||
|
||||
def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
|
||||
"""An absent table id still gets a translated generic entity name."""
|
||||
desc = self._desc()
|
||||
assert desc.translation_key({}) == 'cycle'
|
||||
assert desc.translation_key({'/st/washercourse/vs/0': {}}) == 'cycle'
|
||||
|
||||
|
||||
class TestBuzzerSound:
|
||||
def test_href(self):
|
||||
assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0'
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
"""Unit tests for oven-family capabilities."""
|
||||
from custom_components.localthings.registry.by_type import for_device_by_model
|
||||
from custom_components.localthings.registry.capabilities import oven
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-type detection + full-dump coverage (issue #55)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_oven_fixture_resolves_and_has_no_unbound_hrefs():
|
||||
"""The issue #55 dump previously came back device_type='unknown' with
|
||||
/connected/vs/0 unbound -- resolving via the '-OVEN-' modelNum token
|
||||
fallback must leave every href in the oven registry bound or ignored."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('oven')
|
||||
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
|
||||
assert reg.name == 'oven'
|
||||
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OVEN_SETPOINT — NumberDesc with RMW write semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -115,7 +137,10 @@ def test_oven_mode_rejects_unknown():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OVEN_MODE options-array RMW (lamp, sound, fast_preheat, natural_steam)
|
||||
# OVEN_MODE options-array writes (lamp, sound, fast_preheat, natural_steam).
|
||||
# Confirmed on real hardware (issue #54): a write only needs to carry the
|
||||
# changed token -- the device matches by prefix, evicts the stale token, and
|
||||
# merges the result into the array itself. No read-modify-write needed.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mode_rep(*extra_opts):
|
||||
@@ -124,12 +149,11 @@ def _mode_rep(*extra_opts):
|
||||
]}
|
||||
|
||||
|
||||
def test_lamp_write_replaces_slot():
|
||||
def test_lamp_write_is_single_token():
|
||||
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'lamp')
|
||||
path, body = desc.write_fn('On', _mode_rep())
|
||||
opts = body['x.com.samsung.da.options']
|
||||
assert 'UpperLamp_On' in opts
|
||||
assert 'UpperLamp_Off' not in opts
|
||||
assert path == ['mode', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.options': ['UpperLamp_On']}
|
||||
|
||||
|
||||
def test_lamp_write_requires_existing_options():
|
||||
@@ -137,20 +161,19 @@ def test_lamp_write_requires_existing_options():
|
||||
assert desc.write_fn('On', {}) is None
|
||||
|
||||
|
||||
def test_sound_write_preserves_other_options():
|
||||
def test_sound_write_is_single_token():
|
||||
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'sound')
|
||||
path, body = desc.write_fn('Off', _mode_rep())
|
||||
opts = body['x.com.samsung.da.options']
|
||||
assert 'Sound_Off' in opts
|
||||
assert 'UpperLamp_Off' in opts # other slot unchanged
|
||||
assert body == {'x.com.samsung.da.options': ['Sound_Off']}
|
||||
|
||||
|
||||
def test_natural_steam_appended_if_absent():
|
||||
"""NaturalSteam slot is absent until first write — write_fn must append it."""
|
||||
def test_natural_steam_write_is_single_token():
|
||||
"""NaturalSteam's slot may be absent from the live rep until first
|
||||
write -- the single-token write covers both the insert and replace case
|
||||
identically, since the device merges by prefix either way."""
|
||||
desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'natural_steam')
|
||||
path, body = desc.write_fn('On', _mode_rep()) # no NaturalSteam_* in rep
|
||||
opts = body['x.com.samsung.da.options']
|
||||
assert any(o.startswith('NaturalSteam_') for o in opts)
|
||||
assert body == {'x.com.samsung.da.options': ['NaturalSteam_On']}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for the AHD-WW-TP1-22 range-hood profile."""
|
||||
|
||||
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 range_hood
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import FanDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
def _hood():
|
||||
resources = _load_device('range_hood')
|
||||
info = resources['/information/vs/0']
|
||||
registry = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'],
|
||||
info['x.com.samsung.da.description'],
|
||||
)
|
||||
return registry, resources
|
||||
|
||||
|
||||
def _state():
|
||||
registry, resources = _hood()
|
||||
bound = discover(
|
||||
resources, registry.capabilities, registry.pattern_capabilities,
|
||||
)
|
||||
return flatten(bound, resources)
|
||||
|
||||
|
||||
def test_ahd_model_resolves_to_range_hood_registry():
|
||||
registry, _ = _hood()
|
||||
assert registry is not None
|
||||
assert registry.name == 'range_hood'
|
||||
|
||||
|
||||
def test_range_hood_has_no_unbound_hrefs():
|
||||
registry, resources = _hood()
|
||||
unbound = []
|
||||
discover(
|
||||
resources,
|
||||
registry.capabilities,
|
||||
registry.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_range_hood_fixture_values():
|
||||
state = _state()
|
||||
assert state['fan'] == '14'
|
||||
assert state['alarm_code'] == 'none'
|
||||
assert state['automatic_operation'] is False
|
||||
assert state['lamp'] is False
|
||||
assert state['lamp_brightness'] == '2'
|
||||
assert state['hood_filter_usage'] == 100
|
||||
assert state['hood_filter_status'] == 'wash'
|
||||
assert state['energy_kwh'] == 165.62
|
||||
assert state['clean_level'] == 2
|
||||
assert state['dust'] == 18
|
||||
assert state['fine_dust'] == 14
|
||||
assert state['super_fine_dust'] == 9
|
||||
assert state['periodic_air_sensing'] is False
|
||||
assert state['air_sensing_state'] == 'NonProcessing'
|
||||
assert state['last_air_sensing_level'] == 'Kr2'
|
||||
|
||||
|
||||
def test_one_composite_fan_is_bound():
|
||||
registry, resources = _hood()
|
||||
bound = discover(
|
||||
resources, registry.capabilities, registry.pattern_capabilities,
|
||||
)
|
||||
fans = [entity for entity in bound if isinstance(entity.desc, FanDesc)]
|
||||
assert len(fans) == 1
|
||||
assert fans[0].href == '/hood/fanspeed/vs/0'
|
||||
|
||||
|
||||
def test_fan_write_contract():
|
||||
desc = range_hood.HOOD_FAN.entities[0]
|
||||
rep = {
|
||||
'x.com.samsung.da.hood.supportedFanSpeed': ['14', '15', '16', '17'],
|
||||
}
|
||||
assert desc.write_fn(('power', True), rep) == (
|
||||
['power', '0'], {'value': True},
|
||||
)
|
||||
assert desc.write_fn(('power', False), rep) == (
|
||||
['power', '0'], {'value': False},
|
||||
)
|
||||
assert desc.write_fn(('power', True, '/power/vs/0'), rep) == (
|
||||
['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'},
|
||||
)
|
||||
assert desc.write_fn(('power', False, '/power/vs/0'), rep) == (
|
||||
['power', 'vs', '0'], {'x.com.samsung.da.power': 'Off'},
|
||||
)
|
||||
assert desc.write_fn(('power', True, '/not-a-power-resource'), rep) is None
|
||||
assert desc.write_fn(('speed', '16'), rep) == (
|
||||
['hood', 'fanspeed', 'vs', '0'],
|
||||
{'x.com.samsung.da.hood.fanSpeed': '16'},
|
||||
)
|
||||
assert desc.write_fn(('speed', '99'), rep) is None
|
||||
|
||||
|
||||
def test_lamp_write_contract():
|
||||
lamp, brightness = range_hood.HOOD_LAMP.entities
|
||||
rep = {'x.com.samsung.lamp.range': ['1', '2']}
|
||||
assert lamp.write_fn('On', rep) == (
|
||||
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'On'},
|
||||
)
|
||||
assert lamp.write_fn('Off', rep) == (
|
||||
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'Off'},
|
||||
)
|
||||
assert brightness.write_fn('1', rep) == (
|
||||
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '1'},
|
||||
)
|
||||
assert brightness.write_fn('2', rep) == (
|
||||
['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '2'},
|
||||
)
|
||||
assert brightness.write_fn('Unsupported', rep) is None
|
||||
@@ -0,0 +1,88 @@
|
||||
"""HA fan-entity mapping tests for the range hood."""
|
||||
|
||||
from custom_components.localthings.fan import LocalThingsRangeHoodFan
|
||||
from custom_components.localthings.registry.by_type import range_hood
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import FanDesc
|
||||
from tests.conftest import _load_device
|
||||
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = 'TEST-HOOD-SERIAL'
|
||||
device_info = {}
|
||||
data = {}
|
||||
|
||||
def __init__(self, resources):
|
||||
self.last_resources = resources
|
||||
self.commands = []
|
||||
|
||||
def resource(self, href):
|
||||
return self.last_resources.get(href, {})
|
||||
|
||||
async def async_send_command(self, bound, payload):
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
|
||||
def _entity(resources, coordinator=None):
|
||||
bound = discover(
|
||||
resources,
|
||||
range_hood.REGISTRY.capabilities,
|
||||
range_hood.REGISTRY.pattern_capabilities,
|
||||
)
|
||||
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
|
||||
return LocalThingsRangeHoodFan(
|
||||
coordinator or _FakeCoordinator(resources), fan_bound,
|
||||
)
|
||||
|
||||
|
||||
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
|
||||
entity = _entity(_load_device('range_hood'))
|
||||
assert entity.is_on is False
|
||||
assert entity.speed_count == 4
|
||||
assert entity.percentage == 0
|
||||
|
||||
|
||||
def test_active_codes_map_to_ordered_percentages():
|
||||
resources = _load_device('range_hood')
|
||||
resources['/power/0']['value'] = True
|
||||
|
||||
resources['/hood/fanspeed/vs/0'][
|
||||
'x.com.samsung.da.hood.fanSpeed'
|
||||
] = '14'
|
||||
assert _entity(resources).percentage == 25
|
||||
|
||||
resources['/hood/fanspeed/vs/0'][
|
||||
'x.com.samsung.da.hood.fanSpeed'
|
||||
] = '15'
|
||||
assert _entity(resources).percentage == 50
|
||||
|
||||
resources['/hood/fanspeed/vs/0'][
|
||||
'x.com.samsung.da.hood.fanSpeed'
|
||||
] = '16'
|
||||
assert _entity(resources).percentage == 75
|
||||
|
||||
resources['/hood/fanspeed/vs/0'][
|
||||
'x.com.samsung.da.hood.fanSpeed'
|
||||
] = '17'
|
||||
assert _entity(resources).percentage == 100
|
||||
|
||||
|
||||
async def test_power_write_prefers_standard_resource_when_both_exist():
|
||||
resources = _load_device('range_hood')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('power', True, '/power/0')
|
||||
|
||||
|
||||
async def test_power_write_falls_back_to_vendor_resource():
|
||||
resources = _load_device('range_hood')
|
||||
resources.pop('/power/0')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Tests for select-option display casing (custom_components/localthings/select.py)."""
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from custom_components.localthings.select import _display
|
||||
|
||||
_UNTRANSLATED = SelectDesc(key='x', options=())
|
||||
_TRANSLATED = SelectDesc(key='y', options=(), translation_key='door_alert')
|
||||
_UNTRANSLATED = None
|
||||
_TRANSLATED = 'ice_type'
|
||||
|
||||
|
||||
def test_display_titlecases_a_fully_lowercase_device_native_token():
|
||||
@@ -29,11 +28,20 @@ def test_display_passes_through_an_already_human_friendly_value():
|
||||
|
||||
|
||||
def test_display_lowercases_for_translation_key_lookup():
|
||||
"""An entity with a translation_key must match strings.json's
|
||||
"""An entity with a translation_key must match the catalog's
|
||||
lowercase keys exactly -- unlike the untranslated cases above, this
|
||||
is not a cosmetic transform."""
|
||||
assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3'
|
||||
|
||||
|
||||
def test_unknown_translated_vendor_value_keeps_readable_fallback():
|
||||
"""A firmware-added value must remain readable instead of being mangled."""
|
||||
assert _display('FutureVendorMode', _TRANSLATED) == 'Future Vendor Mode'
|
||||
|
||||
|
||||
def test_known_camel_case_state_uses_snake_case_translation_key():
|
||||
assert _display('ExtraHigh', 'heated_dry') == 'extra_high'
|
||||
|
||||
|
||||
def test_display_passes_through_non_string_values():
|
||||
assert _display(None, _UNTRANSLATED) is None
|
||||
|
||||
@@ -49,7 +49,7 @@ def test_callable_options_receives_full_resource_snapshot():
|
||||
'/other/vs/0': {'codes': ['1C', '1D']},
|
||||
}
|
||||
entity = _make_select(desc, '/x/vs/0', resources)
|
||||
assert entity.options == ['1c', '1d']
|
||||
assert entity.options == ['1C', '1D']
|
||||
assert calls == [resources]
|
||||
|
||||
|
||||
@@ -57,3 +57,46 @@ def test_callable_options_empty_result():
|
||||
desc = SelectDesc(key='cycle', options=lambda resources: [])
|
||||
entity = _make_select(desc, '/x/vs/0', {})
|
||||
assert entity.options == []
|
||||
|
||||
|
||||
def test_callable_translation_key_reresolves_live_not_once_at_construction():
|
||||
"""A callable translation_key (laundry.cycle_select's table-id-gated
|
||||
resolver) must be re-evaluated against current coordinator data on
|
||||
every access, not baked in once at __init__ -- discovery can run while
|
||||
a sibling resource (e.g. /st/washercourse/vs/0) is still an empty stub
|
||||
(see entity.py's _is_included docstring), and a one-time resolution
|
||||
would permanently show untranslated codes even after a later poll
|
||||
populates the real value."""
|
||||
desc = SelectDesc(key='cycle', translation_key=lambda resources: resources.get('key'))
|
||||
resources = {'key': None}
|
||||
entity = _make_select(desc, '/x/vs/0', resources)
|
||||
assert entity.translation_key is None
|
||||
|
||||
resources['key'] = 'washer_cycle_table_02'
|
||||
assert entity.translation_key == 'washer_cycle_table_02'
|
||||
|
||||
|
||||
async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
|
||||
"""Readable fallback labels must still write the exact Samsung token."""
|
||||
class _WritableCoordinator(_FakeCoordinator):
|
||||
data = {'mode': 'FutureVendorMode'}
|
||||
|
||||
def __init__(self, last_resources):
|
||||
super().__init__(last_resources)
|
||||
self.writes = []
|
||||
|
||||
async def async_send_command(self, bound, value):
|
||||
self.writes.append(value)
|
||||
|
||||
desc = SelectDesc(
|
||||
key='mode', translation_key='door_alert',
|
||||
options=('Known', 'FutureVendorMode'), write_fn=lambda *args: None,
|
||||
)
|
||||
capability = Capability(href='/x/vs/0', entities=(desc,))
|
||||
bound = BoundEntity(href='/x/vs/0', capability=capability, desc=desc)
|
||||
coordinator = _WritableCoordinator({})
|
||||
entity = LocalThingsSelect(coordinator, bound)
|
||||
|
||||
assert entity.options[-1] == 'Future Vendor Mode'
|
||||
await entity.async_select_option('Future Vendor Mode')
|
||||
assert coordinator.writes == ['FutureVendorMode']
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Translation catalog architecture tests.
|
||||
|
||||
Home Assistant loads a custom integration's ``translations/<lang>.json``
|
||||
directly -- there is no ``strings.json`` step and no ``[%key:...%]``
|
||||
resolution, both of which belong to Core's build tooling. So
|
||||
``translations/en.json`` is the source of truth here, and these tests hold
|
||||
the two invariants that follow from that: every translation key the Python
|
||||
side names has to exist in it, and every other language has to mirror its
|
||||
shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from string import Formatter
|
||||
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.entities import PLATFORM_OF
|
||||
|
||||
|
||||
INTEGRATION = (
|
||||
Path(__file__).parents[1] / "custom_components" / "localthings"
|
||||
)
|
||||
TRANSLATIONS = INTEGRATION / "translations"
|
||||
|
||||
|
||||
def _load(language: str) -> dict:
|
||||
return json.loads(
|
||||
(TRANSLATIONS / f"{language}.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _languages() -> list[str]:
|
||||
return sorted(path.stem for path in TRANSLATIONS.glob("*.json"))
|
||||
|
||||
|
||||
def _topology(value):
|
||||
if isinstance(value, dict):
|
||||
return {key: _topology(child) for key, child in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_topology(child) for child in value]
|
||||
return None
|
||||
|
||||
|
||||
def _placeholders(value: str) -> set[str]:
|
||||
return {
|
||||
field_name
|
||||
for _, field_name, _, _ in Formatter().parse(value)
|
||||
if field_name is not None
|
||||
}
|
||||
|
||||
|
||||
def _walk_strings(value, path=()):
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
yield from _walk_strings(child, (*path, key))
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
yield from _walk_strings(child, (*path, str(index)))
|
||||
elif isinstance(value, str):
|
||||
yield path, value
|
||||
|
||||
|
||||
def _all_descriptions():
|
||||
capabilities_dir = INTEGRATION / "registry" / "capabilities"
|
||||
seen: set[int] = set()
|
||||
for module_path in capabilities_dir.glob("*.py"):
|
||||
if module_path.stem == "__init__":
|
||||
continue
|
||||
module = importlib.import_module(
|
||||
f"custom_components.localthings.registry.capabilities.{module_path.stem}"
|
||||
)
|
||||
|
||||
def visit(value):
|
||||
if isinstance(value, Capability):
|
||||
if id(value) in seen:
|
||||
return
|
||||
seen.add(id(value))
|
||||
yield from value.entities
|
||||
elif isinstance(value, (tuple, list, set)):
|
||||
for child in value:
|
||||
yield from visit(child)
|
||||
|
||||
for value in vars(module).values():
|
||||
yield from visit(value)
|
||||
|
||||
|
||||
def test_every_language_mirrors_the_english_catalog():
|
||||
"""English is the complete catalog; the rest must match it key for key.
|
||||
|
||||
A missing key silently falls back to English at runtime, so checking the
|
||||
shape is the only way to notice a half-finished translation.
|
||||
"""
|
||||
english = _load("en")
|
||||
english_strings = dict(_walk_strings(english))
|
||||
for language in _languages():
|
||||
if language == "en":
|
||||
continue
|
||||
translated = _load(language)
|
||||
assert _topology(english) == _topology(translated), language
|
||||
|
||||
translated_strings = dict(_walk_strings(translated))
|
||||
for path, value in english_strings.items():
|
||||
# Placeholders are substituted by name, so a translation that
|
||||
# drops or invents one renders a literal '{...}' in the UI.
|
||||
assert _placeholders(value) == _placeholders(
|
||||
translated_strings[path]
|
||||
), (language, path)
|
||||
|
||||
|
||||
def test_no_catalog_carries_unresolved_core_references():
|
||||
"""``[%key:...%]`` never resolves for a custom integration.
|
||||
|
||||
Core's build tooling expands these; nothing does for us, so a reference
|
||||
left in a catalog would reach the UI verbatim.
|
||||
"""
|
||||
for language in _languages():
|
||||
unresolved = [
|
||||
(path, value)
|
||||
for path, value in _walk_strings(_load(language))
|
||||
if "[%key:" in value
|
||||
]
|
||||
assert unresolved == [], language
|
||||
|
||||
|
||||
# The hood fan is its device's primary feature: fan.py sets _attr_name = None
|
||||
# so it presents as the device itself, and never reads a catalog name.
|
||||
UNNAMED_DESCRIPTORS = {("fan", "fan")}
|
||||
|
||||
|
||||
def test_every_descriptor_has_an_entity_catalog_entry():
|
||||
"""A descriptor's name comes from the catalog or nowhere.
|
||||
|
||||
``translation_key`` defaults to ``desc.key`` (entity.py), and there is no
|
||||
Python-side name to fall back on, so a descriptor with no catalog entry
|
||||
is an entity with no name.
|
||||
"""
|
||||
entity_strings = _load("en")["entity"]
|
||||
missing = []
|
||||
for desc in _all_descriptions():
|
||||
platform = PLATFORM_OF[type(desc)]
|
||||
if (platform, desc.key) in UNNAMED_DESCRIPTORS:
|
||||
continue
|
||||
translation_key = desc.translation_key
|
||||
if callable(translation_key):
|
||||
# Runtime table resolvers pick their key out of the catalog
|
||||
# itself (see laundry.cycle_select), so there's nothing static
|
||||
# to check here; the generic 'cycle' fallback is asserted below.
|
||||
continue
|
||||
if translation_key is None:
|
||||
translation_key = desc.key
|
||||
if translation_key not in entity_strings.get(platform, {}):
|
||||
missing.append((platform, desc.key, translation_key))
|
||||
assert missing == []
|
||||
|
||||
# cycle_select falls back to 'cycle' for any course table without its
|
||||
# own entry, and resolves to '<family>_cycle_<table>' where there is one.
|
||||
select_strings = entity_strings["select"]
|
||||
for key in ("cycle", "washer_cycle_table_02", "dryer_cycle_table_03"):
|
||||
assert key in select_strings
|
||||
|
||||
|
||||
def test_all_entity_state_translation_keys_are_lowercase():
|
||||
entity_strings = _load("en")["entity"]
|
||||
for platform in entity_strings.values():
|
||||
for translation in platform.values():
|
||||
for state_key in translation.get("state", {}):
|
||||
assert state_key == state_key.lower()
|
||||
@@ -61,12 +61,22 @@ class TestWasherCourse:
|
||||
assert washer.WASHER_COURSE.href == '/course/vs/0'
|
||||
|
||||
def test_translation_key(self):
|
||||
"""Table-scoped (issue: course codes aren't guaranteed consistent
|
||||
across board generations sharing /course/vs/0 -- FlexWash's older
|
||||
board reports Table_00, not the Table_02 every washer_cycle_table_02
|
||||
name was confirmed against) -- see laundry.cycle_select. Only a
|
||||
verified table gets table-specific state translations."""
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||
assert desc.translation_key == 'washer_cycle'
|
||||
assert callable(desc.translation_key)
|
||||
table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
|
||||
assert desc.translation_key(table_02) == 'washer_cycle_table_02'
|
||||
table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
|
||||
assert desc.translation_key(table_00) == 'cycle'
|
||||
assert desc.translation_key({}) == 'cycle'
|
||||
|
||||
def test_reads_raw_course_code_from_options_array(self):
|
||||
"""rep_fn returns the raw device code; display names come from
|
||||
strings.json via translation_key, not from Python (see select.py's
|
||||
translations/en.json via translation_key, not Python (see select.py's
|
||||
_display())."""
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
|
||||
@@ -91,13 +101,14 @@ class TestWasherCourse:
|
||||
assert desc.exists_fn({}, live) is True
|
||||
|
||||
def test_cycle_write(self):
|
||||
"""Confirmed on real hardware (issue #54): the write only needs to
|
||||
carry the changed token -- the device matches by prefix, evicts the
|
||||
stale token, and merges the result into the array itself."""
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
|
||||
path, body = desc.write_fn('1D', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert body == {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
|
||||
}
|
||||
assert body == {'x.com.samsung.da.options': ['Course_1D']}
|
||||
|
||||
|
||||
class TestDrumClean:
|
||||
@@ -195,14 +206,11 @@ class TestDetergentSoftenerDosing:
|
||||
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
|
||||
|
||||
def test_translation_keys(self):
|
||||
"""detergent_quantity and softener_quantity share one translation_key
|
||||
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers,
|
||||
same shape as fridge.py's shared 'brightness_level' key); hardness
|
||||
and concentration each have their own since their labels differ."""
|
||||
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity'
|
||||
assert self._desc('softener_quantity').translation_key == 'washer_dosing_quantity'
|
||||
assert self._desc('detergent_water_hardness').translation_key == 'washer_detergent_water_hardness'
|
||||
assert self._desc('softener_concentration').translation_key == 'washer_softener_concentration'
|
||||
"""Each entity gets its own translated name and state vocabulary."""
|
||||
assert self._desc('detergent_quantity').translation_key == 'detergent_quantity'
|
||||
assert self._desc('softener_quantity').translation_key == 'softener_quantity'
|
||||
assert self._desc('detergent_water_hardness').translation_key == 'detergent_water_hardness'
|
||||
assert self._desc('softener_concentration').translation_key == 'softener_concentration'
|
||||
|
||||
def test_quantity_and_hardness_options_decode_supported_list(self):
|
||||
assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
|
||||
@@ -219,20 +227,22 @@ class TestDetergentSoftenerDosing:
|
||||
|
||||
def test_quantity_write(self):
|
||||
"""The UI selects a padded supported code ('01'); the write posts the
|
||||
un-padded device code ('1'), mirroring how the device reports it."""
|
||||
un-padded device code ('1'), mirroring how the device reports it.
|
||||
|
||||
Confirmed on real hardware (issue #54): the write only needs to carry
|
||||
the changed token -- the device matches by prefix, evicts the stale
|
||||
token, and merges the result into the array itself. No need to read
|
||||
the current array back and rewrite it whole."""
|
||||
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
|
||||
path, body = self._desc('detergent_quantity').write_fn('01', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options']
|
||||
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
|
||||
# untouched siblings survive the read-modify-write
|
||||
assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options']
|
||||
assert body == {'x.com.samsung.da.options': ['DetergentLevelCtrl_1']}
|
||||
|
||||
def test_hardness_write(self):
|
||||
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
|
||||
path, body = self._desc('softener_concentration').write_fn('03', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
|
||||
assert body == {'x.com.samsung.da.options': ['SoftenerLevel2Ctrl_3']}
|
||||
|
||||
def test_low_reservoir_off_when_alarm_off(self):
|
||||
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
|
||||
@@ -294,18 +304,20 @@ class TestWashOptionToggles:
|
||||
"""write_fn receives the same 'On'/'Off' string switch.py sends
|
||||
(not a bool) -- covers a bug where an earlier `'On' if p else 'Off'`
|
||||
implementation always wrote 'On', since any non-empty string
|
||||
(including 'Off') is truthy."""
|
||||
(including 'Off') is truthy.
|
||||
|
||||
The write body carries only the changed token (issue #54: confirmed
|
||||
the device merges by prefix itself), not the whole options array."""
|
||||
for key in self._keys():
|
||||
prefix = self._prefix(key)
|
||||
rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']}
|
||||
path, body = self._desc(key).write_fn('On', rep)
|
||||
assert path == ['course', 'vs', '0']
|
||||
assert f'{prefix}_On' in body['x.com.samsung.da.options']
|
||||
assert 'GMT_02' in body['x.com.samsung.da.options']
|
||||
assert body == {'x.com.samsung.da.options': [f'{prefix}_On']}
|
||||
|
||||
rep = {'x.com.samsung.da.options': [f'{prefix}_On']}
|
||||
path, body = self._desc(key).write_fn('Off', rep)
|
||||
assert f'{prefix}_Off' in body['x.com.samsung.da.options']
|
||||
assert body == {'x.com.samsung.da.options': [f'{prefix}_Off']}
|
||||
assert f'{prefix}_On' not in body['x.com.samsung.da.options']
|
||||
|
||||
def test_write_rejects_non_on_off_payload(self):
|
||||
@@ -348,8 +360,10 @@ class TestWashOptionToggleValidation:
|
||||
|
||||
def test_rejected_on_an_unsupported_course(self):
|
||||
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
|
||||
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES)
|
||||
assert msg == "Bubble soak isn't available on the selected cycle."
|
||||
translation_key = self._desc('bubble_soak').validate_fn(
|
||||
'On', rep, _EDIT_COURSE_RESOURCES
|
||||
)
|
||||
assert translation_key == 'bubble_soak_unavailable_for_cycle'
|
||||
|
||||
def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
|
||||
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}
|
||||
|
||||
Reference in New Issue
Block a user