From c89f9ca330b1f8dd2e266a7cef94f9ad81c7cd05 Mon Sep 17 00:00:00 2001 From: Tobias Stanzel Date: Sun, 25 Oct 2026 15:18:20 +0100 Subject: [PATCH] fix(levoit): Core 200S display and panel reset across MCU firmware versions Byte-level data from issue #60 on a Core 200S Plus (lap-c201s-musw, MCU 4.0.34) does not match what my captures showed on MCU 2.0.11. The version bytes decode to exactly the firmware the reporter names, so the indices line up and the difference is real: 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 | .. .. .. .. Bytes 6 and 7 are a pair - one holds the display brightness, the other a transient flag that goes 0x01 for a few seconds after any command - and the two swap between firmware versions. That also explains why they appeared locked together in my display capture: toggling the display writes the brightness and trips the flag in the same frame, and the capture ended before the flag expired. Reading display from a fixed byte 6 would have reported the display ON for a few seconds after every command on 4.0.34. The decoder now matches on the value 0x64 across both bytes, since that is exactly what 01 05 A1 writes, which handles either layout without sniffing the version. Same class of problem on the panel filter reset: the MCU's push carries payload 0x01 on 2.0.11 and 0x00 on 4.0.34, so my payload check would have missed it on the Plus - which is what the reporter saw, the component acking the message and publishing nothing. Direction alone identifies the event, because our own reset leaves as a 0x22 and the MCU answers 0x12, so any inbound 0x22 of this type is from the unit's button. Keyed on that instead. Also confirms the 0.0%/1.0% filter readings several people hit on 1.5.0: byte 6 on 4.0.34 is the transient flag, so publishing it as a percentage produced exactly 0 or 1. The revert in this branch removes that. The thread independently confirms both CADR fixes too - Current CADR read 125/83/41 m3/h at speeds 3/2/1, which is 167 x speed / 4, so a 3-speed unit was using a 4-speed divisor and speed 1's 41 m3/h truncated to zero per minute. Documented both layouts, the firmware mapping and a symptom-to-cause table in the device README. Compile tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- components/levoit/README.md | 14 ++++++-- components/levoit/core_status.cpp | 19 +++++++--- components/levoit/decoder.cpp | 20 +++++++---- devices/levoit-core200s/README.md | 58 +++++++++++++++++++++++++++++-- 4 files changed, 94 insertions(+), 17 deletions(-) diff --git a/components/levoit/README.md b/components/levoit/README.md index ab9399f..ce35f3e 100644 --- a/components/levoit/README.md +++ b/components/levoit/README.md @@ -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 diff --git a/components/levoit/core_status.cpp b/components/levoit/core_status.cpp index 2f27acc..ec7aa05 100644 --- a/components/levoit/core_status.cpp +++ b/components/levoit/core_status.cpp @@ -79,11 +79,18 @@ 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 @@ -91,7 +98,11 @@ namespace esphome // 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; diff --git a/components/levoit/decoder.cpp b/components/levoit/decoder.cpp index 797a7fa..071d802 100644 --- a/components/levoit/decoder.cpp +++ b/components/levoit/decoder.cpp @@ -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); diff --git a/devices/levoit-core200s/README.md b/devices/levoit-core200s/README.md index 0ec8272..2f6f155 100644 --- a/devices/levoit-core200s/README.md +++ b/devices/levoit-core200s/README.md @@ -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` | @@ -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 @@ -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.