Add Vital 200S Pro support for MCU FW 2.0.0 with bulk-prefs SET#32
Open
TheDave94 wants to merge 4 commits into
Open
Add Vital 200S Pro support for MCU FW 2.0.0 with bulk-prefs SET#32TheDave94 wants to merge 4 commits into
TheDave94 wants to merge 4 commits into
Conversation
added 4 commits
May 20, 2026 14:01
The Vital 200S Pro (LAP-V201S-AEUR) running MCU firmware 2.0.0 emits four TLVs in its status push that the existing Vital component parses but does not yet surface to Home Assistant. This commit exposes them as read-only entities: - TLV 0x17 — ambient-light "dark detected" reading. New BinarySensorType DARK_DETECTED + binary_sensor type "dark_detected". - TLV 0x18 — sleep-preference type byte. New SelectType SLEEP_PREFERENCE + select type "sleep_preference". Options list contains only "Default" at this stage (the only value observed in baseline captures); a follow-up commit broadens the options and makes the entity writable. - TLV 0x1F — sleep fan level (1-4 explicit, 5 = device-default "auto" sentinel). New NumberType SLEEP_FAN_LEVEL + number type "sleep_fan_level". - TLV 0x20 — sleep duration in minutes, LE16 on the wire. New NumberType SLEEP_MODE_MIN + number type "sleep_minutes" (alias of the existing sleep_mode_min mapping). Adds the corresponding entity stanzas to devices/levoit-vital200s/ common.yaml and the three builder yamls (builder, builder-c3, builder-s3) so the entities appear on every flashing variant. All four entities are read-only in this commit — they reflect MCU state via the existing status decoder but do not initiate writes. A separate commit implements the bulk-preferences SET command that makes the three sleep entities writable.
Switch::publish_state in core ESPHome sets the public `state` field and fires callbacks but does not flip has_state_ on EntityBase. Select::publish_state and Number::publish_state both call set_has_state(true). The asymmetry breaks any caller that uses `if (entity->has_state())` to decide whether the entity carries a fresh user-supplied value worth preferring over a cached default — the check works for select / number but always returns false for switch, even after the user has just toggled it and even after the decoder published the device's reported value. Two paths fixed: LevoitSwitch::write_state — explicit set_has_state(true) after publish_state(state). Handles the user-toggle path (HA service → Switch::turn_on → LevoitSwitch::write_state → on_switch_command). Levoit::publish_switch — set_has_state(true) hoisted above the dedup early-return `if (sw->state == state)`. A decoder publish whose value matches the entity's default (e.g. first decoded false against default sw->state=false) would otherwise skip publish_state entirely and leave has_state_ at false for the rest of the session. No behavior change for callers that don't use has_state(): the public `state` field, callbacks, and ControllerRegistry notifications work exactly as before.
Adds writable Home Assistant entities for the sleep / quick-clean /
daytime preference clusters on the Vital 200S Pro. All edits route
through a single 12-TLV bulk-write command (CMD 02 02 55, local
tags 0x04..0x0F) discovered by reading the stock-firmware disassembly
and verified on-device against MCU FW 2.0.0.
New CommandType: setBulkPrefs. The builder in vital_commands.cpp
composes a 12-TLV payload from a per-cluster cache that the status
decoder maintains. For each user edit, the entity that changed
supplies the new value; the other 11 fields come from the cache.
New entities (six new + three existing made writable):
Sleep cluster — three entities already exposed read-only by the
earlier Vital 200S Pro foundation commit, now made writable:
select.…_sleep_mode_type (TLV 0x18, SET tag 0x04)
number.…_sleep_mode_minutes (TLV 0x20, SET tag 0x0C)
number.…_sleep_fan_level_5_auto (TLV 0x1F, SET tag 0x0B)
Quick-clean cluster — three new entities:
switch.…_quick_clean_preset (TLV 0x19, SET tag 0x05)
number.…_quick_clean_minutes (TLV 0x1A, SET tag 0x06)
number.…_quick_clean_fan_level (TLV 0x1B, SET tag 0x07)
Daytime cluster — three new entities:
switch.…_daytime_preset (TLV 0x21, SET tag 0x0D)
select.…_daytime_fan_mode (TLV 0x22, SET tag 0x0E)
number.…_daytime_fan_level (TLV 0x23, SET tag 0x0F)
The white-noise cluster (TLVs 0x1C/0x1D/0x1E, SET tags 0x08/0x09/0x0A)
is cache-only on the Vital 200S Pro — the device has no white-noise
hardware. The bulk write still includes those three TLVs because the
MCU's parser requires all 12 in every frame; values are echoed back
from cache unchanged. A future Sprout component port that supports
white-noise hardware can add writable entities for those three fields
using the same SET path.
Tag 0x04 (sleep_type) acts as a gate: when set to 0x00 (Default),
the MCU silently drops writes to tags 0x05..0x0F. The component
surfaces this constraint by exposing sleep_mode_type as a writable
select with three options (Default / Custom1 / Custom2); users must
select a non-Default value before other cluster fields apply. This
matches the stock VeSync app's UX, where the Sleep Preset screen
requires selecting a Custom slot before allowing other fields to be
edited.
Other changes folded into this commit:
- Expands the SLEEP_PREFERENCE select options from {"Default"} to
{"Default", "Custom1", "Custom2"}, matching the empirically-observed
byte values for TLV 0x18.
- Adds a 12-entry BulkPrefsCache to Levoit, populated by the status
decoder via the new update_bulk_pref(tlv_id, value) method. The
cache is filled by the first status-push decode at boot — see the
follow-up documentation commit for the ESP32-C3 hardware UART FIFO
retention mechanism that makes this reliable.
- Repurposes the previously-dead SelectType slot 5 from
DAYTIME_FAN_MODE_LEVEL (whose options mixed modes with levels) to
DAYTIME_FAN_MODE (TLV 0x22 fan-mode enum); the old name was
misleading and had no YAML stanza referencing it.
- Adds NumberType slots 15 and 16 for QUICK_CLEAN_FAN_LEVEL and
DAYTIME_FAN_LEVEL respectively; bumps `numbers_[]` capacity to
accommodate the new max index.
- Reduces the device YAML logger level from VERBOSE to DEBUG. The
default-component logger emits a runtime warning that VERBOSE has
measurable CPU overhead on ESP32-C3 and recommends DEBUG for
long-term use; DEBUG preserves the steady-state decoder diagnostics
while suppressing the per-byte frame-parser noise.
YAML stanzas for the six new entities are added to common.yaml and
the three builder configurations (builder, builder-c3, builder-s3).
Adds two reference documents to docs/ capturing the MCU 2.0.0
protocol behavior and the stock-firmware analysis that established
the bulk-preferences SET command. Both are self-contained and
cross-reference each other for navigation.
docs/MCU_2.0.0_baseline.md — the canonical reference for what this
MCU revision emits on the wire and what it accepts in reply. Covers:
- Status push: cadence, frame format, complete 32-TLV inventory
with field semantics, lengths, and decoder behavior; TLVs the
model does not emit (Sprout-only); discrepancies vs LEVOIT_UART.md
for the TLVs whose wire length differs from the upstream spec.
- ESP → MCU command shapes: the cardinal "SET-payload tag namespace
is local to each CMD byte" fact; eight observed command scenarios
with frame bytes and resulting status-push TLV changes; the
subsystem-byte mapping table.
- Bulk-preferences SET (CMD 02 02 55 tags 0x04..0x0F): overview,
SET-tag → status-TLV mapping with the monotonic rule, tag 0x04
gate behavior, storage model, and the two design options for
client UX (auto-bump vs surface-the-constraint) with the
rationale for the choice this component made.
- Unmapped CMD bytes: dispatcher-key inventory with suspected
purposes; the CMD 02 05 55 channel that was investigated and
ruled out as a sleep-prefs candidate; the stock-firmware filter
functionality (filter dust %, usage hours, lifetime) that
doesn't yet have component coverage.
- ESPHome × MCU FIFO retention: documents the ESP32-C3 hardware
UART FIFO mechanism that makes the first MCU status push at
boot get decoded before the API client connects, explains why
the API-streamed log appears to never see a successful decode,
and provides a diagnostic snippet to confirm.
- Gap analysis: per-gap status (CLOSED / OPEN / NOT IMPLEMENTED /
NOT APPLICABLE) for MCU functionality not yet surfaced as
entities.
- Appendix: restore-to-baseline value sequence for the 12
bulk-pref TLVs.
docs/STOCK_FIRMWARE_FINDINGS.md — read-only analysis of the saved
stock-ESP-firmware flash dump. Covers:
- UART frame structure and the klv_pack helper signature and
length encoding.
- The UART send dispatcher at 0x42006e46, its calling convention,
key encoding rule (cmd[2]<<8 | cmd[1]), and complete identified
key → CMD mapping table with call-site PCs.
- The bulk-preferences SET function at 0x420075a6, with the 12
klv_pack calls table, annotated wire-frame example, tag 0x04
gate behavior, storage model, and the explanation of why
setAutoMode* and bulk-prefs coexist on the same CMD byte
(disjoint local-tag namespaces).
- SET-tag → status-TLV mapping (monotonic rule, cluster grouping).
- CMD 02 05 55 (key 0x5505) analysis as an orthogonal channel,
with site A / site B disassembly and a circumstantial filter-
monitor hypothesis labelled "Hypothesis (based on call-site
context, unverified)".
- Reproducing-the-analysis section with the tools, segment
layout, command-line invocations, and a caveat that byte-
pattern search alone is unreliable for identifying CMD usages
(the compiler emits c.lui + addi rather than literal byte
triples).
- Address reference tables: function entries, dispatcher
call-site PCs by key, DROM string-pool symbols.
- Open uncertainties: the dispatcher's downstream queue
behavior, the single-store conclusion's reliance on observable
status-push state rather than RAM inspection, and the
unmapped-CMD-byte items remaining.
No code change. The protocol behavior described here matches what
the preceding implementation commits do.
TheDave94
pushed a commit
to TheDave94/levoit
that referenced
this pull request
May 20, 2026
…guide
User-facing rename of the Vital 200S Pro preset entities to clearer
names, plus icon assignments, clarified select option labels, and a
new user-facing USAGE.md guide.
BREAKING CHANGE: entity_ids change. HA automations / scripts /
dashboards that reference the old entity_ids must be updated:
Old entity_id New entity_id
-------------------------------------------- ----------------------------------------
select.…_sleep_mode_type select.…_sleep_preset
number.…_sleep_mode_minutes number.…_sleep_timer
number.…_sleep_fan_level_5_auto number.…_sleep_fan_speed
switch.…_quick_clean_preset switch.…_quick_clean
number.…_quick_clean_minutes number.…_quick_clean_duration
number.…_quick_clean_fan_level number.…_quick_clean_fan_speed
switch.…_daytime_preset switch.…_daytime_mode
number.…_daytime_fan_level number.…_daytime_fan_speed
binary_sensor.…_dark_detected binary_sensor.…_ambient_darkness
(daytime_fan_mode entity_id unchanged — already clear.)
After flashing, HA will show the old entity_ids as "unavailable" in
the Settings → Devices & Services → Entities page; they can be
deleted from the entity registry to clean up.
Friendly-name changes:
"Sleep Mode Type" → "Sleep Preset"
"Sleep Mode Minutes" → "Sleep Timer"
"Sleep Fan Level (5=auto)" → "Sleep Fan Speed"
"Quick Clean Preset" → "Quick Clean"
"Quick Clean Minutes" → "Quick Clean Duration"
"Quick Clean Fan Level" → "Quick Clean Fan Speed"
"Daytime Preset" → "Daytime Mode"
"Daytime Fan Level" → "Daytime Fan Speed"
"Dark Detected" → "Ambient Darkness"
Lock behavior (sleep_preset select gates editability of all other
preset-cluster fields) and the 1-4/5=auto sentinel on sleep_fan_speed
are documented in devices/levoit-vital200s/USAGE.md rather than as
inline hints in friendly names. ESPHome does not support decoupling
HA entity_id from friendly_name — entity_id is computed directly from
name via sanitize(snake_case(name)). Verified in ESPHome 2026.4.5,
in the dev branch, and on the public roadmap (backlog#85): no
has_entity_name or translation_key field, no open PR adding one,
and the post-2026.7 plan only changes where the name → object_id
computation happens (client-side instead of device-side) — not its
rules. Putting hints in friendly names
would pollute entity_ids; putting them in USAGE.md keeps entity_ids
stable across any future descriptive-text edits.
Select option labels clarified:
sleep_preset: {"Default", "Custom1", "Custom2"}
→ {"Default (locks settings)", "Custom A", "Custom B"}
daytime_fan_mode: {"Manual", "Sleep", "Auto", "Unknown3", "Unknown4", "Pet"}
→ {"Manual", "Sleep", "Auto", "Reserved 3", "Reserved 4", "Pet"}
"Default (locks settings)" surfaces the gate semantics (selecting
Default blocks edits to other sleep / QC / DT cluster fields). The
option strings are part of the select's static enumeration, not
derived into the entity_id, so the parenthetical here is safe.
"Reserved 3" / "Reserved 4" replaces "Unknown3" / "Unknown4" as a
clearer placeholder for byte values the protocol accepts but whose
behavior is unverified.
Icons added for visual disambiguation in the HA UI:
sleep_preset → mdi:weather-night
sleep_timer → mdi:timer-outline (was mdi:sleep)
sleep_fan_speed → mdi:fan (kept)
quick_clean → mdi:broom
quick_clean_duration → mdi:timer-outline (was mdi:broom; broom moves to the switch)
quick_clean_fan_speed → mdi:fan (kept)
daytime_mode → mdi:weather-sunny
daytime_fan_mode → mdi:fan-auto
daytime_fan_speed → mdi:fan (kept)
ambient_darkness → mdi:theme-light-dark (was mdi:weather-night)
New user-facing guide at devices/levoit-vital200s/USAGE.md, scoped
to end-user concepts (not protocol jargon). Covers the three preset
clusters, the shared edit gate behavior, the ambient-darkness sensor
use cases, and a brief explanation of why white-noise controls are
absent on this model.
Internal C++ enum names are NOT renamed (NumberType::SLEEP_FAN_LEVEL,
SelectType::SLEEP_PREFERENCE, etc.). The YAML-to-enum mapping in
component/levoit/{number,select,switch,binary_sensor}/__init__.py
uses the new YAML keys pointing to the existing enum values. This
keeps the persistence-stable enum indices intact and limits the
churn to user-visible surfaces only.
docs/MCU_2.0.0_baseline.md TLV inventory table updated to reference
the new entity names. docs/STOCK_FIRMWARE_FINDINGS.md required no
changes (its remaining references are to stock-firmware symbol
names and protocol-field names, not HA entity names).
The standalone PR tuct#32 branch (feat/vital200s-pro-mcu-2.0.0) on
this fork is unaffected; this rename refactor is intentionally
local-only until any further upstream-PR work decides how to handle
the breaking-change implications.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds Vital 200S Pro support for MCU firmware 2.0.0, including the bulk-preferences SET command that makes the sleep / quick-clean / daytime preset clusters writable from Home Assistant.
Scope
New entities (9): 3 sleep cluster entities made writable, plus 6 new entities across the quick-clean and daytime clusters.
select.…_sleep_mode_type0x180x04number.…_sleep_mode_minutes0x200x0Cnumber.…_sleep_fan_level_5_auto0x1F0x0Bswitch.…_quick_clean_preset0x190x05number.…_quick_clean_minutes0x1A0x06number.…_quick_clean_fan_level0x1B0x07switch.…_daytime_preset0x210x0Dselect.…_daytime_fan_mode0x220x0Enumber.…_daytime_fan_level0x230x0FPlus one read-only addition:
binary_sensor.…_dark_detected(TLV0x17).How writes work
All 12 preference TLVs share a single SET command (CMD
02 02 55, local tags0x04..0x0F) discovered by reading the stock firmware disassembly. Every edit fires one 12-TLV bulk write. The edited entity supplies its new value; the other 11 fields come from a cache populated by the status decoder. White-noise cluster fields (TLVs0x1C/0x1D/0x1E) are cache-only — the Vital 200S Pro has no white-noise hardware, but the MCU's parser requires all 12 in every frame; the values are echoed back unchanged.Tag
0x04(sleep_type) acts as a gate: when set to0x00("Default"), the MCU silently drops writes to tags0x05..0x0F. The component exposessleep_mode_typeas a writable select with three options (Default / Custom1 / Custom2), matching the stock VeSync app's Sleep Preset UX where the user must pick a Custom slot before editing other cluster fields. Seedocs/MCU_2.0.0_baseline.md§4 anddocs/STOCK_FIRMWARE_FINDINGS.md§4 for full protocol details.Commits
Add Vital 200S Pro support for MCU FW 2.0.0 — read-only foundation. Exposes
dark_detected,sleep_mode_type,sleep_fan_level,sleep_mode_minutesas decode-only entities. Builds the YAML stanzas across all three builder variants.LevoitSwitch: set has_state on publish to match Select/Number behavior — independent bug fix in the existing LevoitSwitch (this is the same change submitted in LevoitSwitch: set has_state on publish to match Select/Number behavior #31 as a standalone PR; included here because commit 3 depends on it for the
get_switch()->has_state()pattern in the bulk-write builder). If LevoitSwitch: set has_state on publish to match Select/Number behavior #31 merges first, this commit will rebase cleanly to a no-op.Implement bulk-prefs SET for Vital 200S Pro preference clusters — the actual feature. Adds the
setBulkPrefscommand builder, the 12-TLV cache (bulk_prefs_inLevoit), theupdate_bulk_pref(tlv_id, value)decoder hook, and 6 new entities. Repurposes the previously-deadSelectType::DAYTIME_FAN_MODE_LEVELslot toDAYTIME_FAN_MODEfor the TLV0x22enum.Vital 200S Pro: protocol documentation — adds
docs/MCU_2.0.0_baseline.mdanddocs/STOCK_FIRMWARE_FINDINGS.md. Reference documents covering the MCU 2.0.0 wire behavior and the stock firmware analysis that established the SET command. No code change.Testing
Verified on a real Vital 200S Pro (LAP-V201S-AEUR) running MCU FW 2.0.0:
sleep_type = 0x00, writes to all 11 non-gate TLVs are dropped by the MCU as expected; withsleep_type ≠ 0x00, all 12 TLVs applydocs/MCU_2.0.0_baseline.md§6 for the ESP32-C3 FIFO retention mechanism that makes this reliable)docs/MCU_2.0.0_baseline.md§8 appendix) after testingRelationship to #31
The LevoitSwitch fix in commit 2 is the same change as #31 (which was submitted earlier today as a standalone PR for the general-purpose fix benefit). Two paths forward:
Either order works; I'm happy to coordinate however you prefer.
Note on documentation
The two doc files in commit 4 are substantial (~1200 lines combined). They were written specifically to make follow-on contributions easier — the bulk-prefs SET was non-obvious to derive and took significant disassembly time. If you'd prefer to land just the code without the docs, I can drop commit 4; the reference material would still live in my fork. Let me know.