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
65 changes: 43 additions & 22 deletions components/levoit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,15 +447,16 @@ The vent angle is set as a number entity (45° = nearly closed/upward, 90° = fu

| Feature | Type | Config Key | Description |
|---------|----|------------|-------------|
| Filter Lifetime | number | `filter_lifetime_months` | Expected filter lifespan in months (1–12); used to compute Filter Life % on models other than Core200S |
| Filter Life Left | sensor | `filter_life_left` | Remaining filter life as %; MCU-reported on Core200S, component-computed on other models ⁽¹⁾ |
| Filter Lifetime | number | `filter_lifetime_months` | Expected filter lifespan in months (1–12); used to compute Filter Life % |
| Filter Life Left | sensor | `filter_life_left` | Remaining filter life as %, computed by the component from accumulated CADR |
| Filter Low | binary_sensor | `filter_low` | `on` when Filter Life % drops below 5% ⁽¹⁾ |
| Current CADR | sensor | `current_cadr` | Calculated Clean Air Delivery Rate at current fan speed in m³/h ⁽¹⁾ |
| Reset Filter Stats | button | `reset_filter_stats` | Reset cumulative CADR and runtime counters, or send the MCU filter reset on Core200S — restores Filter Life % to 100% ⁽¹⁾ |
| Reset Filter Stats | button | `reset_filter_stats` | Reset cumulative CADR and runtime counters — restores Filter Life % to 100%. On Core200S it additionally sends the MCU its own filter reset |

> ⁽¹⁾ On Core200S, `filter_life_left` mirrors the MCU counter and `reset_filter_stats`
> sends the MCU's filter reset command. On other models, these values are
> computed by the component.
> On every model the filter percentage is computed by the component from
> accumulated CADR — no Levoit MCU reports it. The Core200S MCU keeps a
> counter of its own that the stock app resets, so the button clears that as
> well, but it is not what feeds the sensor.

#### Auto Mode

Expand Down Expand Up @@ -505,22 +506,42 @@ Auto mode options per model:

#### ESP Version: 1.5.0 - 2026.09.26

* **Core 200S: filter life now comes from the MCU.** The MCU keeps its own
filter counter and reports it in the status frame; the component was
ignoring it and showing an ESP-side estimate derived from `used_cadr`, which
sat at 100% and never moved. `filter_life_left` (and `filter_life_mcu`, if
configured) now publish the real value, and the ESP estimate is suppressed on
this model so it cannot overwrite it. Other models are unchanged
* **Fixes the Display switch reading permanently ON** on the Core 200S — the
same root cause. The status payload puts filter life at byte 6 and display
at byte 7, matching the Core 400S layout, but the Core 200S was decoded with
the Core 300S layout, so a filter value of `100` was read as "display on"
* **Filter Reset now resets the filter**, not just the CADR counters: the
button sends the MCU's own reset command (`01 E4 A5`), captured from the
stock firmware. Previously there was no core implementation of `resetFilter`
at all
* Decoded from UART captures in
[`devices/levoit-core200s/uart`](../../devices/levoit-core200s/uart)
* **Core 200S: the status frame is now fully decoded**, byte by byte, against
stock-firmware UART captures in
[`devices/levoit-core200s/uart`](../../devices/levoit-core200s/uart). Each
mapping below was confirmed by matching a command sent by the stock app
against the status frame it produced — see the
[byte map](../../devices/levoit-core200s/README.md#status-frame-byte-map)
* **Fixes the filter life estimate never moving off 100%.** The per-minute
accumulation did `cadr_per_hour / 60` in integer arithmetic, which
truncates to **zero** for any model under 60 m³/h at the current speed — a
Core 200S on speed 1 (55 m³/h) or in Sleep (34 m³/h), and a Sprout on
speed 1 (36 m³/h), accumulated nothing at all, so the sensor sat at exactly
100% indefinitely. Higher speeds lost 28–46% to the same truncation. The
remainder is now carried between minutes
* **Fixes the estimate decaying 25% too slowly on top of that.** The CADR
calculation used a 4-speed divisor for the Core 200S, which has 3 speeds,
so every level accumulated only three quarters of the air it should have
* Fixes the Superior 6000S losing **speeds 5–9** entirely: the accumulator was
gated on `speed <= 4`, while its fan has 9
* **Filter Reset now also resets the MCU's own counter** (`01 E4 A5`,
captured from the stock firmware). Previously there was no core
implementation of `resetFilter` at all. The sensor itself is still driven by
the ESP-side estimate
* **A filter reset done with the button on the unit now resets the sensor
too.** `01 E4 A5` turns out to be bidirectional: the MCU pushes it back with
payload `0x01` when the panel button is used, and the component now clears
its CADR counters on that. Handled ahead of the payload-dedup cache, so two
panel resets in a row both take effect
* Confirmed unchanged: display at byte 6 (brightness, `0x00`/`0x64`), child
lock at byte 10, nightlight at byte 11. Bytes 8 and 9 are `0x00` in every
frame of every capture
* **Filter life is not on the wire.** A capture taken while the stock app
displayed 99% reports byte 6 as `0x64` (100), and no message in any capture
carries 99 or a runtime counter — the Core 200S filter percentage is kept
by the stock Wi-Fi module and the cloud, not the MCU. An earlier 1.5.0
pre-release read byte 6 as MCU filter life and moved display to byte 7;
both were wrong and are reverted here
* Fix `filter_low` device class — it is a `problem` binary sensor, not a
`battery` one, so a low filter now shows as a problem in Home Assistant

Expand Down
8 changes: 4 additions & 4 deletions components/levoit/button/levoit_button.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ namespace esphome
this->parent_->publish_filter_stats_now();

ESP_LOGI(TAG, "Reset filter stats: used_cadr=0, total_runtime=0");
if (this->parent_->filter_life_from_mcu())
if (this->parent_->has_mcu_filter_reset())
{
// Core200S: the filter percentage is counted by the MCU, so clearing
// the ESP-side counters alone would leave the sensor untouched. Send
// the MCU its own reset as well; the next status frame reports 100%.
// Core200S: the MCU keeps a filter counter of its own that the
// stock app resets with 01 E4 A5, so clear that too. It is not
// what feeds the sensor - the ESP-side estimate above is.
this->parent_->sendCommand(CommandType::resetFilter);
ESP_LOGI(TAG, "Sent MCU filter reset");
}
Expand Down
34 changes: 14 additions & 20 deletions components/levoit/core_status.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,31 +77,25 @@ namespace esphome
{
if (payload_len < 12)
return;
// payload[6] is the MCU's filter life percent, not a display flag: it reads
// 0x64 (100) on a fresh filter, matching what the stock app shows. Display
// sits at payload[7], as on the Core400S - the Core200S does not follow the
// Core300S layout here. Reading [6] as a bool pinned Display permanently on.
uint8_t filter_life_pct = payload[6];
bool display_on = payload[7] != 0;
// Byte map verified command-by-command against the captures in
// devices/levoit-core200s/uart/ (stock firmware, both directions):
// [6] display brightness, 0x00 or 0x64, and [7] display on/off - both
// move together in the same frame as 01 05 A1 (display_on_off.txt)
// [10] child lock, 1:1 with 01 00 D1 (childlock.txt)
// [11] nightlight, 0x00/0x32/0x64, 1:1 with 01 03 A0 (nightlight.txt)
// [8] and [9] are 0x00 in every frame of every capture
// Filter life is NOT in this frame. A capture taken while the stock app
// showed 99% still reports [6] = 0x64 (filter_99.txt), and no message in
// any capture carries 99 or a runtime counter - the Core200S filter
// percentage is kept by the stock Wi-Fi module, not the MCU. So the
// ESP-side CADR estimate is what feeds FILTER_LIFE_LEFT, as on the
// other Core models.
uint8_t fan_speed = payload[5];
bool display_on = payload[6] != 0;
bool child_lock = payload[10] != 0; // Display Lock = child lock
uint8_t nightlight_raw = payload[11]; // 0x00=off, 0x32=mid, 0x64=full
uint8_t nightlight_idx = (nightlight_raw == 0x32) ? 1 : (nightlight_raw == 0x64) ? 2 : 0;

// The MCU keeps this counter itself (SC95F8617) and the ESP cannot reset it,
// so it is published straight through rather than estimated from used_cadr.
if (filter_life_pct <= 100)
{
self->publish_sensor(SensorType::FILTER_LIFE_LEFT, filter_life_pct);
self->publish_sensor(SensorType::FILTER_LIFE_MCU, filter_life_pct);
self->publish_binary_sensor(BinarySensorType::FILTER_LOW, filter_life_pct < 5);
}
else
{
ESP_LOGW(TAG_CORE, "Core200S filter life byte out of range (%u), ignoring",
(unsigned) filter_life_pct);
}

self->publish_switch(SwitchType::DISPLAY, display_on);
self->publish_switch(SwitchType::CHILD_LOCK, child_lock);
self->publish_select(SelectType::NIGHTLIGHT, nightlight_idx);
Expand Down
16 changes: 16 additions & 0 deletions components/levoit/decoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ namespace esphome
self->ackMessage(ptype0, ptype1);
}

// Core200S: the MCU pushes 01 E4 A5 with payload 0x01 when the filter is
// reset with the button on the unit itself (captured in
// devices/levoit-core200s/uart/filter_from_device.txt). The same message
// travels the other way with payload 0x00 as the reset command. Handled
// ahead of the dedup gate below, because two panel resets in a row carry
// an identical payload and the second would otherwise be skipped.
if (model == ModelType::CORE200S && msg_type == 0x22 &&
ptype0 == 0xE4 && ptype1 == 0xA5 &&
payload_len >= 1 && payload[0] != 0x00)
{
ESP_LOGI(TAG_DEC, "Filter reset from the unit's button (Core200S)");
self->set_used_cadr(0);
self->set_total_runtime(0);
self->publish_filter_stats_now();
}

uint8_t h = compute_payload_hash_(payload, payload_len);
ESP_LOGD("levoit.dedup", "model=%d ptype=%02X%02X payload_len=%u hash=0x%02X last=0x%02X",
(int)model, ptype0, ptype1, (unsigned)payload_len, h,
Expand Down
42 changes: 25 additions & 17 deletions components/levoit/levoit.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -357,11 +357,6 @@ namespace esphome

void Levoit::publish_filter_stats_now()
{
// On models where the MCU reports filter life, leave the sensor alone -
// the status decoder publishes the real value. Resetting the ESP-side
// CADR counters still happens, it just does not move this sensor.
if (this->filter_life_from_mcu())
return;
float filter_left = this->calculate_filter_life_left_percent();
#ifdef USE_LEVOIT_SENSOR
auto *se = this->sensors_[st_idx_(SensorType::FILTER_LIFE_LEFT)];
Expand Down Expand Up @@ -849,17 +844,28 @@ namespace esphome
// Fan is enabled - track usage
total_runtime_++;

// Get fan speed level (1-4) from .speed member
// calculate_current_cadr_per_hour() validates the speed against the
// model's own maximum and returns 0 if it is out of range, so no bound
// is needed here. The old `speed <= 4` check silently dropped the
// Superior 6000S's speeds 5-9.
int speed = this->fan_->speed;
if (speed > 0 && speed <= 4) {
// Use helper to compute current CADR/hour, then convert to per-minute
uint32_t cadr_per_hour = this->calculate_current_cadr_per_hour();
uint32_t cadr_per_min = cadr_per_hour / 60;
uint32_t cadr_per_hour = this->calculate_current_cadr_per_hour();
if (cadr_per_hour > 0) {
// Carry the sub-m³ remainder between minutes. Dividing by 60
// directly truncated to zero for any model whose per-hour CADR at
// the current speed was under 60 m³/h - a Core200S on speed 1
// (55 m³/h) or in Sleep (34 m³/h), or a Sprout on speed 1
// (36 m³/h), accumulated nothing at all and the filter estimate
// stayed at exactly 100% forever. Higher speeds lost 28-46% to the
// same truncation.
cadr_remainder_ += cadr_per_hour;
uint32_t cadr_per_min = cadr_remainder_ / 60;
cadr_remainder_ %= 60;
used_cadr_ += cadr_per_min;
ESP_LOGD(TAG, "CADR tracked: +%u m³ (speed=%d, total=%u m³, runtime=%u min)",
(unsigned)cadr_per_min, speed, (unsigned)used_cadr_,
ESP_LOGD(TAG, "CADR tracked: +%u m³ (speed=%d, %u m³/h, carry=%u/60, total=%u m³, runtime=%u min)",
(unsigned)cadr_per_min, speed, (unsigned)cadr_per_hour,
(unsigned)cadr_remainder_, (unsigned)used_cadr_,
(unsigned)total_runtime_);

}

// Publish filter life left (once per minute here)
Expand Down Expand Up @@ -895,8 +901,11 @@ namespace esphome
return 0;
}

// Determine max speed based on model (Core300S has 3 speeds)
uint32_t max_speed = (this->model_ == ModelType::CORE300S) ? 3u
// Determine max speed based on model. Core200S and Core300S have 3
// speeds - using 4 here undercounted their CADR by 25% at every level,
// so the filter estimate decayed a quarter too slowly.
uint32_t max_speed = (this->model_ == ModelType::CORE200S ||
this->model_ == ModelType::CORE300S) ? 3u
: (this->model_ == ModelType::SUPERIOR6000S) ? 9u
: 4u;
if (speed <= 0 || (uint32_t)speed > max_speed)
Expand Down Expand Up @@ -1038,8 +1047,7 @@ namespace esphome
this->publish_sensor(SensorType::CURRENT_CADR, current_cadr_hour);
}

// Every 10 seconds: publish filter life left. publish_filter_stats_now()
// skips models where the MCU owns the value (Core200S).
// Every 10 seconds: publish filter life left.
if (now - last_filter_check >= 10000)
{
last_filter_check = now;
Expand Down
12 changes: 9 additions & 3 deletions components/levoit/levoit.h
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ class Levoit : public Component, public uart::UARTDevice {
void track_cadr_usage(); // Track CADR and runtime based on fan state
uint32_t calculate_current_cadr_per_hour() const; // Compute current CADR/h based on speed and model
float calculate_filter_life_left_percent() const; // Remaining filter life percentage (0-100 with decimals)
// True where the MCU reports filter life itself, so the ESP-side estimate
// from used_cadr must not be published over it. Core200S only so far.
bool filter_life_from_mcu() const { return this->model_ == ModelType::CORE200S; }
// True where the MCU accepts a filter-counter reset of its own, in addition
// to the ESP-side CADR counters. The Core200S does (01 E4 A5, captured from
// the stock firmware); it does not report filter life back, so the ESP
// estimate stays authoritative for the sensor.
bool has_mcu_filter_reset() const { return this->model_ == ModelType::CORE200S; }
void sendCommand(CommandType commandType); // if CommandType exists in your project
void ackMessage(uint8_t ptype0, uint8_t ptype1);
void ackFilterReset(uint8_t ptype0, uint8_t ptype1); // Superior 6000S: filter reset pressed on the panel
Expand Down Expand Up @@ -228,6 +230,10 @@ class Levoit : public Component, public uart::UARTDevice {

// Internal tracked values (persisted in preferences)
uint32_t used_cadr_{0};
// Sub-m³ carry for the per-minute CADR accumulation, always < 60. Deliberately
// not persisted: a reboot loses under 1 m³ against a filter capacity in the
// hundreds of thousands.
uint32_t cadr_remainder_{0};
uint32_t total_runtime_{0};
ESPPreferenceObject pref_used_cadr_;
ESPPreferenceObject pref_total_runtime_;
Expand Down
Loading
Loading