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.