Skip to content
Merged
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
14 changes: 11 additions & 3 deletions components/levoit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -533,9 +533,17 @@ Auto mode options per model:
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
* **Display now works across MCU firmware versions.** Bytes 6 and 7 are a
pair — one carries the brightness (`0x00`/`0x64`) and the other a transient
flag that trips for a few seconds after any command — and **they swap
between firmware versions**: 2.0.11 puts brightness in byte 6, 4.0.34 on
the Core 200S Plus puts it in byte 7. The decoder matches on the value
`0x64` across both bytes instead of a fixed position, so either layout
works without detecting the version
* **A panel filter reset is recognised on all firmwares.** The MCU's push
carries payload `0x01` on 2.0.11 but `0x00` on 4.0.34, so the component now
keys on the message direction instead of the payload
* Confirmed unchanged: child lock at byte 10, nightlight at byte 11
* **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
Expand Down
19 changes: 15 additions & 4 deletions components/levoit/core_status.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,30 @@ namespace esphome
return;
// 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)
// [6] or [7] display brightness, 0x00 or 0x64, written by 01 05 A1
// (display_on_off.txt). WHICH of the two holds it depends on the
// MCU firmware - 2.0.11 puts brightness in [6], while 4.0.34 on
// the Core 200S Plus puts it in [7] (issue #60). The other byte of
// the pair is a transient flag that goes 0x01 for a few seconds
// after any command, which is why both appeared to move together
// in a short capture. Matching on the value 0x64 rather than on a
// position handles both layouts without sniffing the version.
// [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
// [8] and [9] never change within a unit (0x00 on 2.0.11, [8]=0x01 on
// 4.0.34)
// 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;
// setDisplayOn sends 0x64 and setDisplayOff sends 0x00, so the brightness
// byte is the one holding 0x64. Reading [6] as a plain bool reported the
// display ON for a few seconds after every command on 4.0.34, where [6] is
// the transient flag.
bool display_on = (payload[6] == 0x64) || (payload[7] == 0x64);
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;
Expand Down
20 changes: 13 additions & 7 deletions components/levoit/decoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,21 @@ 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
// Core200S: the MCU pushes 01 E4 A5 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.
// travels the other way with payload 0x00 as the reset command.
//
// The payload is NOT a reliable marker: MCU 2.0.11 pushes 0x01, while
// 4.0.34 on the Core 200S Plus pushes 0x00 (issue #60). Any inbound 0x22
// of this type is MCU-originated - our own reset goes out as 0x22 and
// comes back as a 0x12 response - so the direction alone identifies it.
//
// 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)
ptype0 == 0xE4 && ptype1 == 0xA5)
{
ESP_LOGI(TAG_DEC, "Filter reset from the unit's button (Core200S)");
self->set_used_cadr(0);
Expand Down
58 changes: 55 additions & 3 deletions devices/levoit-core200s/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,36 @@ sent against the status frame that followed it in the same capture.
| 3 | Power | `00` / `01` | `01 00 A0` in `startup.txt` |
| 4 | Fan mode | `00` Manual, `01` Sleep (no Auto on the 200S) | `01` seen in `filter_from_device.txt` |
| 5 | Fan speed | `00`–`03` | `01 60 A2` in `startup.txt` |
| 6 | **Display brightness** | `00` off, `64` on | `01 05 A1` in `display_on_off.txt` |
| 7 | unresolved | `00` / `01` | see below |
| 8 | unused | `00` in every frame of every capture | — |
| 6 / 7 | **Display brightness** | `00` off, `64` on | `01 05 A1` in `display_on_off.txt` |
| 7 / 6 | Transient flag | `01` for a few seconds after any command | [#60](https://github.com/tuct/levoit/issues/60) |
| 8 | unused | constant per unit (`00` on 2.0.11, `01` on 4.0.34) | — |
| 9 | unused | `00` in every frame of every capture | — |

#### The display pair swaps between firmware versions

Bytes 6 and 7 are a pair — one holds the **display brightness** (`0x00` / `0x64`,
exactly what `01 05 A1` writes) and the other is a **transient flag** that goes
`0x01` for a few seconds after any command. Which is which **depends on the MCU
firmware**:

| MCU FW | Unit | Brightness | Transient |
|--------|------|-----------|-----------|
| 2.0.11 | Core 200S | byte **6** | byte 7 |
| 4.0.34 | Core 200S Plus (`lap-c201s-musw`) | byte **7** | byte 6 |

```
display on, 2.0.11 : 0B 00 02 01 00 03 | 64 01 | 00 00 01 00
display on, 4.0.34 : 22 00 04 01 00 03 | 01 64 | 01 00 00 00
display off, both : .. .. .. 01 00 03 | 00 00 | .. .. .. ..
```

The component therefore matches on the **value** `0x64` across both bytes
rather than on a position, which handles either layout without having to detect
the firmware version.

The transient flag is also why a short capture is misleading: toggling the
display sets the brightness byte *and* trips the flag in the same frame, so the
two look locked together until you watch for a few seconds longer.
| 10 | **Child lock** | `00` / `01` | `01 00 D1` in `childlock.txt` |
| 11 | **Night light** | `00` off, `32` mid, `64` full | `01 03 A0` in `nightlight.txt` |

Expand Down Expand Up @@ -147,6 +173,26 @@ happened.
| `01 E2 A5` | `00` | Named "filter LED off" in the component; only this payload was ever sent, always shortly after boot, so the name is unverified |
| `01 E4 A5` | `00` | Reset the MCU filter counter, sent when the filter was reset in the app. The MCU sends the same type back with `01` — see below |

### Reported symptoms, and which fix covers them

From [#60](https://github.com/tuct/levoit/issues/60), across several units and
firmware versions — Core 200S, Core 200S-P and Core 200S Plus, MCU 1.1.23,
2.0.11 and 4.0.34:

| Symptom | Cause |
|---------|-------|
| **Filter life stuck at exactly 100%**, for months | Per-minute CADR accumulation truncated to zero at low speeds |
| **A "downward staircase"** instead of the smooth slope a Core 400S shows | Same truncation: nothing accrues on speed 1, whole units jump on speeds 2–3 |
| **Filter life reads 0.0% or 1.0%**, flipping with the display | The 1.5.0 pre-release published byte 6 as a filter percentage. On 4.0.34 byte 6 is the transient flag, so the sensor reported its `0`/`1` |
| **Filter Low shows "Problem"** on a new filter | Follows from the 0.0% above |
| **Panel filter reset does nothing in HA** | The inbound `01 E4 A5` push was not handled |
| **HA's Reset Filter button does not clear the unit's red light** | There was no `resetFilter` implementation for Core models at all |

A useful independent check from that thread: **Current CADR** read
125 / 83 / 41 m³/h at speeds 3 / 2 / 1. That is `167 × speed ÷ 4` — confirming
both the wrong 4-speed divisor on a 3-speed unit, and that speed 1's 41 m³/h
divided by 60 truncates to zero.

### The MCU pushes a filter reset (`01 E4 A5`)

`01 E4 A5` travels **both ways**, and the reverse direction is the more
Expand All @@ -158,6 +204,12 @@ button on the unit rather than the app:
12.761 module -> MCU typ=52 01 E4 A5 plain ack
```

**The payload is not a reliable marker.** MCU 2.0.11 pushes `01`, while 4.0.34
pushes `00` ([#60](https://github.com/tuct/levoit/issues/60)). Direction alone
identifies the event: our own reset leaves as a `0x22` and the MCU answers with
a `0x12`, so any **inbound** `0x22` of this type came from the unit's button.
The component keys on that rather than on the payload.

So the MCU reports the event as a normal `0x22` request that the module
acknowledges like any other. ESPHome handles this: a panel filter reset clears
the ESP-side CADR counters too, so the sensor follows the button on the unit.
Expand Down
Loading