Add device support for AILP_DA-AC-FAC-02011 air conditioner (issue #319)

This board routes purely via /oic/d's oic.d.airconditioner type (no
board-token match) and reports several resources the sibling
TP1X_DA-AC-CAC-01001 board (issue #191) left as a documented gap:

- /display/vs/0, /settings/sound/output/vs/0, /settings/sound/volume/vs/0
  now reuse air_purifier.py's identical-shape capabilities instead of
  duplicating them.
- /settings/sound/mode/vs/0 gets a new airconditioner.SOUND_MODE reading
  the live supportedModes field, sharing laundry.py's existing
  voice/tone/mute translation catalog since the value vocabulary matches.
- /csi/absenceclean/vs/0 and /csi/energysaving/vs/0 are new, genuinely
  useful controls (absence auto-clean toggle, energy-saving mode select
  plus its state/operatingStatus diagnostics).
- /dnd/autosleep/vs/0, /outdoorsharing/vs/0, /lifestyle/survey/vs/0,
  /settings/sound/voice/vs/0 and /csi/information/vs/0 are ignored as
  plumbing/unconfirmed data with no user-actionable state.

Also fixes a latent bug in air_purifier.SOUND_VOLUME: boards that report
minLevel/resolution but no maxLevel (this one) would have produced a
min=0/max=0 number entity instead of self-gating off.

Updates test_airconditioner_cac.py's documented coverage gap now that
sound_mode/sound_output/sound_volume are covered there too.
This commit is contained in:
Marc Billow committed 2026-08-07 14:10:13 +00:00
1 parent f07ae4020e
commit edf77309ba
15 files changed
+1389 -16

No files matched your search

File diff suppressed because it is too large. Load diff
+39
View File
@@ -0,0 +1,39 @@
{
"state_keys": [
"absence_clean",
"ai_energy_level",
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
"alarm_code",
"auto_clean",
"auto_clean_progress",
"auto_clean_running",
"beep",
"climate",
"current_temperature_c",
"display",
"energy_kwh",
"energy_saved_kwh",
"energy_saving_mode",
"energy_saving_operating_status",
"energy_saving_state",
"firmware_update",
"humidity",
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"power_energy_kwh",
"power_watts",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"sound_mode",
"sound_output",
"tropical_night_mode",
"uv_led",
"ventilation_alarm"
]
}
+3
View File
@@ -31,6 +31,9 @@
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"sound_mode",
"sound_output",
"sound_volume",
"super_fine_dust",
"tropical_night_mode",
"uv_led"
@@ -0,0 +1,70 @@
"""Tests for the AILP_DA-AC-FAC-02011_0000 air conditioner (issue #319).
This board has no `_BOARD_TOKEN_TO_KEY` entry (routes via `/oic/d`'s
`oic.d.airconditioner` type only) and reports several resources the sibling
TP1X_DA-AC-CAC-01001 board (issue #191) left as a documented gap:
/display/vs/0 and the /settings/sound/* trio (now shared with
air_purifier.py's identical shapes), plus two genuinely new hrefs
(/csi/absenceclean/vs/0, /csi/energysaving/vs/0).
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
_DEVICE_TYPES = ("oic.wk.d", "oic.d.airconditioner")
def _resources():
return _load_device("airconditioner_ailp_fac")
def _bound():
resources = _resources()
reg = resolve(resources, device_types=_DEVICE_TYPES)
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def test_oic_device_type_resolves_to_airconditioner_registry():
reg = for_device_by_oic_type(_DEVICE_TYPES)
assert reg is not None and reg.name == "airconditioner"
def test_no_unbound_hrefs():
resources = _resources()
reg = resolve(resources, device_types=_DEVICE_TYPES)
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_sound_mode_bound_with_live_supported_modes():
"""This dump has no current `mode` value yet, only supportedModes
(mute/tone/voice) -- confirms the select is bound (present in state,
even if unknown) and its options come from the live field rather than
a static tuple (see laundry.SOUND_MODE's docstring for why that's
preferred whenever the resource carries one)."""
bound, resources = _bound()
desc = next(b.desc for b in bound if b.desc.key == "sound_mode")
assert desc.options_field == "supportedModes"
state = flatten(bound, resources)
assert "sound_mode" in state
assert state["sound_mode"] is None
def test_absence_clean_and_energy_saving_bound():
bound, resources = _bound()
state = flatten(bound, resources)
assert state["absence_clean"] is True
assert state["energy_saving_mode"] == "Off_100"
assert state["energy_saving_state"] == "Off"
def test_sound_volume_self_gates_off_without_max_level():
"""This board's /settings/sound/volume/vs/0 reports minLevel/resolution
but no maxLevel -- air_purifier.SOUND_VOLUME's exists_fn should keep it
unbound-but-covered rather than shipping a min=0/max=0 slider."""
bound, resources = _bound()
state = flatten(bound, resources)
assert "sound_volume" not in state
+12 -9
View File
@@ -6,12 +6,18 @@ token -- this board was the one exception (its oneUiVersion self-reports
"7.0 Air conditioner", but 'CAC' had never been added to the board-token
table), so it silently fell back to common caps and lost its climate entity.
This dump is NOT fully covered yet -- eight hrefs remain unbound (edge
lighting, a second stateful light resource, absence-clean, four
`/settings/sound/*` resources, smart-sensing-cooling), all genuinely new to
this board generation. That's a real device-support gap, left documented
here rather than guessed at, per the 'don't guess' rule -- fixing the
routing regression was the scope of #191.
This dump is NOT fully covered yet -- five hrefs remain unbound (edge
lighting, a second stateful light resource, absence-clean,
`/settings/sound/optimization/vs/0`, smart-sensing-cooling), all genuinely
new to this board generation. That's a real device-support gap, left
documented here rather than guessed at, per the 'don't guess' rule --
fixing the routing regression was the scope of #191.
/settings/sound/mode/vs/0, /settings/sound/output/vs/0 and
/settings/sound/volume/vs/0 used to be on this list too, until issue #319
(a sibling TP1X_DA-AC-FAC-class board) supplied a live dump for them --
airconditioner.SOUND_MODE and the reused air_purifier.SOUND_OUTPUT/
SOUND_VOLUME now cover all three here as well.
/uvled/vs/0 and /filter/airdustPM1filter/vs/0 used to be on this list too,
until issue #270 (TP1X_FAC_TIME_23K) added real capabilities for both --
@@ -30,10 +36,7 @@ _STILL_UNBOUND = frozenset(
"/edgelighting/vs/0",
"/light/stateful/vs/0",
"/mds/absenceclean/vs/0",
"/settings/sound/mode/vs/0",
"/settings/sound/optimization/vs/0",
"/settings/sound/output/vs/0",
"/settings/sound/volume/vs/0",
"/smartsensingcooling/vs/0",
}
)
+24 -6
View File
@@ -6,12 +6,12 @@ import pytest
GOLDEN = Path(__file__).parent / "fixtures" / "golden"
def _new_state_keys(name, resources):
def _new_state_keys(name, resources, device_types=()):
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.discovery import discover
reg = resolve(resources)
reg = resolve(resources, device_types=device_types)
if reg is None:
from custom_components.localthings.registry.registry import CAPABILITIES
@@ -134,6 +134,24 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner():
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_ailp_fac():
"""AILP_DA-AC-FAC-02011_0000 (issue #319) has no board-token match --
resolves purely via /oic/d's oic.d.airconditioner type, exercising the
device_types-only path through resolve()."""
from tests.conftest import _load_device
resources = _load_device("airconditioner_ailp_fac")
golden = json.loads((GOLDEN / "airconditioner_ailp_fac.json").read_text())
state_keys = _new_state_keys(
"airconditioner_ailp_fac", resources, device_types=("oic.wk.d", "oic.d.airconditioner")
)
assert set(state_keys) == set(golden["state_keys"]), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_dehumidifier():
"""TP1X_DA_AC_DHM_01001_0000 (issue #88, AY18CG7500GED) shares the DA_AC_
board family with the room-AC models but carries the '_DHM_' token;
@@ -1095,10 +1113,10 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac():
0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never
been added to the modelNum board-token table. Resolved via the new 'CAC'
token onto the existing airconditioner registry. Not fully covered yet --
ten hrefs remain unbound (edge lighting, PM1 filter, stateful light,
absence-clean, four sound-settings resources, smart-sensing-cooling, UV
LED), all genuinely new to this board generation and out of scope for
the routing fix; see test_airconditioner_cac.py for the documented gap."""
five hrefs remain unbound (edge lighting, stateful light, absence-clean,
sound-optimization, smart-sensing-cooling), all genuinely new to this
board generation and out of scope for the routing fix; see
test_airconditioner_cac.py for the documented gap."""
from tests.conftest import _load_device
resources = _load_device("airconditioner_cac")