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
This commit is contained in:
@@ -109,10 +109,15 @@ Any select whose options are raw device codes (course/cycle, and code-valued
|
||||
settings) must render through translations, not Python:
|
||||
- Set `translation_key='<family>_cycle'` (or similar) on the `SelectDesc`;
|
||||
`options`/`options_field` supply the **raw** codes.
|
||||
- Add the labels to **both** `strings.json` and `translations/en.json` under
|
||||
- Add the labels to `translations/en.json` under
|
||||
`entity.select.<translation_key>.state.<code>`, with the code **lowercased**
|
||||
(e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's
|
||||
the cue to identify and name them.
|
||||
- `en.json` is the only place these live: there is no `strings.json` (Home
|
||||
Assistant doesn't read one from a custom integration), and `select.py`
|
||||
derives its normalization set from the catalog, so there is no Python list
|
||||
to keep in sync. Every other language must mirror `en.json` key for key —
|
||||
`tests/test_translations.py` enforces that.
|
||||
|
||||
## 6. Coverage discipline: bound or ignored
|
||||
|
||||
|
||||
@@ -106,7 +106,10 @@ custom_components/localthings/
|
||||
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
|
||||
One module per HA platform
|
||||
strings.json / translations/ Config-flow copy + entity state translations
|
||||
catalog.py Reads the shipped translation catalog (which keys/states exist)
|
||||
translations/ Config-flow copy + entity name/state translations, one file per
|
||||
language; en.json is the source of truth (no strings.json —
|
||||
Home Assistant never reads one from a custom integration)
|
||||
registry/
|
||||
registry.py Builds the global capability registry, validates href collisions
|
||||
capability.py Capability dataclass (href, entities, transforms)
|
||||
|
||||
@@ -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()
|
||||
@@ -301,7 +301,7 @@ WATER_FILTER = Capability(
|
||||
#
|
||||
# No translation_key: aiLevel's values are plain digit strings, and
|
||||
# select.py's _display() already renders an untranslated numeric string
|
||||
# as-is -- there's nothing a strings.json entry adds that's worth maintaining
|
||||
# as-is -- there's nothing a catalog entry adds that's worth maintaining
|
||||
# against an unknown, growing number of future levels.
|
||||
|
||||
|
||||
@@ -348,14 +348,14 @@ AI_ENERGY_LEVEL = Capability(
|
||||
# cold-tier href, the same reload already required to fix which
|
||||
# platform got picked in that case.
|
||||
SwitchDesc(key='ai_energy_level', field='aiLevel',
|
||||
name='AI energy level', icon='mdi:leaf',
|
||||
name='AI Energy Mode', icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
value_fn=lambda v: v != '0',
|
||||
exists_fn=lambda rep, resources: (
|
||||
len(_ai_energy_supported_levels(rep)) == 1),
|
||||
write_fn=_ai_energy_level_switch_write),
|
||||
SelectDesc(key='ai_energy_level', field='aiLevel',
|
||||
name='AI energy level', icon='mdi:leaf',
|
||||
name='AI Energy Mode level', icon='mdi:leaf',
|
||||
entity_category='config',
|
||||
options=_ai_energy_level_options,
|
||||
exists_fn=lambda rep, resources: (
|
||||
|
||||
@@ -25,7 +25,7 @@ from ..entities import (
|
||||
from .common import normalize_temp_unit
|
||||
|
||||
# Display names for the beverage zone, flex zone, ice type, and
|
||||
# ice-making-status enums below live in strings.json / translations/en.json,
|
||||
# ice-making-status enums below live in translations/en.json,
|
||||
# keyed by the lowercased raw device value — select.py and SensorDesc.options
|
||||
# normalize to lowercase for HA's translation lookup and map back to this
|
||||
# original casing before writing to the device.
|
||||
|
||||
@@ -21,6 +21,7 @@ Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
|
||||
"""
|
||||
from datetime import time as dt_time
|
||||
|
||||
from ...catalog import has_entity_translation
|
||||
from ..capability import Capability
|
||||
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
|
||||
|
||||
@@ -276,12 +277,6 @@ def _table_id(resources, table_href):
|
||||
return rep.get('x.com.samsung.da.st.courseTable')
|
||||
|
||||
|
||||
_TRANSLATED_COURSE_TABLES = {
|
||||
'dryer_cycle': frozenset({'table_03'}),
|
||||
'washer_cycle': frozenset({'table_02'}),
|
||||
}
|
||||
|
||||
|
||||
def cycle_select(*, translation_key, icon, table_href=None):
|
||||
"""A 'Cycle' select over /course/vs/0, labelled from `translation_key`.
|
||||
|
||||
@@ -302,10 +297,13 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
washer_cycle_table_02 was confirmed against Table_02-reporting devices
|
||||
(DA_WM_TP1/TP2 boards); FlexWash's older DA_WM_A51 board reports
|
||||
Table_00 instead, so the same hex code could mean a different course
|
||||
there for all we've verified. Only tables with a complete catalog are
|
||||
assigned a state translation key. Unknown future tables deliberately use
|
||||
the generic key, avoiding misleading translations and missing-translation
|
||||
errors while keeping the exact vendor code for writes.
|
||||
there for all we've verified. So a table-specific key is used only when
|
||||
the shipped catalog actually has one; any other table (Table_00 today,
|
||||
whatever ships next) falls back to the name-only ``cycle`` key, which
|
||||
shows the raw course code rather than a label borrowed from another
|
||||
board generation. Translating a new table is therefore a
|
||||
translations-only change -- add the ``<family>_cycle_<table>`` entry and
|
||||
this resolver picks it up.
|
||||
|
||||
Left at its default for dishwasher, which has no equivalent table-id
|
||||
resource in any dump seen and no evidence its course codes vary by
|
||||
@@ -316,10 +314,10 @@ def cycle_select(*, translation_key, icon, table_href=None):
|
||||
if table_href is not None:
|
||||
def key(resources):
|
||||
table = _table_id(resources, table_href)
|
||||
table_id = table.lower() if isinstance(table, str) else None
|
||||
if table_id in _TRANSLATED_COURSE_TABLES.get(translation_key, ()):
|
||||
return f'{translation_key}_{table_id}'
|
||||
return 'cycle'
|
||||
if not isinstance(table, str) or not table:
|
||||
return 'cycle'
|
||||
candidate = f'{translation_key}_{table.lower()}'
|
||||
return candidate if has_entity_translation('select', candidate) else 'cycle'
|
||||
|
||||
return SelectDesc(
|
||||
key='cycle', name='Cycle', icon=icon, translation_key=key,
|
||||
|
||||
@@ -239,7 +239,7 @@ OVEN_OPERATIONAL_STATE = Capability(
|
||||
value_fn=_int),
|
||||
SensorDesc(key='operation_time_minutes',
|
||||
field='x.com.samsung.da.operationTime',
|
||||
name='Operation time (minutes)', unit='min',
|
||||
name='Operation time', unit='min',
|
||||
state_class='measurement', value_fn=_op_minutes),
|
||||
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
|
||||
name='Estimated finish', device_class='timestamp',
|
||||
|
||||
@@ -23,8 +23,8 @@ from .laundry import (
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Course_XX hex codes. 23 of the codes named in strings.json/translations
|
||||
# under entity.select.washer_cycle.state.<id, lowercased> were captured
|
||||
# Course_XX hex codes. 23 of the codes named in translations/en.json
|
||||
# under entity.select.washer_cycle_table_02.state.<id, lowercased> were captured
|
||||
# from a live WW90DG6U25LEU4's x.com.samsung.da.editCourseList
|
||||
# (EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96), matched
|
||||
# positionally against a Slovak-UI user's screenshots of their app's course
|
||||
@@ -157,8 +157,8 @@ def _drum_clean_last_cleaned(rep):
|
||||
# valid raw codes for its field, same hex-pair shape as EditCourseList.
|
||||
# '<Prefix>Alarm_<On/Off>' is a low-reservoir warning flag.
|
||||
#
|
||||
# Label mapping (entity.select.washer_dosing_quantity/washer_detergent_
|
||||
# water_hardness/washer_softener_concentration in strings.json) is an
|
||||
# Label mapping (entity.select.{detergent,softener}_quantity /
|
||||
# detergent_water_hardness / softener_concentration in translations/en.json) is an
|
||||
# assumed, not cross-device-verified, reading of the single issue #9 dump +
|
||||
# screenshots: LevelCtrl's 4 codes as None/Low/Medium/High (00 has no
|
||||
# on-screen equivalent -- the app's Quantité picker only offers
|
||||
@@ -349,7 +349,7 @@ WASHER_COURSE = Capability(
|
||||
rep_fn=_dosing_low('SoftenerAlarm')),
|
||||
_bool_option_switch('bubble_soak', 'Bubble soak', 'mdi:chart-bubble',
|
||||
'BubbleSoak', 'BubbleSoakSet'),
|
||||
_bool_option_switch('pre_wash', 'Pre wash', 'mdi:washing-machine',
|
||||
_bool_option_switch('pre_wash', 'Pre-wash', 'mdi:washing-machine',
|
||||
'PreWashSetting', 'PreWashAvailableSet'),
|
||||
_bool_option_switch('intensive', 'Intensive', 'mdi:washing-machine',
|
||||
'IntensiveSetting', 'IntensiveAvailableSet'),
|
||||
|
||||
@@ -11,6 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .registry.entities import SelectDesc
|
||||
|
||||
from .catalog import translated_states
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
@@ -31,73 +32,16 @@ async def async_setup_entry(
|
||||
|
||||
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
|
||||
|
||||
# Keep this synchronized with entity.select.*.state in strings.json. A select
|
||||
# can have a translation key for its name without translating its dynamic
|
||||
# Samsung options. Only known states are normalized for HA's lowercase lookup;
|
||||
# unknown future vendor values retain a readable label and exact write value.
|
||||
TRANSLATED_SELECT_STATES: dict[str, frozenset[str]] = {
|
||||
'beverage_zone_mode': frozenset({
|
||||
'sp_ttype_beer_drinks', 'sp_ttype_wine_dessert',
|
||||
}),
|
||||
'brightness_level': frozenset({'33', '66', '100'}),
|
||||
'day_brightness': frozenset({'33', '66', '100'}),
|
||||
'dishwasher_cycle': frozenset({
|
||||
'07', '0e', '80', '83', '84', '86', '8d', '8e', '8f', '90',
|
||||
}),
|
||||
'door_alert': frozenset({'1', '2', '3', '4'}),
|
||||
'dryer_cycle_table_03': frozenset({
|
||||
'16', '18', '19', '1a', '1b', '1c', '1d', '1e', '1f', '20',
|
||||
'23', '24', '25', '27',
|
||||
}),
|
||||
'finish_sound': frozenset({'off', 'on'}),
|
||||
'flex_zone_mode': frozenset({
|
||||
'cv_fdr_beverage', 'cv_fdr_deli', 'cv_fdr_meat',
|
||||
'cv_fdr_soft_freezer', 'cv_fdr_wine',
|
||||
'cv_ttype_rf9000a_beverage', 'cv_ttype_rf9000a_freeze',
|
||||
'cv_ttype_rf9000a_fruit_veggies',
|
||||
'cv_ttype_rf9000a_meat_fish', 'cv_ttype_rf9000a_softfreeze',
|
||||
}),
|
||||
'heated_dry': frozenset({'extra_high', 'high', 'low', 'off'}),
|
||||
'ice_type': frozenset({
|
||||
'off', 'whiskey_iceball_3', 'whiskey_iceball_6',
|
||||
'whiskey_iceball_9',
|
||||
}),
|
||||
'led_brightness': frozenset({'high', 'low'}),
|
||||
'led_night_brightness': frozenset({'high', 'low'}),
|
||||
'oven_mode': frozenset({
|
||||
'air_fry', 'bake', 'broil', 'convection', 'convection_bake',
|
||||
'convection_broil', 'frozen_pizza_plus', 'no_operation',
|
||||
'plate_warm', 'slow_cook',
|
||||
}),
|
||||
'pantry_zone_mode': frozenset({'fdr_deli', 'fdr_drinks', 'fdr_wine'}),
|
||||
'range_burner_power_level': frozenset({'0', 'boost', 'simmer'}),
|
||||
'sound_mode': frozenset({'mute', 'tone', 'voice'}),
|
||||
'spin_speed': frozenset({'high', 'low', 'medium', 'no_spin', 'rinse_hold'}),
|
||||
'buzzer_sound': frozenset({'off', 'on'}),
|
||||
'wash_temperature': frozenset({
|
||||
'cold', 'cool', 'extra_hot', 'hot', 'none', 'warm',
|
||||
}),
|
||||
'washer_cycle_table_02': frozenset({
|
||||
'1b', '1c', '1d', '1e', '1f', '20', '21', '22', '23', '24',
|
||||
'25', '26', '27', '28', '29', '2d', '2e', '2f', '30', '32',
|
||||
'33', '36', '37', '38', '39', '66', '8f', '96',
|
||||
}),
|
||||
'detergent_quantity': frozenset({'00', '01', '02', '03'}),
|
||||
'softener_quantity': frozenset({'00', '01', '02', '03'}),
|
||||
'detergent_water_hardness': frozenset({'01', '02', '03'}),
|
||||
'softener_concentration': frozenset({'01', '02', '03'}),
|
||||
'washer_dry_level': frozenset({
|
||||
'30', '60', '90', '120', '180', '240', 'cupboard', 'none',
|
||||
}),
|
||||
'range_hood_lamp_brightness': frozenset({'1', '2'}),
|
||||
}
|
||||
|
||||
def _translation_state(value: str, known: frozenset[str]) -> str | None:
|
||||
"""Return the catalog state `value` normalizes to, else None.
|
||||
|
||||
def _translation_state(value: str, translation_key: str) -> str | None:
|
||||
"""Return a known HA translation state, or None for a vendor fallback."""
|
||||
known = TRANSLATED_SELECT_STATES.get(translation_key)
|
||||
if known is None:
|
||||
return 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
|
||||
@@ -113,13 +57,13 @@ def _display(value, translation_key: Optional[str]):
|
||||
callers pass the resolved value, e.g. self.translation_key, not
|
||||
the raw descriptor field).
|
||||
|
||||
An entity with a translation_key looks its state up in strings.json,
|
||||
and hassfest requires those keys to be lowercase -- so those values
|
||||
An entity with a translation_key looks its state up in the shipped
|
||||
translation catalog, whose state keys are lowercase -- so those values
|
||||
must be lowercased exactly to match, and the device still expects
|
||||
that same raw casing back on write (callers map the displayed value
|
||||
back to raw via _raw_options()).
|
||||
|
||||
Everything else has no strings.json lookup, so there's no reason to
|
||||
Everything else has no catalog lookup, so there's no reason to
|
||||
destroy the device's own casing. Only two cosmetic fixups apply: a
|
||||
fully lowercase device-native token (e.g. "voice") is title-cased,
|
||||
and a PascalCase token (e.g. "ExtraHigh") gets a space inserted at
|
||||
@@ -130,13 +74,15 @@ def _display(value, translation_key: Optional[str]):
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if translation_key:
|
||||
if translated := _translation_state(value, translation_key):
|
||||
return translated
|
||||
if translation_key not in TRANSLATED_SELECT_STATES:
|
||||
# The entity name is translated, but this select deliberately has
|
||||
# no static state catalog (for example an unknown course table).
|
||||
# Keep its opaque device value untouched.
|
||||
known = translated_states('select', translation_key)
|
||||
if not known:
|
||||
# No state table for this key: either the entity isn't translated
|
||||
# at all, or its name is translated but its options deliberately
|
||||
# aren't (an unrecognized course table, say). Either way the
|
||||
# opaque device value is the best thing to show.
|
||||
return value
|
||||
if translated := _translation_state(value, known):
|
||||
return translated
|
||||
if value.islower():
|
||||
return value.replace('_', ' ').title()
|
||||
return _CAMEL_BOUNDARY_RE.sub(' ', value)
|
||||
|
||||
@@ -1,835 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"any_burner_active": {
|
||||
"name": "Any burner active"
|
||||
},
|
||||
"automatic_operation": {
|
||||
"name": "Automatic operation"
|
||||
},
|
||||
"burner_hot_surface": {
|
||||
"name": "Burner {number} hot surface"
|
||||
},
|
||||
"cloud_connected": {
|
||||
"name": "Cloud connected"
|
||||
},
|
||||
"cooktop_safety_shutoff_enabled": {
|
||||
"name": "Hot surface auto-shutoff enabled"
|
||||
},
|
||||
"current_limit_enabled": {
|
||||
"name": "Current limit enabled"
|
||||
},
|
||||
"cycle_active": {
|
||||
"name": "[%key:component::binary_sensor::entity_component::running::name%]"
|
||||
},
|
||||
"defrost_active": {
|
||||
"name": "Defrost active"
|
||||
},
|
||||
"detergent_low": {
|
||||
"name": "Detergent low"
|
||||
},
|
||||
"device_active": {
|
||||
"name": "Device active"
|
||||
},
|
||||
"door_open": {
|
||||
"name": "[%key:component::binary_sensor::entity_component::door::name%]"
|
||||
},
|
||||
"firmware_update": {
|
||||
"name": "Firmware update available"
|
||||
},
|
||||
"instance_open": {
|
||||
"name": "{instance_name} open"
|
||||
},
|
||||
"paired_hood_connected": {
|
||||
"name": "Paired hood connected"
|
||||
},
|
||||
"paired_hood_light": {
|
||||
"name": "Paired hood light"
|
||||
},
|
||||
"paired_hood_power": {
|
||||
"name": "Paired hood power"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"diagnosis_start": {
|
||||
"name": "Start diagnosis"
|
||||
},
|
||||
"pause": {
|
||||
"name": "[%key:common::action::pause%]"
|
||||
},
|
||||
"selfcheck_start": {
|
||||
"name": "Start self-check"
|
||||
},
|
||||
"start": {
|
||||
"name": "[%key:common::action::start%]"
|
||||
},
|
||||
"stop": {
|
||||
"name": "[%key:common::action::stop%]"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"airconditioner": {
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"turbo": "Turbo"
|
||||
}
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"quiet": "Quiet",
|
||||
"smart": "Smart",
|
||||
"speed": "Speed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "[%key:common::state::low%]",
|
||||
"66": "[%key:common::state::medium%]",
|
||||
"100": "[%key:common::state::high%]"
|
||||
}
|
||||
},
|
||||
"day_brightness": {
|
||||
"name": "Cabinet brightness",
|
||||
"state": {
|
||||
"33": "[%key:common::state::low%]",
|
||||
"66": "[%key:common::state::medium%]",
|
||||
"100": "[%key:common::state::high%]"
|
||||
}
|
||||
},
|
||||
"buzzer_sound": {
|
||||
"name": "Buzzer sound",
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"name": "Cycle"
|
||||
},
|
||||
"detergent_quantity": {
|
||||
"name": "Detergent quantity",
|
||||
"state": {
|
||||
"00": "None",
|
||||
"01": "[%key:common::state::low%]",
|
||||
"02": "[%key:common::state::medium%]",
|
||||
"03": "[%key:common::state::high%]"
|
||||
}
|
||||
},
|
||||
"detergent_water_hardness": {
|
||||
"name": "Detergent water hardness",
|
||||
"state": {
|
||||
"01": "Soft",
|
||||
"02": "[%key:common::state::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": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::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": "[%key:common::state::off%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"extra_high": "Extra high"
|
||||
}
|
||||
},
|
||||
"ice_type": {
|
||||
"name": "{instance_name} type",
|
||||
"state": {
|
||||
"off": "[%key:common::state::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": "[%key:common::state::low%]",
|
||||
"high": "[%key:common::state::high%]"
|
||||
}
|
||||
},
|
||||
"led_night_brightness": {
|
||||
"name": "Door LED night brightness",
|
||||
"state": {
|
||||
"low": "[%key:common::state::low%]",
|
||||
"high": "[%key:common::state::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": "[%key:common::state::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": "[%key:common::state::low%]",
|
||||
"02": "[%key:common::state::medium%]",
|
||||
"03": "[%key:common::state::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": "[%key:common::state::low%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
"high": "[%key:common::state::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": {
|
||||
"20": "Hygiene Steam",
|
||||
"21": "Colours",
|
||||
"22": "Wool",
|
||||
"23": "Outdoor",
|
||||
"24": "Towels",
|
||||
"25": "Synthetics",
|
||||
"26": "Delicates",
|
||||
"27": "Rinse+Spin",
|
||||
"28": "Drain/Spin",
|
||||
"29": "Drum Clean+",
|
||||
"30": "Cloudy Day",
|
||||
"32": "Shirts",
|
||||
"33": "Bedding",
|
||||
"36": "Wash+Dry",
|
||||
"37": "Air Wash",
|
||||
"38": "Cotton Dry",
|
||||
"39": "Synthetics Dry",
|
||||
"66": "Denim",
|
||||
"96": "Less Microfiber",
|
||||
"1c": "Eco 40-60",
|
||||
"1d": "Super Speed",
|
||||
"1b": "Cotton",
|
||||
"1e": "15' Quick Wash",
|
||||
"2f": "Activewear",
|
||||
"2e": "Baby Care",
|
||||
"2d": "Silent Wash",
|
||||
"8f": "Intense Cold",
|
||||
"1f": "Intense Cold"
|
||||
}
|
||||
},
|
||||
"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": "[%key:common::state::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": "[%key:component::sensor::entity_component::temperature::name%]"
|
||||
},
|
||||
"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": "[%key:component::sensor::entity_component::energy::name%]"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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": "[%key:component::sensor::entity_component::power::name%]"
|
||||
},
|
||||
"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": "[%key:common::state::idle%]",
|
||||
"active": "[%key:common::state::active%]",
|
||||
"pause": "Paused"
|
||||
}
|
||||
},
|
||||
"filter_status": {
|
||||
"name": "Filter status",
|
||||
"state": {
|
||||
"normal": "[%key:common::state::normal%]",
|
||||
"wash": "Wash",
|
||||
"replace": "Replace"
|
||||
}
|
||||
},
|
||||
"ice_making_status": {
|
||||
"name": "{instance_name} making status",
|
||||
"state": {
|
||||
"icestatus_stop": "[%key:common::state::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": {
|
||||
"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.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
"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": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "LocalThings 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."
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -616,13 +616,13 @@
|
||||
"name": "Auto door opener"
|
||||
},
|
||||
"auto_release_dry": {
|
||||
"name": "Auto Release Dry"
|
||||
"name": "Auto release dry"
|
||||
},
|
||||
"autofill": {
|
||||
"name": "Autofill pitcher"
|
||||
},
|
||||
"bubble_soak": {
|
||||
"name": "Bubble Soak"
|
||||
"name": "Bubble soak"
|
||||
},
|
||||
"cabinet_light_dim": {
|
||||
"name": "Brighten gradually"
|
||||
|
||||
@@ -228,7 +228,7 @@ class TestAiEnergyLevelSelect:
|
||||
|
||||
def test_no_translation_key(self):
|
||||
"""aiLevel's values are plain digits that render fine untranslated
|
||||
(select.py's _display()) -- no strings.json entry to maintain
|
||||
(select.py's _display()) -- no catalog entry to maintain
|
||||
against an unknown number of future levels."""
|
||||
desc = self._desc()
|
||||
assert desc.translation_key is None
|
||||
|
||||
@@ -28,7 +28,7 @@ def test_display_passes_through_an_already_human_friendly_value():
|
||||
|
||||
|
||||
def test_display_lowercases_for_translation_key_lookup():
|
||||
"""An entity with a translation_key must match strings.json's
|
||||
"""An entity with a translation_key must match the catalog's
|
||||
lowercase keys exactly -- unlike the untranslated cases above, this
|
||||
is not a cosmetic transform."""
|
||||
assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3'
|
||||
|
||||
+73
-38
@@ -1,4 +1,13 @@
|
||||
"""Translation architecture and catalog synchronization tests."""
|
||||
"""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
|
||||
|
||||
@@ -9,16 +18,22 @@ from string import Formatter
|
||||
|
||||
from custom_components.localthings.registry.capability import Capability
|
||||
from custom_components.localthings.registry.entities import PLATFORM_OF
|
||||
from custom_components.localthings.select import TRANSLATED_SELECT_STATES
|
||||
|
||||
|
||||
INTEGRATION = (
|
||||
Path(__file__).parents[1] / "custom_components" / "localthings"
|
||||
)
|
||||
TRANSLATIONS = INTEGRATION / "translations"
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((INTEGRATION / name).read_text(encoding="utf-8"))
|
||||
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):
|
||||
@@ -72,46 +87,53 @@ def _all_descriptions():
|
||||
yield from visit(value)
|
||||
|
||||
|
||||
def test_source_and_english_translation_topology_and_placeholders_match():
|
||||
source = _load("strings.json")
|
||||
english = _load("translations/en.json")
|
||||
assert _topology(source) == _topology(english)
|
||||
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 path, value in _walk_strings(source):
|
||||
assert _placeholders(value) == _placeholders(english_strings[path]), path
|
||||
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_runtime_translation_catalogs_have_no_unresolved_references():
|
||||
for language in ("en", "nl"):
|
||||
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(f"translations/{language}.json")
|
||||
)
|
||||
for path, value in _walk_strings(_load(language))
|
||||
if "[%key:" in value
|
||||
]
|
||||
assert unresolved == []
|
||||
|
||||
|
||||
def test_english_and_dutch_catalog_topology_and_placeholders_match():
|
||||
english = _load("translations/en.json")
|
||||
dutch = _load("translations/nl.json")
|
||||
assert _topology(english) == _topology(dutch)
|
||||
|
||||
dutch_strings = dict(_walk_strings(dutch))
|
||||
for path, value in _walk_strings(english):
|
||||
assert _placeholders(value) == _placeholders(dutch_strings[path]), path
|
||||
assert unresolved == [], language
|
||||
|
||||
|
||||
def test_every_translatable_descriptor_has_an_entity_catalog_entry():
|
||||
entity_strings = _load("strings.json")["entity"]
|
||||
entity_strings = _load("en")["entity"]
|
||||
missing = []
|
||||
for desc in _all_descriptions():
|
||||
translation_key = desc.translation_key
|
||||
if callable(translation_key):
|
||||
# Runtime table resolvers use the generic name-only fallback plus
|
||||
# the static tables currently documented by this integration.
|
||||
# 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 and desc.name is not None:
|
||||
translation_key = desc.key
|
||||
@@ -122,23 +144,36 @@ def test_every_translatable_descriptor_has_an_entity_catalog_entry():
|
||||
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_select_state_normalization_is_synchronized_with_catalog():
|
||||
select_strings = _load("strings.json")["entity"]["select"]
|
||||
translated = {
|
||||
key: frozenset(value["state"])
|
||||
for key, value in select_strings.items()
|
||||
if "state" in value
|
||||
}
|
||||
assert TRANSLATED_SELECT_STATES == translated
|
||||
def test_descriptor_names_match_the_english_catalog():
|
||||
"""``SamsungEntityDescription.name`` never reaches the UI.
|
||||
|
||||
A descriptor carrying a ``name`` is translated under ``desc.key``
|
||||
(entity.py), so the catalog wins and the Python string survives purely
|
||||
as documentation next to the field it describes -- which is only worth
|
||||
keeping if it still says what the UI says. Editing one side alone is
|
||||
otherwise invisible.
|
||||
"""
|
||||
entity_strings = _load("en")["entity"]
|
||||
drifted = []
|
||||
for desc in _all_descriptions():
|
||||
if desc.name is None or desc.translation_key is not None:
|
||||
continue
|
||||
platform = PLATFORM_OF[type(desc)]
|
||||
catalog_name = entity_strings.get(platform, {}).get(desc.key, {}).get("name")
|
||||
if catalog_name != desc.name:
|
||||
drifted.append((platform, desc.key, desc.name, catalog_name))
|
||||
assert drifted == []
|
||||
|
||||
|
||||
def test_all_entity_state_translation_keys_are_lowercase():
|
||||
entity_strings = _load("strings.json")["entity"]
|
||||
entity_strings = _load("en")["entity"]
|
||||
for platform in entity_strings.values():
|
||||
for translation in platform.values():
|
||||
for state_key in translation.get("state", {}):
|
||||
|
||||
@@ -76,7 +76,7 @@ class TestWasherCourse:
|
||||
|
||||
def test_reads_raw_course_code_from_options_array(self):
|
||||
"""rep_fn returns the raw device code; display names come from
|
||||
strings.json via translation_key, not from Python (see select.py's
|
||||
translations/en.json via translation_key, not Python (see select.py's
|
||||
_display())."""
|
||||
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
|
||||
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
|
||||
|
||||
Reference in New Issue
Block a user