Compare commits

...
Author SHA1 Message Date
Claude eb07af3c9a Warn against hard-coding device-specific values in adding-device-support skill
Dumps capture one unit's snapshot, not the field's universe. Add a
dedicated section pointing at the existing options_field/range_field/
native_min_fn machinery for deriving select options and number
ranges/steps live from the device rep instead of transcribing values
from a single dump into Python literals.
2026-07-26 23:48:20 +00:00
Marc Billow aff7647460 Merge pull request #65 from Metal-Eagle/feature/add-completion-time
Feature/add completion time
2026-07-24 23:46:47 -05:00
Jeroen Hof 705da781f1 Merge branch 'main' into feature/add-completion-time 2026-07-25 06:41:17 +02:00
Jeroen Hof 6de1a4090e i18n: simplify completion time translation in English and Dutch 2026-07-25 06:02:45 +02:00
Jeroen Hof ef75db6697 i18n: update completion time translation in English and Dutch 2026-07-25 05:55:47 +02:00
Jeroen Hof 9e290f27ee refactor: update sensor descriptions for completion time and delay start 2026-07-25 05:24:10 +02:00
Jeroen Hof 37bfff152d refactor: remove unused strings.json file 2026-07-25 05:15:34 +02:00
Jeroen Hof 317b7d0c1b fix: add missing newline at end of en.json file 2026-07-25 05:09:55 +02:00
Jeroen Hof ba8e41d310 Implement code changes to enhance functionality and improve performance 2026-07-25 05:08:18 +02:00
Jeroen Hof 724686c42c i18n: add translations for AI Wash and Jeans 2026-07-25 05:01:40 +02:00
Jeroen Hof edccb6ae3f i18n: add new wash options for AI Wash and Jeans 2026-07-25 04:59:37 +02:00
Jeroen Hof 4a91f62a36 i18n: add new translations for AI Wash and Spijkerbroek 2026-07-25 04:49:40 +02:00
Jeroen Hof 1d4d38db14 Merge branch 'main' into feature/add-completion-time 2026-07-25 04:47:22 +02:00
Marc Billow 35e2c79b14 brand: use updated localthing logo 2026-07-24 20:23:48 -05:00
Marc Billow f185951a8a i18n: missed a quick wash translation 2026-07-24 15:42:24 -05:00
Marc Billow adcb8a0ecb chore: add additional translations provided in #1 dutch 2026-07-24 15:33:31 -05:00
Marc Billow dc86c7f0ef Merge pull request #72 from mbillow/claude/home-assistant-i18n-iwwyvr
feat (i18n): refactor and improve translation support
2026-07-24 15:23:39 -05:00
Marc Billow 30d38d855c Merge branch 'main' into claude/home-assistant-i18n-iwwyvr 2026-07-24 15:23:10 -05:00
Marc Billow ee77e3bda8 chore: add additional translations provided in #1 2026-07-24 15:20:19 -05:00
Marc Billow 10c850f2b9 refactor(i18n): drop the vestigial descriptor name field
Since entity.py started routing named descriptors through the catalog
under desc.key, SamsungEntityDescription.name has been read for its
value nowhere -- only twice as a flag, to decide whether an entity was
translated at all. That left 148 English names duplicated between Python
and translations/en.json with nothing keeping them honest: six had
already drifted, invisibly, because editing the Python side changes
nothing a user sees.

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

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

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

Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
2026-07-24 19:45:37 +00:00
Marc Billow 6281b40549 refactor(i18n): make the shipped catalog the single source of truth
PR #68 restated its own translation data in Python: a 60-line
TRANSLATED_SELECT_STATES table of frozensets duplicating every
entity.select.*.state key, a second _TRANSLATED_COURSE_TABLES table
naming which course tables have translations, and a strings.json that
was a 835-line byte-for-byte copy of translations/en.json save 43
[%key:...%] references. Each needed hand-syncing, and one was already
drifting.

Home Assistant loads exactly one file per language for a custom
integration -- translations/<lang>.json. It never reads strings.json and
never resolves [%key:...%]; both belong to Core's build tooling, which
custom integrations don't run through (hassfest skips a missing
strings.json and validates translations/en.json instead). So en.json is
the source, and the new catalog.py reads the keys and states back out of
it for the two decisions Python genuinely has to make:

  - select._display() normalizes a raw Samsung option to a lowercase
    state key only when the catalog knows it, else leaves the vendor's
    casing alone. Derived sets are identical to the removed literals.
  - laundry.cycle_select() keys off a device-reported course table only
    when that table has an entry, else falls back to the name-only
    'cycle' key. Translating Table_00 is now a translations-only change.

Also fixes six names that had already drifted between the Python
descriptors and the catalog, restoring HA's sentence case for two
generic ones (Auto release dry, Bubble soak) and taking the catalog's
wording for the rest, and adds a test so the vestigial descriptor names
can't silently disagree with the UI again.

