Bind AI energy level on washer; switch/select split, drop translations

Issue #40: /energy/ailevel/vs/0 was unbound on a plain washer. The
capability already existed for fridges but was gated off entirely on
single-level hardware (the common case), so it's moved to common.py
(cross-family, like fridge + washer now) and split into two entities:
a switch when supportedAiLevel has exactly one entry (aiLevel is really
just an on/off toggle there), and a select otherwise, with '0' (off)
synthesized back into the select's options since supportedAiLevel never
lists it but it's a real observed value.

Also drops the translation_key/strings.json entries -- aiLevel's raw
digit values already render fine untranslated, and translating a
handful of levels can't cover devices with more.
This commit is contained in:
Marc Billow committed 2026-07-23 00:44:57 +00:00
1 parent 5d9b470d7e
commit d6639c99d4
12 files changed
+212 -80

No files matched your search

@@ -20,7 +20,7 @@ REGISTRY = DeviceRegistry(
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.AI_ENERGY_LEVEL,
common.AI_ENERGY_LEVEL,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
@@ -19,6 +19,7 @@ REGISTRY = DeviceRegistry(
common.ALARMS,
common.ENERGY_METER,
common.WATER_METER,
common.AI_ENERGY_LEVEL,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
fridge.FIRMWARE_UPDATE,
@@ -11,7 +11,7 @@ against live device dumps:
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
def _num(v):
@@ -231,3 +231,63 @@ WATER_FILTER = Capability(
name='Filter status', icon='mdi:filter-check'),
),
)
# AI energy-saving level -- '0' is off, and supportedAiLevel lists the
# additional level(s) the device offers ('1' meaning just "on" on most
# hardware, but multi-level boards have been reported). Verified cross-family:
# fridge (issue #21) and washer (issue #40) both expose this href.
#
# supportedAiLevel is a single-entry list on most captured hardware, where a
# select would offer only one real choice against an implicit "off" -- shown
# as a switch instead. '0' itself is never in supportedAiLevel but has been
# observed live as the off value of aiLevel, so the select synthesizes it
# back in as an explicit option rather than leaving no way to turn off.
#
# 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
# against an unknown, growing number of future levels.
def _ai_energy_supported_levels(rep):
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
be len()-checked as if it were a list."""
sl = rep.get('supportedAiLevel')
return list(sl) if isinstance(sl, (list, tuple)) else []
def _ai_energy_level_options(resources):
rep = resources.get('/energy/ailevel/vs/0') or {}
return ['0', *_ai_energy_supported_levels(rep)]
def _ai_energy_level_write(p, rep, href=None):
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
def _ai_energy_level_switch_write(p, rep, href=None):
levels = _ai_energy_supported_levels(rep)
on_level = levels[0] if levels else '1'
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
AI_ENERGY_LEVEL = Capability(
href='/energy/ailevel/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', 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',
entity_category='config',
options=_ai_energy_level_options,
exists_fn=lambda rep, resources: (
not rep or len(_ai_energy_supported_levels(rep)) > 1),
write_fn=_ai_energy_level_write),
),
)
@@ -588,33 +588,6 @@ FLEX_ZONE = Capability(
),
)
# ---------------------------------------------------------------------------
# AI energy-saving level
#
# supportedAiLevel is a single-entry list ('1' only) on some hardware, where
# a select would offer no real choice — gated to >1 supported level.
# ---------------------------------------------------------------------------
AI_ENERGY_LEVEL = Capability(
href='/energy/ailevel/vs/0',
poll_tier='cold',
entities=(
# Only levels '1'/'2' have translated labels in strings.json/translations;
# a higher supportedAiLevel entry renders as its raw value until added.
SelectDesc(key='ai_energy_level', field='aiLevel',
name='AI energy level', icon='mdi:leaf',
translation_key='ai_energy_level',
entity_category='config',
options_field='supportedAiLevel',
exists_fn=lambda rep, resources: (
not rep or (
isinstance((sl := rep.get('supportedAiLevel')), (list, tuple))
and len(sl) > 1)),
write_fn=lambda p, rep, href=None: (
['energy', 'ailevel', 'vs', '0'], {'aiLevel': p})),
),
)
# ---------------------------------------------------------------------------
# Firmware update
# ---------------------------------------------------------------------------
@@ -148,12 +148,6 @@
"180": "3 hr",
"240": "4 hr"
}
},
"ai_energy_level": {
"state": {
"1": "Level 1",
"2": "Level 2"
}
}
},
"sensor": {
@@ -148,12 +148,6 @@
"180": "3 hr",
"240": "4 hr"
}
},
"ai_energy_level": {
"state": {
"1": "Level 1",
"2": "Level 2"
}
}
},
"sensor": {