Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions components/levoit/binary_sensor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
TYPE_MAP = {
"filter_low": BinarySensorType.FILTER_LOW,
"cover_open": BinarySensorType.COVER_OPEN,
"dark_detected": BinarySensorType.DARK_DETECTED,
}

TYPE_PROPS = {
Expand All @@ -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(
Expand Down
84 changes: 82 additions & 2 deletions components/levoit/levoit.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
{
Expand Down
50 changes: 43 additions & 7 deletions components/levoit/levoit.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;};
Expand All @@ -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};
Expand Down Expand Up @@ -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; }
Expand Down
30 changes: 30 additions & 0 deletions components/levoit/number/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions components/levoit/select/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 24 additions & 5 deletions components/levoit/select/levoit_select.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"});
Expand All @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions components/levoit/switch/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
10 changes: 9 additions & 1 deletion components/levoit/switch/levoit_switch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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_)
{
Expand Down
Loading