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/levoit.cpp b/components/levoit/levoit.cpp index 46c9979..7233d5b 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; + // 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; sw->publish_state(state); @@ -166,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) { @@ -227,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: @@ -277,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: @@ -333,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 35a8409..c3309af 100644 --- a/components/levoit/number/__init__.py +++ b/components/levoit/number/__init__.py @@ -26,8 +26,13 @@ "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, + "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 +75,31 @@ 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, + }), + "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 db76ab6..598378b 100644 --- a/components/levoit/select/__init__.py +++ b/components/levoit/select/__init__.py @@ -15,13 +15,16 @@ 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, + "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..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"}); @@ -46,6 +54,17 @@ 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, 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/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_) { diff --git a/components/levoit/types.h b/components/levoit/types.h index 5b7a891..b05efc0 100644 --- a/components/levoit/types.h +++ b/components/levoit/types.h @@ -52,6 +52,9 @@ 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) + 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) @@ -67,6 +70,9 @@ 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; + 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 { @@ -94,9 +100,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, @@ -127,23 +135,30 @@ 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) + 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; - 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; + static constexpr SelectType SLEEP_PREFERENCE = SelectType::SLEEP_PREFERENCE; @@ -197,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 @@ -248,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 8f3b787..a43a317 100644 --- a/components/levoit/vital_status.cpp +++ b/components/levoit/vital_status.cpp @@ -270,49 +270,84 @@ 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); + 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, "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); + self->update_bulk_pref(0x1F, 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); + 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 c8427be..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 @@ -68,6 +76,26 @@ 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 + - 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 @@ -92,6 +120,14 @@ select: levoit: levoitvital200 name: "Auto Mode" type: auto_mode + - platform: levoit + 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 @@ -130,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-c3.yaml b/devices/levoit-vital200s/levoit-vital200s-builder-c3.yaml index 14ebffb..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 @@ -92,6 +100,26 @@ 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 + - 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 @@ -116,6 +144,14 @@ select: levoit: levoitvital200 name: "Auto Mode" type: auto_mode + - platform: levoit + 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 @@ -154,3 +190,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..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 @@ -92,6 +100,26 @@ 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 + - 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 @@ -116,6 +144,14 @@ select: levoit: levoitvital200 name: "Auto Mode" type: auto_mode + - platform: levoit + 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 @@ -154,3 +190,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..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 @@ -87,6 +95,26 @@ 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 + - 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 @@ -111,6 +139,14 @@ select: levoit: levoitvital200 name: "Auto Mode" type: auto_mode + - platform: levoit + 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 @@ -149,3 +185,7 @@ binary_sensor: levoit: levoitvital200 name: "Filter Low" type: filter_low + - platform: levoit + levoit: levoitvital200 + name: "Dark Detected" + type: dark_detected 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 |