Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET, PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree with `ruff format`, and fixes the pre-existing lint and type-check debt those tools surfaced so both run clean. Production-code type fixes include: HA's ConfigFlowResult vs. the generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via cast() instead of an unchecked annotation, converting HA device_class strings to their proper enum types, a resolve_registry callback typed as `object` instead of `DeviceRegistry | None`, and a couple of other narrow correctness fixes (CA key type validation, an index-out-of-bounds false positive from an empty-tuple fallback, a bool/dict argument swap). Test-file fixes are mechanical: narrowing SamsungEntityDescription to the correct subclass via isinstance()/cast() before accessing subclass-only fields, and asserting Optional write_fn/unit_fn fields are set before calling them.
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""ARTIK051_KRAC_18K room air conditioner (issue #136).
|
|
|
|
This board generation predates every AC dump the registry was built from and
|
|
differs from all of them in three ways, each covered below:
|
|
|
|
* No ``/wind/*`` resources at all -- fan speed and vane direction share one
|
|
``/airflow/vs/0`` resource (``speedLevel`` on the same 0-4 scale as
|
|
``_DEVICE_TO_FAN``, ``direction`` with the same codes as ``_DEVICE_TO_SWING``).
|
|
* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*``
|
|
token in ``/mode/vs/0``'s ``options`` array.
|
|
* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep,
|
|
outdoor temperature, filter time and its alarm interval) are ``options``
|
|
tokens too, where newer boards have dedicated resources.
|
|
|
|
Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware
|
|
by read-back on the unit this fixture is dumped from. The issue #136 unit is
|
|
the same model with a slightly different token set (no ``Spi``,
|
|
``FilterTime_5460``, ``OutdoorTemp_81``), which the token entities' presence
|
|
gating handles the same way it handles newer boards.
|
|
"""
|
|
|
|
from typing import ClassVar
|
|
|
|
from custom_components.localthings.climate import LocalThingsClimate
|
|
from custom_components.localthings.registry.adapter import flatten
|
|
from custom_components.localthings.registry.by_type import (
|
|
airconditioner,
|
|
for_device_by_model,
|
|
)
|
|
from custom_components.localthings.registry.capabilities.airconditioner import (
|
|
HREF_AIRFLOW,
|
|
HREF_WIND_STRENGTH,
|
|
_option_number_write,
|
|
_option_switch_write,
|
|
is_legacy_board,
|
|
)
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from custom_components.localthings.registry.entities import ClimateDesc
|
|
from tests.conftest import _load_device
|
|
|
|
FIXTURE = "airconditioner_artik051_krac_18k"
|
|
MODEL = "ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000"
|
|
|
|
|
|
class _FakeCoordinator:
|
|
device_serial = "TEST-KRAC-SERIAL"
|
|
device_info: ClassVar[dict] = {}
|
|
data: ClassVar[dict] = {}
|
|
|
|
def __init__(self, resources):
|
|
self.last_resources = resources
|
|
self.commands = []
|
|
|
|
def resource(self, href):
|
|
return self.last_resources.get(href, {})
|
|
|
|
def canonical_resources(self, subdevice):
|
|
# Every bound entity in this test uses the default MAIN
|
|
# subdevice, so the canonical view is just the raw snapshot
|
|
# (issue #177 -- see LocalThingsEntity._resources).
|
|
return self.last_resources
|
|
|
|
async def async_send_command(self, bound, payload):
|
|
self.commands.append((bound, payload))
|
|
|
|
|
|
def _discover(resources, registry=airconditioner.REGISTRY):
|
|
unbound = []
|
|
bound = discover(
|
|
resources, registry.capabilities, registry.pattern_capabilities, log=unbound.append
|
|
)
|
|
return bound, unbound
|
|
|
|
|
|
def _state(fixture=FIXTURE):
|
|
resources = _load_device(fixture)
|
|
bound, _ = _discover(resources)
|
|
return flatten(bound, resources)
|
|
|
|
|
|
def _climate(resources, coordinator=None):
|
|
bound, _ = _discover(resources)
|
|
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
|
|
return LocalThingsClimate(
|
|
coordinator or _FakeCoordinator(resources),
|
|
climate_bound,
|
|
)
|
|
|
|
|
|
# -- device type --------------------------------------------------------------
|
|
|
|
|
|
def test_krac_model_resolves_to_the_airconditioner_registry():
|
|
"""The '_RAC_' token check can't see '_KRAC_' -- the 'K' sits between the
|
|
underscore and 'RAC' -- and the consumer-prefix fallback only covers
|
|
washers/dryers/dishwashers, so this model resolved to 'unknown' and
|
|
exposed nothing but power."""
|
|
registry = for_device_by_model(MODEL, "ARTIK051_KRAC_18K")
|
|
assert registry is not None
|
|
assert registry.name == "airconditioner"
|
|
|
|
|
|
def test_no_unbound_hrefs():
|
|
_, unbound = _discover(_load_device(FIXTURE))
|
|
assert unbound == []
|
|
|
|
|
|
# -- option-token entities ----------------------------------------------------
|
|
|
|
|
|
def test_token_entities_present_with_calibrated_values():
|
|
state = _state()
|
|
# token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the
|
|
# official app on this unit, which pins the scale (the .5 here is a later
|
|
# reading). It counts up -- see the descriptor comment and
|
|
# test_filter_alarm_tracks_the_counter_against_its_threshold below.
|
|
assert state["filter_time"] == 171.5
|
|
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
|
|
assert state["outdoor_temperature"] == 19.0
|
|
assert state["beep"] is True
|
|
assert state["good_sleep"] == 0.0
|
|
assert state["spi"] is False
|
|
assert state["auto_clean_legacy"] is False
|
|
assert state["air_monitoring"] is False
|
|
|
|
|
|
def test_token_entities_stay_off_newer_boards():
|
|
"""Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too,
|
|
while also exposing those settings as dedicated resources -- ungated, the
|
|
token entities would duplicate them (auto clean) or apply a scale
|
|
calibrated on another board generation (outdoor temperature)."""
|
|
state = _state("airconditioner_tp1x_rac")
|
|
for key in (
|
|
"spi",
|
|
"auto_clean_legacy",
|
|
"air_monitoring",
|
|
"good_sleep",
|
|
"outdoor_temperature",
|
|
"filter_time",
|
|
):
|
|
assert key not in state, key
|
|
|
|
|
|
def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board():
|
|
"""issue #161: climate.py's _legacy_airflow() delegates to
|
|
capabilities/airconditioner.py's is_legacy_board() instead of
|
|
re-implementing the same presence/absence check, so the token entities
|
|
and the climate card's legacy read/write paths can't drift apart on
|
|
which board generation is in play."""
|
|
legacy_resources = _load_device(FIXTURE)
|
|
assert is_legacy_board(legacy_resources) is True
|
|
assert _climate(legacy_resources)._legacy_airflow() == legacy_resources[HREF_AIRFLOW]
|
|
|
|
newer_resources = _load_device("airconditioner_tp1x_rac")
|
|
assert is_legacy_board(newer_resources) is False
|
|
assert _climate(newer_resources)._legacy_airflow() == {}
|
|
assert HREF_WIND_STRENGTH in newer_resources
|
|
|
|
|
|
def test_absent_token_yields_no_entity():
|
|
"""The issue #136 unit of this same model reports no Spi token."""
|
|
resources = _load_device(FIXTURE)
|
|
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
|
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
|
option for option in options if not option.startswith("Spi_")
|
|
]
|
|
bound, _ = _discover(resources)
|
|
assert "spi" not in flatten(bound, resources)
|
|
|
|
|
|
def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown():
|
|
"""This board has no fivepercentHumidity field; the plain humidity field
|
|
only carries a reading while Air monitoring is on, and the unit switches
|
|
that back off by itself after about a minute."""
|
|
resources = _load_device(FIXTURE)
|
|
assert resources["/humidity/vs/0"]["x.com.samsung.da.humidity"] == "0"
|
|
bound, _ = _discover(resources)
|
|
assert flatten(bound, resources)["humidity"] is None
|
|
|
|
resources["/humidity/vs/0"]["x.com.samsung.da.humidity"] = "51"
|
|
bound, _ = _discover(resources)
|
|
assert flatten(bound, resources)["humidity"] == 51.0
|
|
|
|
|
|
def test_option_writes_carry_one_token():
|
|
"""Both go through option_write's single-token merge, the same mechanism
|
|
the display light already uses on this href. Confirmed on hardware by
|
|
read-back: Spi_On/Spi_Off, and the volume token surviving a write."""
|
|
assert _option_switch_write("Spi")("On", {}) == (
|
|
["mode", "vs", "0"],
|
|
{"x.com.samsung.da.options": ["Spi_On"]},
|
|
)
|
|
# The number platform hands over a float; the device wants an integer token.
|
|
assert _option_number_write("Volume")(70.0, {}) == (
|
|
["mode", "vs", "0"],
|
|
{"x.com.samsung.da.options": ["Volume_70"]},
|
|
)
|
|
|
|
|
|
# -- filter counter and its reset ---------------------------------------------
|
|
|
|
|
|
def _desc(resources, key):
|
|
"""The bound descriptor for `key`, or None when its exists_fn declines it.
|
|
|
|
flatten() gives values, not descriptors, so this is how a test reaches a
|
|
descriptor's own write_fn and options without standing up an HA entity.
|
|
"""
|
|
bound, _ = _discover(resources)
|
|
for item in bound:
|
|
if item.desc.key == key and (
|
|
item.desc.exists_fn is None
|
|
or item.desc.exists_fn(resources.get(item.href) or {}, resources)
|
|
):
|
|
return item.desc
|
|
return None
|
|
|
|
|
|
def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
|
|
"""The interval FilterTime_ is measured against, offered by the app as a
|
|
180/300/500/700 hour radio. All four were walked on hardware while watching
|
|
all 19 resources: the token carries the hour count verbatim and each step
|
|
moved that one field and nothing else, which is also what makes a static
|
|
options tuple defensible here (the board advertises no supported-values
|
|
list for options[] tokens)."""
|
|
state = _state()
|
|
assert state["filter_alarm_time"] == "500" # the fixture's own value
|
|
|
|
desc = _desc(_load_device(FIXTURE), "filter_alarm_time")
|
|
assert desc.options == ("180", "300", "500", "700")
|
|
assert desc.write_fn("180", {}) == (
|
|
["mode", "vs", "0"],
|
|
{"x.com.samsung.da.options": ["FilterAlarmTime_180"]},
|
|
)
|
|
|
|
|
|
def test_filter_alarm_time_stays_off_boards_with_a_real_threshold_resource():
|
|
"""Newer boards carry air_filter_threshold off supportedFilterDesiredUsage;
|
|
two thresholds on one device would be a coin flip for the user.
|
|
|
|
No non-legacy fixture carries a FilterAlarmTime_ token today (only the two
|
|
KRAC dumps have one at all), so asserting on an unmodified newer board
|
|
would pass for the wrong reason -- absent token rather than the
|
|
board-generation gate. The token is injected to exercise the gate itself,
|
|
test_absent_token_yields_no_entity's technique in reverse."""
|
|
newer = _load_device("airconditioner_tp1x_rac")
|
|
assert _desc(newer, "filter_alarm_time") is None
|
|
|
|
mode = newer["/mode/vs/0"]
|
|
mode["x.com.samsung.da.options"] = [
|
|
*(mode.get("x.com.samsung.da.options") or []),
|
|
"FilterAlarmTime_500",
|
|
]
|
|
assert is_legacy_board(newer) is False
|
|
assert _desc(newer, "filter_alarm_time") is None
|
|
|
|
|
|
def test_filter_alarm_tracks_the_counter_against_its_threshold():
|
|
"""Why filter_time is read as elapsed rather than remaining: the same
|
|
options blob carries the threshold, and /alarms/vs/0's filter entry is a
|
|
'FilterAlarm_OFF'/'Deleted' placeholder below it. The sibling unit on the
|
|
same site, at FilterTime_5595 against the same FilterAlarmTime_500, instead
|
|
reported an unsuffixed 'FilterAlarm' in state 'Created'."""
|
|
resources = _load_device(FIXTURE)
|
|
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
|
assert "FilterTime_1715" in options
|
|
assert "FilterAlarmTime_500" in options
|
|
|
|
alarms = resources["/alarms/vs/0"]["x.com.samsung.da.items"]
|
|
filter_alarm = next(
|
|
item for item in alarms if item["x.com.samsung.da.code"].startswith("FilterAlarm")
|
|
)
|
|
assert filter_alarm["x.com.samsung.da.code"] == "FilterAlarm_OFF"
|
|
assert filter_alarm["x.com.samsung.da.state"] == "Deleted"
|
|
|
|
|
|
# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------
|
|
|
|
|
|
def test_fan_mode_reads_the_airflow_speed_level():
|
|
entity = _climate(_load_device(FIXTURE))
|
|
assert entity.fan_mode == "high" # speedLevel 3 in the fixture
|
|
# No supportedModes on this resource, so the full 0-4 scale is offered.
|
|
assert entity.fan_modes == ["auto", "low", "medium", "high", "turbo"]
|
|
|
|
|
|
def test_swing_mode_reads_the_airflow_direction():
|
|
resources = _load_device(FIXTURE)
|
|
entity = _climate(resources)
|
|
assert entity.swing_mode == "off" # 'Fix' in the fixture
|
|
|
|
resources[HREF_AIRFLOW]["x.com.samsung.da.direction"] = "All"
|
|
assert _climate(resources).swing_mode == "both"
|
|
assert "both" in _climate(resources).swing_modes
|
|
|
|
|
|
async def test_fan_and_swing_writes_target_the_airflow_resource():
|
|
resources = _load_device(FIXTURE)
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _climate(resources, coordinator)
|
|
|
|
await entity.async_set_fan_mode("turbo")
|
|
await entity.async_set_swing_mode("both")
|
|
|
|
assert [payload for _, payload in coordinator.commands] == [
|
|
("fan_legacy", "4"),
|
|
("swing_legacy", "All"),
|
|
]
|
|
|
|
|
|
def test_preset_comes_from_the_comode_token():
|
|
resources = _load_device(FIXTURE)
|
|
entity = _climate(resources)
|
|
assert entity.preset_mode == "none" # Comode_Off in the fixture
|
|
# Codes learned by driving this unit through its cloud integration and
|
|
# reading the token back. They go through the same dynamic resolver as a
|
|
# real convenient resource's supportedModes, so 'Nano' resolves to the
|
|
# existing 'nano' preset -- already labelled WindFree in the catalog.
|
|
assert entity.preset_modes == [
|
|
"none",
|
|
"nano",
|
|
"quiet",
|
|
"comfort",
|
|
"2step",
|
|
"speed",
|
|
]
|
|
|
|
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
|
|
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
|
|
"Comode_Nano" if option.startswith("Comode_") else option for option in options
|
|
]
|
|
assert _climate(resources).preset_mode == "nano"
|
|
|
|
|
|
async def test_preset_write_uses_the_token_path():
|
|
resources = _load_device(FIXTURE)
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _climate(resources, coordinator)
|
|
|
|
await entity.async_set_preset_mode("nano")
|
|
|
|
assert coordinator.commands[-1][1] == ("preset_legacy", "Nano")
|
|
|
|
|
|
async def test_newer_boards_keep_the_resource_paths():
|
|
"""The legacy fallbacks are gated on this board's resource shape, so a
|
|
board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""
|
|
resources = _load_device("airconditioner_tp1x_rac")
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _climate(resources, coordinator)
|
|
|
|
await entity.async_set_fan_mode("high")
|
|
await entity.async_set_swing_mode("off")
|
|
await entity.async_set_preset_mode("quiet") # from its own supportedModes
|
|
|
|
kinds = [payload[0] for _, payload in coordinator.commands]
|
|
assert kinds == ["fan", "swing", "preset"]
|