Claude-Session: https://claude.ai/code/session_01GiibJZZLWVvyxq7mc7EDNp
2026-07-24 19:32:09 +00:00
Marc Billow 0b6cc6aa9f Merge branch 'pr68' into claude/home-assistant-i18n-iwwyvr 2026-07-24 19:24:59 +00:00
Hmmbob 5d86dbe7e8 Resolve runtime translation references 2026-07-24 21:13:07 +02:00
Hmmbob 82acc05d38 Limit changes to translation support 2026-07-24 18:40:44 +02:00
Hmmbob da49825e2e Harden unknown vendor value fallbacks 2026-07-24 18:12:01 +02:00
Hmmbob b85af10ae1 Test translation coverage and dynamic fallbacks 2026-07-24 18:08:29 +02:00
Hmmbob f9ebc8e286 Make LocalThings UI fully translatable 2026-07-24 18:08:29 +02:00
Hmmbob d0cfaeebc1 Add Dutch translations 2026-07-24 17:29:50 +02:00
Jeroen Hof 365a1c4722 feat: enhance operational capabilities by adding completion_minutes and updating related logic; remove completion_time references 2026-07-24 15:35:17 +02:00
Jeroen Hof 7be91b56e8 Merge remote-tracking branch 'upstream/main' into feature/add-completion-time 2026-07-24 08:23:42 +02:00
Jeroen Hof 2c283218b0 feat: add completion_minutes and completion_time to state_keys in JSON fixtures 2026-07-24 08:10:17 +02:00
Jeroen Hof ab874797fc Merge branch 'main' into feature/add-completion-time 2026-07-24 07:48:57 +02:00
Jeroen Hof f5d4e2219a fix: remove redundant course codes from parse_edit_course_list test case 2026-07-24 07:47:53 +02:00
Jeroen Hof 86f07abf30 fix: correct expected output for parse_edit_course_list test case 2026-07-24 06:29:53 +02:00
Jeroen Hof b00390c093 fix: correct key for Jeans cycle in strings and translations 2026-07-24 06:27:28 +02:00
Jeroen Hof e913a3c2c0 feat: add new washer cycle options for AI Wash and Jeans 2026-07-24 06:26:24 +02:00
Jeroen Hof b2db4bf357 feat: add completion time and minutes sensors with parsing logic 2026-07-24 06:23:53 +02:00
50 changed files with 2461 additions and 849 deletions
+71 -12
View File
@@ -103,18 +103,77 @@ 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 as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule). entity on a hunch (`ignored.py`'s rule).
## 5. Enum selects need translation support ## 5. Never hard-code the one dump's values
Any select whose options are raw device codes (course/cycle, and code-valued A single `/device/0` dump is **one device on one firmware** — its select options,
settings) must render through translations, not Python: temperature range/increment, and any other "what values are valid here" data are
- Set `translation_key='<family>_cycle'` (or similar) on the `SelectDesc`; **that unit's snapshot**, not the field's universe. Other units of the same model
`options`/`options_field` supply the **raw** codes. (different region, firmware, board revision) can support more, fewer, or
- Add the labels to **both** `strings.json` and `translations/en.json` under differently-stepped values. If the dump reports the live option/range list, wire
`entity.select.<translation_key>.state.<code>`, with the code **lowercased** the descriptor to read it live — don't transcribe what you saw into a Python
(e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's literal:
the cue to identify and name them.
## 6. Coverage discipline: bound or ignored - **Selects**: use `options_field` (a resource field holding the live options
list, e.g. `supportedWaterTemperature`, `iceType.supported`) so `select.py`
reads the current device's real options every time, not `options=(...)` typed
from the dump. Reach for a callable `options` only when the values require
cross-resource computation the field alone can't give you — a static tuple is
right only for genuinely fixed, spec-defined enums (e.g. an OCF-standard field
with a closed value set), never for vendor `supported*` lists.
- **Number ranges/steps**: use `range_field` (a `[min, max]`-shaped field) or
`native_min_fn`/`native_max_fn`/`step_fn` to read bounds from the live rep —
see `oven.py`'s `_setpoint_bounds`. Only fall back to static `native_min`/
`native_max`/`step` when the dump has no such field and the bound is genuinely
fixed by spec, not just "the only value this one unit happened to report."
- **Anywhere else** a field's presence, count, or shape looks like it could vary
by model/config (course lists, capability flags, supported-mode arrays):
check whether the resource carries its own `supported*` companion field before
assuming the observed value is exhaustive.
When you do hard-code something (a genuinely fixed enum, a spec constant), that's
a judgement call worth a one-line comment saying why it's safe — the default
assumption should be "derive it," not "copy it."
## 6. Names and enum labels live in translations, never in Python
Descriptors have **no `name` field**. Every entity is named from the shipped
catalog, keyed by `translation_key` — which defaults to the descriptor's own
`key`. So adding `SensorDesc(key='filter_status', ...)` obliges you to add:
```json
"entity": { "sensor": { "filter_status": { "name": "Filter status" } } }
```
to `translations/en.json`. Skip it and the entity ships nameless;
`tests/test_translations.py` fails the build instead.
- **Sentence case** ("Filter status", not "Filter Status"), per HA's style
guide — capitalize only proper nouns and Samsung feature names ("AI Energy
Mode", "Storm Wash+").
- Set `translation_key` explicitly only to **share** one catalog entry across
descriptors, or to point at a differently-named one. Two descriptors on the
same platform with the same `key` already share an entry — intended for
`common.py`'s OCF/vendor fallback pairs, a silent mislabel otherwise.
- Prefer HA's own vocabulary where it fits: a `device_class` gives you
translated states for free (`binary_sensor` door/running, `sensor`
timestamp/enum), so don't restate them.
Selects whose options are raw device codes (course/cycle, code-valued
settings) additionally need those codes labelled:
- `options`/`options_field` supply the **raw** codes; the catalog maps them.
- Add labels under `entity.select.<translation_key>.state.<code>`, code
**lowercased** (e.g. `"16": "Cotton"`). `select.py` derives which values it
normalizes from the catalog itself, so there is no Python list to keep in
sync — a code with no entry simply renders as the raw code, which is the cue
to identify and name it.
`translations/en.json` is the only place any of this lives: there is no
`strings.json` (Home Assistant doesn't read one from a custom integration) and
no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`.
## 7. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't Every href in the dump must resolve, or the repair fires. If a resource isn't
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`) worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
@@ -128,7 +187,7 @@ friendlier href**.
ignored because washers bind it. When only one family should ignore an href ignored because washers bind it. When only one family should ignore an href
that another binds, scope the ignore to that family's registry. that another binds, scope the ignore to that family's registry.
## 7. Reuse before writing new code ## 8. Reuse before writing new code
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py` Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course (shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
@@ -137,7 +196,7 @@ registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a `laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
shared module rather than copying. shared module rather than copying.
## 8. Lock it in ## 9. Lock it in
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json` 1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs, (`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
+9 -1
View File
@@ -1,3 +1,8 @@
<!-- dark mode -->
<img src="custom_components/localthings/brand/dark_logo@2x.png#gh-dark-mode-only" alt="LocalThings Logo"/>
<!-- light mode -->
<img src="custom_components/localthings/brand/logo@2x.png#gh-light-mode-only" alt="LocalThings Logo"/>
# LocalThings # LocalThings
**A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN. **A native Home Assistant custom integration for local control of newer-generation Samsung connected appliances.** No cloud round-trip. Add a device through HA's normal *Settings > Devices & Services* flow and it talks CoAP-over-DTLS straight to the appliance on your LAN.
@@ -106,7 +111,10 @@ custom_components/localthings/
entity.py Base entity wiring capability registry -> HA entity entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / fan.py / climate.py
One module per HA platform 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/
registry.py Builds the global capability registry, validates href collisions registry.py Builds the global capability registry, validates href collisions
capability.py Capability dataclass (href, entities, transforms) capability.py Capability dataclass (href, entities, transforms)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 146 KiB

+49
View File
@@ -0,0 +1,49 @@
"""The integration's own shipped translation catalog, as data.
Home Assistant loads exactly one file per language for a custom integration:
``translations/<lang>.json``. It does not read ``strings.json`` and it does
not resolve ``[%key:...%]`` references -- both of those belong to Core's
build tooling (``script/translations``), which custom integrations never run
through. So ``translations/en.json`` is not a generated artifact here, it is
the source of truth, and English is the one catalog required to be complete
(hassfest validates it for custom integrations).
That makes the English catalog the authority on *which* translation keys and
states exist, which is something the Python side genuinely needs to know:
* ``select`` has to decide whether to normalize a raw Samsung option into
a lowercase state key (so Home Assistant can look it up) or leave the
vendor's own casing alone as a readable fallback.
* ``laundry.cycle_select`` has to decide whether a device-reported course
table has translations at all before keying off it.
Reading it from the catalog instead of restating it in Python means adding a
state or a course table is a one-file change, and the two can't drift.
"""
from __future__ import annotations
import json
from pathlib import Path
# Read at import, not lazily: custom integrations are imported in an executor
# thread, so this stays off the event loop no matter who asks first.
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
).get('entity', {})
def has_entity_translation(platform: str, translation_key: str) -> bool:
"""Whether `platform`.`translation_key` has an entry in the catalog."""
return translation_key in _ENTITY_CATALOG.get(platform, {})
def translated_states(platform: str, translation_key: str) -> frozenset[str]:
"""The state keys translated for `platform`.`translation_key`.
Empty when the entity has no translation at all, and also when it has a
translated *name* but deliberately no state table (a course list whose
codes we can't map, for instance) -- callers treat both the same way:
leave the device's value untouched.
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get('state', ())) if entry else frozenset()
+26 -7
View File
@@ -368,22 +368,27 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if has_creds: if has_creds:
schema = vol.Schema({vol.Required(CONF_HOST): _TEXT}) schema = vol.Schema({vol.Required(CONF_HOST): _TEXT})
reuse_note = "CA credentials will be reused from the existing device." step_id = "user_reuse"
else: else:
schema = vol.Schema({ schema = vol.Schema({
vol.Required(CONF_HOST): _TEXT, vol.Required(CONF_HOST): _TEXT,
vol.Required(CONF_CA_CERT_PEM): _MULTILINE, vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
vol.Required(CONF_CA_KEY_PEM): _MULTILINE, vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
}) })
reuse_note = "" step_id = "user"
return self.async_show_form( return self.async_show_form(
step_id="user", step_id=step_id,
data_schema=schema, data_schema=schema,
errors=errors, 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( async def async_step_confirm_unknown_type(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> FlowResult: ) -> FlowResult:
@@ -391,14 +396,28 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
return self._create_entry(self._pending_info) 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( return self.async_show_form(
step_id="confirm_unknown_type", step_id="confirm_unknown_type",
data_schema=vol.Schema({}), data_schema=vol.Schema({}),
description_placeholders={ 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): class LocalThingsOptionsFlow(config_entries.OptionsFlow):
"""Per-device options: the remote-control-off write-block override """Per-device options: the remote-control-off write-block override
@@ -487,7 +506,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
"href": href, "href": href,
"current_value": ( "current_value": (
json.dumps(current, indent=2, ensure_ascii=False) json.dumps(current, indent=2, ensure_ascii=False)
if current else "(no cached value)" if current else "{}"
), ),
}, },
) )
@@ -531,7 +550,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})", "code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
"new_value": ( "new_value": (
json.dumps(new_rep, indent=2, ensure_ascii=False) json.dumps(new_rep, indent=2, ensure_ascii=False)
if new_rep else "(no value returned)" if new_rep else "{}"
), ),
}, },
) )
+16 -11
View File
@@ -38,13 +38,6 @@ _LOGGER = logging.getLogger(__name__)
_SEED_PATH = ['device', '0'] _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: class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This """StateCache requires a descriptor with an on_observation hook. This
integration doesn't use per-capability observation hooks, so this is a integration doesn't use per-capability observation hooks, so this is a
@@ -548,12 +541,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
resources = self._cache.snapshot() resources = self._cache.snapshot()
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False) bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
if not bypass_remote_control and not remote_control_enabled(resources): if not bypass_remote_control and not remote_control_enabled(resources):
raise ServiceValidationError(_REMOTE_CONTROL_DISABLED_MESSAGE) raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="remote_control_disabled",
)
validate_fn = getattr(desc, 'validate_fn', None) validate_fn = getattr(desc, 'validate_fn', None)
if validate_fn is not None: if validate_fn is not None:
error = validate_fn(payload, rep, resources) error = validate_fn(payload, rep, resources)
if error: if error:
raise ServiceValidationError(error) raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key=error,
)
result = write_fn(payload, rep, href) result = write_fn(payload, rep, href)
if result is None: if result is None:
self._log.warning("write_fn rejected payload %r for %s", payload, href) self._log.warning("write_fn rejected payload %r for %s", payload, href)
@@ -689,10 +688,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
help users pin down device-specific write behavior without a new help users pin down device-specific write behavior without a new
release.""" release."""
if not isinstance(body, dict) or not body: if not isinstance(body, dict) or not body:
raise ServiceValidationError("Debug write payload must be a non-empty object.") raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="debug_payload_empty",
)
path_segs = [s for s in href.strip('/').split('/') if s] path_segs = [s for s in href.strip('/').split('/') if s]
if not path_segs: if not path_segs:
raise ServiceValidationError("A resource href is required.") raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="resource_href_required",
)
norm_href = '/' + '/'.join(path_segs) norm_href = '/' + '/'.join(path_segs)
async with self._session_lock: async with self._session_lock:
code, new_rep = await self.hass.async_add_executor_job( code, new_rep = await self.hass.async_add_executor_job(
+42 -16
View File
@@ -38,15 +38,32 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
def _derive_name(state_key: str) -> str: 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 Strips a trailing instance number of 0 (singleton), promotes any other
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1". 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) name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
return _snake_to_title(name).strip() return _snake_to_title(name).strip()
def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
"""Return the stable vendor/href instance label used in a name placeholder."""
if bound.instance_name:
return bound.instance_name
source = bound.key_override or state_key
suffix = f"_{bound.desc.key}"
if source.endswith(suffix):
source = source[:-len(suffix)]
elif bound.instance and source.endswith(bound.instance):
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
return _derive_name(source)
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]): class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
"""Base class for all Local Things entities.""" """Base class for all Local Things entities."""
@@ -57,15 +74,21 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._bound = bound self._bound = bound
self._state_key = _key(bound) self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}" self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
if bound.desc.name is not None: if bound.desc.translation_placeholders is not None:
self._attr_name = bound.desc.name self._attr_translation_placeholders = dict(
elif bound.instance_name: bound.desc.translation_placeholders
# A device-given instance name (e.g. an ice maker's "Cubed )
# Ice") takes the place of the href-derived instance label, elif bound.desc.use_instance_name:
# keeping the same entity-specific suffix (issue #27). self._attr_translation_placeholders = {
self._attr_name = f"{bound.instance_name} {_derive_name(bound.desc.key)}".strip() "instance_name": _instance_display_name(bound, self._state_key)
else: }
self._attr_name = _derive_name(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 self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
@@ -73,11 +96,12 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
@property @property
def translation_key(self) -> str | None: def translation_key(self) -> str | None:
"""Override Entity.translation_key (a property upstream, not a """The descriptor's catalog key, defaulting to its own `key`.
plain attribute) so a callable descriptor -- e.g.
laundry.cycle_select's table-id-gated resolver -- is re-evaluated Overrides Entity.translation_key (a property upstream, not a plain
against live coordinator data on every access, not resolved once attribute) so a callable descriptor -- e.g. laundry.cycle_select's
at construction time. 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 Discovery runs on the first /device/0 poll, which the entity
registry already documents can hand a sibling resource an empty registry already documents can hand a sibling resource an empty
@@ -88,7 +112,9 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
value arrives on a later poll. value arrives on a later poll.
""" """
tk = self._bound.desc.translation_key tk = self._bound.desc.translation_key
return tk(self.coordinator.last_resources) if callable(tk) else tk if callable(tk):
return tk(self.coordinator.last_resources)
return tk if tk is not None else self._bound.desc.key
@property @property
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
@@ -47,20 +47,20 @@ from .common import int_or_none, sensor_item_value
from .laundry import bool_option_exists, bool_option_value, option_value, option_write from .laundry import bool_option_exists, bool_option_value, option_value, option_write
_AIR_QUALITY_SENSORS = ( _AIR_QUALITY_SENSORS = (
('dust', 'Dust', 'mdi:blur', 'Dust'), ('dust', 'mdi:blur', 'Dust'),
('fine_dust', 'Fine dust', 'mdi:blur', 'FineDust'), ('fine_dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'Super fine dust', 'mdi:blur', 'SuperFineDust'), ('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'Odor', 'mdi:scent', 'Odor'), ('odor', 'mdi:scent', 'Odor'),
('clean_level', 'Clean level', 'mdi:air-filter', 'CleanLevel'), ('clean_level', 'mdi:air-filter', 'CleanLevel'),
) )
AIR_QUALITY = Capability( AIR_QUALITY = Capability(
href='/sensors/vs/0', href='/sensors/vs/0',
poll_tier='warm', poll_tier='warm',
entities=tuple( entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', name=name, icon=icon, SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t)) value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, name, icon, sensor_type in _AIR_QUALITY_SENSORS for key, icon, sensor_type in _AIR_QUALITY_SENSORS
), ),
) )
@@ -85,7 +85,7 @@ FILTER = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
SensorDesc(key='filter_progress', field='x.com.samsung.da.items', SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
name='Filter progress', unit='%', state_class='measurement', unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic', icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda items: int_or_none( value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))), _consumable_state(items, 'FilterProgress'))),
@@ -97,7 +97,7 @@ DEVICE_ACTIVE = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive', BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
name='Device active', icon='mdi:check-network-outline', icon='mdi:check-network-outline',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda v: bool(v)), value_fn=lambda v: bool(v)),
), ),
@@ -110,10 +110,10 @@ AIRFLOW_GENERIC = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SensorDesc(key='fan_speed_level', field='speed', SensorDesc(key='fan_speed_level', field='speed',
name='Fan speed level', icon='mdi:fan', icon='mdi:fan',
state_class='measurement', entity_category='diagnostic'), state_class='measurement', entity_category='diagnostic'),
SensorDesc(key='fan_direction', field='direction', SensorDesc(key='fan_direction', field='direction',
name='Fan direction', icon='mdi:rotate-3d-variant', icon='mdi:rotate-3d-variant',
entity_category='diagnostic'), entity_category='diagnostic'),
), ),
) )
@@ -124,11 +124,11 @@ AIRFLOW_VS_FALLBACK = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel', SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
name='Fan speed level', icon='mdi:fan', icon='mdi:fan',
state_class='measurement', entity_category='diagnostic', state_class='measurement', entity_category='diagnostic',
value_fn=int_or_none), value_fn=int_or_none),
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction', SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
name='Fan direction', icon='mdi:rotate-3d-variant', icon='mdi:rotate-3d-variant',
entity_category='diagnostic'), entity_category='diagnostic'),
), ),
) )
@@ -150,14 +150,14 @@ MODE = Capability(
href='/mode/vs/0', href='/mode/vs/0',
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='display_light', name='Display light', icon='mdi:led-on', SwitchDesc(key='display_light', icon='mdi:led-on',
entity_category='config', entity_category='config',
rep_fn=bool_option_value('Light'), rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('Light'), exists_fn=bool_option_exists('Light'),
write_fn=_light_write), write_fn=_light_write),
# Read-only -- confirmed NOT the fan-speed selector (see module # Read-only -- confirmed NOT the fan-speed selector (see module
# docstring), actual purpose still unconfirmed. # docstring), actual purpose still unconfirmed.
SensorDesc(key='operating_mode', name='Operating mode', icon='mdi:fan', SensorDesc(key='operating_mode', icon='mdi:fan',
entity_category='diagnostic', entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'), rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('Comode')), exists_fn=bool_option_exists('Comode')),
@@ -109,7 +109,7 @@ AIR_PURIFY = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='air_purify', field='x.com.samsung.da.modes', SwitchDesc(key='air_purify', field='x.com.samsung.da.modes',
name='Air purification', icon='mdi:air-purifier', icon='mdi:air-purifier',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -123,7 +123,7 @@ AUTO_CLEAN = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus', SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus',
name='Auto clean', icon='mdi:spray-bottle', icon='mdi:spray-bottle',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -137,12 +137,12 @@ AIR_FILTER = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent, 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'), icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus', SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
name='Filter status', device_class='enum', device_class='enum',
options=('normal', 'wash', 'replace'), options=('normal', 'wash', 'replace'),
translation_key='air_filter_status', translation_key='filter_status',
icon='mdi:air-filter', entity_category='diagnostic', icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v), value_fn=lambda v: v.lower() if isinstance(v, str) else v),
), ),
@@ -153,7 +153,7 @@ DISPLAY_LIGHT = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
SwitchDesc(key='display_light', field='mode', SwitchDesc(key='display_light', field='mode',
name='Display light', icon='mdi:led-on', icon='mdi:led-on',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -170,7 +170,7 @@ MUTE_ONCE = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='mute_once', field='muteonce', SwitchDesc(key='mute_once', field='muteonce',
name='Mute once', icon='mdi:volume-mute', icon='mdi:volume-mute',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -190,11 +190,11 @@ CURRENT_LIMIT = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
BinarySensorDesc(key='current_limit_enabled', field='operation', BinarySensorDesc(key='current_limit_enabled', field='operation',
name='Current limit enabled', icon='mdi:current-ac', icon='mdi:current-ac',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda v: v == 'On'), value_fn=lambda v: v == 'On'),
SensorDesc(key='current_limit_level', field='modes', SensorDesc(key='current_limit_level', field='modes',
name='Current limit level', icon='mdi:current-ac', icon='mdi:current-ac',
entity_category='diagnostic'), entity_category='diagnostic'),
), ),
) )
@@ -129,7 +129,6 @@ POWER_GENERIC = Capability(
href='/power/0', href='/power/0',
entities=( entities=(
SwitchDesc(key='power_switch', field='value', SwitchDesc(key='power_switch', field='value',
name='Power',
value_fn=lambda v: bool(v), value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})), ['power', '0'], {'value': p == 'On'})),
@@ -141,7 +140,6 @@ POWER_VS_FALLBACK = Capability(
match_fn=lambda rep, resources: '/power/0' not in resources, match_fn=lambda rep, resources: '/power/0' not in resources,
entities=( entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power', SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
name='Power',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'], ['power', 'vs', '0'],
@@ -153,7 +151,7 @@ KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0', href='/kidslock/0',
entities=( entities=(
SwitchDesc(key='child_lock', field='value', SwitchDesc(key='child_lock', field='value',
name='Child lock', device_class='lock', device_class='lock',
value_fn=lambda v: bool(v), value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})), ['kidslock', '0'], {'value': p == 'On'})),
@@ -165,7 +163,7 @@ KIDS_LOCK_VS_FALLBACK = Capability(
match_fn=lambda rep, resources: '/kidslock/0' not in resources, match_fn=lambda rep, resources: '/kidslock/0' not in resources,
entities=( entities=(
SwitchDesc(key='child_lock', field='x.com.samsung.da.kidsLock', 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', value_fn=lambda v: v != 'Ready',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['kidslock', 'vs', '0'], ['kidslock', 'vs', '0'],
@@ -199,7 +197,7 @@ REMOTE_CONTROL_GENERIC = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
BinarySensorDesc(key='remote_control', field='value', BinarySensorDesc(key='remote_control', field='value',
name='Smart Control', device_class='connectivity', device_class='connectivity',
value_fn=lambda v: bool(v)), value_fn=lambda v: bool(v)),
), ),
) )
@@ -211,7 +209,7 @@ REMOTE_CONTROL_VS_FALLBACK = Capability(
entities=( entities=(
BinarySensorDesc(key='remote_control', BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled', field='x.com.samsung.da.remoteControlEnabled',
name='Smart Control', device_class='connectivity', device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'), value_fn=lambda v: str(v).lower() == 'true'),
), ),
) )
@@ -221,7 +219,7 @@ ALARMS = Capability(
poll_tier='hot', poll_tier='hot',
entities=( entities=(
SensorDesc(key='alarm_code', field='x.com.samsung.da.items', 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), entity_category='diagnostic', value_fn=_active_alarm_codes),
), ),
) )
@@ -244,13 +242,13 @@ ENERGY_METER = Capability(
# entity when /device/0 returns a not-yet-fetched stub. On a populated # 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. # rep, hide power only for the dead sentinel or an absent field.
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower', 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, unit='W', value_fn=clamp_power,
exists_fn=lambda rep, resources: not rep or ( exists_fn=lambda rep, resources: not rep or (
rep.get('x.com.samsung.da.instantaneousPower') rep.get('x.com.samsung.da.instantaneousPower')
not in (None, _DEAD_INSTANTANEOUS_POWER))), not in (None, _DEAD_INSTANTANEOUS_POWER))),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower', 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, state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativePower' in rep)), not rep or 'x.com.samsung.da.cumulativePower' in rep)),
@@ -261,7 +259,7 @@ ENERGY_METER = Capability(
# energy_kwh above -- without it, an exists_fn permanently drops the # energy_kwh above -- without it, an exists_fn permanently drops the
# entity if setup happens to land on a not-yet-fetched stub. # entity if setup happens to land on a not-yet-fetched stub.
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption', 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, state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)), not rep or 'x.com.samsung.da.cumulativeConsumption' in rep)),
@@ -269,7 +267,7 @@ ENERGY_METER = Capability(
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/ # baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
# #27) and absent on others (issue #20/#26), unlike cumulativePower. # #27) and absent on others (issue #20/#26), unlike cumulativePower.
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower', 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, state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)), not rep or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
@@ -277,12 +275,12 @@ ENERGY_METER = Capability(
# in-progress current month. Not ever-increasing (each resets at # in-progress current month. Not ever-increasing (each resets at
# month boundary), so no state_class. # month boundary), so no state_class.
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption', 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, unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.monthlyConsumption' in rep)), not rep or 'x.com.samsung.da.monthlyConsumption' in rep)),
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption', 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, unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)), not rep or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
@@ -293,7 +291,7 @@ WATER_METER = Capability(
href='/water/consumption/vs/0', href='/water/consumption/vs/0',
entities=( entities=(
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater', 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', state_class='total_increasing', unit='L', icon='mdi:water',
value_fn=_ml_to_l), value_fn=_ml_to_l),
), ),
@@ -304,10 +302,14 @@ WATER_FILTER = Capability(
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused', match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
entities=( entities=(
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage', SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
name='Filter usage', unit='%', state_class='measurement', unit='%', state_class='measurement',
icon='mdi:filter'), icon='mdi:filter'),
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus', 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
)),
), ),
) )
@@ -324,7 +326,7 @@ WATER_FILTER = Capability(
# #
# No translation_key: aiLevel's values are plain digit strings, and # No translation_key: aiLevel's values are plain digit strings, and
# select.py's _display() already renders an untranslated numeric string # 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. # against an unknown, growing number of future levels.
@@ -371,14 +373,14 @@ AI_ENERGY_LEVEL = Capability(
# cold-tier href, the same reload already required to fix which # cold-tier href, the same reload already required to fix which
# platform got picked in that case. # platform got picked in that case.
SwitchDesc(key='ai_energy_level', field='aiLevel', SwitchDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', icon='mdi:leaf', icon='mdi:leaf',
entity_category='config', entity_category='config',
value_fn=lambda v: v != '0', value_fn=lambda v: v != '0',
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) == 1), len(_ai_energy_supported_levels(rep)) == 1),
write_fn=_ai_energy_level_switch_write), write_fn=_ai_energy_level_switch_write),
SelectDesc(key='ai_energy_level', field='aiLevel', SelectDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', icon='mdi:leaf', icon='mdi:leaf',
entity_category='config', entity_category='config',
options=_ai_energy_level_options, options=_ai_energy_level_options,
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
@@ -394,7 +396,6 @@ FIRMWARE_UPDATE = Capability(
BinarySensorDesc( BinarySensorDesc(
key='firmware_update', key='firmware_update',
field='x.com.samsung.da.newVersionAvailable', field='x.com.samsung.da.newVersionAvailable',
name='Firmware update available',
device_class='update', device_class='update',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None, value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
@@ -407,21 +408,21 @@ SELF_CHECK = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status', SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
name='Self-check status', icon='mdi:stethoscope', icon='mdi:stethoscope',
entity_category='diagnostic'), entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result', 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'), entity_category='diagnostic'),
# List of error codes from the last self-check; joined for display. # List of error codes from the last self-check; joined for display.
# Not every fridge reports the field, hence the exists_fn. # Not every fridge reports the field, hence the exists_fn.
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error', 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', entity_category='diagnostic',
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.error' in rep), 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), 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', ButtonDesc(key='selfcheck_start', field='', payload='Start',
payload='Start', icon='mdi:play-circle-outline', icon='mdi:play-circle-outline',
entity_category='diagnostic', entity_category='diagnostic',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})), ['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
@@ -61,7 +61,6 @@ COOKTOP_POWER = Capability(
BinarySensorDesc( BinarySensorDesc(
key='power_state', key='power_state',
field='x.com.samsung.da.power', field='x.com.samsung.da.power',
name='Power state',
device_class='power', device_class='power',
icon='mdi:stove', icon='mdi:stove',
value_fn=lambda value: str(value).lower() == 'on', value_fn=lambda value: str(value).lower() == 'on',
@@ -83,7 +82,6 @@ COOKTOP_MODE = Capability(
BinarySensorDesc( BinarySensorDesc(
key='any_burner_active', key='any_burner_active',
field='x.com.samsung.da.options', field='x.com.samsung.da.options',
name='Any burner active',
device_class='running', device_class='running',
icon='mdi:fire', icon='mdi:fire',
value_fn=_any_burner_active, value_fn=_any_burner_active,
@@ -92,7 +90,8 @@ COOKTOP_MODE = Capability(
SensorDesc( SensorDesc(
key=f'burner_{slot}_state', key=f'burner_{slot}_state',
field='x.com.samsung.da.options', field='x.com.samsung.da.options',
name=f'Burner {slot} state', translation_key='burner_state',
translation_placeholders={'number': str(slot)},
icon='mdi:gas-burner', icon='mdi:gas-burner',
value_fn=lambda options, slot=slot: _option_value( value_fn=lambda options, slot=slot: _option_value(
options, f'OperationState{slot}' options, f'OperationState{slot}'
@@ -109,14 +108,12 @@ COOKTOP_MODE = Capability(
SensorDesc( SensorDesc(
key='main_timer_state', key='main_timer_state',
field='x.com.samsung.da.options', field='x.com.samsung.da.options',
name='Timer state',
icon='mdi:timer-outline', icon='mdi:timer-outline',
value_fn=lambda options: _option_value(options, 'MainTimerState'), value_fn=lambda options: _option_value(options, 'MainTimerState'),
), ),
SensorDesc( SensorDesc(
key='main_timer_current', key='main_timer_current',
field='x.com.samsung.da.options', field='x.com.samsung.da.options',
name='Timer current value',
icon='mdi:timer-sand', icon='mdi:timer-sand',
enabled_default=False, enabled_default=False,
value_fn=lambda options: _int_or_none( value_fn=lambda options: _int_or_none(
@@ -134,7 +131,6 @@ COOKTOP_CONNECTED = Capability(
BinarySensorDesc( BinarySensorDesc(
key='cloud_connected', key='cloud_connected',
field='x.com.samsung.da.connected', field='x.com.samsung.da.connected',
name='Cloud connected',
device_class='connectivity', device_class='connectivity',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on', value_fn=lambda value: str(value).lower() == 'on',
@@ -150,35 +146,30 @@ PAIRED_HOOD_STATUS = Capability(
BinarySensorDesc( BinarySensorDesc(
key='paired_hood_connected', key='paired_hood_connected',
field='connectionState', field='connectionState',
name='Paired hood connected',
device_class='connectivity', device_class='connectivity',
value_fn=lambda value: str(value).lower() == 'connected', value_fn=lambda value: str(value).lower() == 'connected',
), ),
BinarySensorDesc( BinarySensorDesc(
key='paired_hood_power', key='paired_hood_power',
field='power', field='power',
name='Paired hood power',
device_class='running', device_class='running',
value_fn=lambda value: str(value).lower() == 'on', value_fn=lambda value: str(value).lower() == 'on',
), ),
SensorDesc( SensorDesc(
key='paired_hood_fan_speed', key='paired_hood_fan_speed',
field='fanSpeed', field='fanSpeed',
name='Paired hood fan speed',
icon='mdi:fan', icon='mdi:fan',
value_fn=_int_or_none, value_fn=_int_or_none,
), ),
BinarySensorDesc( BinarySensorDesc(
key='paired_hood_light', key='paired_hood_light',
field='lampState', field='lampState',
name='Paired hood light',
device_class='light', device_class='light',
value_fn=lambda value: str(value).lower() == 'on', value_fn=lambda value: str(value).lower() == 'on',
), ),
SensorDesc( SensorDesc(
key='paired_hood_model', key='paired_hood_model',
field='micomModelId', field='micomModelId',
name='Paired hood model',
icon='mdi:information-outline', icon='mdi:information-outline',
entity_category='diagnostic', entity_category='diagnostic',
enabled_default=False, enabled_default=False,
@@ -186,7 +177,6 @@ PAIRED_HOOD_STATUS = Capability(
SensorDesc( SensorDesc(
key='paired_hood_firmware', key='paired_hood_firmware',
field='firmwareVersion', field='firmwareVersion',
name='Paired hood firmware',
icon='mdi:chip', icon='mdi:chip',
entity_category='diagnostic', entity_category='diagnostic',
enabled_default=False, enabled_default=False,
@@ -18,13 +18,13 @@ DISHWASHER_SETTINGS = Capability(
href='/dishwasher/vs/0', href='/dishwasher/vs/0',
entities=( entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize', SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
name='Sanitize', icon='mdi:bacteria', icon='mdi:bacteria',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'], ['dishwasher', 'vs', '0'],
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})), {'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry', 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', options_field='x.com.samsung.da.supportedHeatedDry',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'], ['dishwasher', 'vs', '0'],
@@ -45,9 +45,9 @@ CYCLE_OPTIONS = Capability(
href='/course/vs/0', href='/course/vs/0',
entities=( entities=(
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'), 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'), '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), 'AutoDoorRelease', gate_on_presence=True),
), ),
) )
@@ -61,10 +61,10 @@ DIAGNOSIS = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart', SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
name='Diagnosis status', icon='mdi:stethoscope', icon='mdi:stethoscope',
entity_category='diagnostic'), entity_category='diagnostic'),
ButtonDesc(key='diagnosis_start', field='', name='Start diagnosis', ButtonDesc(key='diagnosis_start', field='', payload='Start',
payload='Start', icon='mdi:play-circle-outline', icon='mdi:play-circle-outline',
entity_category='diagnostic', entity_category='diagnostic',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})), ['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
@@ -76,7 +76,7 @@ OPERATION_ORIGIN = Capability(
poll_tier='cold', poll_tier='cold',
entities=( entities=(
SensorDesc(key='operation_origin', field='origin', SensorDesc(key='operation_origin', field='origin',
name='Last operation source', icon='mdi:remote', icon='mdi:remote',
entity_category='diagnostic'), entity_category='diagnostic'),
), ),
) )
@@ -24,13 +24,13 @@ DRYER_SETTINGS = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel', 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', 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', 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', 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', value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write), write_fn=_wrinkle_write),
), ),
@@ -58,6 +58,6 @@ DRYER_DIAGNOSIS = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart', 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 from .common import normalize_temp_unit
# Display names for the beverage zone, flex zone, ice type, and # 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 # 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 # normalize to lowercase for HA's translation lookup and map back to this
# original casing before writing to the device. # original casing before writing to the device.
@@ -55,7 +55,8 @@ TEMP_CURRENT_GENERIC = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SensorDesc(key='temperature', field='temperature', 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, device_class='temperature', unit_fn=_temp_unit,
state_class='measurement'), state_class='measurement'),
), ),
@@ -68,7 +69,8 @@ TEMP_SETPOINT_GENERIC = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
NumberDesc(key='setpoint', field='temperature', 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, native_min=-20.0, native_max=50.0,
range_field='range', entity_category='config', range_field='range', entity_category='config',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -87,7 +89,7 @@ ICEMAKER_NIGHTTIME = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='ice_night_mode', field='ice.night.status', 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', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -101,8 +103,9 @@ ICEMAKER_NIGHTTIME = Capability(
# /icemaker/status/vs/0 is kept as exact-href cap and binds first. # /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). # /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", # Entity names interpolate x.com.samsung.da.iceMaker.name ("CUBED_ICE",
# "ICE_BITES") via name_field, not the href's "one"/"two" segment -- these two # "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 # 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 # (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. # select, but users still want them labeled with the device's own names.
@@ -123,17 +126,18 @@ ICEMAKER_GENERIC = Capability(
entities=( entities=(
SensorDesc(key='making_status', SensorDesc(key='making_status',
field='x.com.samsung.da.iceMaker.iceMakingStatus', field='x.com.samsung.da.iceMaker.iceMakingStatus',
name=None, icon='mdi:cube-outline', use_instance_name=True, icon='mdi:cube-outline',
device_class='enum', device_class='enum',
options=('icestatus_stop', 'icestatus_run'), options=('icestatus_stop', 'icestatus_run'),
translation_key='ice_making_status', translation_key='ice_making_status',
value_fn=lambda v: v.lower() if isinstance(v, str) else v), value_fn=lambda v: v.lower() if isinstance(v, str) else v),
SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state', 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', value_fn=lambda v: v == 'On',
write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')), write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')),
SelectDesc(key='type', field='x.com.samsung.da.iceType.desired', 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', translation_key='ice_type',
entity_category='config', entity_category='config',
options_field='x.com.samsung.da.iceType.supported', options_field='x.com.samsung.da.iceType.supported',
@@ -151,7 +155,7 @@ DOOR_ALERT = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SelectDesc(key='door_alert', field='alert.door', SelectDesc(key='door_alert', field='alert.door',
name='Door alarm', icon='mdi:bell-alert', icon='mdi:bell-alert',
translation_key='door_alert', translation_key='door_alert',
entity_category='config', entity_category='config',
options_field='supportedAlert.door', options_field='supportedAlert.door',
@@ -177,12 +181,12 @@ STATUS_LOCK = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='auto_door_opener', field='x.com.samsung.da.ado.devicecontrol', 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', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')), write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')),
SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound', SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound',
name='Sound', icon='mdi:volume-high', icon='mdi:volume-high',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=_status_lock_write('x.com.samsung.da.device.sound')), write_fn=_status_lock_write('x.com.samsung.da.device.sound')),
@@ -207,7 +211,7 @@ DEFROST_DELAY = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost', SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost',
name='Defrost delay', icon='mdi:snowflake-off', icon='mdi:snowflake-off',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -229,7 +233,7 @@ DEFROST_BLOCK_STATUS = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes', BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes',
name='Defrost active', icon='mdi:snowflake-melt', icon='mdi:snowflake-melt',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'), value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'),
), ),
@@ -252,12 +256,12 @@ REFRIGERATION = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge', SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge',
name='Rapid fridge', icon='mdi:fridge-industrial', icon='mdi:fridge-industrial',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')), write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')),
SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing', SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing',
name='Rapid freezing', icon='mdi:snowflake', icon='mdi:snowflake',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')), write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')),
@@ -279,7 +283,7 @@ AUTOFILL = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='autofill', field='x.com.samsung.da.autofill', SwitchDesc(key='autofill', field='x.com.samsung.da.autofill',
name='Autofill pitcher', icon='mdi:cup-water', icon='mdi:cup-water',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=_autofill_write), write_fn=_autofill_write),
@@ -295,7 +299,7 @@ WELCOME_LIGHTING = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='welcome_lighting', field='status', SwitchDesc(key='welcome_lighting', field='status',
name='Welcome lighting', icon='mdi:motion-sensor', icon='mdi:motion-sensor',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -386,15 +390,15 @@ CABINET_LIGHT_ENHANCED = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SelectDesc(key='day_brightness', field='level.brightness.daytime', SelectDesc(key='day_brightness', field='level.brightness.daytime',
name='Cabinet brightness', icon='mdi:brightness-5', icon='mdi:brightness-5',
translation_key='brightness_level', translation_key='day_brightness',
entity_category='config', entity_category='config',
options=_NIGHT_BRIGHTNESS_OPTIONS, options=_NIGHT_BRIGHTNESS_OPTIONS,
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['cabinet', 'light', 'enhanced', 'vs', '0'], ['cabinet', 'light', 'enhanced', 'vs', '0'],
{'level.brightness.daytime': p})), {'level.brightness.daytime': p})),
SelectDesc(key='brightness_level', field='level.brightness.nighttime', SelectDesc(key='brightness_level', field='level.brightness.nighttime',
name='Night brightness', icon='mdi:brightness-4', icon='mdi:brightness-4',
translation_key='brightness_level', translation_key='brightness_level',
entity_category='config', entity_category='config',
options=_NIGHT_BRIGHTNESS_OPTIONS, options=_NIGHT_BRIGHTNESS_OPTIONS,
@@ -402,12 +406,12 @@ CABINET_LIGHT_ENHANCED = Capability(
['cabinet', 'light', 'enhanced', 'vs', '0'], ['cabinet', 'light', 'enhanced', 'vs', '0'],
{'level.brightness.nighttime': p})), {'level.brightness.nighttime': p})),
TimeDesc(key='night_start', field='', TimeDesc(key='night_start', field='',
name='Night light start', icon='mdi:clock-start', icon='mdi:clock-start',
entity_category='config', entity_category='config',
rep_fn=_night_start_value, rep_fn=_night_start_value,
write_fn=_night_start_write), write_fn=_night_start_write),
TimeDesc(key='night_end', field='', TimeDesc(key='night_end', field='',
name='Night light end', icon='mdi:clock-end', icon='mdi:clock-end',
entity_category='config', entity_category='config',
rep_fn=_night_end_value, rep_fn=_night_end_value,
write_fn=_night_end_write), write_fn=_night_end_write),
@@ -431,11 +435,11 @@ CABINET_LIGHT = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl', 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', value_fn=lambda v: v == 'On',
write_fn=_cabinet_light_write), write_fn=_cabinet_light_write),
SwitchDesc(key='cabinet_light_dim', field='light.dimming.status', SwitchDesc(key='cabinet_light_dim', field='light.dimming.status',
name='Brighten gradually', icon='mdi:brightness-auto', icon='mdi:brightness-auto',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -459,7 +463,7 @@ SABBATH = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode', SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode',
name='Sabbath mode', icon='mdi:hands-pray', icon='mdi:hands-pray',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=_sabbath_write), write_fn=_sabbath_write),
@@ -479,7 +483,7 @@ BEVERAGE_ZONE = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SelectDesc(key='beverage_zone_mode', field='roomDesiredMode', SelectDesc(key='beverage_zone_mode', field='roomDesiredMode',
name='Beverage zone mode', icon='mdi:glass-wine', icon='mdi:glass-wine',
translation_key='beverage_zone_mode', translation_key='beverage_zone_mode',
entity_category='config', entity_category='config',
options_field='roomSupportedModes', options_field='roomSupportedModes',
@@ -507,7 +511,7 @@ PANTRY_ZONE = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode', 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', translation_key='pantry_zone_mode',
entity_category='config', entity_category='config',
options_field='x.com.samsung.da.supportedOptions', options_field='x.com.samsung.da.supportedOptions',
@@ -554,7 +558,7 @@ FLEX_ZONE = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SelectDesc(key='flex_zone_mode', SelectDesc(key='flex_zone_mode',
name='Flex zone mode', icon='mdi:thermostat', icon='mdi:thermostat',
translation_key='flex_zone_mode', translation_key='flex_zone_mode',
entity_category='config', entity_category='config',
options_field='x.com.samsung.da.supportedOptions', options_field='x.com.samsung.da.supportedOptions',
@@ -575,7 +579,8 @@ DOOR_GENERIC = Capability(
poll_tier='hot', poll_tier='hot',
entities=( entities=(
BinarySensorDesc(key='open', field='openState', 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'), value_fn=lambda v: v == 'Open'),
), ),
) )
@@ -604,7 +609,7 @@ DOORS_FALLBACK = Capability(
poll_tier='hot', poll_tier='hot',
entities=( entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items', BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door', device_class='door',
value_fn=lambda items: any( value_fn=lambda items: any(
i.get('x.com.samsung.da.openState') == 'Open' i.get('x.com.samsung.da.openState') == 'Open'
for i in (items or []))), for i in (items or []))),
@@ -636,13 +641,13 @@ TEMPERATURES_FALLBACK = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items', 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', device_class='temperature', state_class='measurement',
unit_fn=lambda rep: _temp_item_unit( unit_fn=lambda rep: _temp_item_unit(
rep.get('x.com.samsung.da.items'), 'Freezer'), rep.get('x.com.samsung.da.items'), 'Freezer'),
value_fn=lambda items: _temp_item_value(items, 'Freezer')), value_fn=lambda items: _temp_item_value(items, 'Freezer')),
SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items', 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', device_class='temperature', state_class='measurement',
unit_fn=lambda rep: _temp_item_unit( unit_fn=lambda rep: _temp_item_unit(
rep.get('x.com.samsung.da.items'), 'Fridge'), rep.get('x.com.samsung.da.items'), 'Fridge'),
@@ -665,7 +670,7 @@ ICEMAKER_STATUS_FALLBACK = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
SwitchDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker', 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', value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['icemaker', 'status', 'vs', '0'], ['icemaker', 'status', 'vs', '0'],
@@ -695,17 +700,17 @@ REFRIGERATION_FALLBACK = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
BinarySensorDesc(key='defrost_active', field='defrost', BinarySensorDesc(key='defrost_active', field='defrost',
name='Defrost active', icon='mdi:snowflake-melt', icon='mdi:snowflake-melt',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda v: bool(v), value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources), exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources),
BinarySensorDesc(key='rapid_fridge', field='rapidCool', BinarySensorDesc(key='rapid_fridge', field='rapidCool',
name='Rapid fridge', icon='mdi:fridge-industrial', icon='mdi:fridge-industrial',
entity_category='config', entity_category='config',
value_fn=lambda v: bool(v), value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources), exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources),
BinarySensorDesc(key='rapid_freezing', field='rapidFreeze', BinarySensorDesc(key='rapid_freezing', field='rapidFreeze',
name='Rapid freezing', icon='mdi:snowflake', icon='mdi:snowflake',
entity_category='config', entity_category='config',
value_fn=lambda v: bool(v), value_fn=lambda v: bool(v),
exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources), 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 datetime import time as dt_time
from ...catalog import has_entity_translation
from ..capability import Capability from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
@@ -60,30 +61,30 @@ DOOR_LED = Capability(
href='/doorled/light/vs/0', href='/doorled/light/vs/0',
entities=( entities=(
SelectDesc(key='led_brightness', field='setBrightness', SelectDesc(key='led_brightness', field='setBrightness',
name='Door LED brightness', icon='mdi:brightness-6', icon='mdi:brightness-6',
entity_category='config', entity_category='config',
options=_LED_LEVELS, write_fn=_led_brightness_write), options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight', SwitchDesc(key='led_night_light', field='setNightLight',
name='Door LED night light', icon='mdi:weather-night', icon='mdi:weather-night',
entity_category='config', entity_category='config',
value_fn=lambda v: v == 'On', value_fn=lambda v: v == 'On',
write_fn=_led_night_write), write_fn=_led_night_write),
SelectDesc(key='led_night_brightness', field='setNightLightBrightness', SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
name='Door LED night brightness', icon='mdi:brightness-4', icon='mdi:brightness-4',
entity_category='config', entity_category='config',
options=_LED_LEVELS, options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'], ['doorled', 'light', 'vs', '0'],
{'setNightLightBrightness': p})), {'setNightLightBrightness': p})),
TimeDesc(key='led_night_start', field='setNightLightTimeStart', TimeDesc(key='led_night_start', field='setNightLightTimeStart',
name='Door LED night start', icon='mdi:clock-start', icon='mdi:clock-start',
entity_category='config', entity_category='config',
value_fn=_parse_hm, value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'], ['doorled', 'light', 'vs', '0'],
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})), {'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
TimeDesc(key='led_night_end', field='setNightLightTimeEnd', TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
name='Door LED night end', icon='mdi:clock-end', icon='mdi:clock-end',
entity_category='config', entity_category='config',
value_fn=_parse_hm, value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -96,7 +97,7 @@ SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0', href='/settings/sound/mode/vs/0',
entities=( entities=(
SelectDesc(key='sound_mode', field='mode', SelectDesc(key='sound_mode', field='mode',
name='Sound mode', icon='mdi:volume-high', icon='mdi:volume-high',
entity_category='config', entity_category='config',
options=_SOUND_MODES, write_fn=_sound_mode_write), options=_SOUND_MODES, write_fn=_sound_mode_write),
), ),
@@ -106,7 +107,7 @@ SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0', href='/settings/sound/volume/vs/0',
entities=( entities=(
NumberDesc(key='sound_volume', field='level', NumberDesc(key='sound_volume', field='level',
name='Sound volume', icon='mdi:volume-medium', icon='mdi:volume-medium',
entity_category='config', entity_category='config',
native_min=0, native_max=15, step=5, native_min=0, native_max=15, step=5,
value_fn=lambda v: int(v) if v is not None else None, value_fn=lambda v: int(v) if v is not None else None,
@@ -127,13 +128,13 @@ BUZZER_SOUND = Capability(
href='/buzzersound/vs/0', href='/buzzersound/vs/0',
entities=( entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound', SelectDesc(key='buzzer_sound', field='setBuzzerSound',
name='Buzzer sound', icon='mdi:volume-high', icon='mdi:volume-high',
entity_category='config', entity_category='config',
options_field='supportedBuzzerSound', options_field='supportedBuzzerSound',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})), ['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
SelectDesc(key='finish_sound', field='setFinishSound', SelectDesc(key='finish_sound', field='setFinishSound',
name='Finish sound', icon='mdi:bell-ring', icon='mdi:bell-ring',
entity_category='config', entity_category='config',
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep, exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
options_field='supportedFinishSound', options_field='supportedFinishSound',
@@ -291,23 +292,22 @@ def cycle_select(*, translation_key, icon, table_href=None):
suffixes translation_key with the device's own course-table id, read suffixes translation_key with the device's own course-table id, read
from /st/washercourse/vs/0 or /st/dryercourse/vs/0's from /st/washercourse/vs/0 or /st/dryercourse/vs/0's
x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' -> x.com.samsung.da.st.courseTable (e.g. 'washer_cycle' + 'Table_02' ->
'washer_cycle_table_02'). No table id available at all -- the href 'washer_cycle_table_02'). An absent or unrecognized table id gets the
absent or empty -- gets no translation_key, i.e. the raw course code name-only ``cycle`` translation key while the raw course code remains
displayed as-is. visible and writable.
This matters because course codes are NOT guaranteed consistent across This matters because course codes are NOT guaranteed consistent across
board generations sharing the same /course/vs/0 contract: every code in board generations sharing the same /course/vs/0 contract: every code in
washer_cycle_table_02 was confirmed against Table_02-reporting devices washer_cycle_table_02 was confirmed against Table_02-reporting devices
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports (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 Table_00 instead, so the same hex code could mean a different course
there for all we've verified. Building the key from whatever table the there for all we've verified. So a table-specific key is used only when
device actually reports, rather than gating a single hardcoded key on the shipped catalog actually has one; any other table (Table_00 today,
an exact match, means a table we haven't built translations for yet whatever ships next) falls back to the name-only ``cycle`` key, which
(like Table_00) just falls through Home Assistant's own missing- shows the raw course code rather than a label borrowed from another
translation handling to the same raw-code display -- exactly what board generation. Translating a new table is therefore a
happens today for any individual code within a table's translations translations-only change -- add the ``<family>_cycle_<table>`` entry and
that isn't populated yet -- and adding one later needs new strings.json this resolver picks it up.
entries, not a code change here.
Left at its default for dishwasher, which has no equivalent table-id 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 resource in any dump seen and no evidence its course codes vary by
@@ -318,10 +318,13 @@ def cycle_select(*, translation_key, icon, table_href=None):
if table_href is not None: if table_href is not None:
def key(resources): def key(resources):
table = _table_id(resources, table_href) table = _table_id(resources, table_href)
return f'{translation_key}_{table.lower()}' if table else None 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( return SelectDesc(
key='cycle', name='Cycle', icon=icon, translation_key=key, key='cycle', icon=icon, translation_key=key,
options=cycle_options, options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)), exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'), rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
@@ -360,8 +363,8 @@ def bool_option_exists(prefix):
rep.get('x.com.samsung.da.options'), prefix) is not None rep.get('x.com.samsung.da.options'), prefix) is not None
def bool_option_switch(key, name, icon, prefix, *, entity_category=None, def bool_option_switch(key, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None): gate_on_presence=False, validate_fn=None):
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token. """A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report gate_on_presence self-gates the entity off on models that never report
@@ -373,7 +376,7 @@ def bool_option_switch(key, name, icon, prefix, *, entity_category=None,
building one, if needed, is the caller's job. building one, if needed, is the caller's job.
""" """
return SwitchDesc( 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, exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
rep_fn=bool_option_value(prefix), rep_fn=bool_option_value(prefix),
write_fn=bool_option_write(prefix), write_fn=bool_option_write(prefix),
@@ -396,7 +399,6 @@ JOB_BEGINNING_STATUS = Capability(
entities=( entities=(
SensorDesc(key='job_beginning_status', SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus', field='x.com.samsung.da.currentStatus',
name='Job beginning status',
entity_category='diagnostic'), entity_category='diagnostic'),
), ),
) )
@@ -2,7 +2,8 @@
Shared by dryer/dishwasher/oven/washer families. Shared by dryer/dishwasher/oven/washer families.
""" """
from datetime import datetime, timezone, timedelta import math
from datetime import datetime, timedelta, timezone
from ..capability import Capability from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
@@ -28,17 +29,41 @@ def _int(v):
return None return None
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)
def _remaining_seconds(raw):
"""'HH:MM:SS' (or 'MM:SS') -> total seconds, or None."""
if not isinstance(raw, str):
return None
try:
parts = [int(p) for p in raw.split(':')]
except (ValueError, TypeError):
return None
if len(parts) == 3:
h, m, s = parts
elif len(parts) == 2:
h, m, s = 0, *parts
else:
return None
return h * 3600 + m * 60 + s
def _delay_hours(v): def _delay_hours(v):
"""delayStartTime is a duration until the cycle starts, not a """delayStartTime is a duration until the cycle starts, not a
wall-clock time -- "01:00" means "1 hour from when you press start", wall-clock time -- "01:00" means "1 hour from when you press start",
not "1 AM".""" not "1 AM"."""
if not v: total_seconds = _remaining_seconds(v)
return 0.0 if total_seconds is None:
try: return 0.0 if not v else None
h, m, s = v.split(':') return total_seconds / 3600.0
return int(h) + int(m) / 60 + int(s) / 3600
except Exception:
return None
def _format_delay(hours): def _format_delay(hours):
@@ -53,27 +78,39 @@ def _delay_field(rep):
wall-clock time -- see _delay_hours). Write back whichever key the wall-clock time -- see _delay_hours). Write back whichever key the
device itself is using; default to delayStartTime for hardware that device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior).""" reports neither yet (matches prior behavior)."""
return ('x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep return (
else 'x.com.samsung.da.delayStartTime') 'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
)
def _finish_time(remaining_str): def _finish_time(rep):
if not remaining_str: if not _is_active(rep):
return None return None
try: total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
h, m, s = remaining_str.split(':') if not total_s:
total_s = int(h) * 3600 + int(m) * 60 + int(s)
if total_s == 0:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
except Exception:
return None return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
def _completion_minutes(rep):
"""Parse remaining time into minutes directly from device payload."""
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
total_s = _remaining_seconds(raw)
if total_s is None:
return None
# Avoid the firmware bug where it freezes at 1 minute post-cycle
if rep.get('x.com.samsung.da.progress') == 'Finish':
return 0
return math.ceil(total_s / 60)
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly # Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
# rather than keeping its own copy, since both wrote the identical # rather than keeping its own copy, since both wrote the identical
# state='Ready' RMW. # state='Ready' RMW.
STOP_BUTTON = ButtonDesc(key='stop', field='', name='Stop', payload='Ready', STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
icon='mdi:stop', icon='mdi:stop',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'], ['operational', 'state', 'vs', '0'],
@@ -84,46 +121,50 @@ OPERATIONAL_STATE = Capability(
poll_tier='hot', poll_tier='hot',
entities=( entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state', SensorDesc(key='machine_state', field='x.com.samsung.da.state',
name='Machine state', device_class='enum', device_class='enum',
options=('idle', 'active', 'pause'), options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf), translation_key='machine_state', value_fn=_to_ocf),
# cycle_active is a bool derived from machine_state; used by the # cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active'). # adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state. # Harmless for non-oven appliances — just an extra bool in state.
# Samsung firmware keeps state='Run' after progress reaches 'Finish', # Samsung firmware keeps state='Run' after progress reaches 'Finish',
# so we also gate on progress to avoid a stuck 'Running' indication. # so we also gate on progress to avoid a stuck 'Running' indication.
# Named 'Running' rather than 'Cycle active' -- this href (and the # Named 'Running' in the catalog rather than 'Cycle active' -- this href (and the
# start/pause/stop buttons below) is shared across the dryer/ # start/pause/stop buttons below) is shared across the dryer/
# dishwasher/oven/washer families, and 'cycle' is laundry-specific # dishwasher/oven/washer families, and 'cycle' is laundry-specific
# vocabulary that doesn't fit an oven's bake/roast/etc. # vocabulary that doesn't fit an oven's bake/roast/etc.
BinarySensorDesc(key='cycle_active', device_class='running', BinarySensorDesc(key='cycle_active', device_class='running',
name='Running',
rep_fn=lambda rep: ( rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active' _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish' 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: ( rep_fn=lambda rep: (
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active' 'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress')) else _progress(rep.get('x.com.samsung.da.progress'))
)), )),
SensorDesc(key='progress_percentage', SensorDesc(key='progress_percentage',
name='Progress percent', unit='%', state_class='measurement', unit='%', state_class='measurement',
rep_fn=lambda rep: ( rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active' 0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage')) else _int(rep.get('x.com.samsung.da.progressPercentage'))
)), )),
# Only show finish time when machine is actively running. Samsung # Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and # firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'. # freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp', SensorDesc(key='finish_time', device_class='timestamp',
name='Estimated finish', rep_fn=_finish_time),
rep_fn=lambda rep: (
None if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active' SensorDesc(key='completion_minutes',
or rep.get('x.com.samsung.da.progress') == 'Finish' icon='mdi:clock-outline', unit='min',
else _finish_time(rep.get('x.com.samsung.da.remainingTime')) device_class='duration', state_class='measurement',
)), exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
NumberDesc(key='delay_start_hours', name='Delay start', icon='mdi:timer-plus-outline', rep_fn=_completion_minutes),
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h', device_class='duration', unit='h',
native_min=0, native_max=24, step=1, native_min=0, native_max=24, step=1,
rep_fn=lambda rep: _delay_hours( rep_fn=lambda rep: _delay_hours(
@@ -132,16 +173,17 @@ OPERATIONAL_STATE = Capability(
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'], ['operational', 'state', 'vs', '0'],
{_delay_field(rep): _format_delay(p)})), {_delay_field(rep): _format_delay(p)})),
ButtonDesc(key='start', field='', name='Start', payload='Run', ButtonDesc(key='start', field='', payload='Run',
icon='mdi:play', icon='mdi:play',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'], ['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})), {'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', name='Pause', payload='Pause', ButtonDesc(key='pause', field='', payload='Pause',
icon='mdi:pause', icon='mdi:pause',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'], ['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})), {'x.com.samsung.da.state': p})),
STOP_BUTTON, STOP_BUTTON,
), ),
) )
@@ -228,25 +228,25 @@ OVEN_OPERATIONAL_STATE = Capability(
poll_tier='hot', poll_tier='hot',
entities=( entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state', 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'), device_class='enum', options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf), translation_key='machine_state', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state', BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
name='Running', device_class='running', device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'), value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage', SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage', field='x.com.samsung.da.progressPercentage',
name='Progress percent', unit='%', state_class='measurement', unit='%', state_class='measurement',
value_fn=_int), value_fn=_int),
SensorDesc(key='operation_time_minutes', SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime', field='x.com.samsung.da.operationTime',
name='Operation time (minutes)', unit='min', unit='min',
state_class='measurement', value_fn=_op_minutes), state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime', SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
name='Estimated finish', device_class='timestamp', device_class='timestamp',
value_fn=_finish_time), value_fn=_finish_time),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime', 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, step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write), write_fn=_cook_time_write),
STOP_BUTTON, STOP_BUTTON,
@@ -258,7 +258,7 @@ OVEN_CAVITY = Capability(
poll_tier='hot', poll_tier='hot',
entities=( entities=(
SensorDesc(key='oven_state', field='x.com.samsung.da.state', SensorDesc(key='oven_state', field='x.com.samsung.da.state',
name='Cavity state'), ),
), ),
) )
@@ -291,7 +291,7 @@ OVEN_SETPOINT = Capability(
entities=( entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0] # NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items', 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), native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up', step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]), native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
@@ -301,7 +301,7 @@ OVEN_SETPOINT = Capability(
(items[0].get('x.com.samsung.da.desired') if items else None)), (items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write), write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items', 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, state_class='measurement', unit_fn=_oven_temp_unit,
value_fn=lambda items: _int( value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))), (items[0].get('x.com.samsung.da.current') if items else None))),
@@ -313,7 +313,7 @@ OVEN_DOOR = Capability(
poll_tier='hot', poll_tier='hot',
entities=( entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items', BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
name='Door', device_class='door', device_class='door',
value_fn=lambda items: ( value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open' items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)), if items else None)),
@@ -325,7 +325,7 @@ OVEN_CONNECTED = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected', BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
name='Cloud connected', device_class='connectivity', device_class='connectivity',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda v: v == 'On'), value_fn=lambda v: v == 'On'),
), ),
@@ -344,25 +344,25 @@ OVEN_MODE = Capability(
entities=( entities=(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0] # SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes', SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
name='Cooking mode', icon='mdi:tune', icon='mdi:tune',
options=_OVEN_MODES, options=_OVEN_MODES,
value_fn=lambda v: v[0] if v else None, value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write), write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options', 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', value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_lamp_write), write_fn=_lamp_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options', SwitchDesc(key='sound', field='x.com.samsung.da.options',
name='Sound', icon='mdi:volume-high', icon='mdi:volume-high',
entity_category='config', entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On', value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write), write_fn=_sound_write),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options', 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', value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_fastpreheat_write), write_fn=_fastpreheat_write),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options', 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', value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_naturalsteam_write), write_fn=_naturalsteam_write),
), ),
@@ -81,17 +81,23 @@ def _burner_entities(i):
n = i + 1 n = i + 1
return ( return (
SelectDesc(key=f'burner_{i}_power_level', field='burnerList', 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, options=_power_level_options,
exists_fn=exists, exists_fn=exists,
value_fn=_burner_field_fn(i, 'powerLevel'), value_fn=_burner_field_fn(i, 'powerLevel'),
write_fn=_burner_power_level_write(i)), write_fn=_burner_power_level_write(i)),
SensorDesc(key=f'burner_{i}_state', field='burnerList', 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, exists_fn=exists,
value_fn=_burner_field_fn(i, 'operationState')), value_fn=_burner_field_fn(i, 'operationState')),
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList', 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, exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)), value_fn=_burner_hot_surface_fn(i)),
) )
@@ -102,7 +108,7 @@ COOKTOP_STATUS = Capability(
poll_tier='hot', poll_tier='hot',
entities=( entities=(
SensorDesc(key='cooktop_state', field='operationState', 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)], *[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
), ),
) )
@@ -123,7 +129,6 @@ COOKTOP_SAFETY = Capability(
poll_tier='warm', poll_tier='warm',
entities=( entities=(
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert', BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
name='Hot surface auto-shutoff enabled',
entity_category='config', entity_category='config',
value_fn=lambda v: (v or {}).get('state') == 'on'), value_fn=lambda v: (v or {}).get('state') == 'on'),
), ),
@@ -52,7 +52,6 @@ HOOD_ALARMS = Capability(
SensorDesc( SensorDesc(
key='alarm_code', key='alarm_code',
field='x.com.samsung.da.items', field='x.com.samsung.da.items',
name='Alarm code',
icon='mdi:alert', icon='mdi:alert',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=_active_alarm_codes, value_fn=_active_alarm_codes,
@@ -93,13 +92,11 @@ HOOD_FAN = Capability(
FanDesc( FanDesc(
key='fan', key='fan',
field='x.com.samsung.da.hood.fanSpeed', field='x.com.samsung.da.hood.fanSpeed',
name=None,
write_fn=_hood_fan_write, write_fn=_hood_fan_write,
), ),
BinarySensorDesc( BinarySensorDesc(
key='automatic_operation', key='automatic_operation',
field='x.com.samsung.da.hood.autoOperation', field='x.com.samsung.da.hood.autoOperation',
name='Automatic operation',
icon='mdi:fan-auto', icon='mdi:fan-auto',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on', value_fn=lambda value: str(value).lower() == 'on',
@@ -125,7 +122,6 @@ HOOD_LAMP = Capability(
SwitchDesc( SwitchDesc(
key='lamp', key='lamp',
field='x.com.samsung.lamp.power', field='x.com.samsung.lamp.power',
name='Lamp',
icon='mdi:range-hood', icon='mdi:range-hood',
value_fn=lambda value: str(value).lower() == 'on', value_fn=lambda value: str(value).lower() == 'on',
write_fn=lambda payload, rep, href=None: ( write_fn=lambda payload, rep, href=None: (
@@ -136,7 +132,6 @@ HOOD_LAMP = Capability(
SelectDesc( SelectDesc(
key='lamp_brightness', key='lamp_brightness',
field='x.com.samsung.lamp.current', field='x.com.samsung.lamp.current',
name='Lamp brightness',
icon='mdi:brightness-6', icon='mdi:brightness-6',
translation_key='range_hood_lamp_brightness', translation_key='range_hood_lamp_brightness',
options_field='x.com.samsung.lamp.range', options_field='x.com.samsung.lamp.range',
@@ -153,7 +148,6 @@ HOOD_FILTER = Capability(
SensorDesc( SensorDesc(
key='hood_filter_usage', key='hood_filter_usage',
field='x.com.samsung.da.filterUsage', field='x.com.samsung.da.filterUsage',
name='Filter usage',
unit='%', unit='%',
state_class='measurement', state_class='measurement',
icon='mdi:air-filter', icon='mdi:air-filter',
@@ -163,14 +157,18 @@ HOOD_FILTER = Capability(
SensorDesc( SensorDesc(
key='hood_filter_status', key='hood_filter_status',
field='x.com.samsung.da.filterStatus', field='x.com.samsung.da.filterStatus',
name='Filter status',
icon='mdi:air-filter', icon='mdi:air-filter',
entity_category='diagnostic', 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( SensorDesc(
key='hood_filter_capacity', key='hood_filter_capacity',
field='x.com.samsung.da.filterCapacity', field='x.com.samsung.da.filterCapacity',
name='Filter capacity',
unit='h', unit='h',
icon='mdi:timer-outline', icon='mdi:timer-outline',
entity_category='diagnostic', entity_category='diagnostic',
@@ -188,26 +186,22 @@ AIR_QUALITY = Capability(
SensorDesc( SensorDesc(
key='clean_level', key='clean_level',
field='x.com.samsung.da.items', field='x.com.samsung.da.items',
name='Clean level',
icon='mdi:air-filter', icon='mdi:air-filter',
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'), value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
), ),
SensorDesc( SensorDesc(
key='dust', key='dust',
field='x.com.samsung.da.items', field='x.com.samsung.da.items',
name='Dust',
value_fn=lambda items: sensor_item_value(items, 'Dust'), value_fn=lambda items: sensor_item_value(items, 'Dust'),
), ),
SensorDesc( SensorDesc(
key='fine_dust', key='fine_dust',
field='x.com.samsung.da.items', field='x.com.samsung.da.items',
name='Fine dust',
value_fn=lambda items: sensor_item_value(items, 'FineDust'), value_fn=lambda items: sensor_item_value(items, 'FineDust'),
), ),
SensorDesc( SensorDesc(
key='super_fine_dust', key='super_fine_dust',
field='x.com.samsung.da.items', field='x.com.samsung.da.items',
name='Super fine dust',
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'), value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
), ),
), ),
@@ -221,7 +215,6 @@ AIR_LEVEL_CHECK = Capability(
BinarySensorDesc( BinarySensorDesc(
key='periodic_air_sensing', key='periodic_air_sensing',
field='x.com.samsung.da.periodicSensingActivationState', field='x.com.samsung.da.periodicSensingActivationState',
name='Periodic air sensing',
icon='mdi:radar', icon='mdi:radar',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on', value_fn=lambda value: str(value).lower() == 'on',
@@ -229,14 +222,12 @@ AIR_LEVEL_CHECK = Capability(
SensorDesc( SensorDesc(
key='air_sensing_state', key='air_sensing_state',
field='x.com.samsung.da.sensingState', field='x.com.samsung.da.sensingState',
name='Air sensing state',
icon='mdi:radar', icon='mdi:radar',
entity_category='diagnostic', entity_category='diagnostic',
), ),
SensorDesc( SensorDesc(
key='last_air_sensing_time', key='last_air_sensing_time',
field='x.com.samsung.da.lastSensingTime', field='x.com.samsung.da.lastSensingTime',
name='Last air sensing time',
device_class='timestamp', device_class='timestamp',
entity_category='diagnostic', entity_category='diagnostic',
value_fn=_timestamp, value_fn=_timestamp,
@@ -244,14 +235,12 @@ AIR_LEVEL_CHECK = Capability(
SensorDesc( SensorDesc(
key='last_air_sensing_level', key='last_air_sensing_level',
field='x.com.samsung.da.lastSensingLevel', field='x.com.samsung.da.lastSensingLevel',
name='Last air sensing level',
icon='mdi:air-filter', icon='mdi:air-filter',
entity_category='diagnostic', entity_category='diagnostic',
), ),
SensorDesc( SensorDesc(
key='automatic_ventilation_state', key='automatic_ventilation_state',
field='x.com.samsung.da.autoExeState', field='x.com.samsung.da.autoExeState',
name='Automatic ventilation state',
icon='mdi:fan-auto', icon='mdi:fan-auto',
entity_category='diagnostic', entity_category='diagnostic',
), ),
@@ -266,7 +255,6 @@ AUTO_VENTILATION = Capability(
SensorDesc( SensorDesc(
key='auto_ventilation_action', key='auto_ventilation_action',
field='action', field='action',
name='Auto ventilation action',
icon='mdi:fan-auto', icon='mdi:fan-auto',
), ),
), ),
@@ -23,8 +23,8 @@ from .laundry import (
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Course_XX hex codes. 23 of the codes named in strings.json/translations # Course_XX hex codes. 23 of the codes named in translations/en.json
# under entity.select.washer_cycle.state.<id, lowercased> were captured # under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList # from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched # (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
# positionally against a Slovak-UI user's screenshots of their app's course # positionally against a Slovak-UI user's screenshots of their app's course
@@ -75,19 +75,19 @@ WASHER_SETTINGS = Capability(
href='/washer/vs/0', href='/washer/vs/0',
entities=( entities=(
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature', SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
name='Wash temperature', icon='mdi:thermometer-water', icon='mdi:thermometer-water',
entity_category='config', entity_category='config',
options_field='x.com.samsung.da.supportedWaterTemperature', options_field='x.com.samsung.da.supportedWaterTemperature',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})), ['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel', SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
name='Spin speed', icon='mdi:sync', icon='mdi:sync',
entity_category='config', entity_category='config',
options_field='x.com.samsung.da.supportedSpinLevel', options_field='x.com.samsung.da.supportedSpinLevel',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})), ['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles', SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
name='Rinse cycles', icon='mdi:water-sync', icon='mdi:water-sync',
entity_category='config', entity_category='config',
options_field='x.com.samsung.da.supportedRinseCycles', options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -97,7 +97,7 @@ WASHER_SETTINGS = Capability(
# #22. Self-gates off on plain washers, which never report # #22. Self-gates off on plain washers, which never report
# supportedDryLevel. # supportedDryLevel.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel', SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
name='Dry level', icon='mdi:tumble-dryer', icon='mdi:tumble-dryer',
entity_category='config', entity_category='config',
translation_key='washer_dry_level', translation_key='washer_dry_level',
options_field='x.com.samsung.da.supportedDryLevel', 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. # valid raw codes for its field, same hex-pair shape as EditCourseList.
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag. # '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
# #
# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_ # Label mapping (entity.select.{detergent,softener}_quantity /
# water_hardness/washer_softener_concentration in strings.json) is an # detergent_water_hardness / softener_concentration in translations/en.json) is an
# assumed, not cross-device-verified, reading of the single issue #9 dump + # 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 # screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
# on-screen equivalent -- the app's Quantité picker only offers # on-screen equivalent -- the app's Quantité picker only offers
@@ -259,7 +259,7 @@ def _dosing_low(prefix):
# auto-release-dry toggles -- only this per-course gating is washer-only, so # 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 # 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). # 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): def validate(p, rep, resources):
"""Reject turning on when the selected course's byte in """Reject turning on when the selected course's byte in
`availability_field` isn't 'F0'. Turning off is never blocked. Falls `availability_field` isn't 'F0'. Turning off is never blocked. Falls
@@ -281,11 +281,11 @@ def _bool_option_switch(key, name, icon, prefix, availability_field):
if len(pairs) != len(courses): if len(pairs) != len(courses):
return None return None
if pairs[courses.index(current)] != 'F0': 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 None
return bool_option_switch( return bool_option_switch(
key, name, icon, prefix, key, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate) entity_category='config', gate_on_presence=True, validate_fn=validate)
@@ -294,63 +294,61 @@ WASHER_COURSE = Capability(
entities=( entities=(
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine', cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0'), table_href='/st/washercourse/vs/0'),
SensorDesc(key='drum_clean_cycles_remaining', name='Drum clean due in', SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
icon='mdi:washing-machine-alert', unit='cycles', icon='mdi:washing-machine-alert',
state_class='measurement', state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None, exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining), rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', name='Drum last cleaned', SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
icon='mdi:calendar-clock', device_class='timestamp', icon='mdi:calendar-clock',
entity_category='diagnostic', entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None, exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned), rep_fn=_drum_clean_last_cleaned),
SelectDesc(key='detergent_quantity', name='Detergent quantity', icon='mdi:cup-water', SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
translation_key='washer_dosing_quantity', translation_key='detergent_quantity',
entity_category='config', entity_category='config',
options=_level_options('DetergentLevelCtrl'), options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool( exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)), _level_options('DetergentLevelCtrl')(resources)),
rep_fn=_dosing_level('DetergentLevelCtrl'), rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')), write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness', SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
icon='mdi:water-opacity', translation_key='detergent_water_hardness',
translation_key='washer_detergent_water_hardness',
entity_category='config', entity_category='config',
options=_level_options('DetergentLevel2Ctrl'), options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool( exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)), _level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=_dosing_level('DetergentLevel2Ctrl'), rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')), write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline', SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
translation_key='washer_dosing_quantity', translation_key='softener_quantity',
entity_category='config', entity_category='config',
options=_level_options('SoftenerLevelCtrl'), options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool( exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)), _level_options('SoftenerLevelCtrl')(resources)),
rep_fn=_dosing_level('SoftenerLevelCtrl'), rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')), write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', name='Softener concentration', SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
icon='mdi:flask-plus-outline', translation_key='softener_concentration',
translation_key='washer_softener_concentration',
entity_category='config', entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'), options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool( exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)), _level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'), rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')), write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', name='Detergent low', BinarySensorDesc(key='detergent_low', device_class='problem',
icon='mdi:alert-circle-outline', device_class='problem', icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('DetergentAlarm'), exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')), rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', name='Softener low', BinarySensorDesc(key='softener_low', device_class='problem',
icon='mdi:alert-circle-outline', device_class='problem', icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('SoftenerAlarm'), exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('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'), 'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine', _bool_option_switch('pre_wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'), 'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine', _bool_option_switch('intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'), 'IntensiveSetting', 'IntensiveAvailableSet'),
), ),
) )
@@ -32,7 +32,7 @@ class BoundEntity:
def _snake_to_title(s: str) -> str: def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's """'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() return s.replace('_', ' ').title()
@@ -8,14 +8,14 @@ where a description declares one.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any, Callable, Optional from typing import Any, Callable, Mapping, Optional
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]] WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
# (payload, rep, resources) -> a human-readable rejection message, or None to # (payload, rep, resources) -> a translation key, or None to allow the
# allow the write. resources is the coordinator's full href->rep snapshot, for # write. resources is the coordinator's full href->rep snapshot, for the same
# the same cross-resource lookups exists_fn needs (e.g. reading a sibling # cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
# href's live option list). # option list).
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]] ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
@@ -27,13 +27,18 @@ def _identity(v: Any) -> Any:
class SamsungEntityDescription: class SamsungEntityDescription:
key: str key: str
field: str = '' field: str = ''
name: 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]] translation_key: Any = None # str | Callable[[dict[str, dict]], Optional[str]]
# callable form receives the coordinator's full href->rep resource # callable form receives the coordinator's full href->rep resource
# snapshot and returns the key to use (or None for no translation this # snapshot and returns the key to use -- for a descriptor shared across
# device) -- for a descriptor shared across board generations whose # board generations whose state-code meaning isn't guaranteed consistent
# state-code meaning isn't guaranteed consistent between them; see # between them; see laundry.cycle_select's table-id-gated resolver.
# 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 icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
enabled_default: bool = True enabled_default: bool = True
+29 -4
View File
@@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SelectDesc from .registry.entities import SelectDesc
from .catalog import translated_states
from .const import DOMAIN from .const import DOMAIN
from .coordinator import LocalThingsCoordinator from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included from .entity import LocalThingsEntity, _is_included
@@ -32,6 +33,22 @@ async def async_setup_entry(
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])') _CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
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]): def _display(value, translation_key: Optional[str]):
"""Turn a raw device option/state value into what's shown in the UI. """Turn a raw device option/state value into what's shown in the UI.
@@ -40,13 +57,13 @@ def _display(value, translation_key: Optional[str]):
callers pass the resolved value, e.g. self.translation_key, not callers pass the resolved value, e.g. self.translation_key, not
the raw descriptor field). the raw descriptor field).
An entity with a translation_key looks its state up in strings.json, An entity with a translation_key looks its state up in the shipped
and hassfest requires those keys to be lowercase -- so those values translation catalog, whose state keys are lowercase -- so those values
must be lowercased exactly to match, and the device still expects must be lowercased exactly to match, and the device still expects
that same raw casing back on write (callers map the displayed value that same raw casing back on write (callers map the displayed value
back to raw via _raw_options()). 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 destroy the device's own casing. Only two cosmetic fixups apply: a
fully lowercase device-native token (e.g. "voice") is title-cased, fully lowercase device-native token (e.g. "voice") is title-cased,
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
@@ -57,7 +74,15 @@ def _display(value, translation_key: Optional[str]):
if not isinstance(value, str): if not isinstance(value, str):
return value return value
if translation_key: if translation_key:
return value.lower() 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(): if value.islower():
return value.replace('_', ' ').title() return value.replace('_', ' ').title()
return _CAMEL_BOUNDARY_RE.sub(' ', value) return _CAMEL_BOUNDARY_RE.sub(' ', value)
-1
View File
@@ -61,7 +61,6 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
Disabled by default — it's for troubleshooting, not everyday use.""" Disabled by default — it's for troubleshooting, not everyday use."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = 'Connection mode'
_attr_translation_key = 'connection_mode' _attr_translation_key = 'connection_mode'
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False _attr_entity_registry_enabled_default = False
-302
View File
@@ -1,302 +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"
}
},
"range_hood_lamp_brightness": {
"state": {
"1": "Level 1",
"2": "Level 2"
}
},
"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_table_02": {
"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_table_03": {
"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."
}
},
"options": {
"step": {
"init": {
"title": "Local Things Options",
"menu_options": {
"settings": "Remote control write settings",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Remote control write settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off"
}
},
"debug_write": {
"title": "Debug: write to a resource",
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
"data": {
"href": "Resource href"
}
},
"debug_edit": {
"title": "Debug write: {href}",
"description": "Current value of `{href}`:\n```\n{current_value}\n```\nEnter ONLY the field(s) you want to change below. What you enter is sent to the device as-is (a partial update); it is NOT merged with the fields shown above, so don't paste the whole value back. Mind the types: a numeric string like \"1\" must stay quoted -- a bare 1 is sent as an integer, which some devices reject.",
"data": {
"payload": "Payload to write"
}
},
"debug_result": {
"title": "Debug write result",
"description": "The device returned CoAP code {code}. A 2.xx class means the write was accepted; 4.xx/5.xx means it was rejected. The resource now reads:\n```\n{new_value}\n```\nIf the value is unchanged, the device likely dropped the write. Pick what to do next:",
"menu_options": {
"debug_write": "Write another resource",
"finish": "Finish"
}
}
},
"error": {
"empty_payload": "Enter at least one field to write.",
"write_failed": "The write failed. Check the Home Assistant logs for details."
},
"abort": {
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
}
},
"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."
}
}
}
+734 -184
View File
@@ -1,187 +1,88 @@
{ {
"entity": { "entity": {
"select": { "binary_sensor": {
"door_alert": { "any_burner_active": {
"state": { "name": "Any burner active"
"1": "Alarm 1",
"2": "Alarm 2",
"3": "Alarm 3",
"4": "Alarm 4"
}
}, },
"brightness_level": { "automatic_operation": {
"state": { "name": "Automatic operation"
"33": "Low",
"66": "Medium",
"100": "High"
}
}, },
"range_hood_lamp_brightness": { "burner_hot_surface": {
"state": { "name": "Burner {number} hot surface"
"1": "Level 1",
"2": "Level 2"
}
}, },
"ice_type": { "cloud_connected": {
"state": { "name": "Cloud connected"
"off": "Off",
"whiskey_iceball_3": "3 Balls/Day",
"whiskey_iceball_6": "6 Balls/Day",
"whiskey_iceball_9": "9 Balls/Day"
}
}, },
"beverage_zone_mode": { "cooktop_safety_shutoff_enabled": {
"state": { "name": "Hot surface auto-shutoff enabled"
"sp_ttype_beer_drinks": "Beverage",
"sp_ttype_wine_dessert": "Wine and Dessert"
}
}, },
"pantry_zone_mode": { "current_limit_enabled": {
"state": { "name": "Current limit enabled"
"fdr_wine": "Wine",
"fdr_deli": "Deli",
"fdr_drinks": "Drinks"
}
}, },
"flex_zone_mode": { "cycle_active": {
"state": { "name": "Running"
"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": { "defrost_active": {
"state": { "name": "Defrost active"
"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_table_02": { "detergent_low": {
"state": { "name": "Detergent low"
"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_table_03": { "device_active": {
"state": { "name": "Device active"
"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": { "door_open": {
"state": { "name": "Door"
"00": "None",
"01": "Low",
"02": "Medium",
"03": "High"
}
}, },
"washer_detergent_water_hardness": { "firmware_update": {
"state": { "name": "Firmware update available"
"01": "Soft",
"02": "Medium",
"03": "Hard"
}
}, },
"washer_softener_concentration": { "instance_open": {
"state": { "name": "{instance_name} open"
"01": "1x",
"02": "2x",
"03": "3x"
}
}, },
"washer_dry_level": { "paired_hood_connected": {
"state": { "name": "Paired hood connected"
"none": "Off", },
"cupboard": "Cupboard", "paired_hood_light": {
"30": "30 min", "name": "Paired hood light"
"60": "1 hr", },
"90": "1 hr 30", "paired_hood_power": {
"120": "2 hr", "name": "Paired hood power"
"180": "3 hr", },
"240": "4 hr" "periodic_air_sensing": {
} "name": "Periodic air sensing"
},
"power_state": {
"name": "Power state"
},
"rapid_freezing": {
"name": "Rapid freezing"
},
"rapid_fridge": {
"name": "Rapid fridge"
},
"remote_control": {
"name": "Smart Control"
},
"softener_low": {
"name": "Softener low"
} }
}, },
"sensor": { "button": {
"ice_making_status": { "diagnosis_start": {
"state": { "name": "Start diagnosis"
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
}, },
"connection_mode": { "pause": {
"state": { "name": "Pause"
"observe": "Push (observe)",
"poll": "Polling"
}
}, },
"machine_state": { "selfcheck_start": {
"state": { "name": "Start self-check"
"idle": "Idle",
"active": "Active",
"pause": "Paused"
}
}, },
"air_filter_status": { "start": {
"state": { "name": "Start"
"normal": "Normal", },
"wash": "Wash", "stop": {
"replace": "Replace" "name": "Stop"
}
} }
}, },
"climate": { "climate": {
@@ -189,24 +90,11 @@
"state_attributes": { "state_attributes": {
"fan_mode": { "fan_mode": {
"state": { "state": {
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High",
"turbo": "Turbo" "turbo": "Turbo"
} }
}, },
"swing_mode": {
"state": {
"off": "Fixed",
"both": "All directions",
"vertical": "Up and down"
}
},
"preset_mode": { "preset_mode": {
"state": { "state": {
"none": "Off",
"sleep": "Sleep",
"quiet": "Quiet", "quiet": "Quiet",
"smart": "Smart", "smart": "Smart",
"speed": "Speed" "speed": "Speed"
@@ -214,17 +102,645 @@
} }
} }
} }
},
"number": {
"cook_time": {
"name": "Cook time"
},
"delay_start_hours": {
"name": "Delay start"
},
"instance_setpoint": {
"name": "{instance_name} setpoint"
},
"oven_setpoint": {
"name": "Setpoint"
},
"sound_volume": {
"name": "Sound volume"
}
},
"select": {
"ai_energy_level": {
"name": "AI Energy Mode level"
},
"beverage_zone_mode": {
"name": "Beverage zone mode",
"state": {
"sp_ttype_beer_drinks": "Beverage",
"sp_ttype_wine_dessert": "Wine and dessert"
}
},
"brightness_level": {
"name": "Night brightness",
"state": {
"33": "Low",
"66": "Medium",
"100": "High"
}
},
"day_brightness": {
"name": "Cabinet brightness",
"state": {
"33": "Low",
"66": "Medium",
"100": "High"
}
},
"buzzer_sound": {
"name": "Buzzer sound",
"state": {
"off": "Off",
"on": "On"
}
},
"cycle": {
"name": "Cycle"
},
"detergent_quantity": {
"name": "Detergent quantity",
"state": {
"00": "None",
"01": "Low",
"02": "Medium",
"03": "High"
}
},
"detergent_water_hardness": {
"name": "Detergent water hardness",
"state": {
"01": "Soft",
"02": "Medium",
"03": "Hard"
}
},
"dishwasher_cycle": {
"name": "Cycle",
"state": {
"80": "Delicate",
"83": "Express 60",
"84": "Heavy",
"86": "Normal",
"90": "Self clean",
"0e": "AI Wash",
"07": "Pre blast",
"8d": "Pots and pans",
"8e": "Plastic",
"8f": "Baby Care"
}
},
"door_alert": {
"name": "Door alarm",
"state": {
"1": "Alarm 1",
"2": "Alarm 2",
"3": "Alarm 3",
"4": "Alarm 4"
}
},
"dryer_cycle_table_03": {
"name": "Cycle",
"state": {
"16": "Cotton",
"18": "Synthetics",
"19": "Delicates",
"20": "Iron dry",
"23": "Quick Dry 35",
"24": "Cool air",
"25": "Warm air",
"27": "Time dry",
"1a": "Wool",
"1b": "Bedding",
"1c": "Shirts",
"1d": "Towels",
"1e": "Outdoor",
"1f": "Mixed load"
}
},
"finish_sound": {
"name": "Finish sound",
"state": {
"off": "Off",
"on": "On"
}
},
"flex_zone_mode": {
"name": "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 & vegetables",
"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"
}
},
"heated_dry": {
"name": "Smart Dry",
"state": {
"off": "Off",
"low": "Low",
"high": "High",
"extra_high": "Extra high"
}
},
"ice_type": {
"name": "{instance_name} type",
"state": {
"off": "Off",
"whiskey_iceball_3": "3 balls/day",
"whiskey_iceball_6": "6 balls/day",
"whiskey_iceball_9": "9 balls/day"
}
},
"led_brightness": {
"name": "Door LED brightness",
"state": {
"low": "Low",
"high": "High"
}
},
"led_night_brightness": {
"name": "Door LED night brightness",
"state": {
"low": "Low",
"high": "High"
}
},
"oven_mode": {
"name": "Cooking mode",
"state": {
"no_operation": "No operation",
"bake": "Bake",
"broil": "Broil",
"convection": "Convection",
"convection_bake": "Convection bake",
"convection_broil": "Convection broil",
"frozen_pizza_plus": "Frozen Pizza+",
"slow_cook": "Slow cook",
"plate_warm": "Plate warm",
"air_fry": "Air fry"
}
},
"pantry_zone_mode": {
"name": "Pantry zone mode",
"state": {
"fdr_wine": "Wine",
"fdr_deli": "Deli",
"fdr_drinks": "Drinks"
}
},
"range_burner_power_level": {
"name": "Burner {number} power level",
"state": {
"0": "Off",
"boost": "Boost",
"simmer": "Simmer"
}
},
"range_hood_lamp_brightness": {
"name": "Lamp brightness",
"state": {
"1": "Level 1",
"2": "Level 2"
}
},
"rinse_cycles": {
"name": "Rinse cycles"
},
"softener_concentration": {
"name": "Softener concentration",
"state": {
"01": "1x",
"02": "2x",
"03": "3x"
}
},
"softener_quantity": {
"name": "Softener quantity",
"state": {
"00": "None",
"01": "Low",
"02": "Medium",
"03": "High"
}
},
"sound_mode": {
"name": "Sound mode",
"state": {
"voice": "Voice",
"tone": "Tone",
"mute": "Mute"
}
},
"spin_speed": {
"name": "Spin speed",
"state": {
"rinse_hold": "Rinse hold",
"no_spin": "No spin",
"low": "Low",
"medium": "Medium",
"high": "High"
}
},
"wash_temperature": {
"name": "Wash temperature",
"state": {
"none": "None",
"cold": "Cold",
"cool": "Cool",
"warm": "Warm",
"hot": "Hot",
"extra_hot": "Extra hot"
}
},
"washer_cycle_table_02": {
"name": "Cycle",
"state": {
"01": "Normal",
"04": "Quick Wash",
"17": "Downloaded",
"1b": "Cotton",
"1c": "Eco 40-60",
"1d": "Super Speed",
"1e": "15' Quick Wash",
"1f": "Intense Cold",
"20": "Hygiene Steam",
"21": "Colors",
"22": "Wool",
"23": "Outdoor",
"24": "Towels",
"25": "Synthetics",
"26": "Delicates",
"27": "Rinse+Spin",
"28": "Drain/Spin",
"29": "Drum Clean+",
"2d": "Silent Wash",
"2e": "Baby Care",
"2f": "Activewear",
"30": "Cloudy Day",
"32": "Shirts",
"33": "Bedding",
"36": "Wash+Dry",
"37": "Air Wash",
"38": "Cotton Dry",
"39": "Synthetics Dry",
"53": "Heavy Duty",
"55": "Activewear",
"57": "Delicate",
"5e": "Rinse+Spin",
"65": "Colors",
"66": "Denim",
"7c": "Whites",
"7d": "Bedding/Waterproof",
"7e": "Self-Clean",
"86": "Deep Wash",
"8f": "Intense Cold",
"96": "Less Microfiber",
"2b": "AI Wash",
"2a": "Jeans"
}
},
"washer_dry_level": {
"name": "Dry level",
"state": {
"30": "30 min",
"60": "1 hr",
"90": "1 hr 30",
"120": "2 hr",
"180": "3 hr",
"240": "4 hr",
"none": "Off",
"cupboard": "Cupboard"
}
}
},
"sensor": {
"air_filter_usage": {
"name": "Filter usage"
},
"air_sensing_state": {
"name": "Air sensing state"
},
"alarm_code": {
"name": "Alarm code"
},
"auto_ventilation_action": {
"name": "Auto ventilation action"
},
"automatic_ventilation_state": {
"name": "Automatic ventilation state"
},
"burner_state": {
"name": "Burner {number} state"
},
"clean_level": {
"name": "Clean level"
},
"connection_mode": {
"name": "Connection mode",
"state": {
"observe": "Push (observe)",
"poll": "Polling"
}
},
"cooktop_state": {
"name": "Cooktop state"
},
"current_limit_level": {
"name": "Current limit level"
},
"current_temp_c": {
"name": "Temperature"
},
"diagnosis": {
"name": "Diagnosis"
},
"diagnosis_status": {
"name": "Diagnosis status"
},
"drum_clean_cycles_remaining": {
"name": "Drum clean due in"
},
"drum_clean_last_cleaned": {
"name": "Drum last cleaned"
},
"dry_level": {
"name": "Dry level"
},
"dry_time": {
"name": "Dry time"
},
"dryer_type": {
"name": "Dryer type"
},
"dust": {
"name": "Dust"
},
"energy_kwh": {
"name": "Energy"
},
"energy_last_month_kwh": {
"name": "Energy (last month)"
},
"energy_saved_kwh": {
"name": "Energy saved"
},
"energy_this_month_kwh": {
"name": "Energy (this month)"
},
"fan_direction": {
"name": "Fan direction"
},
"fan_speed_level": {
"name": "Fan speed level"
},
"filter_progress": {
"name": "Filter progress"
},
"filter_usage": {
"name": "Filter usage"
},
"fine_dust": {
"name": "Fine dust"
},
"finish_time": {
"name": "Estimated finish"
},
"completion_minutes": {
"name": "Completion time"
},
"freezer_temperature": {
"name": "Freezer temperature"
},
"fridge_temperature": {
"name": "Fridge temperature"
},
"hood_filter_capacity": {
"name": "Filter capacity"
},
"hood_filter_usage": {
"name": "Filter usage"
},
"instance_temperature": {
"name": "{instance_name} temperature"
},
"job_beginning_status": {
"name": "Job beginning status"
},
"last_air_sensing_level": {
"name": "Last air sensing level"
},
"last_air_sensing_time": {
"name": "Last air sensing time"
},
"main_timer_current": {
"name": "Timer current value"
},
"main_timer_state": {
"name": "Timer state"
},
"odor": {
"name": "Odor"
},
"operating_mode": {
"name": "Operating mode"
},
"operation_origin": {
"name": "Last operation source"
},
"operation_time_minutes": {
"name": "Operation time"
},
"oven_state": {
"name": "Cavity state"
},
"paired_hood_fan_speed": {
"name": "Paired hood fan speed"
},
"paired_hood_firmware": {
"name": "Paired hood firmware"
},
"paired_hood_model": {
"name": "Paired hood model"
},
"power_energy_kwh": {
"name": "Power energy"
},
"power_watts": {
"name": "Power"
},
"progress": {
"name": "Progress"
},
"progress_percentage": {
"name": "Progress percent"
},
"selfcheck_error": {
"name": "Self-check error"
},
"selfcheck_result": {
"name": "Self-check result"
},
"selfcheck_status": {
"name": "Self-check status"
},
"super_fine_dust": {
"name": "Super fine dust"
},
"water_liters": {
"name": "Water consumption"
},
"machine_state": {
"name": "Machine state",
"state": {
"idle": "Idle",
"active": "Active",
"pause": "Paused"
}
},
"filter_status": {
"name": "Filter status",
"state": {
"normal": "Normal",
"wash": "Wash",
"replace": "Replace"
}
},
"ice_making_status": {
"name": "{instance_name} making status",
"state": {
"icestatus_stop": "Idle",
"icestatus_run": "Making ice"
}
}
},
"switch": {
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_purify": {
"name": "Air purification"
},
"auto_clean": {
"name": "Auto clean"
},
"auto_door_opener": {
"name": "Auto door opener"
},
"auto_release_dry": {
"name": "Auto release dry"
},
"autofill": {
"name": "Autofill pitcher"
},
"bubble_soak": {
"name": "Bubble soak"
},
"cabinet_light_dim": {
"name": "Brighten gradually"
},
"cabinet_light_switch": {
"name": "Cabinet light"
},
"child_lock": {
"name": "Child lock"
},
"defrost_delay": {
"name": "Defrost delay"
},
"display_light": {
"name": "Display light"
},
"fast_preheat": {
"name": "Fast preheat"
},
"fridge_sound": {
"name": "Sound"
},
"ice_maker_enabled": {
"name": "Ice maker"
},
"ice_night_mode": {
"name": "Nighttime ice quiet mode"
},
"instance_enabled": {
"name": "{instance_name} enabled"
},
"intensive": {
"name": "Intensive"
},
"lamp": {
"name": "Lamp"
},
"led_night_light": {
"name": "Door LED night light"
},
"mute_once": {
"name": "Mute once"
},
"natural_steam": {
"name": "Natural steam"
},
"power_switch": {
"name": "Power"
},
"pre_wash": {
"name": "Pre-wash"
},
"rapid_freezing": {
"name": "Rapid freezing"
},
"rapid_fridge": {
"name": "Rapid fridge"
},
"sabbath_mode": {
"name": "Sabbath mode"
},
"sanitize": {
"name": "Sanitize"
},
"sound": {
"name": "Sound"
},
"storm_wash": {
"name": "Storm Wash+"
},
"welcome_lighting": {
"name": "Welcome lighting"
},
"wrinkle_prevent": {
"name": "Wrinkle prevent"
}
},
"time": {
"led_night_end": {
"name": "Door LED night end"
},
"led_night_start": {
"name": "Door LED night start"
},
"night_end": {
"name": "Night light end"
},
"night_start": {
"name": "Night light start"
}
} }
}, },
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Add Samsung Appliance", "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}", "description": "Enter the IP address of your Samsung appliance and the AC14K_M CA credentials used to mint device certificates.",
"data": { "data": {
"host": "IP Address", "host": "IP address",
"ca_cert_pem": "CA Certificate (PEM)", "ca_cert_pem": "CA certificate (PEM)",
"ca_key_pem": "CA Private Key (PEM)" "ca_key_pem": "CA private key (PEM)"
}, },
"data_description": { "data_description": {
"host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).", "host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50).",
@@ -232,9 +748,23 @@
"ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer." "ca_key_pem": "The PEM-encoded AC14K_M private key. Paste the full contents including the BEGIN/END PRIVATE KEY header and footer."
} }
}, },
"user_reuse": {
"title": "Add Samsung appliance",
"description": "Enter the IP address of your Samsung appliance. The CA credentials from an existing LocalThings appliance will be reused.",
"data": {
"host": "IP address"
},
"data_description": {
"host": "Local IP address of your Samsung appliance (e.g. 192.168.1.50)."
}
},
"confirm_unknown_type": { "confirm_unknown_type": {
"title": "Appliance type not recognized", "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." "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."
},
"confirm_unknown_type_no_version": {
"title": "Appliance type not recognized",
"description": "This appliance did not report a oneUiVersion and its type couldn't be recognized. 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": { "error": {
@@ -243,13 +773,13 @@
"unknown": "Unexpected error. Check the Home Assistant logs for details." "unknown": "Unexpected error. Check the Home Assistant logs for details."
}, },
"abort": { "abort": {
"already_configured": "This device is already configured." "already_configured": "Device is already configured"
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Local Things Options", "title": "LocalThings options",
"menu_options": { "menu_options": {
"settings": "Remote control write settings", "settings": "Remote control write settings",
"debug_write": "Debug: write to a resource" "debug_write": "Debug: write to a resource"
@@ -298,5 +828,25 @@
"title": "Incomplete capability coverage for {device_name}", "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." "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."
} }
},
"exceptions": {
"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."
},
"debug_payload_empty": {
"message": "Debug write payload must be a non-empty object."
},
"resource_href_required": {
"message": "A resource href is required."
},
"bubble_soak_unavailable_for_cycle": {
"message": "Bubble Soak isn't available on the selected cycle."
},
"pre_wash_unavailable_for_cycle": {
"message": "Pre-wash isn't available on the selected cycle."
},
"intensive_unavailable_for_cycle": {
"message": "Intensive isn't available on the selected cycle."
}
} }
} }
@@ -0,0 +1,852 @@
{
"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": {
"01": "Normaal",
"04": "Snelle was",
"17": "Gedownload",
"1b": "Katoen",
"1c": "Eco 40-60",
"1d": "Super Speed",
"1e": "15' Snelle was",
"1f": "Intensief koud",
"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+",
"2d": "Stille was",
"2e": "Babyverzorging",
"2f": "Sportkleding",
"30": "Bewolkte dag",
"32": "Overhemden",
"33": "Beddengoed",
"36": "Wassen+drogen",
"37": "Air Wash",
"38": "Katoen drogen",
"39": "Synthetisch drogen",
"53": "Intensief",
"55": "Sportkleding",
"57": "Fijn",
"5e": "Spoelen+centrifugeren",
"65": "Bonte was",
"66": "Spijkergoed",
"7c": "Witte was",
"7d": "Beddengoed/Waterdicht",
"7e": "Zelfreinigend",
"86": "Diep wassen",
"8f": "Intensief koud",
"96": "Minder microvezels",
"2b": "AI Wash",
"2a": "Spijkerbroek"
}
},
"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"
},
"completion_minutes": {
"name": "Voltooiingstijd"
},
"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."
}
}
}
+1
View File
@@ -3,6 +3,7 @@
"alarm_code", "alarm_code",
"auto_release_dry", "auto_release_dry",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code", "alarm_code",
"buzzer_sound", "buzzer_sound",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+1
View File
@@ -5,6 +5,7 @@
"bubble_soak", "bubble_soak",
"buzzer_sound", "buzzer_sound",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+1
View File
@@ -3,6 +3,7 @@
"alarm_code", "alarm_code",
"bubble_soak", "bubble_soak",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+1
View File
@@ -2,6 +2,7 @@
"state_keys": [ "state_keys": [
"alarm_code", "alarm_code",
"child_lock", "child_lock",
"completion_minutes",
"cycle", "cycle",
"cycle_active", "cycle_active",
"delay_start_hours", "delay_start_hours",
+34
View File
@@ -38,6 +38,7 @@ async def test_form_second_device_reuses_creds(hass: HomeAssistant) -> None:
DOMAIN, context={'source': 'user'} DOMAIN, context={'source': 'user'}
) )
assert result['type'] == FlowResultType.FORM 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_CERT_PEM not in result['data_schema'].schema
assert CONF_CA_KEY_PEM not in result['data_schema'].schema assert CONF_CA_KEY_PEM not in result['data_schema'].schema
@@ -219,6 +220,39 @@ async def test_unknown_type_shows_confirmation_step(
assert result['data'][CONF_HOST] == MOCK_HOST 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: async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
"""Second add of same serial: flow aborts. """Second add of same serial: flow aborts.
+6 -2
View File
@@ -914,9 +914,12 @@ async def test_send_command_blocked_when_remote_control_disabled(
with patch.object(fake, 'subscribe'): with patch.object(fake, 'subscribe'):
fake.post = _post 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) 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 assert posted is False
@@ -977,8 +980,9 @@ 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) 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) 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') 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( async def test_send_command_bypasses_remote_control_when_option_enabled(
+1 -1
View File
@@ -259,7 +259,7 @@ class TestAiEnergyLevelSelect:
def test_no_translation_key(self): def test_no_translation_key(self):
"""aiLevel's values are plain digits that render fine untranslated """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.""" against an unknown number of future levels."""
desc = self._desc() desc = self._desc()
assert desc.translation_key is None assert desc.translation_key is None
+1 -1
View File
@@ -69,7 +69,7 @@ def test_course_bound_to_shared_course_vs_0():
assert callable(desc.translation_key) assert callable(desc.translation_key)
table_03 = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}} 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(table_03) == 'dryer_cycle_table_03'
assert desc.translation_key({}) is None assert desc.translation_key({}) == 'cycle'
assert desc.options is laundry.cycle_options assert desc.options is laundry.cycle_options
rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']} rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']}
assert desc.rep_fn(rep) == '16' assert desc.rep_fn(rep) == '16'
+23 -17
View File
@@ -1,7 +1,4 @@
"""Tests for LocalThingsEntity's display-name derivation """Tests for translated entity naming and dynamic instance placeholders."""
(custom_components/localthings/entity.py) -- the explicit-name,
device-given-instance-name, and href-derived fallbacks.
"""
from custom_components.localthings.entity import LocalThingsEntity from custom_components.localthings.entity import LocalThingsEntity
from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity 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) return LocalThingsEntity(_FakeCoordinator(), bound)
def test_explicit_name_wins_over_everything(): def test_descriptor_key_is_the_default_translation_key():
desc = BinarySensorDesc(key='enabled', name='Explicit Name') """A descriptor names itself through the catalog, keyed by its own key.
entity = _make_entity(desc, instance_name='Cubed Ice')
assert entity._attr_name == 'Explicit Name'
Nothing sets _attr_name: that would take precedence over HA's
def test_instance_name_prefixes_the_derived_suffix(): translation catalog and make the entity untranslatable.
"""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."""
desc = BinarySensorDesc(key='enabled') 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', entity = _make_entity(desc, key_override='icemaker_one_enabled',
instance_name='Cubed Ice') 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(): def test_href_instance_name_becomes_translation_placeholder():
desc = BinarySensorDesc(key='enabled') desc = BinarySensorDesc(
key='enabled', translation_key='instance_enabled', use_instance_name=True
)
entity = _make_entity(desc, key_override='icemaker_one_enabled') entity = _make_entity(desc, key_override='icemaker_one_enabled')
assert entity._attr_name == 'Icemaker One Enabled' assert entity.translation_placeholders == {'instance_name': 'Icemaker One'}
+12 -16
View File
@@ -4,10 +4,11 @@ from custom_components.localthings.registry.capabilities import laundry
class TestCourseHelpers: class TestCourseHelpers:
def test_parses_edit_course_list(self): def test_parses_edit_course_list(self):
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96' raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A'
assert laundry.parse_edit_course_list(raw) == [ assert laundry.parse_edit_course_list(raw) == [
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27', '1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96', '22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
'2B', '2A'
] ]
def test_parse_edit_course_list_handles_missing_or_malformed(self): def test_parse_edit_course_list_handles_missing_or_malformed(self):
@@ -174,11 +175,9 @@ class TestCycleSelectTableGating:
codes aren't guaranteed consistent across board generations sharing codes aren't guaranteed consistent across board generations sharing
the same /course/vs/0 contract; FlexWash's older board reports a the same /course/vs/0 contract; FlexWash's older board reports a
different course table than every device the shipped translations different course table than every device the shipped translations
were confirmed against). The resolved key is built from whatever table were confirmed against). The resolved key is built from a reported
the device actually reports -- a table with no strings.json entries table id. Devices without one use the generic translated Cycle entity
yet just falls through Home Assistant's own missing-translation name while preserving raw option codes as a safe fallback."""
handling to raw-code display, the same as any individual untranslated
code within an existing table."""
def _desc(self): def _desc(self):
return laundry.cycle_select( return laundry.cycle_select(
@@ -199,20 +198,17 @@ class TestCycleSelectTableGating:
assert callable(desc.translation_key) assert callable(desc.translation_key)
assert desc.translation_key(resources) == 'washer_cycle_table_02' assert desc.translation_key(resources) == 'washer_cycle_table_02'
def test_resolved_key_reflects_an_unbuilt_table_too(self): def test_untranslated_table_uses_generic_cycle_key(self):
"""No gating against a hardcoded 'known good' table -- a table we """An unknown table does not claim another board's state labels."""
haven't shipped translations for yet still gets a key built for
it, just one strings.json has nothing under (raw-code display)."""
desc = self._desc() desc = self._desc()
resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(resources) == 'washer_cycle_table_00' assert desc.translation_key(resources) == 'cycle'
def test_resolves_to_none_when_table_id_is_unknown(self): def test_resolves_to_generic_cycle_when_table_id_is_unknown(self):
"""No href, or an empty rep, gets no translation_key at all -- """An absent table id still gets a translated generic entity name."""
there's nothing to build a key from."""
desc = self._desc() desc = self._desc()
assert desc.translation_key({}) is None assert desc.translation_key({}) == 'cycle'
assert desc.translation_key({'/st/washercourse/vs/0': {}}) is None assert desc.translation_key({'/st/washercourse/vs/0': {}}) == 'cycle'
class TestBuzzerSound: class TestBuzzerSound:
+39 -1
View File
@@ -1,3 +1,5 @@
"""Unit tests for operational state capabilities."""
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
@@ -24,6 +26,42 @@ class TestProgressPercentage:
assert desc.rep_fn(rep) == 42 assert desc.rep_fn(rep) == 42
class TestCompletionMinutes:
"""Unit tests for completion_minutes entity."""
def test_completion_minutes_parsing(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
# 1 hour 25 mins 30 secs -> 85 mins + 1 sec ceiling = 86 mins
rep = {'x.com.samsung.da.remainingTime': '01:25:30'}
assert desc.rep_fn(rep) == 86
# Exact minutes: 1 hour 30 mins 00 secs -> 90 mins
rep_exact = {'x.com.samsung.da.remainingTime': '01:30:00'}
assert desc.rep_fn(rep_exact) == 90
def test_completion_minutes_fallback_key(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {'remainingTime': '00:45:00'}
assert desc.rep_fn(rep) == 45
def test_completion_minutes_stale_finish_gated(self):
"""Firmware freezes remainingTime at '00:01:00' when progress reaches 'Finish'.
Should return 0 to prevent stuck values."""
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {
'x.com.samsung.da.progress': 'Finish',
'x.com.samsung.da.remainingTime': '00:01:00',
}
assert desc.rep_fn(rep) == 0
def test_completion_minutes_missing_or_invalid(self):
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes')
rep = {}
assert desc.rep_fn(rep) is None
class TestDelayFieldFallback: class TestDelayFieldFallback:
def test_reads_delay_end_time_when_delay_start_time_absent(self): def test_reads_delay_end_time_when_delay_start_time_absent(self):
from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE
@@ -53,4 +91,4 @@ class TestDelayFieldFallback:
desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours')
rep = {'x.com.samsung.da.delayStartTime': '00:00:00'} rep = {'x.com.samsung.da.delayStartTime': '00:00:00'}
path, body = desc.write_fn(1.5, rep) path, body = desc.write_fn(1.5, rep)
assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'} assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'}
+11 -2
View File
@@ -2,7 +2,7 @@
from custom_components.localthings.select import _display from custom_components.localthings.select import _display
_UNTRANSLATED = None _UNTRANSLATED = None
_TRANSLATED = 'door_alert' _TRANSLATED = 'ice_type'
def test_display_titlecases_a_fully_lowercase_device_native_token(): def test_display_titlecases_a_fully_lowercase_device_native_token():
@@ -28,11 +28,20 @@ def test_display_passes_through_an_already_human_friendly_value():
def test_display_lowercases_for_translation_key_lookup(): 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 lowercase keys exactly -- unlike the untranslated cases above, this
is not a cosmetic transform.""" is not a cosmetic transform."""
assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3' 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(): def test_display_passes_through_non_string_values():
assert _display(None, _UNTRANSLATED) is None assert _display(None, _UNTRANSLATED) is None
+27 -1
View File
@@ -49,7 +49,7 @@ def test_callable_options_receives_full_resource_snapshot():
'/other/vs/0': {'codes': ['1C', '1D']}, '/other/vs/0': {'codes': ['1C', '1D']},
} }
entity = _make_select(desc, '/x/vs/0', resources) entity = _make_select(desc, '/x/vs/0', resources)
assert entity.options == ['1c', '1d'] assert entity.options == ['1C', '1D']
assert calls == [resources] assert calls == [resources]
@@ -74,3 +74,29 @@ def test_callable_translation_key_reresolves_live_not_once_at_construction():
resources['key'] = 'washer_cycle_table_02' resources['key'] = 'washer_cycle_table_02'
assert entity.translation_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']
+170
View File
@@ -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()
+14 -16
View File
@@ -64,20 +64,19 @@ class TestWasherCourse:
"""Table-scoped (issue: course codes aren't guaranteed consistent """Table-scoped (issue: course codes aren't guaranteed consistent
across board generations sharing /course/vs/0 -- FlexWash's older across board generations sharing /course/vs/0 -- FlexWash's older
board reports Table_00, not the Table_02 every washer_cycle_table_02 board reports Table_00, not the Table_02 every washer_cycle_table_02
name was confirmed against) -- see laundry.cycle_select. The key is name was confirmed against) -- see laundry.cycle_select. Only a
built from whatever table the device reports, not gated against a verified table gets table-specific state translations."""
hardcoded 'known good' value."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert callable(desc.translation_key) assert callable(desc.translation_key)
table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}} table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}}
assert desc.translation_key(table_02) == 'washer_cycle_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'}} table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}}
assert desc.translation_key(table_00) == 'washer_cycle_table_00' assert desc.translation_key(table_00) == 'cycle'
assert desc.translation_key({}) is None assert desc.translation_key({}) == 'cycle'
def test_reads_raw_course_code_from_options_array(self): def test_reads_raw_course_code_from_options_array(self):
"""rep_fn returns the raw device code; display names come from """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()).""" _display())."""
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') 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']} rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
@@ -207,14 +206,11 @@ class TestDetergentSoftenerDosing:
assert self._desc('detergent_quantity').rep_fn(rep) == '7' assert self._desc('detergent_quantity').rep_fn(rep) == '7'
def test_translation_keys(self): def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key """Each entity gets its own translated name and state vocabulary."""
(same 00-03 -> None/Low/Medium/High vocabulary on both dispensers, assert self._desc('detergent_quantity').translation_key == 'detergent_quantity'
same shape as fridge.py's shared 'brightness_level' key); hardness assert self._desc('softener_quantity').translation_key == 'softener_quantity'
and concentration each have their own since their labels differ.""" assert self._desc('detergent_water_hardness').translation_key == 'detergent_water_hardness'
assert self._desc('detergent_quantity').translation_key == 'washer_dosing_quantity' assert self._desc('softener_concentration').translation_key == 'softener_concentration'
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'
def test_quantity_and_hardness_options_decode_supported_list(self): def test_quantity_and_hardness_options_decode_supported_list(self):
assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03'] assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03']
@@ -364,8 +360,10 @@ class TestWashOptionToggleValidation:
def test_rejected_on_an_unsupported_course(self): def test_rejected_on_an_unsupported_course(self):
rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]}
msg = self._desc('bubble_soak').validate_fn('On', rep, _EDIT_COURSE_RESOURCES) translation_key = self._desc('bubble_soak').validate_fn(
assert msg == "Bubble soak isn't available on the selected cycle." '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): def test_pre_wash_and_intensive_use_their_own_availableset_field(self):
rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]} rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]}