Skip to content

fix(levoit): map the Core 200S status frame from captures, revert the filter-life guess - #71

Open
tuct wants to merge 4 commits into
mainfrom
fix/core200s-status-byte-map
Open

tuct wants to merge 4 commits into
mainfrom
fix/core200s-status-byte-map

Conversation

@tuct

@tuct tuct commented Sep 27, 2026

Copy link
Copy Markdown
Owner

What this fixes

The 1.5.0 pre-release read Core 200S status byte 6 as an MCU filter-life percentage and moved the Display switch to byte 7. Both were wrong, and the combination left the Core 200S filter sensor reporting a hard-coded 100% forever — worse than the bug it was meant to fix.

Byte 6 reads 0x64 in every frame of every capture. That looked exactly like a fresh filter at 100%, and the stock app did show 100% at the time. It is the display brightness, with the display on.

How it was settled

New stock-firmware captures, each matching a command the app sent against the status frame it produced:

Capture Command Result
display_on_off.txt 01 05 A1 00/64 bytes 6 and 7 move together, immediately
childlock.txt 01 00 D1 00/01 byte 10, 1:1
nightlight.txt 01 03 A0 00/32/64 byte 11, 1:1

And the decisive one: filter_99.txt was captured while the stock app displayed 99%, covering a full module boot including the module's own 01 61 40 status query. Byte 6 still came back 0x64. 0x63 appears nowhere in the file, and no message in any capture carries a filter percentage or a runtime counter.

So the Core 200S filter counter lives in the stock Wi-Fi module and the cloud, not the MCU. The ESP-side CADR estimate is authoritative again, as on every other Core model.

Ten of the twelve payload bytes are now confirmed by capture; bytes 8 and 9 are 0x00 in every frame of every file.

Also: a real defect, probably the original report

calculate_current_cadr_per_hour() used a 4-speed divisor for the Core 200S, which has 3 speeds — only the Core 300S was special-cased, though levoit_fan.cpp sets speed_count_ = 3 for both. Every level accumulated three quarters of the air it should have, so the filter percentage decayed 25% too slowly. This is the most likely cause of the reports that Core 200S filter life "does not work".

Kept

The MCU filter reset (01 E4 A5) is genuinely captured from the stock firmware and stays. It no longer gates on filter_life_from_mcu(), which is removed; has_mcu_filter_reset() replaces it and the button sends the command in addition to clearing the ESP counters.

Documentation

devices/levoit-core200s/README.md gains a full protocol section: capture index, frame layout, the byte map with the evidence for each row, the observed-payload-only command table, and a written-up account of why the byte 6 inference was wrong — so it is not repeated. The command table deliberately separates payloads that were observed from meanings inherited from the component's existing tables; assuming the unseen half is exactly how this went wrong.

Two loose threads are recorded rather than papered over: the 0B payload on the MCU's reply to 01 E4 A5, and the off-state behaviour of bytes 6/7.

Testing

All three levoit compile tests pass locally (test-all-entities, test-fan-only, test-foreign-platforms). Not yet re-verified on hardware.

🤖 Generated with Claude Code

tuct and others added 4 commits September 27, 2026 22:15
… filter guess

The 1.5.0 pre-release read status byte 6 as an MCU filter-life percentage and
moved the Display switch to byte 7. Both were wrong. Byte 6 reads 0x64 in every
frame of every capture, which looked like a fresh filter at 100% but is simply
the display brightness with the display on.

New stock-firmware captures settle every remaining byte by matching a command
the app sent against the status frame it produced:

  display_on_off.txt  01 05 A1 00/64  -> bytes 6 and 7 move together
  childlock.txt       01 00 D1 00/01  -> byte 10, 1:1
  nightlight.txt      01 03 A0 00/32/64 -> byte 11, 1:1

And filter_99.txt, captured while the stock app displayed 99% and covering a
full module boot with its own 01 61 40 status query, still reports byte 6 as
0x64. The value 0x63 appears nowhere in the file and no message in any capture
carries a filter percentage or a runtime counter, so the Core 200S filter
counter lives in the stock Wi-Fi module and the cloud, not in the MCU. The
ESP-side CADR estimate is authoritative again, as on the other Core models.

Also fixes a real defect found while checking that estimate: the CADR
calculation used a 4-speed divisor for the Core 200S, which has 3 speeds, so
every level accumulated three quarters of the air it should have and the filter
percentage decayed 25% too slowly. That is the likely cause of the reports that
Core 200S filter life does not work.

Kept: the MCU filter reset (01 E4 A5), which is genuinely captured from the
stock firmware. It no longer gates on filter_life_from_mcu(), which is gone;
the button sends it in addition to clearing the ESP counters.

The full byte map, the capture index and the reasoning are written up in
devices/levoit-core200s/README.md so the wrong inference is not repeated.

All three levoit compile tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e estimate

01 E4 A5 turns out to be bidirectional. A new capture with the filter reset
using the button on the unit rather than the app shows the MCU originating it:

  12.755  MCU -> module   typ=22   01 E4 A5   01
  12.761  module -> MCU   typ=52   01 E4 A5

So the MCU does push a filter event on change, in the same family as the reset
command - which was the hypothesis this capture was taken to test. It carries no
percentage, just the fact that a reset happened, so it does not change the
conclusion that the MCU never reports filter life.

The component now clears used_cadr and total_runtime on that push, the same way
it already did for the Superior 6000S panel reset, so the sensor follows the
button on the unit. Handled ahead of the payload-dedup cache, because two panel
resets in a row carry an identical payload and the second would be skipped.

Two corrections to the byte map from the same capture:

* Byte 4 is not always 00. It reads 01 here, which is the Sleep preset, so it is
  a real mode field.
* Byte 7 does not simply follow byte 6. In display_on_off.txt they moved
  together under 01 05 A1, but here byte 6 goes 00 -> 64 while byte 7 stays 00.
  Byte 6 is the byte the display command writes and keeps driving the Display
  switch; byte 7 is marked undecoded rather than claimed as display.

Compile tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
track_cadr_usage() converted CADR/h to CADR/min with `cadr_per_hour / 60` in
integer arithmetic. Any model under 60 m3/h at the current speed truncated to
zero and accumulated nothing at all:

  Core200S speed 1   167*1/3 = 55 m3/h  -> 0
  Core200S Sleep     55 * 0.63 = 34     -> 0
  Sprout   speed 1   145*1/4 = 36       -> 0
  Core400S speed 1   442*1/4 = 110      -> 1   (unaffected)

So a Core 200S left on low or Sleep never moved off 100%, which is what users
reported: six months of running with the sensor pinned at 100%. At higher
speeds the same truncation lost 28-46%, and because the loss is all-or-nothing
per speed the graph became a staircase - flat on speed 1, stepping down on
speed 2 and 3 - against the smooth slope a Core 400S shows on the same code.

The sub-m3 remainder is now carried between minutes, which is exact. It is not
persisted: a reboot loses under 1 m3 against a capacity of several hundred
thousand.

Also drops the `speed <= 4` gate around the accumulation. It silently excluded
the Superior 6000S's speeds 5-9, and it was redundant -
calculate_current_cadr_per_hour() already validates the speed against the
model's own maximum and returns 0 when it is out of range.

Compile tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the red light

The MCU does track filter life - it just never reports a number. Units running
ESPHome rather than the stock module still get the red filter light after a few
months, and ESPHome only drives that LED for Wi-Fi status during boot, stopping
the moment Wi-Fi connects. So the lamp is the MCU's own decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant