From a0c4c7aff61624f22ce2cc0dad89f99f5a79c1e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:27:06 +0000 Subject: [PATCH 1/3] fix: map official JSON extras and correct ES25A3/P30 IDs Official dehumidifier panels hide anion, oscillating, water pump, UV, tone, and display when the control-panel JSON object is null, and show delayed shutdown when hasDelayer is true. Identify ES25A3, P30, B13A3, and RT12 from DeviceListBean.isFanDevice plaintext IDs. Co-authored-by: Stackie Jia --- CHANGELOG.rst | 5 +++ README.rst | 3 ++ src/libdeye/const.py | 76 ++++++++++++++++++++++++++++++++--- tests/test_const.py | 94 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 172 insertions(+), 6 deletions(-) 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..a35de56 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,63 @@ ) 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", +} + +_JSON_FLAG_KEYS = { + "anion": "anion", + "oscillating": "swingWind", + "water_pump": "waterPump", + "uv": "uvLight", + "prompt_sound": "tone", + "screen_display": "displayScreen", +} + def _device( platform: int, @@ -115,13 +175,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 +216,16 @@ 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) + for flag, json_key in _JSON_FLAG_KEYS.items(): + assert config[flag] is (json_key in payload), (filename, flag) + 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) From 2065b8414d64f99bdfff89e72719ab725adc82ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:29:01 +0000 Subject: [PATCH 2/3] test: use literal TypedDict keys in official JSON flag checks Co-authored-by: Stackie Jia --- tests/test_const.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/tests/test_const.py b/tests/test_const.py index a35de56..f148537 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -61,14 +61,6 @@ "7faf2a66c8b811efb3a50242ac480009": "DeyeC65DZ.json", } -_JSON_FLAG_KEYS = { - "anion": "anion", - "oscillating": "swingWind", - "water_pump": "waterPump", - "uv": "uvLight", - "prompt_sound": "tone", - "screen_display": "displayScreen", -} def _device( @@ -221,8 +213,12 @@ def test_product_flags_match_official_control_panel_json() -> None: 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) - for flag, json_key in _JSON_FLAG_KEYS.items(): - assert config[flag] is (json_key in payload), (filename, flag) + 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 From ab4f727bd933288bbb8b2fa80d72a0005b7937f2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:31:01 +0000 Subject: [PATCH 3/3] style: apply ruff format to test_const.py Co-authored-by: Stackie Jia --- tests/test_const.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_const.py b/tests/test_const.py index f148537..802099d 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -62,7 +62,6 @@ } - def _device( platform: int, *,