Skip to content

Add Vital 200S Pro support for MCU FW 2.0.0 with bulk-prefs SET#32

Open
TheDave94 wants to merge 4 commits into
tuct:mainfrom
TheDave94:feat/vital200s-pro-mcu-2.0.0
Open

Add Vital 200S Pro support for MCU FW 2.0.0 with bulk-prefs SET#32
TheDave94 wants to merge 4 commits into
tuct:mainfrom
TheDave94:feat/vital200s-pro-mcu-2.0.0

Conversation

@TheDave94
Copy link
Copy Markdown

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.

Cluster Entity TLV SET tag
Sleep select.…_sleep_mode_type 0x18 0x04
Sleep number.…_sleep_mode_minutes 0x20 0x0C
Sleep number.…_sleep_fan_level_5_auto 0x1F 0x0B
Quick-clean switch.…_quick_clean_preset 0x19 0x05
Quick-clean number.…_quick_clean_minutes 0x1A 0x06
Quick-clean number.…_quick_clean_fan_level 0x1B 0x07
Daytime switch.…_daytime_preset 0x21 0x0D
Daytime select.…_daytime_fan_mode 0x22 0x0E
Daytime number.…_daytime_fan_level 0x23 0x0F

Plus one read-only addition: binary_sensor.…_dark_detected (TLV 0x17).

How writes work

All 12 preference TLVs share a single SET command (CMD 02 02 55, local tags 0x04..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 (TLVs 0x1C/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 to 0x00 ("Default"), the MCU silently drops writes to tags 0x05..0x0F. The component exposes sleep_mode_type as 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. See docs/MCU_2.0.0_baseline.md §4 and docs/STOCK_FIRMWARE_FINDINGS.md §4 for full protocol details.

Commits

  1. 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_minutes as decode-only entities. Builds the YAML stanzas across all three builder variants.

  2. 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.

  3. Implement bulk-prefs SET for Vital 200S Pro preference clusters — the actual feature. Adds the setBulkPrefs command builder, the 12-TLV cache (bulk_prefs_ in Levoit), the update_bulk_pref(tlv_id, value) decoder hook, and 6 new entities. Repurposes the previously-dead SelectType::DAYTIME_FAN_MODE_LEVEL slot to DAYTIME_FAN_MODE for the TLV 0x22 enum.

  4. Vital 200S Pro: protocol documentation — adds docs/MCU_2.0.0_baseline.md and docs/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:

  • All 9 entities read and write correctly end-to-end through HA service calls
  • Gate behavior verified: with sleep_type = 0x00, writes to all 11 non-gate TLVs are dropped by the MCU as expected; with sleep_type ≠ 0x00, all 12 TLVs apply
  • Cache populates correctly via the boot-time status push (see docs/MCU_2.0.0_baseline.md §6 for the ESP32-C3 FIFO retention mechanism that makes this reliable)
  • Restored to clean baseline (see docs/MCU_2.0.0_baseline.md §8 appendix) after testing

Relationship 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.

AutoCoder 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.
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