diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 863d923..333a94c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,11 @@ Unreleased - Fog GET / device-list payloads may omit ``Demisting``, ``WaterTank``, and ``Fan``. Parse them like other optional Fog flags and default to off instead of raising ``KeyError``. +- Re-read official dehumidifier JSON for ``anion``, ``swingWind``, + ``waterPump``, ``uvLight``, ``tone``, ``displayScreen``, and + ``hasDelayer``. Identify ES25A3 / P30 / B13A3 / RT12 from plaintext + ``DeviceListBean.isFanDevice`` product IDs. ``be8f5e6a…`` is ES25A3 + (anion and air-purifier mode), not P30. Version 3.0.2 ============= diff --git a/README.rst b/README.rst index ae58e67..9ca465f 100644 --- a/README.rst +++ b/README.rst @@ -60,6 +60,9 @@ Supported devices: * DYD-P40 * A10 * DYD-P30 +* DYD-ES25A3 +* DYD-B13A3 +* DYD-RT12 * DY-C65DZ/A For devices not in the above list, consider `adding your own definitions here `_. diff --git a/src/libdeye/const.py b/src/libdeye/const.py index 790a4e2..1a702d6 100644 --- a/src/libdeye/const.py +++ b/src/libdeye/const.py @@ -69,10 +69,13 @@ class DeyeProductPartialConfig(TypedDict, total=False): # Mapped from official Deye Smart 4.2.1 ``control_panel/dehumidifier/*.json``. -# Products without a dedicated JSON keep their previously known UI capabilities. -# ``uv`` / ``prompt_sound`` / ``screen_display`` / ``timed_off`` inherit False -# unless a product JSON defines ``uvLight``, ``tone``, ``displayScreen``, or -# ``hasDelayer``. +# Official ``DehumidifierControlPanelUIAty.initViews`` hides a control when +# the JSON object is null; delayed shutdown uses ``hasDelayer``. +# ``anion`` / ``swingWind`` / ``waterPump`` / ``uvLight`` / ``tone`` / +# ``displayScreen`` / ``hasDelayer`` → ``anion`` / ``oscillating`` / +# ``water_pump`` / ``uv`` / ``prompt_sound`` / ``screen_display`` / +# ``timed_off``. Products without a dedicated JSON keep their previously +# known UI capabilities; extras inherit False. PRODUCT_FEATURE_CONFIG: dict[str, DeyeProductPartialConfig] = { "d71936c6951c11f0a8200242ac480009": { # DYD-P40 "mode": [ @@ -626,7 +629,29 @@ class DeyeProductPartialConfig(TypedDict, total=False): "screen_display": True, "timed_off": True, }, - "be8f5e6a893111f0aebc0242ac480009": { # P30 + "be8f5e6a893111f0aebc0242ac480009": { # ES25A3 + "mode": [ + DeyeDeviceMode.MANUAL_MODE, + DeyeDeviceMode.CLOTHES_DRYER_MODE, + DeyeDeviceMode.SLEEP_MODE, + DeyeDeviceMode.AIR_PURIFIER_MODE, + ], + "fan_speed": [ + DeyeFanSpeed.LOW, + DeyeFanSpeed.MIDDLE, + DeyeFanSpeed.HIGH, + ], + "min_target_humidity": 40, + "max_target_humidity": 70, + "anion": True, + "oscillating": True, + "water_pump": False, + "uv": False, + "prompt_sound": True, + "screen_display": True, + "timed_off": True, + }, + "537ed2b4d4c111f080e00242ac480009": { # P30 "mode": [ DeyeDeviceMode.MANUAL_MODE, DeyeDeviceMode.CLOTHES_DRYER_MODE, @@ -647,6 +672,47 @@ class DeyeProductPartialConfig(TypedDict, total=False): "screen_display": True, "timed_off": True, }, + "e2d2d33ad99311f0abd90242ac480009": { # B13A3 + "mode": [ + DeyeDeviceMode.MANUAL_MODE, + DeyeDeviceMode.CLOTHES_DRYER_MODE, + DeyeDeviceMode.SLEEP_MODE, + ], + "fan_speed": [ + DeyeFanSpeed.LOW, + DeyeFanSpeed.HIGH, + ], + "min_target_humidity": 40, + "max_target_humidity": 70, + "anion": True, + "oscillating": False, + "water_pump": False, + "uv": False, + "prompt_sound": True, + "screen_display": True, + "timed_off": True, + }, + "ef387edadb1011f0830f0242ac480009": { # RT12 + "mode": [ + DeyeDeviceMode.MANUAL_MODE, + DeyeDeviceMode.CLOTHES_DRYER_MODE, + DeyeDeviceMode.SLEEP_MODE, + ], + "fan_speed": [ + DeyeFanSpeed.LOW, + DeyeFanSpeed.MIDDLE, + DeyeFanSpeed.HIGH, + ], + "min_target_humidity": 40, + "max_target_humidity": 70, + "anion": False, + "oscillating": False, + "water_pump": False, + "uv": False, + "prompt_sound": True, + "screen_display": True, + "timed_off": True, + }, "7faf2a66c8b811efb3a50242ac480009": { # C65DZ "mode": [ DeyeDeviceMode.MANUAL_MODE, diff --git a/tests/test_const.py b/tests/test_const.py index d0d7d9e..802099d 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -1,5 +1,8 @@ """Tests for product feature configuration and official transport routing.""" +import json +from pathlib import Path + from libdeye.cloud_api import ( DeyeApiResponseDeviceInfo, DeyeDeviceTransport, @@ -10,6 +13,54 @@ ) from libdeye.const import DeyeDeviceMode, DeyeFanSpeed, get_product_feature_config +_OFFICIAL_JSON_DIR = ( + Path(__file__).resolve().parents[1] + / "reverse-engineering" + / "unpacked" + / "control_panel" + / "dehumidifier" +) + +# product_id → official control-panel JSON. IDs for ES25A3 / P30 / B13A3 / RT12 +# come from plaintext ``DeviceListBean.isFanDevice`` (Java String.hashCode +# matches PanelHelper / EquipmentFragment). +_PRODUCT_OFFICIAL_JSON = { + "d71936c6951c11f0a8200242ac480009": "DeYeP40A3.json", + "07dddba41c3011e8829100163e0f811e": "DeYe612S.json", + "e69a5f54983f11ec964d0242ac480009": "DeYeB12A3.json", + "c56f9e0c7d2b11e9829100163e0f811e": "DeYeD50A3.json", + "86cec9fc5c9811e8829100163e0f811e": "DeYeD50B3.json", + "c2c2d92c049f11e8829100163e0f811e": "DeYeE12A3.json", + "8d52bc78f38511e89d4c00163e0c1b21": "DeYeG25A3.json", + "a3850ae49ea511e89d4c00163e0c1b21": "DeYeN20A3.json", + "5ea0feae4b1111ebb73c0242ac480009": "DeYeRLS48A3.json", + "2c4bd0861c3011e89d4c00163e0c1b21": "DeYeT22A3.json", + "6f97c340a43011e7829100163e0f811e": "DeYeTM208.json", + "20eae2ea268511e8829100163e0f811e": "DeYeU20A3.json", + "363b686a31ee11efb7203b3cd9717242": "DeYeU20Air.json", + "2b770cba268611e89d4c00163e0c1b21": "DeYeV58A3.json", + "17ab051af38611e89d4c00163e0c1b21": "DeYeW20A3.json", + "06e8c86cca0811e99d4c00163e0c1b21": "DeYeW20A3.json", + "d74ab1167d9f11e8829100163e0f811e": "DeYeX20A3.json", + "ff71de22187111e99d4c00163e0c1b21": "DeYeZ12A3.json", + "1b351ce6187211e99d4c00163e0c1b21": "DeYeZ20B3.json", + "82547192d2a811e99d4c00163e0c1b21": "DeYeZ20B3.json", + "32c309aa779011ed8cf00242ac480009": "DY890C.json", + "764c37606bc711eea9b10242ac480009": "DY890T.json", + "edd9a010778f11ed97500242ac480009": "DY6138A.json", + "246e3b9a779011ed9a5f0242ac480009": "DY8138C.json", + "5b0033e0f65411ee880a0242ac480009": "DeYe6158EB.json", + "be47762e6bc711eea54d0242ac480009": "DY8138T.json", + "0c44950cc8b811efaf1d0242ac480009": "DeyeY16.json", + "a83dfb084b4211f08c060242ac480009": "DYSC60Y.json", + "744b6884fb294936b4f73f427507aaa3": "DeYeA10.json", + "be8f5e6a893111f0aebc0242ac480009": "DeYeES25A3.json", + "537ed2b4d4c111f080e00242ac480009": "DeYeP30.json", + "e2d2d33ad99311f0abd90242ac480009": "DeYeB13A3.json", + "ef387edadb1011f0830f0242ac480009": "DeYeRT12.json", + "7faf2a66c8b811efb3a50242ac480009": "DeyeC65DZ.json", +} + def _device( platform: int, @@ -115,13 +166,35 @@ def test_get_product_feature_config() -> None: assert a10["timed_off"] is True assert DeyeDeviceMode.SLEEP_MODE in a10["mode"] - p30 = get_product_feature_config("be8f5e6a893111f0aebc0242ac480009") + es25a3 = get_product_feature_config("be8f5e6a893111f0aebc0242ac480009") + assert es25a3["anion"] is True + assert es25a3["oscillating"] is True + assert es25a3["prompt_sound"] is True + assert es25a3["screen_display"] is True + assert es25a3["timed_off"] is True + assert DeyeDeviceMode.AIR_PURIFIER_MODE in es25a3["mode"] + + p30 = get_product_feature_config("537ed2b4d4c111f080e00242ac480009") assert p30["oscillating"] is True assert p30["anion"] is False assert p30["prompt_sound"] is True assert p30["screen_display"] is True assert p30["timed_off"] is True assert DeyeDeviceMode.SLEEP_MODE in p30["mode"] + assert DeyeDeviceMode.AIR_PURIFIER_MODE not in p30["mode"] + + b13a3 = get_product_feature_config("e2d2d33ad99311f0abd90242ac480009") + assert b13a3["anion"] is True + assert b13a3["oscillating"] is False + assert b13a3["prompt_sound"] is True + assert b13a3["timed_off"] is True + assert b13a3["fan_speed"] == [DeyeFanSpeed.LOW, DeyeFanSpeed.HIGH] + + rt12 = get_product_feature_config("ef387edadb1011f0830f0242ac480009") + assert rt12["anion"] is False + assert rt12["prompt_sound"] is True + assert rt12["screen_display"] is True + assert rt12["timed_off"] is True c65dz = get_product_feature_config("7faf2a66c8b811efb3a50242ac480009") assert c65dz["uv"] is True @@ -134,6 +207,20 @@ def test_get_product_feature_config() -> None: assert tm208["oscillating"] is True +def test_product_flags_match_official_control_panel_json() -> None: + """anion/oscillating/pump/uv/tone/display/delayer follow official JSON keys.""" + for product_id, filename in _PRODUCT_OFFICIAL_JSON.items(): + payload = json.loads((_OFFICIAL_JSON_DIR / filename).read_text()) + config = get_product_feature_config(product_id) + assert config["anion"] is ("anion" in payload), filename + assert config["oscillating"] is ("swingWind" in payload), filename + assert config["water_pump"] is ("waterPump" in payload), filename + assert config["uv"] is ("uvLight" in payload), filename + assert config["prompt_sound"] is ("tone" in payload), filename + assert config["screen_display"] is ("displayScreen" in payload), filename + assert config["timed_off"] is bool(payload.get("hasDelayer")), filename + + def test_transport_for_device_follows_official_command_manger() -> None: """Platform 2/3 are Fog HTTP; Combo MQTT is is_combo + combo_V1.0 on Classic.""" classic = _device(DeyeIotPlatform.Classic)