Skip to content

feat: add CAF-LI401S (Cosori Lite 3.8L) air fryer (after webdjoe's refactor) - #550

Open
glombek wants to merge 7 commits into
webdjoe:air-fryer-refactorfrom
glombek:feat/li401s-on-503
Open

glombek wants to merge 7 commits into
webdjoe:air-fryer-refactorfrom
glombek:feat/li401s-on-503

Conversation

@glombek

@glombek glombek commented Sep 22, 2026 •

Copy link
Copy Markdown

Adds the Cosori Lite 3.8L (CAF-LI401S, EU) to the air fryer refactor.

No new device class — the model speaks the same bypassV2 protocol as the
TurboBlaze (getAirfryerStatus / startCook / endCook, cook parameters
carried in stepArray[stepIndex]), so VeSyncTurboBlazeFryer drives it as-is.
The change is one AirFryerMap entry, one new cook mode member and tests.

I based this on #503 rather than #537, since the refactor here already
generalises everything that PR implements by hand.

Everything below was read off a physical unit

I own a CAF-LI401S and took every value in this entry from it rather than
inferring from the other fryers. That mattered — my first draft copied the Dual
Blaze modes and was wrong in several ways.

Cook modes. Staging each preset button on the unit and reading back
stepArray gives these mode strings:

Button API mode
(none selected) Custom
Chicken Chicken
Fries Fries
Bacon Bacon
Steak Steak
Veggies Veggies
Keep warm Warm

Only the mode strings are recorded. The temperature, time and shake time behind
each preset are user-editable in the VeSync app, so what a given device reports
is that owner's configuration rather than a device default — not something worth
baking into the map.

Points worth flagging:

  • the fries preset reports Fries, not the FrenchFries string the Dual
    Blaze uses
  • Bacon had no AirFryerCookModes member, so this adds one
  • this model exposes no AirFry, Bake, Roast, Seafood, Frozen or Reheat mode
  • every program reports recipeId 1, preset or manual — the unit does not
    vary the recipe ID the way the Dual Blaze does

Default preset. AirFryerPresets.air_fry (mode AirFry) is not usable
here, as that mode does not exist on the unit. AirFryerPresets.custom is used
instead: its mode, recipe ID and recipe type match what the device reports, and
its target_temp / cook_time / temp_unit never reach the wire — callers
overwrite them via prepare_temperature() and convert_time_for_api(), and the
request takes tempUnit from the device's live unit.

Fries vs FrenchFries. Handled through the per-device cook_modes map
rather than a new enum member: AirFryerCookModes.FRENCH_FRIES points at
'Fries' here and stays 'FrenchFries' for the Dual Blaze. Only Bacon needed
a new member, having none at all.

Worth flagging that I cannot tell whether that split is per model or
regional
. The unit I read is an EU CAF-LI401S. The Dual Blaze entries both use
'FrenchFries', but the CAF-P583S-KEU entry looks derived from the -KUS one
rather than independently confirmed — it switches temp_unit to Celsius while
keeping temperature_step_f=5 — so it isn't independent evidence for the EU
side. If the alias turns out to follow region rather than model, the other
entries' mode strings would need the same scrutiny. Anyone with a US
CAF-LI401S, or a hardware-confirmed EU Dual Blaze, could settle it quickly; I
have only the one device and did not want to generalise from it.

Temperature range. Both temperature_range_c and temperature_range_f are
populated as the other entries do, using the same rounded bounds as #537 rather
than exact conversions of one another. min_temp / max_temp resolve from the
pair against the live temp_unit, so this stays correct whichever unit the
device reports. My unit reports "c" with no user-facing setting to change it,
so the unit looks to follow account region; temp_unit in the map is only the
pre-poll default.

Cook status. standby, cooking and heating are confirmed from the
device. The rest of status_map is carried over from the other bypassV2 fryers
and marked in a comment as unobserved, rather than presented as verified.

Live cook

Parsed state during a cook I started at the appliance:

class : VeSyncTurboBlazeFryer

cook_status    : cooking      cook_mode      : Steak
cook_set_temp  : 75           cook_set_time  : 900
cook_last_time : 681          current_temp   : 134
is_cooking     : True         temp_unit      : c

current_temp tracked the chamber through the heat-up (30 → 125 → 150 °C) and
the countdown decremented correctly. end() also works:

BEFORE: cook_status=cooking is_running=True  remaining=490
end() returned: True
AFTER : cook_status=standby is_running=False remaining=None

The fryer physically stopped, which I confirmed at the unit.

One thing for you to weigh in on

1. Preheat reports an empty stepArray, so it reads as standby.

While preheating, the unit returns no cook step at all:

preheat staged   stepArray: []  preheatSetTime: 240  preheatTemp: 205  cookStatus: standby
preheat running  stepArray: []  preheatLastTime: 235 cookStatus: heating
                 stepArray: []  preheatLastTime: 229 currentTemp: 45

VeSyncTurboBlazeFryer.get_details() returns early on not resp_model.stepArray
and calls set_standby(), so an actively heating fryer is reported as standby
and preheatSetTime / preheatTemp are discarded.

I have deliberately not claimed AirFryerFeatures.PREHEAT in this entry,
since the class cannot currently surface it, and I have not changed
get_details() because that is shared behaviour and I can only test one of the
three devices it serves. Happy to send a fix if you tell me the shape you want —
the obvious one is to fall through to a preheat branch when stepArray is empty
but preheatSetTime is set.

Tests

Added per the contributing guide, so the new device is covered rather than just
registered:

  • standby and cooking fixtures in call_json_fryers.py, shaped from my device's
    real getAirfryerStatus payload (including the hasPro / linkageStatus
    keys it returns)
  • parametrised CAF-LI401S.update.standby and .cooking cases in
    test_fryers.py
  • recorded request YAML at src/tests/api/vesynckitchen/CAF-LI401S.yaml, written
    with pytest --write_api

Checks

Run in a venv created with pip install -e .[dev]:

Check Result
pytest 400 passed, 3 skipped
pylint src/pyvesync 10.00/10
ruff check src/pyvesync all checks passed
mypy src/pyvesync 2 errors, both pre-existing on this branch (vesyncthermostat.py)
flake8 src/pyvesync 1 error, pre-existing on this branch (fryer_models.py:42 E501)

The mypy and flake8 findings are identical with and without my change; I left
them alone as they're out of scope here.

glombek and others added 7 commits September 22, 2026 14:03
The CAF-LI401S uses the same bypassV2 protocol as the TurboBlaze
(getAirfryerStatus / startCook / endCook, with cook parameters carried in
stepArray[stepIndex]), so VeSyncTurboBlazeFryer drives it without a new
device class. Adds a single AirFryerMap entry.

Verified against physical hardware: discovery, live cook state parsing
(status, mode, set temperature, countdown, chamber temperature) and end().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds standby and cooking fixtures, recorded bypassV2 API request YAML and
parametrised detail tests for the CAF-LI401S, per the contributing guide's
requirement that each new device include tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The initial entry copied its cook modes and default preset from the Dual
Blaze, which does not match this model. Read the real values off a physical
unit by staging each preset button and reading stepArray:

  Custom  175 C / 15 min      Chicken 210 C / 13 min
  Fries   200 C / 17 min      Bacon   160 C / 12 min
  Steak   160 C / 15 min      Veggies 200 C /  8 min
  Warm     75 C /  5 min

Corrections:
- the fries preset reports 'Fries', not 'FrenchFries'
- adds Bacon, which had no AirFryerCookModes member
- adds Warm; drops AirFry, Bake, Roast, Seafood, Frozen and Reheat, none of
  which the unit exposes
- default_preset was AirFryerPresets.air_fry (400 F AirFry), which this
  Celsius-only model cannot accept; adds a Celsius manual preset instead
- records that 'standby', 'cooking' and 'heating' are observed, and that
  preheat reports an empty stepArray

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Celsius and Fahrenheit ranges are sensible rounded bounds rather than
exact conversions of each other, matching the values used in webdjoe#537. The
temp_unit in the map is only the pre-poll default; the unit the device
reports appears to follow account region rather than a user setting, so a
US unit of this model may report 'f'.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops the device-specific manual preset. AirFryerPresets.custom already
carries the mode, recipe ID and recipe type this unit reports, and its
target_temp/cook_time/temp_unit are never sent: every caller overwrites
them via prepare_temperature() and convert_time_for_api(), and the request
takes tempUnit from the device's live unit.

const.py is now a single added enum member for the Bacon cook mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The EU unit read here reports 'Fries' where the Dual Blaze entries use
'FrenchFries'. The EU Dual Blaze entry appears derived from the US one
rather than confirmed against hardware, so it is not independent evidence,
and per-model versus regional cannot be distinguished from one device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-preset temperature, time and shake time read from the test device
are user-editable in the VeSync app, so they describe that owner's
configuration rather than the model. Only the mode strings, which are
fixed API identifiers, are recorded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@glombek glombek changed the title feat: add CAF-LI401S (Cosori Lite 3.8L) air fryer feat: add CAF-LI401S (Cosori Lite 3.8L) air fryer (after webdjoe's refactor) Sep 22, 2026
@webdjoe

webdjoe commented Nov 1, 2026

Copy link
Copy Markdown
Owner

@glombek Thank you for the hard work on this PR. Can you send the full packet captures? I made some updates to the refactor branch that should support most of this API structure. Please reach out to me on discord with the packet captures: homefire1608

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants