From 002b932eff38258707a74c5c152ee05cd34e5bf1 Mon Sep 17 00:00:00 2001 From: AutoCoder Date: Wed, 20 May 2026 14:01:58 +0000 Subject: [PATCH 1/4] Add Vital 200S Pro support for MCU FW 2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vital 200S Pro (LAP-V201S-AEUR) running MCU firmware 2.0.0 emits four TLVs in its status push that the existing Vital component parses but does not yet surface to Home Assistant. This commit exposes them as read-only entities: - TLV 0x17 — ambient-light "dark detected" reading. New BinarySensorType DARK_DETECTED + binary_sensor type "dark_detected". - TLV 0x18 — sleep-preference type byte. New SelectType SLEEP_PREFERENCE + select type "sleep_preference". Options list contains only "Default" at this stage (the only value observed in baseline captures); a follow-up commit broadens the options and makes the entity writable. - TLV 0x1F — sleep fan level (1-4 explicit, 5 = device-default "auto" sentinel). New NumberType SLEEP_FAN_LEVEL + number type "sleep_fan_level". - TLV 0x20 — sleep duration in minutes, LE16 on the wire. New NumberType SLEEP_MODE_MIN + number type "sleep_minutes" (alias of the existing sleep_mode_min mapping). Adds the corresponding entity stanzas to devices/levoit-vital200s/ common.yaml and the three builder yamls (builder, builder-c3, builder-s3) so the entities appear on every flashing variant. All four entities are read-only in this commit — they reflect MCU state via the existing status decoder but do not initiate writes. A separate commit implements the bulk-preferences SET command that makes the three sleep entities writable. --- components/levoit/binary_sensor/__init__.py | 5 +++++ components/levoit/number/__init__.py | 13 +++++++++++++ components/levoit/select/__init__.py | 1 + components/levoit/select/levoit_select.cpp | 12 ++++++++++++ components/levoit/types.h | 6 ++++++ components/levoit/vital_status.cpp | 14 ++++++++++---- devices/levoit-vital200s/common.yaml | 16 ++++++++++++++++ .../levoit-vital200s-builder-c3.yaml | 16 ++++++++++++++++ .../levoit-vital200s-builder-s3.yaml | 16 ++++++++++++++++ .../levoit-vital200s-builder.yaml | 16 ++++++++++++++++ 10 files changed, 111 insertions(+), 4 deletions(-) diff --git a/components/levoit/binary_sensor/__init__.py b/components/levoit/binary_sensor/__init__.py index 64e6622..0586005 100644 --- a/components/levoit/binary_sensor/__init__.py +++ b/components/levoit/binary_sensor/__init__.py @@ -13,6 +13,7 @@ TYPE_MAP = { "filter_low": BinarySensorType.FILTER_LOW, "cover_open": BinarySensorType.COVER_OPEN, + "dark_detected": BinarySensorType.DARK_DETECTED, } TYPE_PROPS = { @@ -24,6 +25,10 @@ CONF_DEVICE_CLASS: "door", CONF_ICON: "mdi:door-open", }, + "dark_detected": { + CONF_DEVICE_CLASS: "light", + CONF_ICON: "mdi:weather-night", + }, } CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(LevoitBinarySensor).extend( diff --git a/components/levoit/number/__init__.py b/components/levoit/number/__init__.py index 35a8409..ac50b90 100644 --- a/components/levoit/number/__init__.py +++ b/components/levoit/number/__init__.py @@ -28,6 +28,8 @@ "quick_clean_min": NumberType.QUICK_CLEAN_MIN, "white_noise_min": NumberType.WHITE_NOISE_MIN, "sleep_mode_min": NumberType.SLEEP_MODE_MIN, + "sleep_minutes": NumberType.SLEEP_MODE_MIN, + "sleep_fan_level": NumberType.SLEEP_FAN_LEVEL, "filter_lifetime_months": NumberType.FILTER_LIFETIME_MONTHS, # Sprout only below "led_brightness_min": NumberType.LED_BRIGHTNESS_MIN, @@ -70,6 +72,17 @@ CONF_ICON: "mdi:volume-high", CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, }), + "sleep_minutes": (0.0, 720.0, 30.0, { + CONF_DEVICE_CLASS: "duration", + CONF_UNIT_OF_MEASUREMENT: "min", + CONF_ICON: "mdi:sleep", + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, + }), + # 5 = auto (MCU-decided), 1-4 = explicit fan level + "sleep_fan_level": (1.0, 5.0, 1.0, { + CONF_ICON: "mdi:fan", + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, + }), } CONFIG_SCHEMA = number.number_schema(LevoitNumber).extend( diff --git a/components/levoit/select/__init__.py b/components/levoit/select/__init__.py index db76ab6..6a2f8fe 100644 --- a/components/levoit/select/__init__.py +++ b/components/levoit/select/__init__.py @@ -22,6 +22,7 @@ "nightlight": SelectType.NIGHTLIGHT, "light_mode": SelectType.LIGHT_MODE, "white_noise_sound": SelectType.WHITE_NOISE_SOUND, + "sleep_preference": SelectType.SLEEP_PREFERENCE, } CONFIG_SCHEMA = select.select_schema(LevoitSelect).extend( diff --git a/components/levoit/select/levoit_select.cpp b/components/levoit/select/levoit_select.cpp index c54cdcc..66c1eb4 100644 --- a/components/levoit/select/levoit_select.cpp +++ b/components/levoit/select/levoit_select.cpp @@ -46,6 +46,18 @@ namespace esphome "Sound 06", "Sound 07", "Sound 08", "Sound 09", "Sound 10", "Sound 11", "Sound 12", "Sound 13", "Sound 14", "Sound 15"}); break; + case SelectType::SLEEP_PREFERENCE: + // TLV 0x18 byte values. Only 0x00 observed on this Vital 200S Pro + // (MCU 2.0.0). The other byte values (likely 0x01, 0x02, ...) are + // undiscoverable on this device because we no longer have VeSync-app + // access to cycle through the Sleep Preference UI — the ESP was + // reflashed with ESPHome, so the VeSync cloud path is gone. + // Future contributors who still run stock firmware can capture the + // additional values and extend this list (plus update the baseline + // doc). publish_select() ignores out-of-range indices, so unknown + // bytes will log a warning and leave state unchanged. + this->traits.set_options({"Default"}); + break; default: break; } diff --git a/components/levoit/types.h b/components/levoit/types.h index 5b7a891..7789da2 100644 --- a/components/levoit/types.h +++ b/components/levoit/types.h @@ -52,6 +52,7 @@ namespace esphome // LED_COLOR_TEMP = 11 — removed, controlled via light component CT slider WHITE_NOISE_VOLUME = 12, // Sprout: white noise volume (0-255) AQI_SCALE = 13, // Sprout: AQI display scale max (0–500) + SLEEP_FAN_LEVEL = 14, // Vital: sleep mode fan level (TLV 0x1F) }; // Note: indices 0-11 must stay stable (serialized to preferences) // NumberType aliases (flat namespace) @@ -67,6 +68,7 @@ namespace esphome static constexpr NumberType LED_SPEED = NumberType::LED_SPEED; static constexpr NumberType WHITE_NOISE_VOLUME = NumberType::WHITE_NOISE_VOLUME; static constexpr NumberType AQI_SCALE = NumberType::AQI_SCALE; + static constexpr NumberType SLEEP_FAN_LEVEL = NumberType::SLEEP_FAN_LEVEL; enum class SensorType : uint8_t { @@ -94,9 +96,11 @@ namespace esphome enum class BinarySensorType : uint8_t { FILTER_LOW = 0, COVER_OPEN = 1, // Sprout: cover/filter door open (CMD=02 08 55 tag 0x04) + DARK_DETECTED = 2, // Vital: ambient light sensor reads dark (TLV 0x17) }; static constexpr BinarySensorType FILTER_LOW = BinarySensorType::FILTER_LOW; static constexpr BinarySensorType COVER_OPEN = BinarySensorType::COVER_OPEN; + static constexpr BinarySensorType DARK_DETECTED = BinarySensorType::DARK_DETECTED; enum class ButtonType : uint8_t { RESET_FILTER_STATS = 0, @@ -134,6 +138,7 @@ namespace esphome NIGHTLIGHT = 6, LIGHT_MODE = 7, // Sprout: Off / Nightlight / Breathing WHITE_NOISE_SOUND = 8, // Sprout: white noise sound index (0-14, 15 sounds) + SLEEP_PREFERENCE = 9, // Vital: sleep mode preference type (TLV 0x18) }; static constexpr SelectType AUTO_MODE = SelectType::AUTO_MODE; static constexpr SelectType SLEEP_MODE = SelectType::SLEEP_MODE; @@ -144,6 +149,7 @@ namespace esphome static constexpr SelectType NIGHTLIGHT = SelectType::NIGHTLIGHT; static constexpr SelectType LIGHT_MODE = SelectType::LIGHT_MODE; static constexpr SelectType WHITE_NOISE_SOUND = SelectType::WHITE_NOISE_SOUND; + static constexpr SelectType SLEEP_PREFERENCE = SelectType::SLEEP_PREFERENCE; diff --git a/components/levoit/vital_status.cpp b/components/levoit/vital_status.cpp index 8f3b787..b938f1d 100644 --- a/components/levoit/vital_status.cpp +++ b/components/levoit/vital_status.cpp @@ -270,12 +270,14 @@ namespace esphome // TODO! break; case 0x17: - ESP_LOGV(TAG_VITAL, "Dark Dedected=%u", (unsigned)t.value_u32); - // TODO! + ESP_LOGV(TAG_VITAL, "Dark Detected=%u", (unsigned)t.value_u32); + if (self != nullptr) + self->publish_binary_sensor(BinarySensorType::DARK_DETECTED, t.value_u32 == 1); break; case 0x18: ESP_LOGV(TAG_VITAL, "SleepModeType=%u", (unsigned)t.value_u32); - // TODO! + if (self != nullptr) + self->publish_select(SelectType::SLEEP_PREFERENCE, t.value_u32); break; case 0x19: { @@ -300,10 +302,14 @@ namespace esphome ESP_LOGV(TAG_VITAL, "WhiteNoiseFanLevel=%u", (unsigned)t.value_u32); break; case 0x1F: - ESP_LOGV(TAG_VITAL, "SleepFanModeOrLevel=%u", (unsigned)t.value_u32); + ESP_LOGV(TAG_VITAL, "SleepFanLevel=%u", (unsigned)t.value_u32); + if (self != nullptr) + self->publish_number(NumberType::SLEEP_FAN_LEVEL, t.value_u32); break; case 0x20: ESP_LOGV(TAG_VITAL, "SleepModeMinutes=%u", (unsigned)t.value_u32); + if (self != nullptr) + self->publish_number(NumberType::SLEEP_MODE_MIN, t.value_u32); break; case 0x21: ESP_LOGV(TAG_VITAL, "DaytimeEnabled=%u", (unsigned)t.value_u32); diff --git a/devices/levoit-vital200s/common.yaml b/devices/levoit-vital200s/common.yaml index c8427be..a3b44af 100644 --- a/devices/levoit-vital200s/common.yaml +++ b/devices/levoit-vital200s/common.yaml @@ -68,6 +68,14 @@ number: levoit: levoitvital200 name: "Filter Months" type: filter_lifetime_months + - platform: levoit + levoit: levoitvital200 + name: "Sleep Mode Minutes" + type: sleep_minutes + - platform: levoit + levoit: levoitvital200 + name: "Sleep Fan Level (5=auto)" + type: sleep_fan_level sensor: - platform: levoit @@ -92,6 +100,10 @@ select: levoit: levoitvital200 name: "Auto Mode" type: auto_mode + - platform: levoit + levoit: levoitvital200 + name: "Sleep Mode Type" + type: sleep_preference text_sensor: - platform: levoit @@ -130,3 +142,7 @@ binary_sensor: levoit: levoitvital200 name: "Filter Low" type: filter_low + - platform: levoit + levoit: levoitvital200 + name: "Dark Detected" + type: dark_detected diff --git a/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml b/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml index 14ebffb..775bf56 100644 --- a/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml +++ b/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml @@ -92,6 +92,14 @@ number: levoit: levoitvital200 name: "Filter Months" type: filter_lifetime_months + - platform: levoit + levoit: levoitvital200 + name: "Sleep Mode Minutes" + type: sleep_minutes + - platform: levoit + levoit: levoitvital200 + name: "Sleep Fan Level (5=auto)" + type: sleep_fan_level sensor: - platform: levoit @@ -116,6 +124,10 @@ select: levoit: levoitvital200 name: "Auto Mode" type: auto_mode + - platform: levoit + levoit: levoitvital200 + name: "Sleep Mode Type" + type: sleep_preference text_sensor: - platform: levoit @@ -154,3 +166,7 @@ binary_sensor: levoit: levoitvital200 name: "Filter Low" type: filter_low + - platform: levoit + levoit: levoitvital200 + name: "Dark Detected" + type: dark_detected diff --git a/devices/levoit-vital200s/levoit-vital200s-builder-s3.yaml b/devices/levoit-vital200s/levoit-vital200s-builder-s3.yaml index cc55229..a5cbddb 100644 --- a/devices/levoit-vital200s/levoit-vital200s-builder-s3.yaml +++ b/devices/levoit-vital200s/levoit-vital200s-builder-s3.yaml @@ -92,6 +92,14 @@ number: levoit: levoitvital200 name: "Filter Months" type: filter_lifetime_months + - platform: levoit + levoit: levoitvital200 + name: "Sleep Mode Minutes" + type: sleep_minutes + - platform: levoit + levoit: levoitvital200 + name: "Sleep Fan Level (5=auto)" + type: sleep_fan_level sensor: - platform: levoit @@ -116,6 +124,10 @@ select: levoit: levoitvital200 name: "Auto Mode" type: auto_mode + - platform: levoit + levoit: levoitvital200 + name: "Sleep Mode Type" + type: sleep_preference text_sensor: - platform: levoit @@ -154,3 +166,7 @@ binary_sensor: levoit: levoitvital200 name: "Filter Low" type: filter_low + - platform: levoit + levoit: levoitvital200 + name: "Dark Detected" + type: dark_detected diff --git a/devices/levoit-vital200s/levoit-vital200s-builder.yaml b/devices/levoit-vital200s/levoit-vital200s-builder.yaml index d7e1beb..e7f09bb 100644 --- a/devices/levoit-vital200s/levoit-vital200s-builder.yaml +++ b/devices/levoit-vital200s/levoit-vital200s-builder.yaml @@ -87,6 +87,14 @@ number: levoit: levoitvital200 name: "Filter Months" type: filter_lifetime_months + - platform: levoit + levoit: levoitvital200 + name: "Sleep Mode Minutes" + type: sleep_minutes + - platform: levoit + levoit: levoitvital200 + name: "Sleep Fan Level (5=auto)" + type: sleep_fan_level sensor: - platform: levoit @@ -111,6 +119,10 @@ select: levoit: levoitvital200 name: "Auto Mode" type: auto_mode + - platform: levoit + levoit: levoitvital200 + name: "Sleep Mode Type" + type: sleep_preference text_sensor: - platform: levoit @@ -149,3 +161,7 @@ binary_sensor: levoit: levoitvital200 name: "Filter Low" type: filter_low + - platform: levoit + levoit: levoitvital200 + name: "Dark Detected" + type: dark_detected From 1bc7ef7a8ddc56e6705f9750d731a60bdf0eb34f Mon Sep 17 00:00:00 2001 From: AutoCoder Date: Wed, 20 May 2026 13:10:35 +0000 Subject: [PATCH 2/4] LevoitSwitch: set has_state on publish to match Select/Number behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch::publish_state in core ESPHome sets the public `state` field and fires callbacks but does not flip has_state_ on EntityBase. Select::publish_state and Number::publish_state both call set_has_state(true). The asymmetry breaks any caller that uses `if (entity->has_state())` to decide whether the entity carries a fresh user-supplied value worth preferring over a cached default — the check works for select / number but always returns false for switch, even after the user has just toggled it and even after the decoder published the device's reported value. Two paths fixed: LevoitSwitch::write_state — explicit set_has_state(true) after publish_state(state). Handles the user-toggle path (HA service → Switch::turn_on → LevoitSwitch::write_state → on_switch_command). Levoit::publish_switch — set_has_state(true) hoisted above the dedup early-return `if (sw->state == state)`. A decoder publish whose value matches the entity's default (e.g. first decoded false against default sw->state=false) would otherwise skip publish_state entirely and leave has_state_ at false for the rest of the session. No behavior change for callers that don't use has_state(): the public `state` field, callbacks, and ControllerRegistry notifications work exactly as before. --- components/levoit/levoit.cpp | 7 +++++++ components/levoit/switch/levoit_switch.cpp | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/components/levoit/levoit.cpp b/components/levoit/levoit.cpp index 46c9979..a7ee18f 100644 --- a/components/levoit/levoit.cpp +++ b/components/levoit/levoit.cpp @@ -100,6 +100,13 @@ namespace esphome auto *sw = switches_[st_idx_(type)]; if (!sw) return; + // Flip has_state_ before the dedup early-return below: a + // decoder publish whose value matches the entity's default + // (false against default sw->state=false) would otherwise + // skip publish_state and leave has_state() returning false + // forever. See companion fix in LevoitSwitch::write_state + // for the rationale on why Switch needs this explicitly. + sw->set_has_state(true); if (sw->state == state) return; sw->publish_state(state); diff --git a/components/levoit/switch/levoit_switch.cpp b/components/levoit/switch/levoit_switch.cpp index 14412fc..8d90b63 100644 --- a/components/levoit/switch/levoit_switch.cpp +++ b/components/levoit/switch/levoit_switch.cpp @@ -12,8 +12,16 @@ namespace esphome } void LevoitSwitch::write_state(bool state) { - // Optimistic update for HA UI + // Optimistic update for HA UI. Switch::publish_state in core + // ESPHome sets the public `state` field and fires callbacks but + // does not flip has_state_ — unlike Select::publish_state and + // Number::publish_state, which both call set_has_state(true). + // Set it explicitly so `if (entity->has_state())` guards in + // callers (e.g. command builders that distinguish "user just + // toggled this" from "never published") behave uniformly across + // switch / number / select. this->publish_state(state); + this->set_has_state(true); if (!parent_) { From b56113c1bd501779a6dba4928cde607de67190bd Mon Sep 17 00:00:00 2001 From: AutoCoder Date: Wed, 20 May 2026 14:10:34 +0000 Subject: [PATCH 3/4] Implement bulk-prefs SET for Vital 200S Pro preference clusters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds writable Home Assistant entities for the sleep / quick-clean / daytime preference clusters on the Vital 200S Pro. All edits route through a single 12-TLV bulk-write command (CMD 02 02 55, local tags 0x04..0x0F) discovered by reading the stock-firmware disassembly and verified on-device against MCU FW 2.0.0. New CommandType: setBulkPrefs. The builder in vital_commands.cpp composes a 12-TLV payload from a per-cluster cache that the status decoder maintains. For each user edit, the entity that changed supplies the new value; the other 11 fields come from the cache. New entities (six new + three existing made writable): Sleep cluster — three entities already exposed read-only by the earlier Vital 200S Pro foundation commit, now made writable: select.…_sleep_mode_type (TLV 0x18, SET tag 0x04) number.…_sleep_mode_minutes (TLV 0x20, SET tag 0x0C) number.…_sleep_fan_level_5_auto (TLV 0x1F, SET tag 0x0B) Quick-clean cluster — three new entities: switch.…_quick_clean_preset (TLV 0x19, SET tag 0x05) number.…_quick_clean_minutes (TLV 0x1A, SET tag 0x06) number.…_quick_clean_fan_level (TLV 0x1B, SET tag 0x07) Daytime cluster — three new entities: switch.…_daytime_preset (TLV 0x21, SET tag 0x0D) select.…_daytime_fan_mode (TLV 0x22, SET tag 0x0E) number.…_daytime_fan_level (TLV 0x23, SET tag 0x0F) The white-noise cluster (TLVs 0x1C/0x1D/0x1E, SET tags 0x08/0x09/0x0A) is cache-only on the Vital 200S Pro — the device has no white-noise hardware. The bulk write still includes those three TLVs because the MCU's parser requires all 12 in every frame; values are echoed back from cache unchanged. A future Sprout component port that supports white-noise hardware can add writable entities for those three fields using the same SET path. Tag 0x04 (sleep_type) acts as a gate: when set to 0x00 (Default), the MCU silently drops writes to tags 0x05..0x0F. The component surfaces this constraint by exposing sleep_mode_type as a writable select with three options (Default / Custom1 / Custom2); users must select a non-Default value before other cluster fields apply. This matches the stock VeSync app's UX, where the Sleep Preset screen requires selecting a Custom slot before allowing other fields to be edited. Other changes folded into this commit: - Expands the SLEEP_PREFERENCE select options from {"Default"} to {"Default", "Custom1", "Custom2"}, matching the empirically-observed byte values for TLV 0x18. - Adds a 12-entry BulkPrefsCache to Levoit, populated by the status decoder via the new update_bulk_pref(tlv_id, value) method. The cache is filled by the first status-push decode at boot — see the follow-up documentation commit for the ESP32-C3 hardware UART FIFO retention mechanism that makes this reliable. - Repurposes the previously-dead SelectType slot 5 from DAYTIME_FAN_MODE_LEVEL (whose options mixed modes with levels) to DAYTIME_FAN_MODE (TLV 0x22 fan-mode enum); the old name was misleading and had no YAML stanza referencing it. - Adds NumberType slots 15 and 16 for QUICK_CLEAN_FAN_LEVEL and DAYTIME_FAN_LEVEL respectively; bumps `numbers_[]` capacity to accommodate the new max index. - Reduces the device YAML logger level from VERBOSE to DEBUG. The default-component logger emits a runtime warning that VERBOSE has measurable CPU overhead on ESP32-C3 and recommends DEBUG for long-term use; DEBUG preserves the steady-state decoder diagnostics while suppressing the per-byte frame-parser noise. YAML stanzas for the six new entities are added to common.yaml and the three builder configurations (builder, builder-c3, builder-s3). --- components/levoit/levoit.cpp | 89 ++++++++++++-- components/levoit/levoit.h | 50 ++++++-- components/levoit/number/__init__.py | 17 +++ components/levoit/select/__init__.py | 6 +- components/levoit/select/levoit_select.cpp | 37 +++--- components/levoit/switch/__init__.py | 1 + components/levoit/types.h | 19 ++- components/levoit/vital_commands.cpp | 115 ++++++++++++++++++ components/levoit/vital_status.cpp | 43 +++++-- devices/levoit-vital200s/common.yaml | 26 +++- .../levoit-vital200s-builder-c3.yaml | 26 +++- .../levoit-vital200s-builder-s3.yaml | 26 +++- .../levoit-vital200s-builder.yaml | 26 +++- 13 files changed, 434 insertions(+), 47 deletions(-) diff --git a/components/levoit/levoit.cpp b/components/levoit/levoit.cpp index a7ee18f..7233d5b 100644 --- a/components/levoit/levoit.cpp +++ b/components/levoit/levoit.cpp @@ -100,12 +100,12 @@ namespace esphome auto *sw = switches_[st_idx_(type)]; if (!sw) return; - // Flip has_state_ before the dedup early-return below: a - // decoder publish whose value matches the entity's default - // (false against default sw->state=false) would otherwise - // skip publish_state and leave has_state() returning false - // forever. See companion fix in LevoitSwitch::write_state - // for the rationale on why Switch needs this explicitly. + // Mark the entity as "has state" BEFORE the dedup early-return below, + // so a decoder publish that matches the entity's default-initialized + // value (e.g. boot decode of qc_enabled=0 against the default sw->state=false) + // still flips has_state_ from false to true. ESPHome's Switch::publish_state + // doesn't do this itself (unlike Select/Number); see comment in + // LevoitSwitch::write_state for the rationale. sw->set_has_state(true); if (sw->state == state) return; @@ -173,6 +173,46 @@ namespace esphome // Store the desired state; platform entity will publish from its loop binary_sensor_states_[bs_idx_(type)] = state; } + + void Levoit::update_bulk_pref(uint8_t tlv_id, uint32_t value) + { + // Maps status TLV id (0x18..0x23) to BulkPrefsCache field + + // seen_mask bit. The seen_mask uses (tlv_id - 0x18) as bit + // position so the 12 TLVs map linearly to bits 0..11. + if (tlv_id < 0x18 || tlv_id > 0x23) { + ESP_LOGW("levoit.bulk_prefs", "update_bulk_pref: out-of-range tlv_id=0x%02X", tlv_id); + return; + } + const bool was_valid = bulk_prefs_.valid(); + switch (tlv_id) { + case 0x18: bulk_prefs_.sleep_type = (uint8_t)value; break; + case 0x19: bulk_prefs_.qc_enabled = (uint8_t)value; break; + case 0x1A: bulk_prefs_.qc_min = (uint16_t)value; break; + case 0x1B: bulk_prefs_.qc_fan = (uint8_t)value; break; + case 0x1C: bulk_prefs_.wn_enabled= (uint8_t)value; break; + case 0x1D: bulk_prefs_.wn_min = (uint16_t)value; break; + case 0x1E: bulk_prefs_.wn_fan = (uint8_t)value; break; + case 0x1F: bulk_prefs_.sleep_fan = (uint8_t)value; break; + case 0x20: bulk_prefs_.sleep_min = (uint16_t)value; break; + case 0x21: bulk_prefs_.dt_enabled= (uint8_t)value; break; + case 0x22: bulk_prefs_.dt_mode = (uint8_t)value; break; + case 0x23: bulk_prefs_.dt_level = (uint8_t)value; break; + } + bulk_prefs_.seen_mask |= (uint16_t)(1u << (tlv_id - 0x18)); + ESP_LOGD("levoit.bulk_prefs", + "update tlv=0x%02X val=%u seen_mask=0x%03X", + tlv_id, (unsigned)value, (unsigned)bulk_prefs_.seen_mask); + if (!was_valid && bulk_prefs_.valid()) { + ESP_LOGI("levoit.bulk_prefs", + "cache now VALID — sleep[type=%u fan=%u min=%u] " + "QC[en=%u fan=%u min=%u] WN[en=%u fan=%u min=%u] " + "DT[en=%u mode=%u lvl=%u]", + bulk_prefs_.sleep_type, bulk_prefs_.sleep_fan, bulk_prefs_.sleep_min, + bulk_prefs_.qc_enabled, bulk_prefs_.qc_fan, bulk_prefs_.qc_min, + bulk_prefs_.wn_enabled, bulk_prefs_.wn_fan, bulk_prefs_.wn_min, + bulk_prefs_.dt_enabled, bulk_prefs_.dt_mode, bulk_prefs_.dt_level); + } + } #ifdef USE_LIGHT void Levoit::publish_sprout_light(bool on, float brightness, float color_temp, bool breathing) { @@ -234,9 +274,14 @@ namespace esphome this->sendCommand(state ? setLightDetectOn : setLightDetectOff); break; - // You’ll need to add command types for these if not present yet: case SwitchType::QUICK_CLEAN: - // TODO: sendCommand(state ? setQuickCleanOn : setQuickCleanOff); + case SwitchType::DAYTIME_ENABLED: + // Part of the 12-TLV bulk write — see setBulkPrefs. The new + // switch state has already been optimistically published by + // LevoitSwitch::write_state before this handler runs (and + // set_has_state(true) is now called explicitly there — see + // e567c31), so the builder reads it via get_switch(...)->state. + this->sendCommand(setBulkPrefs); break; case SwitchType::WHITE_NOISE: @@ -284,6 +329,20 @@ namespace esphome this->sendCommand(setAutoModeEfficient); // takes value from number: Room Size break; + case NumberType::SLEEP_MODE_MIN: + case NumberType::SLEEP_FAN_LEVEL: + case NumberType::QUICK_CLEAN_MIN: + case NumberType::QUICK_CLEAN_FAN_LEVEL: + case NumberType::DAYTIME_FAN_LEVEL: + // Bulk-prefs cluster fields — all route through the same 12-TLV + // write at CMD 02 02 55 tags 0x04..0x0F. The builder reads the + // new value via get_number(...)->state (optimistically published + // by LevoitNumber::control before this handler fires) and the + // rest from bulk_prefs_ cache. Sleep_type byte must be non-zero + // (Custom1/Custom2) for writes to non-type fields to apply — + // see docs/STOCK_FIRMWARE_FINDINGS.md "Gate behavior". + this->sendCommand(setBulkPrefs); + break; case NumberType::LED_BRIGHTNESS_MIN: case NumberType::LED_SPEED: @@ -340,6 +399,20 @@ namespace esphome } break; + case SelectType::SLEEP_PREFERENCE: + case SelectType::DAYTIME_FAN_MODE: + // Bulk-prefs SET: SLEEP_PREFERENCE is the gate byte (TLV 0x18); + // DAYTIME_FAN_MODE is the daytime preset's fan-mode enum + // (TLV 0x22). The builder reads the new option index via + // active_index() on the optimistically-published select. For + // SLEEP_PREFERENCE, value 0 (Default) locks tags 0x05..0x0F; + // values 1/2 (Custom1/Custom2) unlock writes to the rest of + // the cluster (see docs/STOCK_FIRMWARE_FINDINGS.md "Gate + // behavior"). DAYTIME_FAN_MODE is a non-gate field that + // requires sleep_type ≠ 0 to apply. + this->sendCommand(setBulkPrefs); + break; + case SelectType::NIGHTLIGHT: switch (value) { diff --git a/components/levoit/levoit.h b/components/levoit/levoit.h index 31154e2..7114ec6 100644 --- a/components/levoit/levoit.h +++ b/components/levoit/levoit.h @@ -90,6 +90,7 @@ class Levoit : public Component, public uart::UARTDevice { LevoitFan *get_fan() const { return this->fan_; } LevoitNumber *get_number(NumberType type) const { return numbers_[nt_idx_(type)]; } LevoitSelect *get_select(SelectType type) const { return selects_[sl_idx_(type)]; } + LevoitSwitch *get_switch(SwitchType type) const { return switches_[st_idx_(type)]; } class LevoitBinarySensor *get_binary_sensor(BinarySensorType type) const { return binary_sensors_[bs_idx_(type)]; } bool get_binary_sensor_state(BinarySensorType type) const { return binary_sensor_states_[bs_idx_(type)]; } void start_timer(){this->timer_active_ = true; this->timer_stop_pending_ = false;}; @@ -112,14 +113,48 @@ class Levoit : public Component, public uart::UARTDevice { total_runtime_ = value; pref_total_runtime_.save(&total_runtime_); } - - + + // Bulk-prefs cache — mirrors the 12 status TLVs (0x18..0x23) the MCU + // emits on every push for the sleep / quick-clean / white-noise / + // daytime preference clusters. Used by the upcoming setBulkPrefs SET + // command (Stage 3) to echo non-edited fields back to the MCU in a + // single 12-TLV bulk write. Populated by `update_bulk_pref` from + // `vital_status.cpp` cases 0x18..0x23. The cache is filled by the + // first boot-time decode (see "ESPHome × MCU interaction — boot-time + // FIFO retention decode" in docs/MCU_2.0.0_baseline.md); after that, + // status pushes with identical payloads are dedup-skipped and the + // cache stays valid for the entire session. + // + // seen_mask bit layout: bit i = status TLV (0x18 + i) has been seen + // at least once since boot. valid() requires all 12 bits set. + struct BulkPrefsCache { + uint8_t sleep_type{0}; // TLV 0x18, bit 0 + uint8_t qc_enabled{0}; // TLV 0x19, bit 1 + uint16_t qc_min{0}; // TLV 0x1A, bit 2 + uint8_t qc_fan{0}; // TLV 0x1B, bit 3 + uint8_t wn_enabled{0}; // TLV 0x1C, bit 4 + uint16_t wn_min{0}; // TLV 0x1D, bit 5 + uint8_t wn_fan{0}; // TLV 0x1E, bit 6 + uint8_t sleep_fan{0}; // TLV 0x1F, bit 7 + uint16_t sleep_min{0}; // TLV 0x20, bit 8 + uint8_t dt_enabled{0}; // TLV 0x21, bit 9 + uint8_t dt_mode{0}; // TLV 0x22, bit 10 + uint8_t dt_level{0}; // TLV 0x23, bit 11 + uint16_t seen_mask{0}; + bool valid() const { return (seen_mask & 0x0FFF) == 0x0FFF; } + }; + const BulkPrefsCache &get_bulk_prefs() const { return bulk_prefs_; } + void update_bulk_pref(uint8_t tlv_id, uint32_t value); + protected: - LevoitSwitch *switches_[16] {nullptr}; // enough for your types; or use exact count - LevoitNumber *numbers_[16] {nullptr}; // enough for your types; or use exact count - LevoitSensor *sensors_[16] {nullptr}; // enough for your types; or use exact count - LevoitSelect *selects_[16] {nullptr}; // enough for your types; or use exact count - LevoitTextSensor *text_sensor_[16] {nullptr}; // enough for your types; or use exact count + LevoitSwitch *switches_[16] {nullptr}; // max SwitchType index = 6 (LED_RING) + // Bumped from [16] to [24] in Stage 5: max NumberType index is now 16 + // (DAYTIME_FAN_LEVEL). At [16], register_number for that slot would have + // written out of bounds. [24] leaves headroom for future additions. + LevoitNumber *numbers_[24] {nullptr}; + LevoitSensor *sensors_[16] {nullptr}; // max SensorType index = 8 (FAN_RPM) + LevoitSelect *selects_[16] {nullptr}; // max SelectType index = 9 (SLEEP_PREFERENCE) + LevoitTextSensor *text_sensor_[16] {nullptr}; // max TextSensorType index = 6 (SPROUT_EVENT) class LevoitBinarySensor *binary_sensors_[8] {nullptr}; bool binary_sensor_states_[8] {false}; class LevoitButton *buttons_[4] {nullptr}; @@ -150,6 +185,7 @@ class Levoit : public Component, public uart::UARTDevice { uint32_t total_runtime_{0}; ESPPreferenceObject pref_used_cadr_; ESPPreferenceObject pref_total_runtime_; + BulkPrefsCache bulk_prefs_; // helper to convert enum to index static constexpr uint8_t st_idx_(SwitchType t) { return (uint8_t)t; } static constexpr uint8_t nt_idx_(NumberType t) { return (uint8_t)t; } diff --git a/components/levoit/number/__init__.py b/components/levoit/number/__init__.py index ac50b90..c3309af 100644 --- a/components/levoit/number/__init__.py +++ b/components/levoit/number/__init__.py @@ -26,6 +26,9 @@ "efficiency_room_size": NumberType.EFFICIENCY_ROOM_SIZE, #VITALS only below "quick_clean_min": NumberType.QUICK_CLEAN_MIN, + "quick_clean_minutes": NumberType.QUICK_CLEAN_MIN, + "quick_clean_fan_level": NumberType.QUICK_CLEAN_FAN_LEVEL, + "daytime_fan_level": NumberType.DAYTIME_FAN_LEVEL, "white_noise_min": NumberType.WHITE_NOISE_MIN, "sleep_mode_min": NumberType.SLEEP_MODE_MIN, "sleep_minutes": NumberType.SLEEP_MODE_MIN, @@ -83,6 +86,20 @@ CONF_ICON: "mdi:fan", CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, }), + "quick_clean_minutes": (5.0, 60.0, 5.0, { + CONF_DEVICE_CLASS: "duration", + CONF_UNIT_OF_MEASUREMENT: "min", + CONF_ICON: "mdi:broom", + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, + }), + "quick_clean_fan_level": (1.0, 5.0, 1.0, { + CONF_ICON: "mdi:fan", + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, + }), + "daytime_fan_level": (1.0, 5.0, 1.0, { + CONF_ICON: "mdi:fan", + CONF_ENTITY_CATEGORY: ENTITY_CATEGORY_CONFIG, + }), } CONFIG_SCHEMA = number.number_schema(LevoitNumber).extend( diff --git a/components/levoit/select/__init__.py b/components/levoit/select/__init__.py index 6a2f8fe..598378b 100644 --- a/components/levoit/select/__init__.py +++ b/components/levoit/select/__init__.py @@ -15,10 +15,12 @@ TYPE_MAP = { "auto_mode": SelectType.AUTO_MODE, "sleep_mode": SelectType.SLEEP_MODE, - "quick_clean_fan_level": SelectType.QUICK_CLEAN_FAN_LEVEL, + # "quick_clean_fan_level" removed — Vital quick-clean fan level is a + # NumberType (1–5 integer), not a select. Old SelectType entry was + # dead code (no YAML used it). "white_noise_fan_level": SelectType.WHITE_NOISE_FAN_LEVEL, "sleep_mode_fan_mode_level": SelectType.SLEEP_MODE_FAN_MODE_LEVEL, - "daytime_fan_mode_level": SelectType.DAYTIME_FAN_MODE_LEVEL, + "daytime_fan_mode": SelectType.DAYTIME_FAN_MODE, "nightlight": SelectType.NIGHTLIGHT, "light_mode": SelectType.LIGHT_MODE, "white_noise_sound": SelectType.WHITE_NOISE_SOUND, diff --git a/components/levoit/select/levoit_select.cpp b/components/levoit/select/levoit_select.cpp index 66c1eb4..164128c 100644 --- a/components/levoit/select/levoit_select.cpp +++ b/components/levoit/select/levoit_select.cpp @@ -22,17 +22,25 @@ namespace esphome case SelectType::SLEEP_MODE: this->traits.set_options({"Default","Custom"}); break; - case SelectType::QUICK_CLEAN_FAN_LEVEL: - this->traits.set_options({"Low","Medium","High","Highest","Minimum"}); - break; + // SelectType::QUICK_CLEAN_FAN_LEVEL case removed — see types.h note. case SelectType::WHITE_NOISE_FAN_LEVEL: this->traits.set_options({"Low","Medium","High","Highest","Minimum"}); break; case SelectType::SLEEP_MODE_FAN_MODE_LEVEL: this->traits.set_options({"Low","Medium","High","Highest","Minimum"}); break; - case SelectType::DAYTIME_FAN_MODE_LEVEL: - this->traits.set_options({"Auto","Low","Medium","High","Highest","Pet"}); + case SelectType::DAYTIME_FAN_MODE: + // Vital TLV 0x22: daytime preset fan-mode enum byte. Empirically + // observed values: 0x00=Manual, 0x01=Sleep, 0x02=Auto, 0x05=Pet + // (parallels the fan-mode byte at TLV 0x03). Bytes 0x03 and 0x04 + // were not exposed through the VeSync app captures we have; if + // the MCU ever pushes those, label them Unknown3/Unknown4 in HA + // so the publish_select index lookup doesn't fall outside the + // options list (which would leave has_state()=false and the + // entity stuck at "unknown" — see fix in 2f5d90b for the + // analogous SLEEP_PREFERENCE case). + // See docs/MCU_2.0.0_baseline.md TLV inventory. + this->traits.set_options({"Manual", "Sleep", "Auto", "Unknown3", "Unknown4", "Pet"}); break; case SelectType::NIGHTLIGHT: this->traits.set_options({"Off","Mid","Full"}); @@ -47,16 +55,15 @@ namespace esphome "Sound 11", "Sound 12", "Sound 13", "Sound 14", "Sound 15"}); break; case SelectType::SLEEP_PREFERENCE: - // TLV 0x18 byte values. Only 0x00 observed on this Vital 200S Pro - // (MCU 2.0.0). The other byte values (likely 0x01, 0x02, ...) are - // undiscoverable on this device because we no longer have VeSync-app - // access to cycle through the Sleep Preference UI — the ESP was - // reflashed with ESPHome, so the VeSync cloud path is gone. - // Future contributors who still run stock firmware can capture the - // additional values and extend this list (plus update the baseline - // doc). publish_select() ignores out-of-range indices, so unknown - // bytes will log a warning and leave state unchanged. - this->traits.set_options({"Default"}); + // TLV 0x18 byte values, empirically verified on MCU 2.0.0 via + // on-device probing (Probe 3 + recovery steps): 0x00=Default, + // 0x01=Custom1, 0x02=Custom2. The byte is also the "gate" for + // the bulk-prefs SET path — when it's 0x00, writes to the other + // 11 cluster TLVs (0x05..0x0F local tags on CMD 02 02 55) are + // silently dropped by the MCU. See docs/STOCK_FIRMWARE_FINDINGS.md + // ("Gate behavior") and docs/MCU_2.0.0_baseline.md ("Sleep / QC / + // WN / DT bulk write") for the full protocol. + this->traits.set_options({"Default", "Custom1", "Custom2"}); break; default: break; diff --git a/components/levoit/switch/__init__.py b/components/levoit/switch/__init__.py index 0d15107..7c0901e 100644 --- a/components/levoit/switch/__init__.py +++ b/components/levoit/switch/__init__.py @@ -16,6 +16,7 @@ "light_detect": SwitchType.LIGHT_DETECT, "quick_clean": SwitchType.QUICK_CLEAN, "white_noise": SwitchType.WHITE_NOISE, + "daytime_enabled": SwitchType.DAYTIME_ENABLED, "led_ring": SwitchType.LED_RING, } diff --git a/components/levoit/types.h b/components/levoit/types.h index 7789da2..b05efc0 100644 --- a/components/levoit/types.h +++ b/components/levoit/types.h @@ -53,6 +53,8 @@ namespace esphome WHITE_NOISE_VOLUME = 12, // Sprout: white noise volume (0-255) AQI_SCALE = 13, // Sprout: AQI display scale max (0–500) SLEEP_FAN_LEVEL = 14, // Vital: sleep mode fan level (TLV 0x1F) + QUICK_CLEAN_FAN_LEVEL = 15, // Vital: quick clean fan level (TLV 0x1B) + DAYTIME_FAN_LEVEL = 16, // Vital: daytime fan level (TLV 0x23) }; // Note: indices 0-11 must stay stable (serialized to preferences) // NumberType aliases (flat namespace) @@ -69,6 +71,8 @@ namespace esphome static constexpr NumberType WHITE_NOISE_VOLUME = NumberType::WHITE_NOISE_VOLUME; static constexpr NumberType AQI_SCALE = NumberType::AQI_SCALE; static constexpr NumberType SLEEP_FAN_LEVEL = NumberType::SLEEP_FAN_LEVEL; + static constexpr NumberType QUICK_CLEAN_FAN_LEVEL = NumberType::QUICK_CLEAN_FAN_LEVEL; + static constexpr NumberType DAYTIME_FAN_LEVEL = NumberType::DAYTIME_FAN_LEVEL; enum class SensorType : uint8_t { @@ -131,10 +135,14 @@ namespace esphome { AUTO_MODE = 0, SLEEP_MODE = 1, - QUICK_CLEAN_FAN_LEVEL = 2, + // = 2 reserved (was SelectType::QUICK_CLEAN_FAN_LEVEL, never used + // by any YAML; quick-clean fan level is now NumberType::QUICK_CLEAN_FAN_LEVEL) WHITE_NOISE_FAN_LEVEL = 3, SLEEP_MODE_FAN_MODE_LEVEL = 4, - DAYTIME_FAN_MODE_LEVEL = 5, + DAYTIME_FAN_MODE = 5, // Vital: daytime preset fan-mode enum (TLV 0x22). + // Renamed from DAYTIME_FAN_MODE_LEVEL — the + // old name was misleading (TLV 0x22 is a fan-mode + // enum, not a level value). NIGHTLIGHT = 6, LIGHT_MODE = 7, // Sprout: Off / Nightlight / Breathing WHITE_NOISE_SOUND = 8, // Sprout: white noise sound index (0-14, 15 sounds) @@ -142,10 +150,11 @@ namespace esphome }; static constexpr SelectType AUTO_MODE = SelectType::AUTO_MODE; static constexpr SelectType SLEEP_MODE = SelectType::SLEEP_MODE; - static constexpr SelectType QUICK_CLEAN_FAN_LEVEL = SelectType::QUICK_CLEAN_FAN_LEVEL; + // (SelectType QUICK_CLEAN_FAN_LEVEL alias removed; the flat-namespace + // name now refers to NumberType::QUICK_CLEAN_FAN_LEVEL above.) static constexpr SelectType WHITE_NOISE_FAN_LEVEL = SelectType::WHITE_NOISE_FAN_LEVEL; static constexpr SelectType SLEEP_MODE_FAN_MODE_LEVEL = SelectType::SLEEP_MODE_FAN_MODE_LEVEL; - static constexpr SelectType DAYTIME_FAN_MODE_LEVEL = SelectType::DAYTIME_FAN_MODE_LEVEL; + static constexpr SelectType DAYTIME_FAN_MODE = SelectType::DAYTIME_FAN_MODE; static constexpr SelectType NIGHTLIGHT = SelectType::NIGHTLIGHT; static constexpr SelectType LIGHT_MODE = SelectType::LIGHT_MODE; static constexpr SelectType WHITE_NOISE_SOUND = SelectType::WHITE_NOISE_SOUND; @@ -203,6 +212,7 @@ namespace esphome setSproutWhiteNoiseModeOn, // CMD=02 02 55: PAY=10 01 01 (enable WN fan mode) setSproutWhiteNoiseModeOff, // CMD=02 02 55: PAY=10 01 00 (disable WN fan mode) setSproutAqiScale, // CMD=02 06 55: sets AQI display scale max (0–500) + setBulkPrefs, // CMD=02 02 55 tags 0x04..0x0F: bulk sleep/QC/WN/DT prefs (12 TLVs, Vital) COMMAND_TYPE_MAX // dedicated command for setSleepModeCustom @@ -254,6 +264,7 @@ namespace esphome "setSproutWhiteNoiseModeOn", "setSproutWhiteNoiseModeOff", "setSproutAqiScale", + "setBulkPrefs", }; static_assert( sizeof(names) / sizeof(names[0]) == COMMAND_TYPE_MAX, diff --git a/components/levoit/vital_commands.cpp b/components/levoit/vital_commands.cpp index a0b4c62..3f3f80c 100644 --- a/components/levoit/vital_commands.cpp +++ b/components/levoit/vital_commands.cpp @@ -2,6 +2,9 @@ #include "levoit_message.h" #include "levoit.h" #include "number/levoit_number.h" +#include "select/levoit_select.h" +#include "switch/levoit_switch.h" +#include "esphome/components/switch/switch.h" #include "esphome/core/log.h" namespace esphome @@ -167,6 +170,118 @@ namespace esphome payload = {}; break; + case CommandType::setBulkPrefs: + { + // Bulk write for sleep / quick-clean / white-noise / daytime + // preference clusters under CMD 02 02 55, tags 0x04..0x0F. + // Discovered via stock-firmware disassembly (see + // docs/STOCK_FIRMWARE_FINDINGS.md "Sleep / quick-clean / white- + // noise / daytime bulk write") and verified on MCU 2.0.0. + // Protocol summary: + // - SET tag = status TLV - 0x14 (monotonic mapping). + // - Tag 0x04 (sleep_type) is the gate: when 0x00 (Default), the + // MCU silently drops tags 0x05..0x0F. To change any of those + // fields the user must first switch sleep_type to a non- + // Default value (Custom1/Custom2). + // - Storage is a single shared store (not per-preset), so non- + // Default writes overwrite the stored values regardless of + // which "preset" they're nominally associated with. + msg_type = {0x02, 0x02, 0x55}; + + const auto &c = self->get_bulk_prefs(); + if (!c.valid()) { + ESP_LOGW(TAG_VITAL_CMD, + "setBulkPrefs: cache not fully populated (seen_mask=0x%03X) " + "— refusing to send.", (unsigned)c.seen_mask); + return {}; // empty -> sendCommand skips transmission + } + + // Start every field at its cached value; override only where an + // entity exists AND has been published. LevoitSelect/LevoitNumber + // call publish_state() BEFORE on_*_command, so by the time this + // builder runs, the entity reflects the new value. + uint8_t sleep_type = c.sleep_type; + uint8_t qc_enabled = c.qc_enabled; + uint16_t qc_min = c.qc_min; + uint8_t qc_fan = c.qc_fan; + uint8_t wn_enabled = c.wn_enabled; + uint16_t wn_min = c.wn_min; + uint8_t wn_fan = c.wn_fan; + uint8_t sleep_fan = c.sleep_fan; + uint16_t sleep_min = c.sleep_min; + uint8_t dt_enabled = c.dt_enabled; + uint8_t dt_mode = c.dt_mode; + uint8_t dt_level = c.dt_level; + + if (auto *sel = self->get_select(SelectType::SLEEP_PREFERENCE)) { + if (auto idx = sel->active_index()) sleep_type = (uint8_t)idx.value(); + } + if (auto *n = self->get_number(NumberType::SLEEP_FAN_LEVEL)) { + if (n->has_state()) sleep_fan = (uint8_t)n->state; + } + if (auto *n = self->get_number(NumberType::SLEEP_MODE_MIN)) { + if (n->has_state()) sleep_min = (uint16_t)n->state; + } + // QC cluster (Stage 4). Optimistic publish in LevoitSwitch / LevoitNumber + // means the entity reflects the new value by the time we read it here. + if (auto *sw = self->get_switch(SwitchType::QUICK_CLEAN)) { + if (sw->has_state()) qc_enabled = sw->state ? 1 : 0; + } + if (auto *n = self->get_number(NumberType::QUICK_CLEAN_MIN)) { + if (n->has_state()) qc_min = (uint16_t)n->state; + } + if (auto *n = self->get_number(NumberType::QUICK_CLEAN_FAN_LEVEL)) { + if (n->has_state()) qc_fan = (uint8_t)n->state; + } + // DT cluster (Stage 5). + if (auto *sw = self->get_switch(SwitchType::DAYTIME_ENABLED)) { + if (sw->has_state()) dt_enabled = sw->state ? 1 : 0; + } + if (auto *sel = self->get_select(SelectType::DAYTIME_FAN_MODE)) { + if (auto idx = sel->active_index()) dt_mode = (uint8_t)idx.value(); + } + if (auto *n = self->get_number(NumberType::DAYTIME_FAN_LEVEL)) { + if (n->has_state()) dt_level = (uint8_t)n->state; + } + // WN cluster is cache-only by design — Vital 200S Pro has no WN + // hardware, but the MCU still requires those 3 fields in every + // bulk write or the parser silently drops the whole frame. + + ESP_LOGD(TAG_VITAL_CMD, + "setBulkPrefs: sleep[type=%u fan=%u min=%u] " + "QC[en=%u fan=%u min=%u] WN[en=%u fan=%u min=%u] " + "DT[en=%u mode=%u lvl=%u]", + sleep_type, sleep_fan, sleep_min, + qc_enabled, qc_fan, qc_min, + wn_enabled, wn_fan, wn_min, + dt_enabled, dt_mode, dt_level); + if (sleep_type == 0) { + ESP_LOGW(TAG_VITAL_CMD, + "setBulkPrefs: sleep_type=0 (Default) — MCU will drop " + "writes to tags 0x05..0x0F. Only the type byte applies."); + } + + const uint8_t qc_min_lo = qc_min & 0xFF, qc_min_hi = (qc_min >> 8) & 0xFF; + const uint8_t wn_min_lo = wn_min & 0xFF, wn_min_hi = (wn_min >> 8) & 0xFF; + const uint8_t sleep_min_lo = sleep_min & 0xFF, sleep_min_hi = (sleep_min >> 8) & 0xFF; + + payload = { + 0x04, 0x01, sleep_type, + 0x05, 0x01, qc_enabled, + 0x06, 0x02, qc_min_lo, qc_min_hi, + 0x07, 0x01, qc_fan, + 0x08, 0x01, wn_enabled, + 0x09, 0x02, wn_min_lo, wn_min_hi, + 0x0A, 0x01, wn_fan, + 0x0B, 0x01, sleep_fan, + 0x0C, 0x02, sleep_min_lo, sleep_min_hi, + 0x0D, 0x01, dt_enabled, + 0x0E, 0x01, dt_mode, + 0x0F, 0x01, dt_level, + }; + break; + } + case CommandType::setWifiLedOn: msg_type = {0x02, 0x18, 0x50}; payload = {0x01, 0x01, 0x01, 0x02, 0x02, 0x7D, 0x00, 0x03, 0x02, 0x7D, 0x00, 0x04, 0x01, 0x00}; diff --git a/components/levoit/vital_status.cpp b/components/levoit/vital_status.cpp index b938f1d..a43a317 100644 --- a/components/levoit/vital_status.cpp +++ b/components/levoit/vital_status.cpp @@ -276,49 +276,78 @@ namespace esphome break; case 0x18: ESP_LOGV(TAG_VITAL, "SleepModeType=%u", (unsigned)t.value_u32); - if (self != nullptr) + if (self != nullptr) { self->publish_select(SelectType::SLEEP_PREFERENCE, t.value_u32); + self->update_bulk_pref(0x18, t.value_u32); + } break; case 0x19: - { ESP_LOGV(TAG_VITAL, "QuickCleanEnabled=%u", (unsigned)t.value_u32); - // TODO! + if (self != nullptr) { + self->publish_switch(SwitchType::QUICK_CLEAN, t.value_u32 == 1); + self->update_bulk_pref(0x19, t.value_u32); + } break; - } - // TODO! case 0x1A: ESP_LOGV(TAG_VITAL, "QuickCleanMinutes=%u", (unsigned)t.value_u32); + if (self != nullptr) { + self->publish_number(NumberType::QUICK_CLEAN_MIN, t.value_u32); + self->update_bulk_pref(0x1A, t.value_u32); + } break; case 0x1B: ESP_LOGV(TAG_VITAL, "QuickCleanFanLevel=%u", (unsigned)t.value_u32); + if (self != nullptr) { + self->publish_number(NumberType::QUICK_CLEAN_FAN_LEVEL, t.value_u32); + self->update_bulk_pref(0x1B, t.value_u32); + } break; case 0x1C: ESP_LOGV(TAG_VITAL, "WhiteNoiseEnabled=%u", (unsigned)t.value_u32); + if (self != nullptr) self->update_bulk_pref(0x1C, t.value_u32); break; case 0x1D: ESP_LOGV(TAG_VITAL, "WhiteNoiseMinutes=%u", (unsigned)t.value_u32); + if (self != nullptr) self->update_bulk_pref(0x1D, t.value_u32); break; case 0x1E: ESP_LOGV(TAG_VITAL, "WhiteNoiseFanLevel=%u", (unsigned)t.value_u32); + if (self != nullptr) self->update_bulk_pref(0x1E, t.value_u32); break; case 0x1F: ESP_LOGV(TAG_VITAL, "SleepFanLevel=%u", (unsigned)t.value_u32); - if (self != nullptr) + if (self != nullptr) { self->publish_number(NumberType::SLEEP_FAN_LEVEL, t.value_u32); + self->update_bulk_pref(0x1F, t.value_u32); + } break; case 0x20: ESP_LOGV(TAG_VITAL, "SleepModeMinutes=%u", (unsigned)t.value_u32); - if (self != nullptr) + if (self != nullptr) { self->publish_number(NumberType::SLEEP_MODE_MIN, t.value_u32); + self->update_bulk_pref(0x20, t.value_u32); + } break; case 0x21: ESP_LOGV(TAG_VITAL, "DaytimeEnabled=%u", (unsigned)t.value_u32); + if (self != nullptr) { + self->publish_switch(SwitchType::DAYTIME_ENABLED, t.value_u32 == 1); + self->update_bulk_pref(0x21, t.value_u32); + } break; case 0x22: ESP_LOGV(TAG_VITAL, "DaytimeFanMode=%u", (unsigned)t.value_u32); + if (self != nullptr) { + self->publish_select(SelectType::DAYTIME_FAN_MODE, t.value_u32); + self->update_bulk_pref(0x22, t.value_u32); + } break; case 0x23: ESP_LOGV(TAG_VITAL, "DaytimeFanLevel=%u", (unsigned)t.value_u32); + if (self != nullptr) { + self->publish_number(NumberType::DAYTIME_FAN_LEVEL, t.value_u32); + self->update_bulk_pref(0x23, t.value_u32); + } break; // Sprout-specific tags (also present in CMD=02 00 55, same TLV format) diff --git a/devices/levoit-vital200s/common.yaml b/devices/levoit-vital200s/common.yaml index a3b44af..0c0bf9c 100644 --- a/devices/levoit-vital200s/common.yaml +++ b/devices/levoit-vital200s/common.yaml @@ -1,7 +1,7 @@ #Do not use this file directly, it is included in the device yaml files. Put common configuration here. # Enable logging logger: - level: VERBOSE + level: DEBUG api: encryption: @@ -54,6 +54,14 @@ switch: levoit: levoitvital200 name: "Light Detect" type: light_detect + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Preset" + type: quick_clean + - platform: levoit + levoit: levoitvital200 + name: "Daytime Preset" + type: daytime_enabled number: - platform: levoit @@ -76,6 +84,18 @@ number: levoit: levoitvital200 name: "Sleep Fan Level (5=auto)" type: sleep_fan_level + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Minutes" + type: quick_clean_minutes + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Fan Level" + type: quick_clean_fan_level + - platform: levoit + levoit: levoitvital200 + name: "Daytime Fan Level" + type: daytime_fan_level sensor: - platform: levoit @@ -104,6 +124,10 @@ select: levoit: levoitvital200 name: "Sleep Mode Type" type: sleep_preference + - platform: levoit + levoit: levoitvital200 + name: "Daytime Fan Mode" + type: daytime_fan_mode text_sensor: - platform: levoit diff --git a/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml b/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml index 775bf56..779d727 100644 --- a/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml +++ b/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml @@ -25,7 +25,7 @@ external_components: ref: main components: [levoit] logger: - level: VERBOSE + level: DEBUG api: encryption: @@ -78,6 +78,14 @@ switch: levoit: levoitvital200 name: "Light Detect" type: light_detect + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Preset" + type: quick_clean + - platform: levoit + levoit: levoitvital200 + name: "Daytime Preset" + type: daytime_enabled number: - platform: levoit @@ -100,6 +108,18 @@ number: levoit: levoitvital200 name: "Sleep Fan Level (5=auto)" type: sleep_fan_level + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Minutes" + type: quick_clean_minutes + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Fan Level" + type: quick_clean_fan_level + - platform: levoit + levoit: levoitvital200 + name: "Daytime Fan Level" + type: daytime_fan_level sensor: - platform: levoit @@ -128,6 +148,10 @@ select: levoit: levoitvital200 name: "Sleep Mode Type" type: sleep_preference + - platform: levoit + levoit: levoitvital200 + name: "Daytime Fan Mode" + type: daytime_fan_mode text_sensor: - platform: levoit diff --git a/devices/levoit-vital200s/levoit-vital200s-builder-s3.yaml b/devices/levoit-vital200s/levoit-vital200s-builder-s3.yaml index a5cbddb..0239376 100644 --- a/devices/levoit-vital200s/levoit-vital200s-builder-s3.yaml +++ b/devices/levoit-vital200s/levoit-vital200s-builder-s3.yaml @@ -25,7 +25,7 @@ external_components: ref: main components: [levoit] logger: - level: VERBOSE + level: DEBUG api: encryption: @@ -78,6 +78,14 @@ switch: levoit: levoitvital200 name: "Light Detect" type: light_detect + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Preset" + type: quick_clean + - platform: levoit + levoit: levoitvital200 + name: "Daytime Preset" + type: daytime_enabled number: - platform: levoit @@ -100,6 +108,18 @@ number: levoit: levoitvital200 name: "Sleep Fan Level (5=auto)" type: sleep_fan_level + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Minutes" + type: quick_clean_minutes + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Fan Level" + type: quick_clean_fan_level + - platform: levoit + levoit: levoitvital200 + name: "Daytime Fan Level" + type: daytime_fan_level sensor: - platform: levoit @@ -128,6 +148,10 @@ select: levoit: levoitvital200 name: "Sleep Mode Type" type: sleep_preference + - platform: levoit + levoit: levoitvital200 + name: "Daytime Fan Mode" + type: daytime_fan_mode text_sensor: - platform: levoit diff --git a/devices/levoit-vital200s/levoit-vital200s-builder.yaml b/devices/levoit-vital200s/levoit-vital200s-builder.yaml index e7f09bb..2835e8c 100644 --- a/devices/levoit-vital200s/levoit-vital200s-builder.yaml +++ b/devices/levoit-vital200s/levoit-vital200s-builder.yaml @@ -20,7 +20,7 @@ external_components: ref: main components: [levoit] logger: - level: VERBOSE + level: DEBUG api: encryption: @@ -73,6 +73,14 @@ switch: levoit: levoitvital200 name: "Light Detect" type: light_detect + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Preset" + type: quick_clean + - platform: levoit + levoit: levoitvital200 + name: "Daytime Preset" + type: daytime_enabled number: - platform: levoit @@ -95,6 +103,18 @@ number: levoit: levoitvital200 name: "Sleep Fan Level (5=auto)" type: sleep_fan_level + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Minutes" + type: quick_clean_minutes + - platform: levoit + levoit: levoitvital200 + name: "Quick Clean Fan Level" + type: quick_clean_fan_level + - platform: levoit + levoit: levoitvital200 + name: "Daytime Fan Level" + type: daytime_fan_level sensor: - platform: levoit @@ -123,6 +143,10 @@ select: levoit: levoitvital200 name: "Sleep Mode Type" type: sleep_preference + - platform: levoit + levoit: levoitvital200 + name: "Daytime Fan Mode" + type: daytime_fan_mode text_sensor: - platform: levoit From a326d7bdddcc5815a82213cd78161c6c64690173 Mon Sep 17 00:00:00 2001 From: AutoCoder Date: Wed, 20 May 2026 14:11:11 +0000 Subject: [PATCH 4/4] Vital 200S Pro: protocol documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two reference documents to docs/ capturing the MCU 2.0.0 protocol behavior and the stock-firmware analysis that established the bulk-preferences SET command. Both are self-contained and cross-reference each other for navigation. docs/MCU_2.0.0_baseline.md — the canonical reference for what this MCU revision emits on the wire and what it accepts in reply. Covers: - Status push: cadence, frame format, complete 32-TLV inventory with field semantics, lengths, and decoder behavior; TLVs the model does not emit (Sprout-only); discrepancies vs LEVOIT_UART.md for the TLVs whose wire length differs from the upstream spec. - ESP → MCU command shapes: the cardinal "SET-payload tag namespace is local to each CMD byte" fact; eight observed command scenarios with frame bytes and resulting status-push TLV changes; the subsystem-byte mapping table. - Bulk-preferences SET (CMD 02 02 55 tags 0x04..0x0F): overview, SET-tag → status-TLV mapping with the monotonic rule, tag 0x04 gate behavior, storage model, and the two design options for client UX (auto-bump vs surface-the-constraint) with the rationale for the choice this component made. - Unmapped CMD bytes: dispatcher-key inventory with suspected purposes; the CMD 02 05 55 channel that was investigated and ruled out as a sleep-prefs candidate; the stock-firmware filter functionality (filter dust %, usage hours, lifetime) that doesn't yet have component coverage. - ESPHome × MCU FIFO retention: documents the ESP32-C3 hardware UART FIFO mechanism that makes the first MCU status push at boot get decoded before the API client connects, explains why the API-streamed log appears to never see a successful decode, and provides a diagnostic snippet to confirm. - Gap analysis: per-gap status (CLOSED / OPEN / NOT IMPLEMENTED / NOT APPLICABLE) for MCU functionality not yet surfaced as entities. - Appendix: restore-to-baseline value sequence for the 12 bulk-pref TLVs. docs/STOCK_FIRMWARE_FINDINGS.md — read-only analysis of the saved stock-ESP-firmware flash dump. Covers: - UART frame structure and the klv_pack helper signature and length encoding. - The UART send dispatcher at 0x42006e46, its calling convention, key encoding rule (cmd[2]<<8 | cmd[1]), and complete identified key → CMD mapping table with call-site PCs. - The bulk-preferences SET function at 0x420075a6, with the 12 klv_pack calls table, annotated wire-frame example, tag 0x04 gate behavior, storage model, and the explanation of why setAutoMode* and bulk-prefs coexist on the same CMD byte (disjoint local-tag namespaces). - SET-tag → status-TLV mapping (monotonic rule, cluster grouping). - CMD 02 05 55 (key 0x5505) analysis as an orthogonal channel, with site A / site B disassembly and a circumstantial filter- monitor hypothesis labelled "Hypothesis (based on call-site context, unverified)". - Reproducing-the-analysis section with the tools, segment layout, command-line invocations, and a caveat that byte- pattern search alone is unreliable for identifying CMD usages (the compiler emits c.lui + addi rather than literal byte triples). - Address reference tables: function entries, dispatcher call-site PCs by key, DROM string-pool symbols. - Open uncertainties: the dispatcher's downstream queue behavior, the single-store conclusion's reliance on observable status-push state rather than RAM inspection, and the unmapped-CMD-byte items remaining. No code change. The protocol behavior described here matches what the preceding implementation commits do. --- docs/MCU_2.0.0_baseline.md | 614 ++++++++++++++++++++++++++++++++ docs/STOCK_FIRMWARE_FINDINGS.md | 591 ++++++++++++++++++++++++++++++ 2 files changed, 1205 insertions(+) create mode 100644 docs/MCU_2.0.0_baseline.md create mode 100644 docs/STOCK_FIRMWARE_FINDINGS.md diff --git a/docs/MCU_2.0.0_baseline.md b/docs/MCU_2.0.0_baseline.md new file mode 100644 index 0000000..ea141b9 --- /dev/null +++ b/docs/MCU_2.0.0_baseline.md @@ -0,0 +1,614 @@ +# Levoit Vital 200S Pro / MCU 2.0.0 — UART Protocol Baseline + +## 1. Overview + +What this document covers: the **observable wire behavior** of the +MCU on a Levoit Vital 200S Pro running MCU firmware **2.0.0** — +what it emits over UART on its own initiative, what it accepts from +the ESP side, and what its responses look like. This is the +canonical reference for the next contributor extending the ESPHome +component for this MCU revision. + +Document scope contrast: + +| Document | Scope | +|-------------------------------------|--------------------------------------------------------------| +| [`LEVOIT_UART.md`](../LEVOIT_UART.md) | Upstream protocol primer covering frame envelope and family-level CMD conventions across all Levoit models | +| `MCU_2.0.0_baseline.md` *(this doc)* | Wire-level behavior specific to MCU 2.0.0 on the Vital 200S Pro | +| [`STOCK_FIRMWARE_FINDINGS.md`](./STOCK_FIRMWARE_FINDINGS.md) | Stock-ESP-firmware disassembly: dispatcher, klv_pack, function entries, address references | + +Cross-references in this document point to the other two; complete +overlap is avoided. Anything that requires reading the disassembled +firmware to verify lives in `STOCK_FIRMWARE_FINDINGS.md`; anything +that's a fact about MCU-emit or MCU-accept behavior lives here. + +MCU firmware version is confirmed from the first status push: TLV +`0x01` carries `00 00 02` (patch / minor / major, little-endian), +i.e. **firmware 2.0.0**. + +--- + +## 2. Status push (MCU → ESP) + +### 2.1 Frame envelope and cadence + +The MCU emits a single `CMD = 02 00 55` (SEND, `msg_type = 0x22`) +status frame every **~5 seconds** with no command needed. Every push +contains the **same 32 TLVs** in the same order. Frame length is +**114 bytes** (`` byte `0x6C`). + +The wire order has one quirk worth noting: TLV `0x20` is emitted +**before** TLV `0x1F`, consistent across every push observed. +TLV-id-ordered traversal is therefore unsafe; parsers must consume +TLVs in wire order rather than expecting ID-monotonic placement. + +### 2.2 Complete TLV inventory + +| TLV | Len | Field | Decoder in `vital_status.cpp` | HA entity | +|--------|-----|--------------------------------------------------|-------------------------------|--------------------------------------------| +| `0x00` | 1 | Device ID byte | logged only | — | +| `0x01` | 3 | MCU version (patch / minor / major) | publishes text_sensor | `sensor.…_mcu_version` | +| `0x02` | 1 | Power | drives `fan->apply_device_status(power=…)` | `fan.…` | +| `0x03` | 1 | Fan mode | drives `fan->apply_device_status(mode=…)` | `fan.…` preset | +| `0x04` | 1 | Fan level | drives `fan->apply_device_status(speed=…)` | `fan.…` speed pct | +| `0x05` | 1 | Fan speed alt encoding | logged only | — (probably duplicate of `0x04`) | +| `0x06` | 1 | Display illuminated | publishes switch | `switch.…_display` | +| `0x07` | 1 | Display state | logged only | — | +| `0x08` | 1 | Unknown — always `0x00` observed | logged only | — | +| `0x09` | 1 | AQI level (1–4) | publishes sensor | `sensor.…_aqi` | +| `0x0A` | 1 | Air quality detail (0=error, non-zero ok) | publishes text_sensor | `sensor.…_error` | +| `0x0B` | 2 | PM2.5 LE16 | publishes sensor | `sensor.…_pm_2_5` | +| `0x0E` | 1 | Child lock | publishes switch | `switch.…_child_lock` | +| `0x0F` | 1 | Auto mode (Default / Quiet / Efficient) | publishes select | `select.…_auto_mode` | +| `0x10` | 2 | Efficiency room size raw LE16 (× 10.764 × 1.3 = m²) | publishes number | `number.…_auto_mode_room_size` | +| `0x11` | 2 | Efficiency high-fan seconds remaining LE16 | publishes text_sensor + sensor| `sensor.…_auto_high_remaining_run_time` | +| `0x12` | 1 | Auto mode profile (sub-state) | logged only | — | +| `0x13` | 1 | Light Detect toggle | publishes switch | `switch.…_light_detect` | +| `0x16` | 1 | Wi-Fi LED state | logged only | — | +| `0x17` | 1 | Dark detected (ambient-light reading) | publishes binary_sensor | `binary_sensor.…_dark_detected` | +| `0x18` | 1 | Sleep type | publishes select + caches | `select.…_sleep_mode_type` | +| `0x19` | 1 | Quick-clean enabled | publishes switch + caches | `switch.…_quick_clean_preset` | +| `0x1A` | 2 | Quick-clean minutes LE16 | publishes number + caches | `number.…_quick_clean_minutes` | +| `0x1B` | 1 | Quick-clean fan | publishes number + caches | `number.…_quick_clean_fan_level` | +| `0x1C` | 1 | White-noise enabled | caches (no entity — see §7) | — | +| `0x1D` | 2 | White-noise minutes LE16 | caches (no entity) | — | +| `0x1E` | 1 | White-noise fan | caches (no entity) | — | +| `0x1F` | 1 | Sleep fan | publishes number + caches | `number.…_sleep_fan_level_5_auto` | +| `0x20` | 2 | Sleep minutes LE16 | publishes number + caches | `number.…_sleep_mode_minutes` | +| `0x21` | 1 | Daytime enabled | publishes switch + caches | `switch.…_daytime_preset` | +| `0x22` | 1 | Daytime mode | publishes select + caches | `select.…_daytime_fan_mode` | +| `0x23` | 1 | Daytime fan level | publishes number + caches | `number.…_daytime_fan_level` | + +"Caches" in the decoder column refers to `bulk_prefs_` (declared in +`components/levoit/levoit.h`), the cache that the bulk-preferences +SET builder reads to fill non-edited fields — see §4 below. + +### 2.3 TLVs not emitted by this model + +`0x0C`, `0x0D` (Sprout-only PM1.0 / PM10), `0x14`, `0x15` +(unallocated / unused in this firmware), `0x24`, `0x25`, `0x26`, +`0x27` (Sprout-only LED ring / breathing / fan-RPM). All eight have +decoder cases in `vital_status.cpp` but they're gated by +`model == SPROUT` so they never run on a Vital. + +### 2.4 Discrepancies vs `LEVOIT_UART.md` + +The upstream protocol primer documents fixed lengths for several +TLVs that differ from what the wire actually emits on MCU 2.0.0: + +| TLV | LEVOIT_UART.md says | Wire is | Notes | +|-------|---------------------|---------|----------------------------------------------------| +| `0x00`| 4 bytes | 1 byte | Device ID on Vital 200S Pro is a single byte (`0x02`) | +| `0x1A`| 1 byte | 2 bytes | Quick-clean minutes is LE16 | +| `0x1D`| 1 byte | 2 bytes | White-noise minutes is LE16 | +| `0x20`| 1 byte | 2 bytes | Sleep-mode minutes is LE16 | + +The existing parser uses the **wire length byte** (`len_code`) +rather than spec-fixed lengths, so all four decode correctly today; +`LEVOIT_UART.md` could be updated to reflect the actual lengths for +this MCU revision. + +--- + +## 3. ESP → MCU commands + +### 3.1 Frame envelope + +ESP→MCU frames use the standard envelope described in +[`LEVOIT_UART.md`](../LEVOIT_UART.md) and broken down at the +byte level in [`STOCK_FIRMWARE_FINDINGS.md` §2.1](./STOCK_FIRMWARE_FINDINGS.md#21-frame-envelope): + +``` +A5 22 00 00 +``` + +The `` byte increments per outgoing packet (independent counter +from the MCU→ESP direction). `` is computed across the whole +frame excluding the checksum byte itself. + +### 3.2 SET-payload tag namespace is local to each CMD byte + +Cardinal fact for anyone designing a new SET command: + +**Status-push TLV tag IDs (`0x00`–`0x27`) are NOT the same as +SET-command payload tag IDs.** Each `02 55` CMD has its +own local payload-tag namespace that conventionally starts at +`0x01`. A SET frame that re-uses status-TLV IDs against the wrong +CMD byte is well-formed but nonsensical to the MCU: the frame gets +the standard `0x12` ACK (received and parsed) and the unrecognised +tags are silently dropped. + +Confirmed local-tag → status-TLV correspondences: + +| ESP→MCU command (CMD bytes) | Local payload tags | Status TLV(s) updated | +|-------------------------------------|--------------------------------------------------------|------------------------| +| `02 02 55` set fan mode | `01` = fan mode value (`0x00/0x01/0x02/0x05`) | `0x03` | +| `02 02 55` set auto-mode pref | `02` = auto-mode index, `03` = room size LE16 | `0x0F`, `0x10` | +| `02 02 55` bulk-preferences SET | `04`..`0F` (12 TLVs — see §4) | `0x18`..`0x23` | +| `02 03 55` set fan speed | `01` = fan speed (1–4) | `0x04` | +| `02 04 55` set display | `01` = display brightness | `0x06` / `0x07` | +| `02 11 55` set light detect | `01` = on/off | `0x13` | +| `02 40 51` set child lock | `01` = on/off | `0x0E` | +| `02 18 50` set Wi-Fi LED | `01`..`04` (mode + colors) | `0x16` | +| `02 19 50` set timer | `01` = duration in seconds (4-byte LE) | — (timer ack via `02 1A 50`) | + +The dispatcher-level evidence for how the MCU routes a `02 XX 55` +CMD byte to its handler lives in +[`STOCK_FIRMWARE_FINDINGS.md` §3](./STOCK_FIRMWARE_FINDINGS.md#3-the-uart-send-dispatcher-0x42006e46). + +### 3.3 Confirmed command shapes — observed scenarios + +The following ESP→MCU frames have been captured on the wire and the +resulting status-push TLV changes observed. These shapes are +patterns to mirror when designing additional SET commands. + +#### `fan.set_preset_mode preset_mode=Sleep` + +``` +A5 22 4D 07 00 88 02 02 55 00 01 01 01 + └ CMD ┘ └ TLV: tag=01 len=01 val=01 (Sleep) ┘ +``` +Code: `CommandType::setFanModeSleep`. TLV changes: +- `0x03` 2 → 1 (fan mode → Sleep) +- `0x06` 1 → 0 (Sleep mode dims the display as a side effect) + +The preference TLVs `0x18` / `0x1F` / `0x20` do **not** change — +entering Sleep mode via the preset switches the active fan mode +(TLV `0x03`), not the sleep *preference* values. + +#### `fan.set_preset_mode preset_mode=Pet` + +``` +A5 22 52 07 00 7F 02 02 55 00 01 01 05 + └ CMD ┘ └ TLV: tag=01 len=01 val=05 (Pet) ┘ +``` +Code: `CommandType::setFanModePet`. TLV changes: +- `0x03` 2 → 5 (fan mode → Pet) +- `0x04` 0 → 3 (Pet uses fan level 3 by default) + +#### `fan.set_preset_mode preset_mode=Auto` + +``` +A5 22 56 07 00 7E 02 02 55 00 01 01 02 + └ CMD ┘ └ TLV: tag=01 len=01 val=02 (Auto) ┘ +``` +Code: `CommandType::setFanModeAuto`. TLV changes: +- `0x04` 0 → 4 (Auto kicked fan up to level 4 in response to room) +- `0x11` 0 → 0x155 (efficiency high-fan counter started counting) + +#### `select.select_option Quiet` on `auto_mode` + +``` +A5 22 64 0B 00 67 02 02 55 00 02 01 01 03 02 00 00 + └ CMD ┘ └ TLV 02 = Auto Quiet ┘ └ TLV 03 = room size 0 ┘ +``` +Code: `CommandType::setAutoModeQuiet`. TLV changes: +- `0x0F` 2 → 1 (auto mode profile → Quiet) + +#### `select.select_option "Room Size"` on `auto_mode` + +``` +A5 22 68 0B 00 03 02 02 55 00 02 01 02 03 02 5E 01 + └ CMD ┘ └ TLV 02=Efficient ┘ └ TLV 03=size raw 0x015E ┘ +``` +Code: `CommandType::setAutoModeEfficient` (with `efficiency_room_size` +number state ≈ 25 m² → raw 0x015E = 350). TLV changes: +- `0x04` 0 → 4 (fan ramped up in response to room-size mode) +- `0x11` 0 → 0x156 (counter incrementing) + +#### `switch.toggle` on display + +``` +A5 22 76 07 00 5E 02 04 55 00 01 01 00 + └ CMD ┘ └ TLV: tag=01 len=01 val=00 (off) ┘ +``` +Code: `CommandType::setDisplayOff`. CMD bytes differ from the fan-mode +family: `02 04 55` instead of `02 02 55`. TLV changes: +- `0x06` 1 → 0 (display illuminated → off) +- `0x07` 1 → 0 (display state → off) + +#### `switch.toggle` on light_detect + +``` +A5 22 82 07 00 45 02 11 55 00 01 01 00 + └ CMD ┘ └ TLV: tag=01 len=01 val=00 (off) ┘ +``` +Code: `CommandType::setLightDetectOff`. CMD bytes: `02 11 55`. TLV +changes: +- `0x13` 1 → 0 (light detect → off) +- `0x12` 1 → 0 (auto-mode profile reset — light_detect interacts with auto) + +### 3.4 Subsystem-byte mapping + +Compact reference for the `` byte under the `02 XX 55` +family, with the status TLVs each subsystem affects: + +| `` | Subsystem | Status TLV(s) reflected | +|------------|----------------------------------------------|-------------------------| +| `0x02` | Fan mode + auto-mode prefs + bulk-prefs SET | `0x03`, `0x0F`, `0x10`, `0x18`..`0x23` | +| `0x03` | Fan speed (manual) | `0x04` | +| `0x04` | Display | `0x06`, `0x07` | +| `0x11` | Light detect | `0x13` | +| `0x40` *(02 40 51 family)* | Child lock | `0x0E` | +| `0x18` *(02 18 50 family)* | Wi-Fi LED | `0x16` | +| `0x19` *(02 19 50 family)* | Timer | — (separate ack frame) | + +--- + +## 4. Bulk-preferences SET (CMD 02 02 55 tags 0x04–0x0F) + +### 4.1 Overview + +The MCU's sleep / quick-clean / white-noise / daytime preference +subsystem is **not** a per-CMD-byte family — it is a single +**12-TLV bulk write** under the **same `02 02 55` CMD byte** that +also handles fan-mode and auto-mode preferences. The handler +dispatches on tag IDs within the payload: + +| Tag range in `02 02 55` payload | Handler | +|--------------------------------|-----------------------------------------------------------| +| `0x01` | Fan mode (existing `setFanMode*`) | +| `0x02` + `0x03` | Auto-mode preference (existing `setAutoMode*`) | +| `0x04`..`0x0F` | Bulk preferences (sleep / QC / WN / daytime) | + +The three subsystems share the CMD byte because their local-tag +namespaces are disjoint. For the function-level disassembly of the +bulk-prefs writer (entry `0x420075a6`, 12 chained `klv_pack` calls, +total wire frame 49 bytes), see +[`STOCK_FIRMWARE_FINDINGS.md` §4](./STOCK_FIRMWARE_FINDINGS.md#4-bulk-preferences-set--the-12-tlv-write). + +### 4.2 SET-tag → status-TLV mapping + +**Monotonic rule: `set_tag = status_tlv − 0x14`**, with field +lengths matching exactly. + +| SET tag | Length | → Status TLV | Field | +|---------|--------|--------------|------------------------| +| `0x04` | 1 B | `0x18` | sleep_type (gate — §4.3) | +| `0x05` | 1 B | `0x19` | quick_clean_enabled | +| `0x06` | LE16 | `0x1A` | quick_clean_minutes | +| `0x07` | 1 B | `0x1B` | quick_clean_fan | +| `0x08` | 1 B | `0x1C` | white_noise_enabled | +| `0x09` | LE16 | `0x1D` | white_noise_minutes | +| `0x0A` | 1 B | `0x1E` | white_noise_fan | +| `0x0B` | 1 B | `0x1F` | sleep_fan | +| `0x0C` | LE16 | `0x20` | sleep_minutes | +| `0x0D` | 1 B | `0x21` | daytime_enabled | +| `0x0E` | 1 B | `0x22` | daytime_mode | +| `0x0F` | 1 B | `0x23` | daytime_level | + +The mapping is interleaved across clusters (sleep type at `0x04`, +quick-clean fields at `0x05`–`0x07`, white-noise fields at `0x08`–`0x0A`, +sleep fan/min at `0x0B`–`0x0C`, daytime fields at `0x0D`–`0x0F`), +matching the order of `klv_pack` calls in the stock-firmware +writer — see [`STOCK_FIRMWARE_FINDINGS.md` §5](./STOCK_FIRMWARE_FINDINGS.md#5-set-tag--status-tlv-mapping) +for the full disassembly-derived mapping. + +### 4.3 Tag `0x04` gate behavior + +Empirically verified by on-device writes against MCU FW 2.0.0: + +- When the bulk-write frame contains `tag=0x04 value=0x00` + (sleep_type = Default), the MCU applies **only** the `0x04` + change to status TLV `0x18`. Writes to tags `0x05..0x0F` are + silently dropped: the MCU returns the standard `0x12` ACK and + the subsequent status push shows the original values for the + other 11 fields. +- When `tag=0x04 value != 0x00` (observed non-zero values are + `0x01` and `0x02`, corresponding to "Custom1" / "Custom2" in the + stock app), the MCU applies all 12 writes. + +Storage model: a **single shared field set**, not per-preset slots. +Switching the type byte does not switch among different stored +field values; the cluster TLVs always reflect the most recent +non-Default write. See +[`STOCK_FIRMWARE_FINDINGS.md` §4.5](./STOCK_FIRMWARE_FINDINGS.md#45-storage-model) +for the experimental evidence. + +### 4.4 Implementation pattern for ESPHome components + +When editing any sleep / QC / WN / daytime field while the active +type is Default, the gate semantics in §4.3 require one of two +design choices for client UX: + +**Option A — auto-bump on edit.** Send the bulk write with +tag `0x04` temporarily set to a non-Default value (e.g. `0x01`), +then send a second bulk write with `0x04 = 0x00` to restore the +type label. Two frames per user edit; the type byte flickers +visibly on the wire and in the status push during the +transaction. + +**Option B — surface the constraint via conditional availability.** +Expose the type as a writable select and document that other +cluster edits only persist while type ≠ Default. The user changes +the type before adjusting other fields. One frame per edit; the +type byte stays stable; the constraint is visible in the UX. + +This component chose **Option B**. Rationale: one wire frame per +edit (no transient type-byte flicker visible in HA or in the MCU +status push), no rollback story to design if the second frame in +an auto-bump pair fails, and the constraint matches the stock +VeSync app's UX behavior (its Sleep Preset UI requires selecting a +Custom slot before allowing other fields to be edited). Option A +remains reasonable for future implementations whose constraints +favor automation-driven edits where the type-byte flicker is +acceptable. + +Either approach reads the other 11 fields from a cache populated +by the status decoder (`bulk_prefs_` in `components/levoit/levoit.h`, +updated via `Levoit::update_bulk_pref()` from the `0x18`..`0x23` +decoder cases in `vital_status.cpp`). The cache is filled by the +first decode of a status push at boot — see §6. + +--- + +## 5. Unmapped or partially-mapped CMD bytes + +### 5.1 Dispatcher-key inventory and suspected purposes + +The stock firmware's UART dispatcher accepts a wider set of +`02 XX 55` / `02 XX 51` / `02 XX 50` keys than the ESPHome component +currently uses. The following are observed in the dispatcher map +but have no implementation in `vital_commands.cpp`: + +| CMD bytes | Stock-fw payload shape | Suspected feature | +|--------------|-------------------------------------------------------------------------------------|----------------------------------------------------| +| `02 01 50` | empty payload | unknown (single dispatcher reference) | +| `02 04 50` | various | timer-related (4 references) | +| `02 04 51` | `{01 01 byte}` (single value) | possible filter status indicator | +| `02 05 51` | `{01 01 a, 02 01 b}` (two bytes) | possible filter calibration | +| `02 07 51` | empty payload (query) | probable read-back paired with `02 04 51` / `02 05 51` | +| `02 41 51` | `{01 01 byte}` | child-lock variant | +| `02 44 51` | unknown call site | unknown | +| `02 01 55` | empty payload (single site) | unknown — looks like a "request status" trigger | +| `02 05 55` | site A: `{01 01 byte}`; site B: `{03 00}` empty-value query | orthogonal to preferences — see §5.2 | +| `02 0A 55` | single site | Sprout-family overlap (white-noise on Sprout) — unused on Vital | + +Function-entry PCs and call-site PCs for each key are catalogued in +[`STOCK_FIRMWARE_FINDINGS.md` §3.3](./STOCK_FIRMWARE_FINDINGS.md#33-identified-key--cmd-mappings) +and [`§8.2`](./STOCK_FIRMWARE_FINDINGS.md#82-dispatcher-call-sites-by-key). + +### 5.2 CMD `02 05 55` — orthogonal to preferences + +This CMD was investigated as a sleep-preferences SET candidate +based on dispatcher-multiplicity heuristics, then ruled out: frames +matching the stock-firmware's site-A shape (`A5 22 07 00 +02 05 55 00 01 01 `) sent on-device produce a clean `0x12` ACK +but no change to any preference TLV. The actual sleep / QC / WN / DT +write path is the bulk-prefs SET at CMD `02 02 55` (§4). + +Circumstantial disassembly evidence (counter at DRAM `0x3fc9d7a4` +incremented before each call, 20-byte struct memcpy in the same +basic block, surrounding compilation-unit string constants like +`filter dust percent` and `filter use time`) points to a +filter-monitor report channel. Full evidence is in +[`STOCK_FIRMWARE_FINDINGS.md` §6](./STOCK_FIRMWARE_FINDINGS.md#6-cmd-02-05-55-key-0x5505--analyzed-orthogonal-to-preferences), +including the explicit "Hypothesis (based on call-site context, +unverified)" qualification. + +Confirming or refuting the filter-monitor hypothesis would +require capturing this CMD's emission during a stock-firmware +filter-life event — see §7 gap #5. + +### 5.3 Stock-firmware filter functionality not yet covered + +The stock ESP firmware exposes several filter-related computations +that the ESPHome component does not currently surface. Evidence is +the DROM string pool of the stock image: + +| Stock-fw feature | String evidence | Current component coverage | +|----------------------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| +| Filter dust calculation | `filter dust percent: %d`, `filter dust = %d` | Not surfaced. Component computes `filter_life_left` locally from CADR × time, not from MCU readings. | +| Filter usage hours | `filter use time = %d`, `filter use Time: %d` | Not surfaced. | +| Filter min/max lifetime hours | `filter max liftetime hour`, `filter min liftetime hour` *(typo from stock binary)* | Not surfaced. Component exposes `filter_lifetime_months` as user input only. | +| Filter algorithm version | `filter algorithm version = 0x%04x` | Not surfaced. | +| Cleaning-before-bed feature | `cleaningBeforeBe[d]` | Not surfaced. | +| Pet mode (`enterPetMode`) | symbol present in stock binary | Covered as `fan.…` preset "Pet" | + +Filter monitoring appears to live behind the `02 05 55` channel +(§5.2). The status push does not carry filter-hours or +dust-percent data, so reading the MCU's actual filter measurements +would require a query/response transaction on a separate CMD, +not in the current status-push parser. + +--- + +## 6. ESPHome × MCU interaction — boot-time FIFO retention decode + +A subtle behavior worth documenting in advance, since reading the +device's API-streamed logs without this context naturally suggests +the dedup is broken. **This is normal operation, not a bug.** + +### 6.1 The mechanism + +- The MCU pushes a `CMD = 02 00 55` status frame every 5 s on its + own clock, independent of any ESP state. +- ESP32-C3 UART RX has a **128-byte hardware FIFO** that begins + accumulating received bytes from the moment the GPIO pin is + configured — well before any application code runs. +- A status frame is 114 bytes, which fits cleanly in one FIFO load. +- After an ESP OTA reboot, the bootloader + IDF startup + ESPHome + `App::setup()` takes ~3–5 s before the API server is ready to + accept client connections. + +### 6.2 What happens at boot + +If an MCU status push arrives during the boot window (which it +typically does — every 5 s of boot, statistically guaranteed): + +1. Bytes land in the hardware UART FIFO. No driver listening yet. +2. ESPHome's UART driver initialises during component setup. FIFO + contents are drained into the software RX buffer. +3. The levoit frame parser reads the buffered bytes, recognises a + complete `A5 22 ...` frame, calls `process_message` → + `dispatch_decoder` → `payload_changed_` (which sees an empty + cache, claims `cache[0]`, returns `true`) → `decode_vital_status`. +4. `decode_vital_status` iterates all 32 TLVs and calls the + corresponding `publish_*` methods on each entity. Each entity + ends with `has_state() == true` and the correct value, even + though no API client is connected yet. +5. ESPHome's task log buffer (default **768 bytes**) records the + `dispatch:` and `dedup:` log lines. The boot dump from + `App::setup()` then writes ~10 KB of `dump_config` output, + overwriting that log line many times over before the API + client finally connects. +6. The API client connects and sends `SubscribeStatesRequest`. + ESPHome's `InitialStateIterator` (in + `components/api/subscribe_state.{h,cpp}`) walks every entity + and calls `send__state` for each, which transmits `state` + + `missing_state = !has_state()`. HA receives the correct state + for every entity that successfully published. +7. The **next** MCU push arrives ~5 s later with identical + payload bytes (no state has changed). Hash matches `cache[0]`, + `payload_changed_` returns `false`, decoder logs + `skip decode (unchanged payload)`. This is correct — repeated + decodes would be no-ops anyway, since the publish methods + dedup on value equality internally. + +### 6.3 Why this looks alarming in logs + +From the API-streamed log alone, every visible `0055` push is +`skip decode`, and no visible `dispatch:` for any decode that ever +ran. The dedup appears broken ("never decodes anything"). It isn't +— the one successful decode happened pre-handshake, its log line +was eaten by the 768-byte ring buffer, and the dedup is correctly +short-circuiting visually-redundant subsequent pushes. + +### 6.4 How to confirm + +The decisive test is to dump the cache contents at the top of +`payload_changed_`: + +```cpp +ESP_LOGI("levoit.dedup", + "ENTER model=%u ptype=%02X%02X computed_hash=0x%02X", + model, ptype0, ptype1, hash); +for (uint8_t i = 0; i < 16; i++) { + if (g_payload_hash_cache[i].valid) + ESP_LOGI("levoit.dedup", + " cache[%u]: model=%u ptype=%02X%02X hash=0x%02X", + i, g_payload_hash_cache[i].model, + g_payload_hash_cache[i].ptype0, + g_payload_hash_cache[i].ptype1, + g_payload_hash_cache[i].hash); +} +``` + +On the first visible `payload_changed_` call for a given `ptype`, +the cache will already contain a matching entry. Comparing the +`seq` byte in the visible push to the one that would have arrived +5 s earlier shows the gap matching the boot / UART-init window. + +### 6.5 Implication for new features + +Entities only need to publish correctly **on first decode** to +reach HA — the `InitialStateIterator` handles the rest. If an +entity shows `unknown` in HA despite the MCU pushing a valid +value, the cause is **not** the dedup; check whether the +component's `publish_` rejected the value (e.g. +`Levoit::publish_select` early-returns when +`value >= options.size()`, leaving `has_state()` at false). The +fix is on the entity-config side, not the decoder side. + +The `bulk_prefs_` cache populates from this same boot-time +FIFO-retention decode. By the time the API client connects, the +cache is already valid, so `setBulkPrefs` can fire immediately +when the user toggles an entity. No "wait for first decode" +gating is required. + +--- + +## 7. Gap analysis — MCU functionality not yet covered by ESPHome entities + +Ranked by user-visible utility (high → low). "Status" reflects the +state of each gap as of this revision. + +| # | Gap | Source | Suggested entity | Effort | Utility | Status | +|---|------------------------------------------------------------------------------|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|----------------|---------------|--------------------------------------------------------------------------------------------------------------------------------| +| 1 | Sleep / QC / DT cluster SET — writable cluster fields | CMD `02 02 55` tags `0x04..0x0F` (§4) | Writable entities for sleep type / fan / min, QC enabled / fan / min, DT enabled / mode / level | 1–2 h | High | **CLOSED.** 9 writable entities, all routing through `setBulkPrefs`. See `LevoitSwitch` / `LevoitNumber` / `LevoitSelect` configs in `devices/levoit-vital200s/`. | +| 1a | Bulk-prefs SET — WN cluster (TLVs `0x1C`/`0x1D`/`0x1E`) | CMD `02 02 55` tags `0x08`/`0x09`/`0x0A` | Writable entities for wn_enabled / wn_min / wn_fan | 15 min | None for Vital 200S Pro | **NOT IMPLEMENTED.** Vital 200S Pro has no white-noise hardware. The cluster is cache-only on this model and echoed back unchanged in every `setBulkPrefs` write (the MCU still requires all 12 TLVs in the bulk frame, per §4). Future Sprout work would add these entities and reuse the same SET path. | +| 2 | Auto-mode profile (TLV `0x12`) | `0x12` status push | sensor or text_sensor; meaning unclear (likely a sub-state of `0x0F`) | 15 min | Low–Medium | OPEN — currently logged only. | +| 3 | Wi-Fi LED state read-back (TLV `0x16`) | `0x16` status push | switch or sensor; component sets the LED via `setWifiLed*` but never reads it | 15 min | Low | OPEN — currently logged only. | +| 4 | Display state vs Display illuminated (TLV `0x07` vs `0x06`) | `0x07` status push | possibly a `_display_state` sensor distinguishing "off / dim / full / forced-off-by-light-detect" | 15 min | Low | OPEN — `0x07` currently logged only. | +| 5 | Filter actual usage hours / dust % — MCU-side measurement | Suspected CMD `02 05 55` filter-monitor channel (§5.2) | sensor exposing MCU-reported filter use vs the component's CADR-based estimate | 2–4 h | Medium | OPEN — would replace the heuristic with the real number. Requires a new query / response handler. | +| 6 | Device ID (TLV `0x00`) | `0x00` status push | none needed; ESPHome handles `unique_id` differently | 0 | None | NOT APPLICABLE — gap reframed as out-of-scope rather than open. | +| 7 | Filter calibration / algorithm version | DROM strings only; no observed TLV (§5.3) | — | unknown | Low | OPEN — diagnostic value only; not in any status push or known CMD. | +| 8 | Pet mode / Sleep / Auto / Manual as writable select | Already via `fan.set_preset_mode` | — | 0 | covered | COVERED — no change. | + +The single high-priority gap (#1, bulk-prefs SET) is now CLOSED. +Remaining open gaps (#2, #3, #4, #5, #7) are incremental polish or +require new MCU channels not yet identified. Gap #1a (WN cluster) +is a Sprout-tracked item, not a Vital 200S Pro gap. + +--- + +## 8. Appendix: Restore-to-baseline values for bulk-pref TLVs + +The following sequence restores all 12 bulk-pref TLVs to clean +defaults. Order matters: the gate-close write (step 9) is +intentional, since closing the gate first would lock the subsequent +value writes. Pace each write with ~3–5 seconds between commands so +the MCU status push reflects each change before the next command +fires. + +**Pre-check**: confirm `select.…_sleep_mode_type` is `Custom1` or +`Custom2` (gate open) before starting. If not, set it to `Custom1` +first — this is not part of the numbered sequence below. + +| # | Entity | Target value | TLV | SET tag | +|---|---------------------------------------|---------------|--------|---------| +| 1 | `switch.…_quick_clean_preset` | `off` (0) | `0x19` | `0x05` | +| 2 | `number.…_quick_clean_minutes` | `5` | `0x1A` | `0x06` | +| 3 | `number.…_quick_clean_fan_level` | `3` | `0x1B` | `0x07` | +| 4 | `switch.…_daytime_preset` | `off` (0) | `0x21` | `0x0D` | +| 5 | `select.…_daytime_fan_mode` | `Auto` (2) | `0x22` | `0x0E` | +| 6 | `number.…_daytime_fan_level` | `3` | `0x23` | `0x0F` | +| 7 | `number.…_sleep_mode_minutes` | `480` | `0x20` | `0x0C` | +| 8 | `number.…_sleep_fan_level_5_auto` | `5` | `0x1F` | `0x0B` | +| 9 | `select.…_sleep_mode_type` | `Default` (0) | `0x18` | `0x04` | + +Step 9 closes the gate. By §4.3 the gate-close write flips only TLV +`0x18` and leaves TLVs `0x19..0x23` at the values set by steps 1–8. + +The three white-noise TLVs (`0x1C` / `0x1D` / `0x1E`) are +**preserved at the MCU's existing values** by every bulk write — +they have no writable entity on this model, and the SET payload +echoes the cache value back. There is no need to set them in a +restore-to-baseline sequence. + +--- + +## Footer + +| Item | Value | +|---------------------------------|----------------------------------------------------| +| Device | Levoit Vital 200S Pro (LAP-V201S-AEUR) | +| MCU firmware version | 2.0.0 (confirmed via TLV `0x01` = `00 00 02`) | +| Initial capture date | 2026-05-19 | +| Final verification date | 2026-05-20 | +| ESPHome component branch | `feature/vital200s-extended` | + +Cross-references: + +- [`../LEVOIT_UART.md`](../LEVOIT_UART.md) — upstream protocol primer (frame envelope, model families) +- [`./STOCK_FIRMWARE_FINDINGS.md`](./STOCK_FIRMWARE_FINDINGS.md) — stock-firmware disassembly (dispatcher, klv_pack, function entries, address references) diff --git a/docs/STOCK_FIRMWARE_FINDINGS.md b/docs/STOCK_FIRMWARE_FINDINGS.md new file mode 100644 index 0000000..698a597 --- /dev/null +++ b/docs/STOCK_FIRMWARE_FINDINGS.md @@ -0,0 +1,591 @@ +# Levoit Vital 200S Pro — Stock Firmware UART Analysis + +## 1. Overview + +Read-only analysis of the original Levoit ESP firmware shipped on the +Vital 200S Pro, performed to identify the UART command path used by the +device for sleep, quick-clean, white-noise, and daytime preference +writes. No code is executed; no UART writes are sent. The firmware +image, dispatcher table, and SET handler are documented below so a +future contributor extending the ESPHome component for a related Levoit +model can re-derive analogous findings without repeating the analysis. + +**Firmware analyzed:** `VS_WFON_APR_LAP-V201S-AEUR_OFL_EU` v1.3.0-rc1, +compiled 2025-03-25, ESP-IDF dc489539. Backup file is 4,194,304 bytes, +starts with `0xE9` (ESP image magic). + +**Headline findings:** + +* The bulk-preferences SET is a single 12-TLV write at CMD `02 02 55`, + local tags `0x04..0x0F`, emitted by the function at `0x420075a6`. +* Tag `0x04` (sleep_type) gates writes to tags `0x05..0x0F`: when + `0x04 = 0x00`, the MCU silently drops the other 11 fields. +* The storage model is a single shared field set across all "preset" + labels — not per-preset slots. +* Local-tag ↔ status-TLV mapping is monotonic: + `set_tag = status_tlv − 0x14`. + +--- + +## 2. UART frame structure + +### 2.1 Frame envelope + +Every ESP↔MCU frame uses the layout already documented in +[`LEVOIT_UART.md`](../LEVOIT_UART.md): + +``` +A5 22 00 00 +``` + +`A5` is the start byte. `0x22` is the SEND message type for commands. +`` is the per-direction sequence counter that increments per +frame. `` is the total frame length. `` is the one-byte +checksum computed across the whole frame excluding byte index 5 +(the checksum field itself). + +### 2.2 The `klv_pack` helper (`0x4200b568`) + +Every Vital command builder composes its payload via a shared +serialization helper. Reverse-engineered signature: + +```c +size_t klv_pack(void *buf, + size_t buf_size, + uint8_t tag, + int16_t len, + const void *value_ptr); +// a0 a1 a2 a3 a4 +``` + +Behavior, derived from the disassembly at `0x4200b568..0x4200b630`: + +| `len` | Bytes written | Return value | +|--------------|----------------------------------------------------------------|--------------| +| 0 | `{tag, 0x00}` — 2 bytes, `value_ptr` ignored | 2 | +| 1..127 | `{tag, len, *memcpy(value_ptr, len)}` — `len + 2` bytes | `len + 2` | +| 128..32767 | `{tag, (len>>8) \| 0x80, len & 0xFF, value...}` long-form | `len + 3` | + +If `buf == NULL`, the function returns the size that would have been +written without performing the copy. Callers chain calls by advancing +`buf` by the previous return value. + +--- + +## 3. The UART send dispatcher (`0x42006e46`) + +### 3.1 Calling convention + +Every sender call site has the same shape: + +``` + +c.lui a0, 0x5 +addi a0, a0, # a0 = 0x5XXX ← key constant +jal ra, 0x42006e46 # send_uart_dispatcher +c.beqz a0, # return value 0 = success + +``` + +The dispatcher accepts a 16-bit "key" in register `a0`. The key encodes +`(cmd[2] << 8) | cmd[1]`, with `cmd[0] = 0x02` implied (the Vital +command-family prefix). + +### 3.2 Internal behavior + +The dispatcher is **not a UART writer**. It assembles a 135-byte +heap-allocated message envelope plus a 24-byte routing struct on the +stack, then posts to a queue consumed by a downstream UART task: + +``` +0x42006e46(key=a0, a1, buf=a2, len=a3, a4): + alloc 135-byte envelope (heap), zero-initialise + write 24-byte routing struct on stack: + struct[4] = 1 + struct[8] = key (e.g. 0x5505) + struct[12] = buf (TLV payload ptr) + struct[16] = len LE16 + struct[20] = envelope_ptr (set later) + envelope[0] = a1 byte (caller-supplied context byte) + if a4 != 0: + envelope[1] = 0x01 + memcpy(envelope[2..61], a4, 60) ; 60 bytes of context + memcpy(envelope[62..62+len-1], buf, len) ; TLV payload + envelope[102] = len byte (truncated to 1B) + jal 0x420113ca(0, &struct) ; post to message queue +``` + +`0x42021024` is a heap-alloc trampoline that tail-calls into +`0x40390e92` (IRAM). `0x420113ca` is a generic queue poster that hands +the routing struct off to the downstream UART task. The final wire +frame is constructed by that downstream task, not by the dispatcher. + +### 3.3 Identified key → CMD mappings + +The dispatcher key encoding has been verified against every key +constant emitted by an identified call site in the IROM segment: + +| Key | CMD bytes | Feature | Call-site PCs | +|----------|------------|--------------------------------------|------------------------------------------------| +| `0x5502` | `02 02 55` | fan mode + auto-mode prefs | `0x420070b2`, `0x42007574`, `0x42007722` | +| `0x5503` | `02 03 55` | fan speed | `0x42007120` | +| `0x5504` | `02 04 55` | display | `0x42007198` | +| `0x5511` | `02 11 55` | light detect | `0x42007796` | +| `0x5140` | `02 40 51` | child lock | `0x42007322` | +| `0x5141` | `02 41 51` | child-lock family variant | `0x420073f2` | +| `0x5104` | `02 04 51` | unmapped (filter-LED candidate) | `0x4200745a` | +| `0x5105` | `02 05 51` | unmapped | `0x420074e4` | +| `0x5107` | `02 07 51` | unmapped | `0x420077d0` | +| `0x5144` | `02 44 51` | unmapped | (not yet pinpointed) | +| `0x5501` | `02 01 55` | unmapped (single empty-payload call) | `0x42007818` | +| `0x550a` | `02 0A 55` | Sprout-family overlap | `0x4200793a` | +| `0x5505` | `02 05 55` | orthogonal — see §7 | `0x420072ac`, `0x42007388` | + +The "fan mode + auto-mode prefs" key `0x5502` is also the key used by +the bulk-preferences SET (§4); the local-tag namespace under that CMD +is wide enough for multiple subsystems to coexist (§4.6). + +--- + +## 4. Bulk-preferences SET — the 12-TLV write + +For component-implementation use — the SET-tag → status-TLV mapping +in the format a decoder/builder needs — see +[`MCU_2.0.0_baseline.md` §4.2](./MCU_2.0.0_baseline.md#42-set-tag--status-tlv-mapping). + +### 4.1 Overview + +| Item | Value | +|-------------------------|----------------------------------------------------| +| Function entry | `0x420075a6` | +| Dispatcher call site | `0x42007726` | +| Dispatcher key | `0x5502` | +| CMD bytes | `02 02 55` | +| Caller-supplied context | 33-byte struct (passed as first arg) | +| Payload size | 39 bytes | +| Total wire frame | 49 bytes (39 payload + 10-byte envelope) | + +### 4.2 The 12 `klv_pack` calls + +The function packs twelve TLVs in this exact order, reading source +bytes from offsets inside the 33-byte input struct: + +| # | Local tag | Length | Source (cfg offset) | Wire bytes | +|---|-----------|--------|---------------------|------------------------------| +| 1 | `0x04` | 1 | `cfg[0]` | `04 01 ` | +| 2 | `0x05` | 1 | `cfg[4]` | `05 01 ` | +| 3 | `0x06` | 2 LE16 | `cfg[8..9]` | `06 02 ` | +| 4 | `0x07` | 1 | `cfg[10]` | `07 01 ` | +| 5 | `0x08` | 1 | `cfg[12]` | `08 01 ` | +| 6 | `0x09` | 2 LE16 | `cfg[16..17]` | `09 02 ` | +| 7 | `0x0A` | 1 | `cfg[18]` | `0A 01 ` | +| 8 | `0x0B` | 1 | `cfg[19]` | `0B 01 ` | +| 9 | `0x0C` | 2 LE16 | `cfg[20..21]` | `0C 02 ` | +|10 | `0x0D` | 1 | `cfg[24]` | `0D 01 ` | +|11 | `0x0E` | 1 | `cfg[28]` | `0E 01 ` | +|12 | `0x0F` | 1 | `cfg[32]` | `0F 01 ` | + +After the twelfth `klv_pack` returns, the function loads +`addi a0, a0, 1282 # 0x5502` and `jal 0x42006e46` to dispatch. + +### 4.3 Wire-frame example + +A complete bulk-write frame with `sleep_type = 1 (Custom1)` and the +other 11 fields at illustrative defaults: + +``` +A5 22 19 2B 00 F7 02 02 55 00 + 04 01 01 ← sleep_type = 1 (gate unlocked) + 05 01 01 ← quick_clean_enabled + 06 02 05 00 ← quick_clean_minutes (LE16 = 5) + 07 01 03 ← quick_clean_fan + 08 01 01 ← white_noise_enabled + 09 02 2D 00 ← white_noise_minutes (LE16 = 45) + 0A 01 01 ← white_noise_fan + 0B 01 05 ← sleep_fan (5 = auto) + 0C 02 E0 01 ← sleep_minutes (LE16 = 480) + 0D 01 01 ← daytime_enabled + 0E 01 02 ← daytime_fan_mode + 0F 01 01 ← daytime_fan_level +``` + +`` byte `0x2B` = 43 bytes total minus the 6-byte fixed framing +header equals the 39 payload bytes of klv_pack output. + +### 4.4 Tag `0x04` gate behavior + +Empirically verified by on-device writes against MCU FW 2.0.0: + +* When the bulk-write frame contains `tag=0x04 value=0x00`, the MCU + applies *only* the `0x04` change to status TLV `0x18`. Writes to tags + `0x05..0x0F` are silently dropped: the MCU returns the standard + `0x12` ACK and the subsequent status push shows the original values + for the other 11 fields. +* When `tag=0x04 value != 0x00` (the observed non-zero values are + `0x01` and `0x02`, corresponding to "Custom1" and "Custom2" in the + stock app), the MCU applies all 12 writes. + +Client implementations that expose the cluster fields as writable +entities must therefore either: + +1. Pre-set `tag=0x04` to a non-zero value before writing any other + cluster field, or +2. Surface the gate to the user (e.g. as a writable select) and + document that other cluster edits do not persist while + `tag=0x04 = 0x00`. + +### 4.5 Storage model + +The MCU exposes a single shared field set, **not per-preset slots**. +This is observable from the status push: + +* Writing a non-Default `tag=0x04` followed by new values for tags + `0x05..0x0F` updates the corresponding status TLVs `0x18..0x23`. +* Subsequently writing `tag=0x04 = 0x00` flips status TLV `0x18` to + zero but leaves status TLVs `0x19..0x23` unchanged. Switching back + to the same non-Default `tag=0x04` value does not restore any + "remembered" per-preset values — TLVs `0x19..0x23` continue to + reflect whatever was last written. + +The `tag=0x04` byte is therefore an active-profile label, not a slot +selector. There is no per-preset storage within the firmware's +observable state. + +### 4.6 Coexistence with `setAutoMode*` on the same CMD byte + +The same CMD `02 02 55` is used by an older multi-TLV builder for +auto-mode preferences. Function entry `0x42007512`, dispatcher key +`0x5502`. Annotated disassembly of its klv_pack sequence: + +``` +; --- function prologue: stash caller args --- +42007512: c.addi16sp sp, -80 +42007516: c.swsp a0, 12(sp) ; sp+12 = auto_mode byte +4200751a: sh a1, 10(sp) ; sp+10..sp+11 = room_size LE16 + +; --- klv_pack #1: writes {0x02, 0x01, mode} --- +42007538: c.addi4spn a4, sp, 12 ; value_ptr = sp+12 +4200753a: c.li a3, 1 ; len = 1 +4200753c: c.li a2, 2 ; tag = 0x02 (local namespace) +4200753e: addi a1, zero, 40 ; buf_size = 40 +42007542: c.addi4spn a0, sp, 24 ; buf = sp+24 +42007544: jal 0x4200b568 ; klv_pack + +; --- klv_pack #2: writes {0x03, 0x02, lo, hi} --- +42007556: addi a4, sp, 10 ; value_ptr = sp+10 (room_size) +4200755a: c.li a3, 2 ; len = 2 +4200755c: c.li a2, 3 ; tag = 0x03 (local namespace) +4200755e: c.add a0, a5 ; buf = original + first_write_size +42007560: jal 0x4200b568 ; klv_pack + +; --- dispatcher call --- +4200756a: c.lui a0, 0x5 +42007574: addi a0, a0, 1282 # 0x5502 ; key = 0x5502 → CMD 02 02 55 +42007578: jal 0x42006e46 +``` + +The auto-pref builder writes local tags `0x02` and `0x03`; the +bulk-prefs builder (`0x420075a6`) writes local tags `0x04..0x0F`. The +two namespaces are disjoint, so both subsystems can share the same CMD +byte. The MCU's parser dispatches on tag IDs within the `02 02 55` +payload, applying changes selectively per tag. + +This explains why an ESPHome component that already implements +`setAutoMode*` writes against `CMD 02 02 55` can add a bulk-preferences +SET against the same CMD byte without conflict — the local-tag +namespaces never overlap. + +--- + +## 5. SET tag ↔ status TLV mapping + +The 12 local tags in the bulk write correspond to the 12 status TLVs +that the MCU pushes for the four cluster subsystems. The mapping is +monotonic: **`set_tag = status_tlv − 0x14`**, with field lengths +matching exactly. + +| SET tag | Length | Status TLV | Field | +|---------|---------|------------|-----------------------------| +| `0x04` | 1 B | `0x18` | sleep_type (gate; see §4.4) | +| `0x05` | 1 B | `0x19` | quick_clean_enabled | +| `0x06` | LE16 | `0x1A` | quick_clean_minutes | +| `0x07` | 1 B | `0x1B` | quick_clean_fan | +| `0x08` | 1 B | `0x1C` | white_noise_enabled | +| `0x09` | LE16 | `0x1D` | white_noise_minutes | +| `0x0A` | 1 B | `0x1E` | white_noise_fan | +| `0x0B` | 1 B | `0x1F` | sleep_fan | +| `0x0C` | LE16 | `0x20` | sleep_minutes | +| `0x0D` | 1 B | `0x21` | daytime_enabled | +| `0x0E` | 1 B | `0x22` | daytime_fan_mode | +| `0x0F` | 1 B | `0x23` | daytime_fan_level | + +Cluster grouping (purely organisational, not enforced by the +protocol): + +* Sleep: `0x04` (type) + `0x0B` (fan) + `0x0C` (minutes) +* Quick-clean: `0x05` (enabled) + `0x07` (fan) + `0x06` (minutes) +* White-noise: `0x08` (enabled) + `0x0A` (fan) + `0x09` (minutes) +* Daytime: `0x0D` (enabled) + `0x0E` (mode) + `0x0F` (level) + +Within each cluster, the field order is `enabled / fan / minutes` for +the three audio-and-air clusters and `enabled / mode / level` for the +daytime cluster. + +--- + +## 6. CMD `02 05 55` (key `0x5505`) — analyzed, orthogonal to preferences + +This CMD was a candidate for the sleep-preferences SET before the +bulk-write at key `0x5502` was identified (§4). It is documented here +so a future contributor can recognise the channel without re-walking +the analysis. + +For the gap-analysis context — how this CMD fits into the broader +inventory of MCU commands the component does not yet use — see +[`MCU_2.0.0_baseline.md` §5.2](./MCU_2.0.0_baseline.md#52-cmd-02-05-55--orthogonal-channel). + +### 6.1 Site A — function `0x4200726e`, dispatcher call at `0x420072b0` + +Builds a single-TLV payload then dispatches: + +``` +; --- prologue --- +4200726e: c.addi16sp sp, -80 +42007274: c.swsp a0, 12(sp) ; sp+12 = caller's a0 byte + +; --- klv_pack (only one call): writes {0x01, 0x01, byte} --- +4200728e: c.addi4spn a4, sp, 12 ; value_ptr = sp+12 (the byte) +42007290: c.li a3, 1 ; len = 1 +42007292: c.li a2, 1 ; tag = 0x01 +42007294: addi a1, zero, 40 +42007298: c.addi4spn a0, sp, 24 +4200729a: jal 0x4200b568 + +; --- dispatcher --- +420072a2: c.lui a0, 0x5 +420072ac: addi a0, a0, 1285 # 0x5505 ; CMD 02 05 55 +420072b0: jal 0x42006e46 +``` + +Wire bytes: `A5 22 07 00 02 05 55 00 01 01 `. + +### 6.2 Site B — function `0x42007354`, dispatcher call at `0x4200738c` + +Builds a `{tag=0x03, len=0}` empty-value TLV — the conventional shape +of a query / "report this field" message in this firmware family: + +``` +; --- klv_pack (only one call): writes {0x03, 0x00} — NO value --- +4200736a: c.li a4, 0 ; value_ptr = NULL +4200736c: c.li a3, 0 ; len = 0 +4200736e: c.li a2, 3 ; tag = 0x03 +42007374: c.addi4spn a0, sp, 8 +42007376: jal 0x4200b568 + +420737e: c.lui a0, 0x5 +42007388: addi a0, a0, 1285 # 0x5505 ; CMD 02 05 55 +4200738c: jal 0x42006e46 +``` + +Wire bytes: `A5 22 06 00 02 05 55 00 03 00`. + +### 6.3 Empirical behavior + +Frames matching the site-A shape (single-byte write at local tag +`0x01`) sent on-device produce a clean `0x12` ACK from the MCU but no +change to any status TLV — including the 12 preference TLVs at +`0x18..0x23` and the surrounding diagnostic TLVs at `0x16/0x17`. The +MCU recognises the CMD byte and the frame, but the data is consumed by +a subsystem that does not surface state in the status push. + +### 6.4 Hypothesis (based on call-site context, unverified) + +The site-A function `0x4200726e` is invoked from two upstream callers +at PCs `0x420010ec` and `0x42001432`. Both callers: + +* Increment a counter at DRAM `0x3fc9d7a4` immediately before the call. +* Memcpy a 20-byte struct in the same basic block. +* Live in a compilation unit whose DROM string-pool region contains + the literals `Read air filter info fail`, `filter dust percent`, + `filter use time = %d`, `Create filter timer fail!!!`, and + `filter algorithm version = 0x%04x`. + +The "increment counter → memcpy struct → emit single-byte report" +pattern matches a periodic filter-usage report channel, and the +surrounding filter-related string constants reinforce that +association. However: no caller of `0x4200726e` was traced to +completion, and the 20-byte struct contents were not extracted. The +"filter monitor channel" association is therefore a hypothesis based +on circumstantial call-site context, not on inspecting the data that +the site actually writes. Independent verification (e.g. capturing +this CMD's emission during a filter-life event on the stock firmware) +remains open. + +--- + +## 7. Reproducing the analysis + +### 7.1 Tools used + +* `esptool image-info` — segment layout and entry-point +* `strings -n 5` — DROM symbol extraction +* `riscv32-esp-elf-objdump -D -b binary -m riscv:rv32 -M no-aliases + --adjust-vma=0x42000020 /tmp/stock_irom.bin` — IROM disassembly + (RV32 with no-aliases option suppresses pseudo-instruction + expansion, making compiler-emitted patterns easier to grep) + +### 7.2 ESP image segment layout + +| Segment | Type | Vaddr | File offset | Length | +|---------|------------------|--------------|-------------|-----------| +| 0 | DROM (`.rodata`) | `0x3C0F0020` | `0x10018` | `0x21288` | +| 1 | DRAM | `0x3FC95E00` | `0x312A8` | `0x03178` | +| 2 | IRAM | `0x40380000` | `0x34428` | `0x0BBE8` | +| 3 | IROM (`.text`) | `0x42000020` | `0x40018` | `0xE2CA0` | +| 4 | IRAM | `0x4038BBE8` | `0x122CC0` | `0x0A1C4` | + +Vaddrs match ESP32-C3 memory layout (DROM at `0x3C0x_xxxx`, DRAM at +`0x3FCx_xxxx`, IRAM at `0x4038_xxxx`, IROM at `0x42xx_xxxx`). The +RISC-V architecture confirms ESP32-C3 specifically (not the older +Xtensa-based ESP32). + +### 7.3 Why byte-pattern search alone is unreliable + +A literal grep for `02 XX 55` byte triples in the image is **not** a +reliable signal for identifying CMD-byte usage. The compiler emits +the CMD-bytes constants via paired `c.lui` + `addi` instructions +(loading a 12-bit immediate into the upper half of `a0`, then adding +the lower half) rather than storing them as literal byte triples. +Of all 32 possible `02 XX 55` triples in the image: + +* `02 04 55` (display) and `02 11 55` (light detect) never appear as + contiguous byte triples at all. +* `02 02 55` (auto pref) appears 2× — both in non-code data. + +To locate a CMD usage, search the disassembly for the corresponding +`0x55XX` immediate following an `addi a0, a0, ` after a +`c.lui a0, 0x5`, and walk backward through the call site to identify +the function entry. + +### 7.4 Caveat: dispatcher call-site count is not a reliable identifier + +Matching by dispatcher call-site count alone is unreliable. The +bulk-prefs SET at key `0x5502` has a single call site (`0x420075a6`), +whereas key `0x5505` has two (`0x4200726e` + `0x42007354`). A +superficial multiplicity heuristic — assuming a "set" handler should +appear at the same frequency as related "set" / "get" pairs elsewhere +in the dispatcher — would have favored the wrong key. Cross-reference +`klv_pack` signatures (§2.2) and string-pool symbols (§8.3) rather +than relying on call-site counts. + +--- + +## 8. Address reference + +### 8.1 Function entries + +| PC | Function | +|--------------|----------------------------------------------------------------| +| `0x42006e46` | UART send dispatcher (key in `a0`) | +| `0x4200b568` | `klv_pack` TLV serializer | +| `0x42007512` | auto-pref multi-TLV builder (key `0x5502`, tags 0x02+0x03) | +| `0x420075a6` | **bulk-preferences SET (key `0x5502`, tags 0x04..0x0F)** | +| `0x4200726e` | `0x5505` site A — single-byte write at local tag `0x01` | +| `0x42007354` | `0x5505` site B — empty-value query at local tag `0x03` | +| `0x42021024` | heap-alloc trampoline (tail-calls to `0x40390e92` in IRAM) | +| `0x420113ca` | message-queue poster (downstream of dispatcher) | +| `0x40390e92` | heap allocator implementation (IRAM) | + +### 8.2 Dispatcher call sites by key + +(All keys reached via the `0x42006e46` dispatcher; repeated from §3.3 +for navigation convenience.) + +| Key | Call-site PCs | +|----------|--------------------------------------------------------| +| `0x5502` | `0x420070b2`, `0x42007574`, `0x42007722` | +| `0x5503` | `0x42007120` | +| `0x5504` | `0x42007198` | +| `0x5511` | `0x42007796` | +| `0x5140` | `0x42007322` | +| `0x5141` | `0x420073f2` | +| `0x5104` | `0x4200745a` | +| `0x5105` | `0x420074e4` | +| `0x5107` | `0x420077d0` | +| `0x5501` | `0x42007818` | +| `0x5505` | `0x420072ac` (site A), `0x42007388` (site B) | +| `0x550a` | `0x4200793a` | + +### 8.3 DROM string-pool symbols + +Identified via `strings -n 5` on segment 0. The function entries +backing the named symbols were **not** all pinpointed; the symbols +themselves are reference markers in the data segment. + +| Symbol | File offset | Vaddr | +|-----------------------------------------|-------------|--------------| +| `sleepPreferenceType` | 72,904 | `0x3C0F1CD0` | +| `duringSleepSpeedLevel` | 73,480 | `0x3C0F1F10` | +| `duringSleepMinutes` | 73,572 | `0x3C0F1F6C` | +| `purifier_bypass_set_sleep_preference` | 74,308 | `0x3C0F224C` | +| `setSleepPreference` | 74,524 | `0x3C0F2324` | +| `alterSleepModePreference` | 76,136 | `0x3C0F2970` | +| `enterSleepMode` | 76,180 | `0x3C0F299C` | +| `purifier_uart_set_sleep_preference` | 79,120 | `0x3C0F3518` | +| `insleep_minutes` | 81,600 | `0x3C0F3EC8` | +| `insleep_fanlevel` | 81,632 | `0x3C0F3EE8` | +| `alterAutoModePreference` | 76,100 | `0x3C0F294C` | +| `setAutoPreference` | 74,440 | `0x3C0F22D0` | +| `purifier_uart_set_auto_preference` | 78,840 | `0x3C0F3400` | + +GCC's `-fmerge-strings` optimisation pools shorter strings as suffixes +of longer ones in this image, so multiple `ESP_LOG` sites can +legitimately reference mid-string offsets that happen to land inside +larger symbols used elsewhere. Symbol presence alone does not identify +the function — the byte-store sequence at each call site is the +authoritative signal. + +--- + +## 9. Open uncertainties + +* The dispatcher (`0x42006e46`) downstream of its queue post was not + fully traced. The 24-byte routing struct it produces is consumed by + a different task, and the wire-frame assembly logic on that side + was not disassembled. Frames produced by the ESPHome component's + `build_levoit_message()` helper are byte-identical to what the stock + firmware emits for every + identified key (verified empirically against fan / auto / display / + light-detect / child-lock writes), which strongly suggests the wire + shape is fully described by the key + payload pair; but the + downstream task is not reverse-engineered here. +* The "single shared store" conclusion for the bulk-prefs storage + model (§4.5) is based on observable status push state across + consecutive writes, not on locating and reading the underlying RAM + data structure. An exhaustive scan of MCU RAM around the time of + preset switches could surface per-preset slots that aren't reflected + in the status push; this has not been done. +* CMD `02 05 55` (key `0x5505`, §6) is documented as orthogonal to the + preference subsystem but the actual subsystem it serves is a + hypothesis based on call-site context (§6.4). Confirming it via + capture during a stock-firmware filter-life event would close this + open question. +* Keys `0x5104`, `0x5105`, `0x5107`, `0x5144`, `0x5501`, `0x550a` + are observed in the dispatcher table but the functions that + populate their payloads were not traced. Each represents a small + amount of additional reverse-engineering work for a contributor + interested in mapping unused MCU features. + +--- + +## Footer + +| Item | Value | +|---------------------------------|--------------------------------------------------------| +| Stock ESP firmware | `VS_WFON_APR_LAP-V201S-AEUR_OFL_EU` v1.3.0-rc1 | +| Stock firmware compiled | 2025-03-25 | +| Stock firmware ESP-IDF | dc489539 | +| SoC architecture | ESP32-C3 (RISC-V RV32, single-core) | +| MCU firmware verified against | 2.0.0 | +| Protocol verification date | 2026-05-20 | +| Compiler version | not directly observable from the image |