Skip to content

VeSync Air Fryers↲

pyvesync supports these Cosori smart air fryers. All of them are in manager.devices.air_fryers after get_devices().

Model Class Chambers Preheat Pause / resume Unit before first update()
CS137-AF, CS158-AF (3.7 and 5.8 Qt.) VeSyncAirFryer158 1 ✔ ✔ °F
CAF-DC601S TurboBlaze 6 Qt. VeSyncTurboBlazeFryer 1 ✔ °F
CAF-P583S Dual Blaze 6.8 Qt. (KUS, KEU) VeSyncDualBlazeFryer 1 °F (KUS), °C (KEU)
CAF-TF101S Dual Air Fryer VeSyncDualAirFryer 1, 2, whole, both °C
CAF-DC111S-AEU Turbo Tower Pro VeSyncDualAirFryer 1 (upper), 2 (lower), both °C

The CAF-TF102S uses VeSyncDualAirFryer and the same chambers, without the Dry mode. Its mapping comes from the VeSync app and is pending hardware verification. The CAF-P583S KEU has no packet capture; it is assumed to match the KUS apart from its Celsius unit. The CAF-DC111S-AEU mapping comes from the VeSync app and the status captures supplied with PR #536; its cook and end requests are pending hardware verification.

Every fryer has set_mode(), set_mode_from_recipe(), end() and update(). stop() and resume() are implemented for the CS137/CS158 and the CAF-DC111S-AEU, cook_from_preheat() only for the CS137/CS158; on the other fryers they log a message and return False without sending a request. Air fryers have no on/off command and no turn_on() / turn_off(); the CS137/CS158 toggle_switch() is deprecated and sends no request.

Quick start↲

import asyncio
from pyvesync import VeSync


async def main():
    async with VeSync(username="EMAIL", password="PASSWORD") as manager:
        await manager.login()
        await manager.get_devices()

        fryer = manager.devices.air_fryers[0]
        await fryer.update()
        print(fryer.state.cook_status, fryer.temp_unit)

        # Cook for 15 minutes at 400 °F (converted on Celsius fryers)
        if await fryer.set_mode(15 * 60, 400, temp_unit='f'):
            print(fryer.state.cook_time_remaining)

        await fryer.end()


asyncio.run(main())

Every control method returns True when the device accepted the command and False otherwise. A rejected argument (unsupported chamber, cook mode, temperature, time or unit) returns False before any request is sent, with a log message saying why. The control methods and update() log problems rather than raising them.

A failed or unreadable update() (no response, an error code, an unknown cook status, an out-of-range step) logs a message and leaves the chamber state as it was, so a command afterwards still sees the last known phase. On the dual-chamber fryers (CAF-TF101S/TF102S, CAF-DC111S-AEU) both chambers take the connection status and update time from the response, and a chamber with an unknown status keeps its state. A reported temperature unit pyvesync does not recognise is logged and the current unit kept.

A command the fryer refuses returns False and leaves the state as it was; fryer.last_response names the reason. For example, the CAF-DC111S-AEU can refuse a Sync cook (chamber=BOTH) with BYPASS_SYNC_FINISH_FAIL_1 (open the other basket), BYPASS_SYNC_FINISH_FAIL_2 (reduce the cooking time of the current basket) or BYPASS_AVATAR_SYNC_FAIL (Sync cannot start while cooking).

Cooking↲

await fryer.set_mode(cook_time, cook_temp, *, temp_unit=None,
                     preheat_time=None, cook_mode=None, chamber=CHAMBER_1)
  • cook_time and preheat_time are in seconds, as whole numbers (a fraction is dropped, except on the fryers with per-mode time limits, which reject it). cook_time must be at least 1 second. The CS137/CS158 API counts in whole minutes, so pyvesync rounds the time down to the minute (90 s is sent as 1 minute) and rejects a cook_time under 60 s. The CAF-TF101S/TF102S and CAF-DC111S-AEU also check each mode's range and step (see Cook time limits). Other fryers have no upper limit: a longer time is sent as given and the fryer may refuse it.
  • cook_temp is in the fryer's temp_unit, or in temp_unit if you pass one (see Temperature units).
  • With no cook_mode, the fryer's default preset is used: Manual Cooking on the CS137/CS158 and Air Fry on the others. Only the preset's mode, recipe ID, name and type are sent; the time and temperature are yours. See Cook modes for the other modes.
  • The temperature is rounded to the nearest step of the mode, never rejected for being off-step, and then checked against the mode's range (see Temperature limits).
  • preheat_time starts a preheat phase on fryers with preheat. There it must be 0 (no preheat) or at least one API time unit: 1 second, or 60 seconds on the CS137/CS158, which also rounds it down to whole minutes. A negative or shorter time returns False without a request. The CAF-P583S and the dual-chamber fryers (CAF-TF101S/TF102S, CAF-DC111S-AEU) have no preheat and drop it (with a debug log).
  • pyvesync sends readyStart false. The CS137/CS158 capture shows the cook then starts at once, without the start button; the other models are assumed to behave the same.

After a successful command the chamber's state is set from the request (status, set time, remaining time, temperature and mode), and the remaining times count down locally. The next update() replaces them with what the device reports.

Recipes↲

get_mode_recipe(cook_mode) returns a cook mode's preset at the VeSync app's default time and temperature, as an AirFryerPresetRecipe, or None if the fryer has no such mode. Pass it to set_mode_from_recipe(), changing the time, temperature or preheat first if needed:

from dataclasses import replace
from pyvesync.const import AirFryerCookModes

recipe = fryer.get_mode_recipe(AirFryerCookModes.ROAST)
if recipe is not None:
    recipe = replace(recipe, cook_time=25 * 60, preheat_time=3 * 60)
    await fryer.set_mode_from_recipe(recipe)

AirFryerPresetRecipe holds recipe_name, cook_mode, recipe_id, recipe_type, target_temp, temp_unit ('f' or 'c'), cook_time and preheat_time (both in seconds; preheat_time is optional). The recipe's temperature is converted to the fryer's unit if needed, rounded and checked against the limits of the recipe's cook_mode. The times are checked as for set_mode(). The recipe's cook_mode must be one of the fryer's modes (see Cook modes); otherwise the method returns False without a request. Its mode, ID, name and type are sent as given, so start from get_mode_recipe() rather than inventing them. cook_mode is the API mode string ('AirFry', 'poultry') or an AirFryerCookModes key; the API mode string is what is sent. A recipe temp_unit that does not start with c or f returns False without a request.

fryer.default_preset is the recipe set_mode() copies when no cook_mode is given. Each fryer has its own copy, so changing it affects only that fryer. Its own time and temperature are pyvesync defaults and can differ from get_mode_recipe() for the same mode; use get_mode_recipe() for the app's defaults.

Controlling a cook↲

Method What it does Fryers
end(chamber) Ends the cook or preheat and returns the chamber to standby. All
stop(chamber) Pauses the cook or preheat. CS137/CS158, CAF-DC111S-AEU
resume(chamber) Resumes a paused cook or preheat. CS137/CS158, CAF-DC111S-AEU
cook_from_preheat(chamber) Starts the cook once preheat has finished (PREHEAT_END). CS137/CS158

On the CS137/CS158, end(), stop() and resume() use the last known status (from update() or the last successful command) to choose between the cook and preheat commands. Paused, basket-out and preheat-finished count as in a phase.

  • stop() and resume() are always sent: the preheat command during a preheat phase, otherwise the cook command (also in standby). They don't check whether the fryer is already paused or running.
  • end() returns False without a request when the fryer is in neither a cook nor a preheat phase (standby, or APPOINTING, so a scheduled cook cannot be cancelled).
  • Every successful command sets the state to the phase it sent (stop() gives COOK_STOP or PREHEAT_STOP, resume() gives COOKING or HEATING), so the next command picks the matching one without an update(). The remaining time carries over, so resume() while running does not restart the countdown.

Call update() first if the fryer may have changed state on its own.

The CAF-DC111S-AEU pauses and resumes with pauseWork / resumeWork and a chamber (the app has no pause button, the fryer's panel does). stop(1) or stop(2) pauses one chamber while the other keeps cooking; BOTH pauses both in one request. A paused chamber reports cookStop with its remaining time held, and during a Sync cook the other chamber's HOLD wait stops too. resume() continues from the held time. Verified on firmware 2.0.02 (MCU 1.0.10). The TF101S/TF102S have no RESUMABLE feature, so stop() and resume() return False without a request there.

On the dual-chamber fryers, pulling a basket out pauses that chamber (pullOut) and pushing it back in resumes it; no TF101S capture of this exists yet.

Reading status↲

update() polls the device. Single-chamber fryers keep their status in fryer.state; dual-chamber fryers have state_chamber_1 and state_chamber_2 (state is state_chamber_1), and get_chamber_state() returns the state for one chamber. All times are in seconds and all temperatures in the fryer's temp_unit.

Attribute Meaning
cook_status An AirFryerCookStatus member (see below).
cook_mode, recipe The API mode string and recipe name of the current or last cook (cook_mode is kept in standby). After a local command, recipe is set only on the dual-chamber fryers; the other fryers fill it on update().
cook_set_temp, current_temp The set and measured temperatures. The dual-chamber fryers' status has no measured-temperature field, so current_temp stays None.
cook_set_time, preheat_set_time The set cook and preheat times.
cook_time_remaining, preheat_time_remaining Remaining cook and preheat time, counting down from the last update while running (and during HOLD while the other chamber cooks, where cook_time_remaining is the wait before the chamber starts). None outside that phase.
total_time_remaining Time until the cook finishes, preheat included. None in standby, and on the CS137/CS158 during preheat once an update() has run (that fryer reports no cook time then).
is_cooking, is_preheating, is_running is_cooking is True in COOKING, is_preheating in HEATING, is_running in either.
is_in_cook_mode, is_in_preheat_mode True in any cook or preheat status, running or not (for example COOK_STOP, COOK_END, PREHEAT_END), and with the basket out during that phase.
can_resume True when paused (COOK_STOP or PREHEAT_STOP).

AirFryerCookStatus values (from pyvesync.const). Not every fryer reports every status:

Member Meaning
STANDBY Idle.
HEATING Preheating.
PREHEAT_STOP Preheat paused.
PREHEAT_END Preheat finished, waiting for cook_from_preheat() (CS137/CS158).
COOKING Cooking.
COOK_STOP Cook paused.
PULL_OUT Basket pulled out.
COOK_END Cook finished.
READY Set up and waiting to start on the fryer.
APPOINTING Waiting for a delayed start set in the app (CS137/CS158). pyvesync cannot set one.
HOLD Sync cook with different times: this chamber waits so both finish together (CAF-DC111S-AEU). cook_time_remaining is the wait and total_time_remaining includes the cook; both count down only while the other chamber is cooking, and stop while it is paused. Counts as cook mode; is_running is False. Start one with set_chamber_modes(), the app or the fryer; pyvesync's BOTH sends the same settings to both chambers, so it does not report this status.

The CAF-P583S status map maps keeping (keep-warm) to COOKING; no capture of it exists yet. COMPLETED and UNKNOWN are in the enum but no supported fryer reports them.

The CAF-TF101S and CAF-TF102S also map HOLD and COOK_END. That follows the VeSync app; no capture or report shows either status on these models.

Each state's to_dict() / to_json() gives all of these values at once.

Chambers↲

Every fryer method that targets a basket takes a chamber argument, an AirFryerChamber member (from pyvesync.const) or its integer value. The values match the API's workChamber / chamber fields.

Member Value Meaning
CHAMBER_1 1 Chamber 1 (left; upper on the CAF-DC111S-AEU). The default, and the only chamber on single-basket fryers.
CHAMBER_2 2 Chamber 2 (right; lower on the CAF-DC111S-AEU). Dual-chamber fryers only.
WHOLE 3 The merged basket with the divider removed. CAF-TF101S/TF102S only (WHOLE_CHAMBER feature).
BOTH 4 Both chambers with the same settings, sent as the app's Sync mode (both chambers finish together). Dual-chamber fryers only.

supported_chambers lists the chambers a fryer accepts, and is_dual_chamber is True for dual-basket models.

On a dual-chamber fryer:

from pyvesync.const import AirFryerChamber

await fryer.set_mode(600, 200, chamber=AirFryerChamber.CHAMBER_2)
await fryer.set_mode_from_recipe(recipe, chamber=AirFryerChamber.BOTH)
await fryer.end(AirFryerChamber.BOTH)

await fryer.update()
chamber_state = fryer.get_chamber_state(AirFryerChamber.CHAMBER_2)
if chamber_state is not None:
    remaining = chamber_state.cook_time_remaining

A chamber the fryer does not support makes the method return False without sending a request. get_chamber_state() returns None (with a debug log) for BOTH, which names two states (read state_chamber_1 and state_chamber_2 instead), and for a chamber the fryer does not support.

sync_chambers and work_chamber are read-only. update() sets both from the device; sync_chambers is also set by a successful start or end(BOTH). They are the same on the fryer and on both chamber states, and on single-chamber fryers they are always False and None.

  • sync_chambers is True only when the device reports the app's Sync mode (both chambers finish together). The app's Match mode reports False. A successful set_mode() or set_mode_from_recipe() also sets it (True for BOTH, False otherwise), as does set_chamber_modes() (True for SYNC, False for MATCH), and end(BOTH) clears it.
  • work_chamber is the chamber the device reports as active. It is None when the fryer is idle, and always None on single-chamber fryers. Besides update(), a successful WHOLE start sets it to WHOLE, any other start clears a WHOLE value, and a successful end(WHOLE) or end(BOTH) clears it.

To cook both chambers, pass chamber=AirFryerChamber.BOTH. During a Sync cook, end() with one chamber sends an end for that chamber only; use end(AirFryerChamber.BOTH) to end both. During a whole-basket cook (work_chamber is WHOLE), end() with the default CHAMBER_1 ends the whole basket.

Different recipes per chamber (CAF-DC111S-AEU)↲

set_chamber_modes() starts cooking in both chambers from recipes. It needs the AirFryerFeatures.CHAMBER_MODES feature, which only the CAF-DC111S-AEU has; other fryers return False without a request.

  • AirFryerSyncType.SYNC (the default) takes one recipe per chamber: the first for the upper basket, the second for the lower. They can differ in mode, temperature and time. The shorter recipe waits in HOLD so both finish together.
  • AirFryerSyncType.OFF takes one recipe per chamber, and each starts at once and runs on its own time.
  • AirFryerSyncType.MATCH takes one recipe and uses it for both chambers.
from dataclasses import replace
from pyvesync.const import AirFryerSyncType

first = fryer.get_mode_recipe('AirFry')
second = fryer.get_mode_recipe('Bake')
if first is not None and second is not None:
    first = replace(first, target_temp=180, temp_unit='c', cook_time=180)
    second = replace(second, target_temp=160, temp_unit='c', cook_time=300)
    await fryer.set_chamber_modes(first, second, sync_type=AirFryerSyncType.SYNC)

    # Or start one recipe in both chambers instead
    await fryer.set_chamber_modes(second, sync_type=AirFryerSyncType.MATCH)

Both recipes are checked before any request, so an invalid second recipe does not start the first. A refused command leaves both chamber states unchanged. set_mode(..., chamber=BOTH) is unchanged and sends the same settings to both chambers.

Cook modes↲

cook_modes lists each fryer's presets from the VeSync app, keyed by AirFryerCookModes (from pyvesync.const). Each entry holds the mode string the API uses, the preset's recipe ID and name, its default time and temperatures, and its temperature limits. get_cook_mode() looks one up by key or by API mode string.

Fryer Modes
CS137/CS158 CUSTOM (Manual Cooking, the default), STEAK, CHICKEN, SEAFOOD, BACON, VEGETABLES, ROOT_VEGETABLES, FROZEN, DESSERTS, SHRIMP, BREAD, FRENCH_FRIES
CAF-DC601S AIRFRY (default), ROAST, BROIL, FROZEN, REHEAT, BAKE, DRY, PROOF, WARM
CAF-P583S AIRFRY (default), BROIL, ROAST, BAKE, CHICKEN, STEAK, SEAFOOD, VEGETABLES, FRENCH_FRIES, FROZEN, REHEAT
CAF-TF101S AIRFRY (default), ROAST, BAKE, GRILL, REHEAT, DRY
CAF-TF102S AIRFRY (default), ROAST, BAKE, GRILL, REHEAT
CAF-DC111S-AEU AIRFRY (default), BAKE, ROAST, GRILL, REHEAT, DRY, PROOF

set_mode(..., cook_mode=...) accepts an AirFryerCookModes key or the API mode string, and sends that preset's recipe ID, name and type. With no cook_mode it sends the fryer's default preset. set_mode(cook_mode=...) with a mode the fryer does not have returns False without sending a request.

set_mode_from_recipe() sends the recipe's own cook_mode and checks its temperature against that mode's limits, or the default mode's limits if the fryer has no such mode.

from pyvesync.const import AirFryerCookModes

# Bake exists on the CAF-DC601S, CAF-P583S, CAF-TF101S/TF102S and CAF-DC111S-AEU
await fryer.set_mode(1200, 330, temp_unit='f', cook_mode=AirFryerCookModes.BAKE)

# The preset at its app defaults, for set_mode_from_recipe()
recipe = fryer.get_mode_recipe(AirFryerCookModes.BAKE)
if recipe is not None:
    await fryer.set_mode_from_recipe(recipe)

Temperature limits↲

Each cook mode has its own temperature range and step in both Celsius and Fahrenheit, taken from the VeSync app. temp_limits(cook_mode) returns them in the fryer's current temp_unit; with no mode, or a mode the fryer does not have, it returns the default mode's limits.

limits = fryer.temp_limits('Grill')  # CAF-TF101S/TF102S Grill
print(limits.min_temp, limits.max_temp, limits.step)

A requested temperature is rounded to the nearest multiple of its mode's step, then checked against its mode's range. Off-step temperatures are always rounded, never rejected. Rounding uses Python's round(), so a value exactly halfway between two steps goes to the even multiple: with a 10° step, 395 becomes 400 but 385 becomes 380. A temperature outside the range makes the method return False without sending a request.

min_temp and max_temp are the widest span across all of the fryer's modes, for display. A temperature inside that span can still be outside the range of the mode you cook with; check temp_limits(cook_mode) before calling set_mode(). temperature_step is the default mode's step.

Cook time limits↲

The CAF-DC111S-AEU limits each mode's cook time to the range and step in the VeSync app:

Mode Range Step
AirFry, Bake, Roast, Grill, Reheat 1-60 minutes 1 minute
Dry 30 minutes-24 hours 30 minutes
Proof 15 minutes-12 hours 15 minutes

The CAF-TF101S and CAF-TF102S have their own limits, also from the app. The CAF-TF102S has no Dry mode.

Mode Range Step
AirFry, Reheat 1-60 minutes 1 minute
Roast, Bake 1 minute-4 hours 1 minute
Grill 1-30 minutes 1 minute
Dry 30 minutes-48 hours 30 minutes

Times are still given in seconds. A time outside the range or off the step makes the method return False without sending a request. Unlike a temperature, a time is never rounded, because that would silently change how long the food cooks. A whole number given as a float or string (600.0, '600') is accepted; a fraction or a boolean is not.

Other fryers have no per-mode time limits and check only the minimum described under Cooking.

Temperature units↲

The fryer's temp_unit comes from each update(). Before the first update it is the device map's default for that model or region. Temperatures in the state, and cook_temp passed to set_mode(), are in this unit.

Pass temp_unit to give cook_temp in another unit; pyvesync converts it to the device unit before sending:

await fryer.set_mode(600, 390, temp_unit='f')  # sent as 200 C on a Celsius fryer

temp_unit accepts a TemperatureUnits member or a string starting with c or f ('c', 'celsius', 'F', ...). Any other string makes the method return False without sending a request.

A unit changed on the device between update() and a command is not detected; call update() first if the unit may have changed.

pyvesync converts with the formula (TemperatureUnits.to_celsius / to_fahrenheit) and then rounds to the cook mode's step. The VeSync app instead pairs each mode's Celsius and Fahrenheit lists by position, so a converted temperature can be one step away from what the app shows for the same input. Pass cook_temp in the device's own unit to avoid conversion.

API reference↲

pyvesync.devices.vesynckitchen ↲

VeSync Cosori air fryers.

  • VeSyncAirFryer158: CS137/CS158 (Bypass V1 API, times in minutes).
  • VeSyncTurboBlazeFryer: CAF-DC601S (bypassV2 startCook).
  • VeSyncDualBlazeFryer: CAF-P583S (bypassV2 startCook).
  • VeSyncDualAirFryer: CAF-TF101S / CAF-TF102S / CAF-DC111S (bypassV2 startMultiCook, two chambers).

A successful command sets the chamber state locally from the request; it does not call update(). Call update() to read what the device reports. A failed or unreadable poll logs and leaves the state unchanged. The CS137/CS158 end(), stop(), resume() and cook_from_preheat() choose the cook or preheat command from the last known state, which every successful command keeps in the phase it sent.

pyvesync.devices.vesynckitchen.VeSyncAirFryer158 ↲

Bases: VeSyncFryer

Cosori CS137/CS158 air fryer (Bypass V1 API).

The API counts time in whole minutes, so times are truncated to the minute when sent, and a cook time under 60 seconds is rejected. This is the only fryer class that implements stop(), resume() and cook_from_preheat(). They and end() choose the cook or preheat command from the last known state. stop() and resume() are sent in any state (the cook command when not preheating), and every successful command sets the state to the phase it sent, so the next command matches. A device whose config module is not in AIRFRYER_PID_MAP raises VeSyncError, and the device container skips it with a warning.

Parameters:

Name Type Description Default
details ResponseDeviceDetailsModel

Device details.

required
manager VeSync

Manager class.

required
feature_map AirFryerMap

Device feature map.

required

Attributes:

Name Type Description
pid str

Product ID from AIRFRYER_PID_MAP, by config module.

request_keys tuple[str, ...]

Keys copied into every request body.

last_response ResponseInfo

Last response from API call.

manager VeSync

Manager object for API calls.

device_name str

Name of device.

device_image str

URL for device image.

cid str

Device ID.

connection_type str

Connection type of device.

device_type str

Type of device.

type str

Type of device.

uuid str

UUID of device, not always present.

config_module str

Configuration module of device.

mac_id str

MAC ID of device.

current_firm_version str

Current firmware version of device.

device_region str

Region of device. (US, EU, etc.)

sub_device_no int

Sub-device number of device.

product_type str

Product type of device.

Source code in src/pyvesync/devices/vesynckitchen.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
class VeSyncAirFryer158(VeSyncFryer):
    """Cosori CS137/CS158 air fryer (Bypass V1 API).

    The API counts time in whole minutes, so times are truncated to the minute
    when sent, and a cook time under 60 seconds is rejected. This is the only
    fryer class that implements ``stop()``, ``resume()`` and
    ``cook_from_preheat()``. They and ``end()`` choose the cook or preheat
    command from the last known state. ``stop()`` and ``resume()`` are sent in
    any state (the cook command when not preheating), and every successful
    command sets the state to the phase it sent, so the next command matches.
    A device whose config module is not in ``AIRFRYER_PID_MAP`` raises
    ``VeSyncError``, and the device container skips it with a warning.

    Args:
        details (ResponseDeviceDetailsModel): Device details.
        manager (VeSync): Manager class.
        feature_map (AirFryerMap): Device feature map.

    Attributes:
        pid (str): Product ID from ``AIRFRYER_PID_MAP``, by config module.
        request_keys (tuple[str, ...]): Keys copied into every request body.
        last_response (ResponseInfo): Last response from API call.
        manager (VeSync): Manager object for API calls.
        device_name (str): Name of device.
        device_image (str): URL for device image.
        cid (str): Device ID.
        connection_type (str): Connection type of device.
        device_type (str): Type of device.
        type (str): Type of device.
        uuid (str): UUID of device, not always present.
        config_module (str): Configuration module of device.
        mac_id (str): MAC ID of device.
        current_firm_version (str): Current firmware version of device.
        device_region (str): Region of device. (US, EU, etc.)
        sub_device_no (int): Sub-device number of device.
        product_type (str): Product type of device.
    """

    __slots__ = (
        'last_update',
        'ready_start',
        'refresh_interval',
    )

    request_keys: tuple[str, ...] = (
        'acceptLanguage',
        'appVersion',
        'phoneBrand',
        'phoneOS',
        'accountID',
        'cid',
        'configModule',
        'debugMode',
        'traceId',
        'timeZone',
        'token',
        'userCountryCode',
        'uuid',
        'pid',
    )

    def __init__(
        self,
        details: ResponseDeviceDetailsModel,
        manager: VeSync,
        feature_map: AirFryerMap,
    ) -> None:
        """Init the VeSync Air Fryer 158 class."""
        super().__init__(details, manager, feature_map)
        self.features: list[str] = feature_map.features
        self.ready_start = False
        if self.config_module not in AIRFRYER_PID_MAP:
            msg = (
                'Report this error as an issue - '
                f'{self.config_module} not found in PID map for {self.device_type}'
            )
            raise VeSyncError(msg)
        self.pid = AIRFRYER_PID_MAP[self.config_module]

    @deprecated('There is no on/off function for Air Fryers.')
    async def toggle_switch(self, toggle: bool | None = None) -> bool:
        """Do nothing; air fryers have no on/off command.

        No request is sent.

        Returns:
            bool: ``toggle``, or ``not is_on`` when ``toggle`` is None.
        """
        return toggle if toggle is not None else not self.is_on

    def _build_158_request(
        self,
        request_model: type[models.Fryer158RequestModel],
        update_dict: dict | None = None,
        method: str = 'bypass',
    ) -> models.Fryer158RequestModel:
        """Build API request body for the Bypass V1 endpoint.

        Args:
            request_model (type[models.Fryer158RequestModel]): The request model to use.
            update_dict (dict | None): Additional keys to add on.
            method (str): The method to use in the outer body, defaults to bypass.

        Returns:
            models.Fryer158RequestModel: The request body built from
            ``request_model``.
        """
        body = Helpers.get_defaultvalues_attributes(self.request_keys).copy()
        body.update(Helpers.get_manager_attributes(self.manager, self.request_keys))
        body.update(Helpers.get_device_attributes(self, self.request_keys))
        body['method'] = method
        body.update(update_dict or {})
        return request_model.from_dict(body)

    async def call_158_api(
        self,
        request_model: type[models.Fryer158RequestModel],
        update_dict: dict | None = None,
        method: str = 'bypass',
        endpoint: str = 'bypass',
    ) -> dict | None:
        """Send a Cosori 158 API request.

        This uses the `_build_158_request` method to send API requests
        to the Cosori 158 API.

        Args:
            request_model (type[models.Fryer158RequestModel]): The request model to use.
            update_dict (dict | None): Additional keys to add on.
            method (str): The method to use in the outer body.
            endpoint (str): The last part of the url path, defaults to
                `bypass`, e.g. `/cloud/v1/deviceManaged/bypass`.

        Returns:
            dict | None: The response JSON, or None if there was no valid
            response.
        """
        request = self._build_158_request(request_model, update_dict, method)
        url_path = BYPASS_V1_PATH + endpoint
        resp_dict, _ = await self.manager.async_call_api(
            url_path, 'post', request, Helpers.req_header_bypass()
        )

        return resp_dict

    def _build_base_request(
        self, cook_set_time: int, recipe: AirFryerPresetRecipe | None = None
    ) -> dict[str, int | str | bool]:
        """Build base cook or preheat request body.

        This allows a custom recipe to be passed, but defaults to manual
        cooking. The cook_set_time argument is required and will override
        the default time in the recipe.
        """
        cook_base: dict[str, int | str | bool] = {}
        cook_base['cookSetTime'] = cook_set_time
        if recipe is None:
            cook_base['recipeId'] = self.default_preset.recipe_id
            cook_base['customRecipe'] = self.default_preset.recipe_name
            cook_base['mode'] = self.default_preset.cook_mode
            cook_base['recipeType'] = self.default_preset.recipe_type
        else:
            cook_base['recipeId'] = recipe.recipe_id
            cook_base['customRecipe'] = recipe.recipe_name
            cook_base['mode'] = recipe.cook_mode
            cook_base['recipeType'] = recipe.recipe_type

        cook_base['accountId'] = self.manager.account_id
        cook_base['tempUnit'] = self.temp_unit.label
        # readyStart False starts the cook at once; True waits for the start button.
        cook_base['readyStart'] = False
        return cook_base

    def _build_cook_request(
        self,
        cook_time: int,
        cook_temp: int,
        recipe: AirFryerPresetRecipe | None = None,
    ) -> dict[str, int | str | bool]:
        """Internal command to build cookMode API command."""
        cook_mode = self._build_base_request(cook_time, recipe)
        cook_mode['appointmentTs'] = 0
        cook_mode['cookSetTemp'] = cook_temp
        cook_mode['cookStatus'] = AirFryer158Command.COOKING
        return cook_mode

    def _build_preheat_request(
        self,
        cook_time: int,
        preheat_time: int,
        cook_temp: int,
        recipe: AirFryerPresetRecipe | None = None,
    ) -> dict[str, int | str | bool]:
        """Internal command to build preheat API command.

        Both times are in the device's API time units.
        """
        preheat_mode = self._build_base_request(cook_time, recipe)
        preheat_mode['targetTemp'] = cook_temp
        preheat_mode['preheatSetTime'] = preheat_time
        preheat_mode['preheatStatus'] = AirFryer158Command.HEATING
        return preheat_mode

    async def get_details(self) -> None:
        cmd = {'getStatus': 'status'}
        jsoncmd = {'jsonCmd': cmd}
        resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=jsoncmd)

        if not isinstance(resp, dict) or 'result' not in resp:
            logger.debug(
                'Invalid response for get_details for %s: %s', self.device_name, resp
            )
            self.state.connection_status = ConnectionStatus.OFFLINE
            return None
        if not isinstance(resp['result'], dict):
            logger.debug(
                'No result in get_details response for %s: %s', self.device_name, resp
            )
            return None

        resp_model = process_bypassv1_result(
            self,
            logger,
            'get_details',
            resp,
            models.Fryer158Result,
        )

        if resp_model is None or resp_model.returnStatus is None:
            logger.debug(
                'No returnStatus in get_details response for %s', self.device_name
            )
            return None

        return_status = resp_model.returnStatus
        if return_status.cookStatus not in self.status_map:
            logger.warning(
                'Unknown cook status %s for %s, keeping the last state',
                return_status.cookStatus,
                self.device_name,
            )
            return None
        if return_status.tempUnit is not None:
            self.temp_unit = return_status.tempUnit
        return self.state.set_state(
            cook_status=self.status_map[return_status.cookStatus],
            cook_time=return_status.cookSetTime,
            cook_last_time=return_status.cookLastTime,
            # Preheat and pull-out-during-preheat states report targetTemp only.
            cook_temp=return_status.cookSetTemp
            if return_status.cookSetTemp is not None
            else return_status.targetTemp,
            cook_mode=return_status.mode,
            preheat_set_time=return_status.preheatSetTime,
            preheat_last_time=return_status.preheatLastTime,
            current_temp=return_status.current_temp,
            recipe=return_status.customRecipe,
        )

    async def end(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        if self._validate_chamber(chamber) is None:
            return False
        if self.state.is_in_cook_mode is True:
            preheat = False
        elif self.state.is_in_preheat_mode is True:
            preheat = True
        else:
            logger.debug(
                'Cannot end %s as it is not cooking or preheating', self.device_name
            )
            return False
        if not await self._send_phase_command('end', AirFryer158Command.END, preheat):
            return False
        self.state.set_standby()
        return True

    async def _send_phase_command(
        self, method_name: str, command: AirFryer158Command, preheat: bool
    ) -> bool:
        """Send a ``preheatStatus`` or ``cookStatus`` change command.

        Args:
            method_name (str): Calling method, for the logs.
            command (AirFryer158Command): The status value to send.
            preheat (bool): True for the ``preheat`` command, False for
                ``cookMode``.

        Returns:
            bool: True if the device accepted the command.
        """
        if preheat:
            cmd = {'preheat': {'preheatStatus': command}}
        else:
            cmd = {'cookMode': {'cookStatus': command}}
        resp = await self.call_158_api(
            models.Fryer158RequestModel, update_dict={'jsonCmd': cmd}
        )
        return Helpers.process_dev_response(logger, method_name, self, resp) is not None

    async def stop(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        if self._validate_chamber(chamber) is None:
            return False
        preheat = self.state.is_in_preheat_mode
        if not await self._send_phase_command('stop', AirFryer158Command.STOP, preheat):
            return False
        if preheat:
            self.state.set_preheat_stop_state()
        else:
            self.state.set_cook_stop_state()
        return True

    async def resume(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        if self._validate_chamber(chamber) is None:
            return False
        preheat = self.state.is_in_preheat_mode
        command = AirFryer158Command.HEATING if preheat else AirFryer158Command.COOKING
        if not await self._send_phase_command('resume', command, preheat):
            return False
        if preheat:
            self.state.set_preheat_resume_state()
        else:
            self.state.set_cook_resume_state()
        return True

    async def set_mode_from_recipe(
        self,
        recipe: AirFryerPresetRecipe,
        *,
        chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
    ) -> bool:
        if self._validate_chamber(chamber) is None:
            return False
        prepared = self._prepare_recipe(recipe)
        if prepared is None:
            return False
        recipe = prepared
        cook_time = self.convert_time_for_api(recipe.cook_time)
        preheat_time = (
            self.convert_time_for_api(recipe.preheat_time)
            if recipe.preheat_time is not None
            else None
        )
        if preheat_time is not None and preheat_time > 0:
            cook_status = AirFryerCookStatus.HEATING
            preheat_req = self._build_preheat_request(
                cook_time=cook_time,
                preheat_time=preheat_time,
                cook_temp=recipe.target_temp,
                recipe=recipe,
            )
            cmd = {'preheat': preheat_req}
        else:
            cook_status = AirFryerCookStatus.COOKING
            cook_req = self._build_cook_request(
                cook_time=cook_time, cook_temp=recipe.target_temp, recipe=recipe
            )
            cmd = {'cookMode': cook_req}
        json_cmd = {'jsonCmd': cmd}
        resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd)
        r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp)
        if r is None:
            return False
        self.state.set_state(
            cook_status=cook_status,
            cook_time=cook_time,
            cook_last_time=cook_time,
            cook_temp=recipe.target_temp,
            cook_mode=recipe.cook_mode,
            preheat_set_time=preheat_time,
            preheat_last_time=preheat_time,
        )
        return True

    async def set_mode(
        self,
        cook_time: int,
        cook_temp: int,
        *,
        temp_unit: TemperatureUnits | str | None = None,
        preheat_time: int | None = None,
        cook_mode: str | None = None,
        chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
    ) -> bool:
        unit = self._resolve_temp_unit(temp_unit)
        if unit is None:
            return False
        recipe = self._recipe_for_set_mode(
            cook_time, cook_temp, cook_mode, preheat_time, temp_unit=unit
        )
        if recipe is None:
            return False
        return await self.set_mode_from_recipe(recipe, chamber=chamber)

    async def cook_from_preheat(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        if self._validate_chamber(chamber) is None:
            return False
        if self.state.cook_status != AirFryerCookStatus.PREHEAT_END:
            logger.debug('Cannot start cook from preheat for %s', self.device_name)
            return False
        cmd = {
            'cookMode': {
                'mode': self.state.cook_mode,
                'accountId': self.manager.account_id,
                'cookStatus': AirFryer158Command.COOKING,
                'tempUnit': self.temp_unit.label,
            }
        }
        json_cmd = {'jsonCmd': cmd}
        resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd)
        r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp)
        if r is None:
            return False
        self.state.set_state(cook_status=AirFryerCookStatus.COOKING)
        return True

features instance-attribute ↲

features: list[str] = feature_map.features

Inherited From VeSyncBaseDevice

pid instance-attribute ↲

pid = AIRFRYER_PID_MAP[self.config_module]

Inherited From VeSyncFryer

toggle_switch async deprecated ↲

toggle_switch(toggle: bool | None = None) -> bool
Deprecated

There is no on/off function for Air Fryers.

Do nothing; air fryers have no on/off command.

No request is sent.

Returns:

Name Type Description
bool bool

toggle, or not is_on when toggle is None.

Source code in src/pyvesync/devices/vesynckitchen.py
@deprecated('There is no on/off function for Air Fryers.')
async def toggle_switch(self, toggle: bool | None = None) -> bool:
    """Do nothing; air fryers have no on/off command.

    No request is sent.

    Returns:
        bool: ``toggle``, or ``not is_on`` when ``toggle`` is None.
    """
    return toggle if toggle is not None else not self.is_on

call_158_api async ↲

call_158_api(
    request_model: type[Fryer158RequestModel],
    update_dict: dict | None = None,
    method: str = 'bypass',
    endpoint: str = 'bypass',
) -> dict | None

Send a Cosori 158 API request.

This uses the _build_158_request method to send API requests to the Cosori 158 API.

Parameters:

Name Type Description Default
request_model type[Fryer158RequestModel]

The request model to use.

required
update_dict dict | None

Additional keys to add on.

None
method str

The method to use in the outer body.

'bypass'
endpoint str

The last part of the url path, defaults to bypass, e.g. /cloud/v1/deviceManaged/bypass.

'bypass'

Returns:

Type Description
dict | None

dict | None: The response JSON, or None if there was no valid

dict | None

response.

Source code in src/pyvesync/devices/vesynckitchen.py
async def call_158_api(
    self,
    request_model: type[models.Fryer158RequestModel],
    update_dict: dict | None = None,
    method: str = 'bypass',
    endpoint: str = 'bypass',
) -> dict | None:
    """Send a Cosori 158 API request.

    This uses the `_build_158_request` method to send API requests
    to the Cosori 158 API.

    Args:
        request_model (type[models.Fryer158RequestModel]): The request model to use.
        update_dict (dict | None): Additional keys to add on.
        method (str): The method to use in the outer body.
        endpoint (str): The last part of the url path, defaults to
            `bypass`, e.g. `/cloud/v1/deviceManaged/bypass`.

    Returns:
        dict | None: The response JSON, or None if there was no valid
        response.
    """
    request = self._build_158_request(request_model, update_dict, method)
    url_path = BYPASS_V1_PATH + endpoint
    resp_dict, _ = await self.manager.async_call_api(
        url_path, 'post', request, Helpers.req_header_bypass()
    )

    return resp_dict

get_details async ↲

get_details() -> None

Inherited From VeSyncBaseDevice

Get device details.

This method is defined in each device class to contain the logic to pull the device state from the API and update the device's state attribute. The update() method should be called to update the device state.

Source code in src/pyvesync/devices/vesynckitchen.py
async def get_details(self) -> None:
    cmd = {'getStatus': 'status'}
    jsoncmd = {'jsonCmd': cmd}
    resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=jsoncmd)

    if not isinstance(resp, dict) or 'result' not in resp:
        logger.debug(
            'Invalid response for get_details for %s: %s', self.device_name, resp
        )
        self.state.connection_status = ConnectionStatus.OFFLINE
        return None
    if not isinstance(resp['result'], dict):
        logger.debug(
            'No result in get_details response for %s: %s', self.device_name, resp
        )
        return None

    resp_model = process_bypassv1_result(
        self,
        logger,
        'get_details',
        resp,
        models.Fryer158Result,
    )

    if resp_model is None or resp_model.returnStatus is None:
        logger.debug(
            'No returnStatus in get_details response for %s', self.device_name
        )
        return None

    return_status = resp_model.returnStatus
    if return_status.cookStatus not in self.status_map:
        logger.warning(
            'Unknown cook status %s for %s, keeping the last state',
            return_status.cookStatus,
            self.device_name,
        )
        return None
    if return_status.tempUnit is not None:
        self.temp_unit = return_status.tempUnit
    return self.state.set_state(
        cook_status=self.status_map[return_status.cookStatus],
        cook_time=return_status.cookSetTime,
        cook_last_time=return_status.cookLastTime,
        # Preheat and pull-out-during-preheat states report targetTemp only.
        cook_temp=return_status.cookSetTemp
        if return_status.cookSetTemp is not None
        else return_status.targetTemp,
        cook_mode=return_status.mode,
        preheat_set_time=return_status.preheatSetTime,
        preheat_last_time=return_status.preheatLastTime,
        current_temp=return_status.current_temp,
        recipe=return_status.customRecipe,
    )

end async ↲

end(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

End the current cooking or preheating session.

On success the chamber state is set to standby. On the CS137/CS158 the command depends on the last known state, and it returns False without a request when that state is neither cooking nor preheating. On the CAF-TF101S/TF102S, CHAMBER_1 during a whole-basket cook (work_chamber WHOLE) ends the whole basket.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to end for. Default is CHAMBER_1. An unsupported chamber returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/devices/vesynckitchen.py
async def end(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    if self._validate_chamber(chamber) is None:
        return False
    if self.state.is_in_cook_mode is True:
        preheat = False
    elif self.state.is_in_preheat_mode is True:
        preheat = True
    else:
        logger.debug(
            'Cannot end %s as it is not cooking or preheating', self.device_name
        )
        return False
    if not await self._send_phase_command('end', AirFryer158Command.END, preheat):
        return False
    self.state.set_standby()
    return True

stop async ↲

stop(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

Stop (Pause) the current cooking or preheating session.

Supported by the CS137/CS158 (VeSyncAirFryer158) and dual fryers with the RESUMABLE feature (CAF-DC111S-AEU); other fryers return False without a request. The command is always sent: the preheat stop when the last known state is a preheat phase, otherwise the cook stop. Every successful command sets the state to the phase it sent, so the next command picks the matching one.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to stop for. Default is CHAMBER_1.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/devices/vesynckitchen.py
async def stop(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    if self._validate_chamber(chamber) is None:
        return False
    preheat = self.state.is_in_preheat_mode
    if not await self._send_phase_command('stop', AirFryer158Command.STOP, preheat):
        return False
    if preheat:
        self.state.set_preheat_stop_state()
    else:
        self.state.set_cook_stop_state()
    return True

resume async ↲

resume(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

Resume a paused cooking or preheating session.

Supported by the CS137/CS158 (VeSyncAirFryer158) and dual fryers with the RESUMABLE feature (CAF-DC111S-AEU); other fryers return False without a request. The command is always sent: the preheat resume when the last known state is a preheat phase, otherwise the cook resume. The remaining time carries over, so a resume while running does not restart the countdown.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to resume for. Default is CHAMBER_1.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/devices/vesynckitchen.py
async def resume(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    if self._validate_chamber(chamber) is None:
        return False
    preheat = self.state.is_in_preheat_mode
    command = AirFryer158Command.HEATING if preheat else AirFryer158Command.COOKING
    if not await self._send_phase_command('resume', command, preheat):
        return False
    if preheat:
        self.state.set_preheat_resume_state()
    else:
        self.state.set_cook_resume_state()
    return True

set_mode_from_recipe async ↲

set_mode_from_recipe(
    recipe: AirFryerPresetRecipe,
    *,
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start a cook from a recipe.

recipe.cook_mode must be one of the fryer's cook modes (see get_cook_mode()); its API mode string is sent. recipe.target_temp is converted from recipe.temp_unit to the device unit, rounded to the mode's step and checked against its limits. The times are checked as in set_mode(). A failed check (including an invalid recipe.temp_unit) logs a warning and returns False without a request. The recipe passed in is not changed.

Parameters:

Name Type Description Default
recipe AirFryerPresetRecipe

The preset recipe to use. Its recipe ID, name and type are sent as given.

required
chamber AirFryerChamber | int

The chamber to cook in. Default is CHAMBER_1. A chamber not in supported_chambers returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Note

Get a fryer's preset with get_mode_recipe() and change it with dataclasses.replace. See AirFryerPresetRecipe.

Source code in src/pyvesync/devices/vesynckitchen.py
async def set_mode_from_recipe(
    self,
    recipe: AirFryerPresetRecipe,
    *,
    chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
) -> bool:
    if self._validate_chamber(chamber) is None:
        return False
    prepared = self._prepare_recipe(recipe)
    if prepared is None:
        return False
    recipe = prepared
    cook_time = self.convert_time_for_api(recipe.cook_time)
    preheat_time = (
        self.convert_time_for_api(recipe.preheat_time)
        if recipe.preheat_time is not None
        else None
    )
    if preheat_time is not None and preheat_time > 0:
        cook_status = AirFryerCookStatus.HEATING
        preheat_req = self._build_preheat_request(
            cook_time=cook_time,
            preheat_time=preheat_time,
            cook_temp=recipe.target_temp,
            recipe=recipe,
        )
        cmd = {'preheat': preheat_req}
    else:
        cook_status = AirFryerCookStatus.COOKING
        cook_req = self._build_cook_request(
            cook_time=cook_time, cook_temp=recipe.target_temp, recipe=recipe
        )
        cmd = {'cookMode': cook_req}
    json_cmd = {'jsonCmd': cmd}
    resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd)
    r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp)
    if r is None:
        return False
    self.state.set_state(
        cook_status=cook_status,
        cook_time=cook_time,
        cook_last_time=cook_time,
        cook_temp=recipe.target_temp,
        cook_mode=recipe.cook_mode,
        preheat_set_time=preheat_time,
        preheat_last_time=preheat_time,
    )
    return True

set_mode async ↲

set_mode(
    cook_time: int,
    cook_temp: int,
    *,
    temp_unit: TemperatureUnits | str | None = None,
    preheat_time: int | None = None,
    cook_mode: str | None = None,
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start a cook with the given time and temperature.

cook_temp is converted to the device unit if needed, rounded to the cook mode's step (see round_temperature()) and checked against that mode's limits (see temp_limits()). Out of range logs a warning and returns False without a request. On success the chamber state is set locally from the request; call update() for the device's reported state.

Parameters:

Name Type Description Default
cook_time int

The cooking time in seconds, at least 1 (60 on the CS137/CS158, whose API uses minutes and truncates to whole minutes). A shorter time returns False without a request. A mode's time_limits, where declared, also apply; a time outside them returns False without a request. Other modes have no upper limit.

required
cook_temp int

The cooking temperature, in temp_unit.

required
temp_unit TemperatureUnits | str | None

The unit of cook_temp; defaults to the device's current temp_unit. An unrecognised unit string returns False without a request.

None
preheat_time int | None

The preheating time in seconds, if any. Used only on fryers with the PREHEAT feature: dropped on the CAF-P583S and ignored on the dual-chamber fryers. On fryers with preheat it must be 0 or at least one API time unit (60 seconds on the CS137/CS158); otherwise False without a request.

None
cook_mode str | None

Cook mode to use, an AirFryerCookModes key or the API mode string (see get_cook_mode()). Its recipe ID, name and type are sent. None uses the default preset. A mode the fryer does not have returns False without a request.

None
chamber AirFryerChamber | int

The chamber to cook in. Default is CHAMBER_1. A chamber not in supported_chambers returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/devices/vesynckitchen.py
async def set_mode(
    self,
    cook_time: int,
    cook_temp: int,
    *,
    temp_unit: TemperatureUnits | str | None = None,
    preheat_time: int | None = None,
    cook_mode: str | None = None,
    chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
) -> bool:
    unit = self._resolve_temp_unit(temp_unit)
    if unit is None:
        return False
    recipe = self._recipe_for_set_mode(
        cook_time, cook_temp, cook_mode, preheat_time, temp_unit=unit
    )
    if recipe is None:
        return False
    return await self.set_mode_from_recipe(recipe, chamber=chamber)

cook_from_preheat async ↲

cook_from_preheat(
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start cooking after the preheat has finished.

Only the CS137/CS158 (VeSyncAirFryer158) supports this; other fryers return False without a request. The last known cook_status must be PREHEAT_END, so call update() after the preheat finishes; otherwise it returns False without a request.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to start cooking for. Default is CHAMBER_1.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/devices/vesynckitchen.py
async def cook_from_preheat(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    if self._validate_chamber(chamber) is None:
        return False
    if self.state.cook_status != AirFryerCookStatus.PREHEAT_END:
        logger.debug('Cannot start cook from preheat for %s', self.device_name)
        return False
    cmd = {
        'cookMode': {
            'mode': self.state.cook_mode,
            'accountId': self.manager.account_id,
            'cookStatus': AirFryer158Command.COOKING,
            'tempUnit': self.temp_unit.label,
        }
    }
    json_cmd = {'jsonCmd': cmd}
    resp = await self.call_158_api(models.Fryer158RequestModel, update_dict=json_cmd)
    r = Helpers.process_dev_response(logger, 'cook_from_preheat', self, resp)
    if r is None:
        return False
    self.state.set_state(cook_status=AirFryerCookStatus.COOKING)
    return True

pyvesync.devices.vesynckitchen.VeSyncTurboBlazeFryer ↲

Bases: BypassV2Mixin, VeSyncFryer

Cosori TurboBlaze air fryer (CAF-DC601S, bypassV2 startCook).

Single chamber; times are in seconds. A preheat is sent only when the device map has the PREHEAT feature, otherwise preheat_time is dropped with a debug log. stop(), resume() and cook_from_preheat() are not implemented and return False. The reported currentTemp is in Celsius and is converted to temp_unit. A local set_mode() does not set state.recipe. A failed poll, an unknown cookStatus or an out-of-range stepIndex leaves the state unchanged.

Source code in src/pyvesync/devices/vesynckitchen.py
class VeSyncTurboBlazeFryer(BypassV2Mixin, VeSyncFryer):
    """Cosori TurboBlaze air fryer (CAF-DC601S, bypassV2 ``startCook``).

    Single chamber; times are in seconds. A preheat is sent only when the
    device map has the ``PREHEAT`` feature, otherwise ``preheat_time`` is
    dropped with a debug log. ``stop()``, ``resume()`` and
    ``cook_from_preheat()`` are not implemented and return False. The reported
    ``currentTemp`` is in Celsius and is converted to ``temp_unit``. A local
    ``set_mode()`` does not set ``state.recipe``. A failed poll, an unknown
    ``cookStatus`` or an out-of-range ``stepIndex`` leaves the state unchanged.
    """

    __slots__ = ()

    def _payload_update(self) -> dict | None:
        """Return extra payload keys sent on every request, if any."""
        return None

    def _cook_request_dict(
        self, recipe: AirFryerPresetRecipe
    ) -> dict[str, int | str | bool | dict]:
        """Build the startCook data values from a recipe."""
        cook_req: dict[str, int | str | bool | dict] = {}
        cook_req['accountId'] = self.manager.account_id
        if recipe.preheat_time is not None and recipe.preheat_time > 0:
            cook_req['hasPreheat'] = int(True)
        cook_req['hasWarm'] = False
        cook_req['mode'] = recipe.cook_mode
        # readyStart False starts the cook at once; True waits for the start button.
        cook_req['readyStart'] = False
        cook_req['recipeId'] = recipe.recipe_id
        cook_req['recipeName'] = recipe.recipe_name
        cook_req['recipeType'] = recipe.recipe_type
        cook_req['tempUnit'] = self.temp_unit.code
        cook_req['startAct'] = {
            'cookSetTime': self.convert_time_for_api(recipe.cook_time),
            'cookTemp': recipe.target_temp,
            'preheatTemp': recipe.target_temp if recipe.preheat_time else 0,
            'shakeTime': 0,
        }
        return cook_req

    def _build_cook_request(self, recipe: AirFryerPresetRecipe) -> RequestBaseModel:
        return models.FryerTurboBlazeRequestData.from_dict(
            self._cook_request_dict(recipe)
        )

    async def get_details(self) -> None:
        resp = await self.call_bypassv2_api(
            payload_method='getAirfryerStatus',
            payload_update=self._payload_update(),
        )
        resp_model = process_bypassv2_result(
            self,
            logger,
            'get_details',
            resp,
            models.FryerTurboBlazeDetailResult,
        )

        if resp_model is None:
            return

        # Standby responses report the unit too (P583S-KUS capture).
        if resp_model.tempUnit:
            self.temp_unit = resp_model.tempUnit

        if (
            resp_model.cookStatus == AirFryerCookStatus.STANDBY.value
            or not resp_model.stepArray
        ):
            self.state.set_standby()
            return

        if resp_model.cookStatus not in self.status_map:
            logger.warning(
                'Unknown cook status %s for %s, keeping the last state',
                resp_model.cookStatus,
                self.device_name,
            )
            return

        if not 0 <= resp_model.stepIndex < len(resp_model.stepArray):
            logger.warning(
                'stepIndex %s out of range for %s steps on %s, keeping the last state',
                resp_model.stepIndex,
                len(resp_model.stepArray),
                self.device_name,
            )
            return

        cook_step = resp_model.stepArray[resp_model.stepIndex]

        # currentTemp from the bypassV2 getAirfryerStatus response is the
        # device's hardware sensor reading, which the firmware always
        # reports in Celsius regardless of the reported tempUnit. The
        # tempUnit field governs only the echoed cookTemp/preheatTemp
        # values. Normalize so consumers can compare cook_temp and
        # current_temp without a unit-aware crutch.
        _current_temp = resp_model.currentTemp
        if _current_temp is not None and self.temp_unit == TemperatureUnits.FAHRENHEIT:
            _current_temp = round(
                TemperatureUnits.to_fahrenheit(_current_temp, TemperatureUnits.CELSIUS)
            )

        self.state_chamber_1.set_state(
            cook_status=self.status_map[resp_model.cookStatus],
            cook_time=cook_step.cookSetTime,
            cook_last_time=cook_step.cookLastTime,
            cook_temp=cook_step.cookTemp,
            cook_mode=cook_step.mode,
            # preheatSetTime is 0 when the cook has no preheat step.
            preheat_set_time=resp_model.preheatSetTime or None,
            preheat_last_time=resp_model.preheatLastTime,
            current_temp=_current_temp,
            recipe=cook_step.recipeName or None,
            total_last_time=resp_model.totalTimeRemaining,
        )

    async def end(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        if self._validate_chamber(chamber) is None:
            return False
        payload_method = 'endCook'
        resp = await self.call_bypassv2_api(
            payload_method=payload_method,
            payload_update=self._payload_update(),
        )
        r = Helpers.process_dev_response(logger, 'end', self, resp)
        if r is None:
            return False
        self.state.set_standby()
        return True

    async def set_mode_from_recipe(
        self,
        recipe: AirFryerPresetRecipe,
        *,
        chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
    ) -> bool:
        if self._validate_chamber(chamber) is None:
            return False
        prepared = self._prepare_recipe(recipe)
        if prepared is None:
            return False
        recipe = prepared
        payload_method = 'startCook'
        data = self._build_cook_request(recipe)
        resp = await self.call_bypassv2_api(
            payload_method=payload_method,
            data=data.to_dict(),
            payload_update=self._payload_update(),
        )
        r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp)
        if r is None:
            return False
        cook_time = self.convert_time_for_api(recipe.cook_time)
        preheat_time = (
            self.convert_time_for_api(recipe.preheat_time)
            if recipe.preheat_time
            else None
        )
        self.state.set_state(
            # A program with preheat runs the preheat phase first.
            cook_status=AirFryerCookStatus.HEATING
            if preheat_time
            else AirFryerCookStatus.COOKING,
            cook_time=cook_time,
            cook_last_time=cook_time,
            cook_temp=recipe.target_temp,
            cook_mode=recipe.cook_mode,
            preheat_set_time=preheat_time,
            preheat_last_time=preheat_time,
        )
        return True

    async def set_mode(
        self,
        cook_time: int,
        cook_temp: int,
        *,
        temp_unit: TemperatureUnits | str | None = None,
        preheat_time: int | None = None,
        cook_mode: str | None = None,
        chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
    ) -> bool:
        unit = self._resolve_temp_unit(temp_unit)
        if unit is None:
            return False
        recipe = self._recipe_for_set_mode(
            cook_time, cook_temp, cook_mode, preheat_time, temp_unit=unit
        )
        if recipe is None:
            return False
        return await self.set_mode_from_recipe(recipe, chamber=chamber)

get_details async ↲

get_details() -> None

Inherited From VeSyncBaseDevice

Get device details.

This method is defined in each device class to contain the logic to pull the device state from the API and update the device's state attribute. The update() method should be called to update the device state.

Source code in src/pyvesync/devices/vesynckitchen.py
async def get_details(self) -> None:
    resp = await self.call_bypassv2_api(
        payload_method='getAirfryerStatus',
        payload_update=self._payload_update(),
    )
    resp_model = process_bypassv2_result(
        self,
        logger,
        'get_details',
        resp,
        models.FryerTurboBlazeDetailResult,
    )

    if resp_model is None:
        return

    # Standby responses report the unit too (P583S-KUS capture).
    if resp_model.tempUnit:
        self.temp_unit = resp_model.tempUnit

    if (
        resp_model.cookStatus == AirFryerCookStatus.STANDBY.value
        or not resp_model.stepArray
    ):
        self.state.set_standby()
        return

    if resp_model.cookStatus not in self.status_map:
        logger.warning(
            'Unknown cook status %s for %s, keeping the last state',
            resp_model.cookStatus,
            self.device_name,
        )
        return

    if not 0 <= resp_model.stepIndex < len(resp_model.stepArray):
        logger.warning(
            'stepIndex %s out of range for %s steps on %s, keeping the last state',
            resp_model.stepIndex,
            len(resp_model.stepArray),
            self.device_name,
        )
        return

    cook_step = resp_model.stepArray[resp_model.stepIndex]

    # currentTemp from the bypassV2 getAirfryerStatus response is the
    # device's hardware sensor reading, which the firmware always
    # reports in Celsius regardless of the reported tempUnit. The
    # tempUnit field governs only the echoed cookTemp/preheatTemp
    # values. Normalize so consumers can compare cook_temp and
    # current_temp without a unit-aware crutch.
    _current_temp = resp_model.currentTemp
    if _current_temp is not None and self.temp_unit == TemperatureUnits.FAHRENHEIT:
        _current_temp = round(
            TemperatureUnits.to_fahrenheit(_current_temp, TemperatureUnits.CELSIUS)
        )

    self.state_chamber_1.set_state(
        cook_status=self.status_map[resp_model.cookStatus],
        cook_time=cook_step.cookSetTime,
        cook_last_time=cook_step.cookLastTime,
        cook_temp=cook_step.cookTemp,
        cook_mode=cook_step.mode,
        # preheatSetTime is 0 when the cook has no preheat step.
        preheat_set_time=resp_model.preheatSetTime or None,
        preheat_last_time=resp_model.preheatLastTime,
        current_temp=_current_temp,
        recipe=cook_step.recipeName or None,
        total_last_time=resp_model.totalTimeRemaining,
    )

end async ↲

end(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

End the current cooking or preheating session.

On success the chamber state is set to standby. On the CS137/CS158 the command depends on the last known state, and it returns False without a request when that state is neither cooking nor preheating. On the CAF-TF101S/TF102S, CHAMBER_1 during a whole-basket cook (work_chamber WHOLE) ends the whole basket.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to end for. Default is CHAMBER_1. An unsupported chamber returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/devices/vesynckitchen.py
async def end(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    if self._validate_chamber(chamber) is None:
        return False
    payload_method = 'endCook'
    resp = await self.call_bypassv2_api(
        payload_method=payload_method,
        payload_update=self._payload_update(),
    )
    r = Helpers.process_dev_response(logger, 'end', self, resp)
    if r is None:
        return False
    self.state.set_standby()
    return True

set_mode_from_recipe async ↲

set_mode_from_recipe(
    recipe: AirFryerPresetRecipe,
    *,
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start a cook from a recipe.

recipe.cook_mode must be one of the fryer's cook modes (see get_cook_mode()); its API mode string is sent. recipe.target_temp is converted from recipe.temp_unit to the device unit, rounded to the mode's step and checked against its limits. The times are checked as in set_mode(). A failed check (including an invalid recipe.temp_unit) logs a warning and returns False without a request. The recipe passed in is not changed.

Parameters:

Name Type Description Default
recipe AirFryerPresetRecipe

The preset recipe to use. Its recipe ID, name and type are sent as given.

required
chamber AirFryerChamber | int

The chamber to cook in. Default is CHAMBER_1. A chamber not in supported_chambers returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Note

Get a fryer's preset with get_mode_recipe() and change it with dataclasses.replace. See AirFryerPresetRecipe.

Source code in src/pyvesync/devices/vesynckitchen.py
async def set_mode_from_recipe(
    self,
    recipe: AirFryerPresetRecipe,
    *,
    chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
) -> bool:
    if self._validate_chamber(chamber) is None:
        return False
    prepared = self._prepare_recipe(recipe)
    if prepared is None:
        return False
    recipe = prepared
    payload_method = 'startCook'
    data = self._build_cook_request(recipe)
    resp = await self.call_bypassv2_api(
        payload_method=payload_method,
        data=data.to_dict(),
        payload_update=self._payload_update(),
    )
    r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp)
    if r is None:
        return False
    cook_time = self.convert_time_for_api(recipe.cook_time)
    preheat_time = (
        self.convert_time_for_api(recipe.preheat_time)
        if recipe.preheat_time
        else None
    )
    self.state.set_state(
        # A program with preheat runs the preheat phase first.
        cook_status=AirFryerCookStatus.HEATING
        if preheat_time
        else AirFryerCookStatus.COOKING,
        cook_time=cook_time,
        cook_last_time=cook_time,
        cook_temp=recipe.target_temp,
        cook_mode=recipe.cook_mode,
        preheat_set_time=preheat_time,
        preheat_last_time=preheat_time,
    )
    return True

set_mode async ↲

set_mode(
    cook_time: int,
    cook_temp: int,
    *,
    temp_unit: TemperatureUnits | str | None = None,
    preheat_time: int | None = None,
    cook_mode: str | None = None,
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start a cook with the given time and temperature.

cook_temp is converted to the device unit if needed, rounded to the cook mode's step (see round_temperature()) and checked against that mode's limits (see temp_limits()). Out of range logs a warning and returns False without a request. On success the chamber state is set locally from the request; call update() for the device's reported state.

Parameters:

Name Type Description Default
cook_time int

The cooking time in seconds, at least 1 (60 on the CS137/CS158, whose API uses minutes and truncates to whole minutes). A shorter time returns False without a request. A mode's time_limits, where declared, also apply; a time outside them returns False without a request. Other modes have no upper limit.

required
cook_temp int

The cooking temperature, in temp_unit.

required
temp_unit TemperatureUnits | str | None

The unit of cook_temp; defaults to the device's current temp_unit. An unrecognised unit string returns False without a request.

None
preheat_time int | None

The preheating time in seconds, if any. Used only on fryers with the PREHEAT feature: dropped on the CAF-P583S and ignored on the dual-chamber fryers. On fryers with preheat it must be 0 or at least one API time unit (60 seconds on the CS137/CS158); otherwise False without a request.

None
cook_mode str | None

Cook mode to use, an AirFryerCookModes key or the API mode string (see get_cook_mode()). Its recipe ID, name and type are sent. None uses the default preset. A mode the fryer does not have returns False without a request.

None
chamber AirFryerChamber | int

The chamber to cook in. Default is CHAMBER_1. A chamber not in supported_chambers returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/devices/vesynckitchen.py
async def set_mode(
    self,
    cook_time: int,
    cook_temp: int,
    *,
    temp_unit: TemperatureUnits | str | None = None,
    preheat_time: int | None = None,
    cook_mode: str | None = None,
    chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
) -> bool:
    unit = self._resolve_temp_unit(temp_unit)
    if unit is None:
        return False
    recipe = self._recipe_for_set_mode(
        cook_time, cook_temp, cook_mode, preheat_time, temp_unit=unit
    )
    if recipe is None:
        return False
    return await self.set_mode_from_recipe(recipe, chamber=chamber)

pyvesync.devices.vesynckitchen.VeSyncDualBlazeFryer ↲

Bases: VeSyncTurboBlazeFryer

Cosori Dual Blaze Air Fryer Class (CAF-P583S).

Uses the TurboBlaze bypassV2 protocol with the request shape from the CAF-P583S-KUS capture (2025-05-21): startCook data uses accountID (not accountId) and sends hasLinkage False, and every payload carries subDeviceNo and subDeviceType. The KEU variant has no capture and is assumed to match. The P583S has no preheat, so preheat_time is dropped with a debug log.

Source code in src/pyvesync/devices/vesynckitchen.py
class VeSyncDualBlazeFryer(VeSyncTurboBlazeFryer):
    """Cosori Dual Blaze Air Fryer Class (CAF-P583S).

    Uses the TurboBlaze bypassV2 protocol with the request shape from the
    CAF-P583S-KUS capture (2025-05-21): ``startCook`` data uses ``accountID``
    (not ``accountId``) and sends ``hasLinkage`` False, and every payload
    carries ``subDeviceNo`` and ``subDeviceType``. The KEU variant has no
    capture and is assumed to match. The P583S has no preheat, so
    ``preheat_time`` is dropped with a debug log.
    """

    __slots__ = ()

    def _payload_update(self) -> dict | None:
        return dict(BYPASSV2_SUB_DEVICE_PAYLOAD)

    def _build_cook_request(self, recipe: AirFryerPresetRecipe) -> RequestBaseModel:
        cook_req = self._cook_request_dict(recipe)
        cook_req['accountID'] = cook_req.pop('accountId')
        cook_req['hasLinkage'] = False
        return models.FryerDualBlazeRequestData.from_dict(cook_req)

pyvesync.devices.vesynckitchen.VeSyncDualAirFryer ↲

Bases: BypassV2Mixin, VeSyncFryer

Cosori Dual Air Fryer Class (CAF-TF101S, CAF-TF102S, CAF-DC111S).

Chambers are selected with AirFryerChamber:

  • CHAMBER_1 / CHAMBER_2: one chamber (left/right on the TF101S/TF102S, upper/lower on the DC111S).
  • WHOLE: the merged basket with the divider removed (TwinFry models only, WHOLE_CHAMBER feature).
  • BOTH: both chambers with identical settings, sent as the app's Sync mode (workChamber 4, syncType 2: both chambers finish together).

During a Sync cook with different times, the DC111S reports the chamber that waits to start as AirFryerCookStatus.HOLD. Its wait counts down only while the other chamber is cooking. set_chamber_modes() starts a Sync cook with different settings per chamber, on fryers with the CHAMBER_MODES feature; other fryers need the app or the fryer for that.

The device has no preheat, pause, or resume API: preheat_time and a recipe's preheat_time are ignored, and stop() / resume() return False. Pausing is handled physically by pulling the basket out, which triggers a cookStop or pullOut state. Pushing the basket back in resumes cooking automatically. The status has no temperature reading, so current_temp stays None.

Parameters:

Name Type Description Default
details ResponseDeviceDetailsModel

Device details.

required
manager VeSync

Manager class.

required
feature_map AirFryerMap

Device feature map.

required

Attributes:

Name Type Description
state_chamber_1 FryerState

State for chamber 1 (left) or whole.

state_chamber_2 FryerState

State for chamber 2 (right).

sync_chambers bool

Read-only. True only for the app's Sync mode (syncType 2). Set by update(); a successful set_mode() / set_mode_from_recipe() sets it True for BOTH and False for any other chamber, a successful set_chamber_modes() sets it True for SYNC and False otherwise, and end(BOTH) sets it False.

work_chamber AirFryerChamber | None

Read-only. Chamber the device reports as active (workChamber); None when idle. Set by update(). A successful WHOLE start sets WHOLE, any other start clears a WHOLE value, and ending WHOLE or BOTH clears it. end(CHAMBER_1) while it is WHOLE ends the whole basket.

A failed poll leaves both chamber states unchanged, apart from the connection status and update time, which both chambers take from the response. A chamber with an unknown cookStatus keeps its state.

Source code in src/pyvesync/devices/vesynckitchen.py
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
class VeSyncDualAirFryer(BypassV2Mixin, VeSyncFryer):
    """Cosori Dual Air Fryer Class (CAF-TF101S, CAF-TF102S, CAF-DC111S).

    Chambers are selected with ``AirFryerChamber``:

    - ``CHAMBER_1`` / ``CHAMBER_2``: one chamber (left/right on the
      TF101S/TF102S, upper/lower on the DC111S).
    - ``WHOLE``: the merged basket with the divider removed (TwinFry models
      only, ``WHOLE_CHAMBER`` feature).
    - ``BOTH``: both chambers with identical settings, sent as the app's Sync
      mode (``workChamber`` 4, ``syncType`` 2: both chambers finish together).

    During a Sync cook with different times, the DC111S reports the chamber
    that waits to start as ``AirFryerCookStatus.HOLD``. Its wait counts down
    only while the other chamber is cooking. ``set_chamber_modes()`` starts
    a Sync cook with different settings per chamber, on fryers with the
    ``CHAMBER_MODES`` feature; other fryers need the app or the fryer for that.

    The device has no preheat, pause, or resume API: ``preheat_time`` and a
    recipe's ``preheat_time`` are ignored, and ``stop()`` / ``resume()``
    return False. Pausing is handled physically by pulling the basket out,
    which triggers a ``cookStop`` or ``pullOut`` state. Pushing the basket
    back in resumes cooking automatically. The status has no temperature
    reading, so ``current_temp`` stays None.

    Args:
        details (ResponseDeviceDetailsModel): Device details.
        manager (VeSync): Manager class.
        feature_map (AirFryerMap): Device feature map.

    Attributes:
        state_chamber_1 (FryerState): State for chamber 1 (left) or whole.
        state_chamber_2 (FryerState): State for chamber 2 (right).
        sync_chambers (bool): Read-only. True only for the app's Sync mode
            (``syncType`` 2). Set by ``update()``; a successful
            ``set_mode()`` / ``set_mode_from_recipe()`` sets it True for
            ``BOTH`` and False for any other chamber, a successful
            ``set_chamber_modes()`` sets it True for ``SYNC`` and False
            otherwise, and ``end(BOTH)`` sets it False.
        work_chamber (AirFryerChamber | None): Read-only. Chamber the device
            reports as active (``workChamber``); None when idle. Set by
            ``update()``. A successful ``WHOLE`` start sets ``WHOLE``, any other
            start clears a ``WHOLE`` value, and ending ``WHOLE`` or ``BOTH``
            clears it. ``end(CHAMBER_1)`` while it is ``WHOLE`` ends the whole
            basket.

    A failed poll leaves both chamber states unchanged, apart from the
    connection status and update time, which both chambers take from the
    response. A chamber with an unknown ``cookStatus`` keeps its state.
    """

    __slots__ = ()

    def _get_sync_type(self, chamber: AirFryerChamber) -> AirFryerSyncType:
        """Return the API syncType value for a validated chamber."""
        if chamber == AirFryerChamber.BOTH:
            return AirFryerSyncType.SYNC
        return AirFryerSyncType.OFF

    def _set_reported_sync(self, value: bool) -> None:
        """Set ``sync_chambers`` on both chamber states."""
        self.state_chamber_1.sync_chambers = value
        self.state_chamber_2.sync_chambers = value

    def _set_reported_work_chamber(self, value: AirFryerChamber | None) -> None:
        """Set ``work_chamber`` on both chamber states."""
        self.state_chamber_1.work_chamber = value
        self.state_chamber_2.work_chamber = value

    async def get_details(self) -> None:
        """Get device details from the API.

        Polls ``getAirfryerMultiStatus`` and updates state for each chamber.
        """
        resp = await self.call_bypassv2_api(
            payload_method='getAirfryerMultiStatus',
            payload_update=dict(BYPASSV2_SUB_DEVICE_PAYLOAD),
        )
        resp_model = process_bypassv2_result(
            self,
            logger,
            'get_details',
            resp,
            models.FryerDualMultiStatusResult,
        )

        # process_bypassv2_result only updates self.state (chamber 1).
        self.state_chamber_2.last_update_ts = self.state_chamber_1.last_update_ts
        self.state_chamber_2.connection_status = self.state_chamber_1.connection_status
        if resp_model is None:
            return

        if resp_model.tempUnit is not None:
            self.temp_unit = resp_model.tempUnit

        # Update sync state and active chamber from API response
        self._set_reported_sync(resp_model.syncType == AirFryerSyncType.SYNC)
        try:
            self._set_reported_work_chamber(AirFryerChamber(resp_model.workChamber))
        except ValueError:
            if resp_model.workChamber != 0:
                logger.debug(
                    'Unknown workChamber %s for %s',
                    resp_model.workChamber,
                    self.device_name,
                )
            self._set_reported_work_chamber(None)

        # Track which chambers were updated
        updated_chambers: set[AirFryerChamber] = set()

        for status_item in resp_model.statusList:
            ch = self._apply_chamber_status(status_item)
            if ch is not None:
                updated_chambers.add(ch)

        # Set any chambers not in the response to standby. Whole-chamber
        # status is reported as a single chamber 3 item, so chamber 2 idles.
        if (
            AirFryerChamber.CHAMBER_1 not in updated_chambers
            and AirFryerChamber.WHOLE not in updated_chambers
        ):
            self.state_chamber_1.set_standby()
        if AirFryerChamber.CHAMBER_2 not in updated_chambers:
            self.state_chamber_2.set_standby()

        self._pause_held_chamber(self.state_chamber_1, self.state_chamber_2)
        self._pause_held_chamber(self.state_chamber_2, self.state_chamber_1)

    @staticmethod
    def _pause_held_chamber(held: FryerState, other: FryerState) -> None:
        """Stop a held chamber's countdown while the other chamber is not cooking.

        During a Sync cook the held chamber's wait is the other chamber's
        remaining time minus its own cook time, so it only advances while the
        other chamber cooks (DC111S captures: the wait stays put while the
        other chamber is in ``cookStop``).
        """
        if (
            held.cook_status == AirFryerCookStatus.HOLD
            and other.cook_status != AirFryerCookStatus.COOKING
        ):
            held.last_timestamp = None

    def _apply_chamber_status(
        self, status_item: models.FryerDualChamberStatusItem
    ) -> AirFryerChamber | None:
        """Update one chamber's state from a ``statusList`` item.

        A chamber with an unknown ``cookStatus`` keeps its last state but still
        counts as reported.

        Args:
            status_item (models.FryerDualChamberStatusItem): The status item.

        Returns:
            AirFryerChamber | None: The reported chamber, or None (after a
            warning) if the chamber number is unknown.
        """
        chamber_state = self.get_chamber_state(status_item.chamber)
        if chamber_state is None:
            logger.warning(
                'Unknown chamber %s in status for %s',
                status_item.chamber,
                self.device_name,
            )
            return None
        ch = AirFryerChamber(status_item.chamber)
        if status_item.cookStatus not in self.status_map:
            logger.warning(
                'Unknown cook status %s for %s chamber %d, keeping its last state',
                status_item.cookStatus,
                self.device_name,
                status_item.chamber,
            )
        elif status_item.cookStatus == AirFryerCookStatus.STANDBY.value:
            chamber_state.set_standby()
        else:
            chamber_state.set_state(
                cook_status=self.status_map[status_item.cookStatus],
                cook_time=status_item.cookSetTime,
                cook_last_time=status_item.currentRemainingTime,
                cook_temp=status_item.cookTemp,
                cook_mode=status_item.mode,
                recipe=status_item.recipeName or None,
                total_last_time=status_item.totalTimeRemaining,
            )
        return ch

    async def end(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        """End the current cooking session.

        Args:
            chamber (AirFryerChamber | int): Chamber to end. ``BOTH`` ends
                both chambers. ``CHAMBER_1`` during a whole-basket cook
                (``work_chamber`` ``WHOLE``) ends the whole basket. During a
                Sync cook (``sync_chambers``), one chamber sends an end for
                that chamber only.

        Returns:
            bool: True on success, and the ended chamber state(s) are set to
            standby. False without a request for an unsupported chamber.
        """
        member = self._validate_chamber(chamber)
        if member is None:
            return False
        if (
            member == AirFryerChamber.CHAMBER_1
            and self.work_chamber == AirFryerChamber.WHOLE
        ):
            logger.debug(
                '%s is cooking in the whole basket; ending WHOLE', self.device_name
            )
            member = AirFryerChamber.WHOLE
        if self.sync_chambers and member in (
            AirFryerChamber.CHAMBER_1,
            AirFryerChamber.CHAMBER_2,
        ):
            logger.debug(
                '%s is in Sync mode; end(%s) ends one chamber, '
                'pass AirFryerChamber.BOTH to end both',
                self.device_name,
                member.name,
            )
        resp = await self.call_bypassv2_api(
            payload_method='endCook',
            data={'chamber': int(member)},
            payload_update=dict(BYPASSV2_SUB_DEVICE_PAYLOAD),
        )
        r = Helpers.process_dev_response(logger, 'end', self, resp)
        if r is None:
            return False

        if member == AirFryerChamber.BOTH:
            self.state_chamber_1.set_standby()
            self.state_chamber_2.set_standby()
            self._set_reported_sync(False)
        else:
            self._chamber_state(member).set_standby()
        if member in (AirFryerChamber.WHOLE, AirFryerChamber.BOTH):
            self._set_reported_work_chamber(None)
        return True

    async def _pause_or_resume(
        self, chamber: AirFryerChamber | int, method: str, name: str
    ) -> list[FryerState] | None:
        """Send pauseWork / resumeWork for a chamber.

        Returns the affected chamber states on success, None otherwise.
        """
        member = self._validate_chamber(chamber)
        if member is None:
            return None
        if AirFryerFeatures.RESUMABLE not in self.features:
            logger.info('%s not supported by this fryer.', name)
            return None
        if member == AirFryerChamber.WHOLE:
            logger.info('%s is not supported for the whole basket.', name)
            return None
        resp = await self.call_bypassv2_api(
            payload_method=method,
            data={'chamber': int(member)},
            payload_update=dict(BYPASSV2_SUB_DEVICE_PAYLOAD),
        )
        if Helpers.process_dev_response(logger, name, self, resp) is None:
            return None
        if member == AirFryerChamber.BOTH:
            return [self.state_chamber_1, self.state_chamber_2]
        return [self._chamber_state(member)]

    async def stop(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        """Pause cooking in a chamber (``pauseWork``).

        Needs the ``RESUMABLE`` feature. ``BOTH`` pauses both chambers in one
        request. During a Sync cook the other chamber's ``HOLD`` wait stops
        counting down too. The paused chamber reports ``cookStop`` and keeps
        its remaining time.

        Args:
            chamber (AirFryerChamber | int): Chamber to pause.

        Returns:
            bool: True on success.
        """
        states = await self._pause_or_resume(chamber, 'pauseWork', 'stop')
        if states is None:
            return False
        for state in states:
            if state.is_in_cook_mode:
                state.set_cook_stop_state()
        return True

    async def resume(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        """Resume a paused chamber (``resumeWork``).

        Needs the ``RESUMABLE`` feature. ``BOTH`` resumes both chambers in one
        request. The remaining time carries over.

        Args:
            chamber (AirFryerChamber | int): Chamber to resume.

        Returns:
            bool: True on success.
        """
        states = await self._pause_or_resume(chamber, 'resumeWork', 'resume')
        if states is None:
            return False
        for state in states:
            if state.cook_status == AirFryerCookStatus.COOK_STOP:
                state.set_cook_resume_state()
        return True

    def _build_cook_configs(
        self,
        recipe: AirFryerPresetRecipe,
        chamber: AirFryerChamber,
    ) -> list[models.FryerDualCookConfig]:
        """Build cookConfigs list for startMultiCook request.

        Args:
            recipe: The recipe to cook.
            chamber: Validated chamber. ``BOTH`` gives configs for chambers
                1 and 2.

        Returns:
            List of FryerDualCookConfig for the API request.
        """
        config_dict = {
            'cookSetTime': self.convert_time_for_api(recipe.cook_time),
            'cookTemp': recipe.target_temp,
            'mode': recipe.cook_mode,
            'recipeId': recipe.recipe_id,
            'recipeName': recipe.recipe_name,
            'recipeType': recipe.recipe_type,
        }
        if AirFryerFeatures.COOK_CONFIG_SHAKE_TIME in self.features:
            config_dict['shakeTime'] = 0

        if chamber == AirFryerChamber.BOTH:
            return [
                models.FryerDualCookConfig.from_dict({**config_dict, 'chamber': 1}),
                models.FryerDualCookConfig.from_dict({**config_dict, 'chamber': 2}),
            ]
        return [
            models.FryerDualCookConfig.from_dict(
                {**config_dict, 'chamber': int(chamber)}
            ),
        ]

    async def set_mode_from_recipe(
        self,
        recipe: AirFryerPresetRecipe,
        *,
        chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
    ) -> bool:
        """Start cooking with a preset recipe.

        Args:
            recipe: The preset recipe to use. Its ``preheat_time`` is ignored.
            chamber (AirFryerChamber | int): Chamber to cook in. ``BOTH``
                cooks both chambers in sync.

        Returns:
            True if the command was successful.
        """
        member = self._validate_chamber(chamber)
        if member is None:
            return False
        prepared = self._prepare_recipe(recipe)
        if prepared is None:
            return False
        cook_configs = self._build_cook_configs(prepared, member)
        return await self._start_cook_configs(
            cook_configs, member, self._get_sync_type(member)
        )

    async def _start_cook_configs(
        self,
        configs: list[models.FryerDualCookConfig],
        chamber: AirFryerChamber,
        sync_type: AirFryerSyncType,
    ) -> bool:
        """Send validated configs to ``startMultiCook`` and update the state.

        Args:
            configs: The chamber configs to start.
            chamber: Validated chamber sent as ``workChamber``.
            sync_type: The ``syncType`` to send.

        Returns:
            True if the command was successful.
        """
        start_data = models.FryerDualStartCookData.from_dict(
            {
                'accountId': self.manager.account_id,
                'cookConfigs': [c.to_dict() for c in configs],
                # readyStart False starts the cook at once; True waits for the
                # start button.
                'readyStart': False,
                'syncType': int(sync_type),
                'tempUnit': self.temp_unit.code,
                'workChamber': int(chamber),
            }
        )

        resp = await self.call_bypassv2_api(
            payload_method='startMultiCook',
            data=start_data.to_dict(),
            payload_update=dict(BYPASSV2_SUB_DEVICE_PAYLOAD),
        )
        r = Helpers.process_dev_response(logger, 'set_mode_from_recipe', self, resp)
        if r is None:
            return False

        self._set_reported_sync(sync_type == AirFryerSyncType.SYNC)
        if chamber == AirFryerChamber.WHOLE:
            self._set_reported_work_chamber(AirFryerChamber.WHOLE)
        elif self.work_chamber == AirFryerChamber.WHOLE:
            self._set_reported_work_chamber(None)
        # In a Sync cook with different times, the shorter chamber waits for the
        # difference so both finish together.
        longest = max(config.cookSetTime for config in configs)
        separate_times = sync_type == AirFryerSyncType.SYNC and any(
            config.cookSetTime != longest for config in configs
        )
        for config in configs:
            wait_time = longest - config.cookSetTime if separate_times else 0
            # readyStart is False, so the cook starts without the start button.
            self._chamber_state(AirFryerChamber(config.chamber)).set_state(
                cook_status=(
                    AirFryerCookStatus.HOLD if wait_time else AirFryerCookStatus.COOKING
                ),
                cook_time=config.cookSetTime,
                cook_last_time=wait_time or config.cookSetTime,
                total_last_time=longest if separate_times else None,
                cook_temp=config.cookTemp,
                cook_mode=config.mode,
                recipe=config.recipeName,
            )
        return True

    async def set_chamber_modes(
        self,
        chamber_1: AirFryerPresetRecipe,
        chamber_2: AirFryerPresetRecipe | None = None,
        *,
        sync_type: AirFryerSyncType = AirFryerSyncType.SYNC,
    ) -> bool:
        if AirFryerFeatures.CHAMBER_MODES not in self.features:
            return await super().set_chamber_modes(
                chamber_1, chamber_2, sync_type=sync_type
            )
        if sync_type not in (
            AirFryerSyncType.OFF,
            AirFryerSyncType.SYNC,
            AirFryerSyncType.MATCH,
        ):
            logger.warning(
                'Invalid chamber sync type %s for %s', sync_type, self.device_name
            )
            return False
        if (chamber_2 is None) != (sync_type == AirFryerSyncType.MATCH):
            logger.warning('Invalid chamber recipe combination for %s', self.device_name)
            return False
        first = self._prepare_recipe(chamber_1)
        if first is None:
            return False
        second = first if chamber_2 is None else self._prepare_recipe(chamber_2)
        if second is None:
            return False
        configs = [
            *self._build_cook_configs(first, AirFryerChamber.CHAMBER_1),
            *self._build_cook_configs(second, AirFryerChamber.CHAMBER_2),
        ]
        return await self._start_cook_configs(configs, AirFryerChamber.BOTH, sync_type)

    async def set_mode(
        self,
        cook_time: int,
        cook_temp: int,
        *,
        temp_unit: TemperatureUnits | str | None = None,
        preheat_time: int | None = None,
        cook_mode: str | None = None,
        chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
    ) -> bool:
        """Set cooking mode with manual parameters.

        Args:
            cook_time: Cooking time in seconds.
            cook_temp: Cooking temperature, in ``temp_unit``.
            temp_unit: Unit of ``cook_temp``; defaults to the device's
                current ``temp_unit``. An unrecognised unit string returns
                False without a request.
            preheat_time: Not used for this device.
            cook_mode: Cook mode, an ``AirFryerCookModes`` key or the API
                mode string (e.g. 'AirFry'). None uses the default preset.
            chamber (AirFryerChamber | int): Chamber to cook in. ``BOTH``
                cooks both chambers in sync.

        Returns:
            True if the command was successful.
        """
        del preheat_time  # not supported by this device
        unit = self._resolve_temp_unit(temp_unit)
        if unit is None:
            return False
        recipe = self._recipe_for_set_mode(
            cook_time, cook_temp, cook_mode, None, temp_unit=unit
        )
        if recipe is None:
            return False
        return await self.set_mode_from_recipe(recipe, chamber=chamber)

get_details async ↲

get_details() -> None

Inherited From VeSyncBaseDevice

Get device details from the API.

Polls getAirfryerMultiStatus and updates state for each chamber.

Source code in src/pyvesync/devices/vesynckitchen.py
async def get_details(self) -> None:
    """Get device details from the API.

    Polls ``getAirfryerMultiStatus`` and updates state for each chamber.
    """
    resp = await self.call_bypassv2_api(
        payload_method='getAirfryerMultiStatus',
        payload_update=dict(BYPASSV2_SUB_DEVICE_PAYLOAD),
    )
    resp_model = process_bypassv2_result(
        self,
        logger,
        'get_details',
        resp,
        models.FryerDualMultiStatusResult,
    )

    # process_bypassv2_result only updates self.state (chamber 1).
    self.state_chamber_2.last_update_ts = self.state_chamber_1.last_update_ts
    self.state_chamber_2.connection_status = self.state_chamber_1.connection_status
    if resp_model is None:
        return

    if resp_model.tempUnit is not None:
        self.temp_unit = resp_model.tempUnit

    # Update sync state and active chamber from API response
    self._set_reported_sync(resp_model.syncType == AirFryerSyncType.SYNC)
    try:
        self._set_reported_work_chamber(AirFryerChamber(resp_model.workChamber))
    except ValueError:
        if resp_model.workChamber != 0:
            logger.debug(
                'Unknown workChamber %s for %s',
                resp_model.workChamber,
                self.device_name,
            )
        self._set_reported_work_chamber(None)

    # Track which chambers were updated
    updated_chambers: set[AirFryerChamber] = set()

    for status_item in resp_model.statusList:
        ch = self._apply_chamber_status(status_item)
        if ch is not None:
            updated_chambers.add(ch)

    # Set any chambers not in the response to standby. Whole-chamber
    # status is reported as a single chamber 3 item, so chamber 2 idles.
    if (
        AirFryerChamber.CHAMBER_1 not in updated_chambers
        and AirFryerChamber.WHOLE not in updated_chambers
    ):
        self.state_chamber_1.set_standby()
    if AirFryerChamber.CHAMBER_2 not in updated_chambers:
        self.state_chamber_2.set_standby()

    self._pause_held_chamber(self.state_chamber_1, self.state_chamber_2)
    self._pause_held_chamber(self.state_chamber_2, self.state_chamber_1)

end async ↲

end(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

End the current cooking session.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

Chamber to end. BOTH ends both chambers. CHAMBER_1 during a whole-basket cook (work_chamber WHOLE) ends the whole basket. During a Sync cook (sync_chambers), one chamber sends an end for that chamber only.

CHAMBER_1

Returns:

Name Type Description
bool bool

True on success, and the ended chamber state(s) are set to

bool

standby. False without a request for an unsupported chamber.

Source code in src/pyvesync/devices/vesynckitchen.py
async def end(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    """End the current cooking session.

    Args:
        chamber (AirFryerChamber | int): Chamber to end. ``BOTH`` ends
            both chambers. ``CHAMBER_1`` during a whole-basket cook
            (``work_chamber`` ``WHOLE``) ends the whole basket. During a
            Sync cook (``sync_chambers``), one chamber sends an end for
            that chamber only.

    Returns:
        bool: True on success, and the ended chamber state(s) are set to
        standby. False without a request for an unsupported chamber.
    """
    member = self._validate_chamber(chamber)
    if member is None:
        return False
    if (
        member == AirFryerChamber.CHAMBER_1
        and self.work_chamber == AirFryerChamber.WHOLE
    ):
        logger.debug(
            '%s is cooking in the whole basket; ending WHOLE', self.device_name
        )
        member = AirFryerChamber.WHOLE
    if self.sync_chambers and member in (
        AirFryerChamber.CHAMBER_1,
        AirFryerChamber.CHAMBER_2,
    ):
        logger.debug(
            '%s is in Sync mode; end(%s) ends one chamber, '
            'pass AirFryerChamber.BOTH to end both',
            self.device_name,
            member.name,
        )
    resp = await self.call_bypassv2_api(
        payload_method='endCook',
        data={'chamber': int(member)},
        payload_update=dict(BYPASSV2_SUB_DEVICE_PAYLOAD),
    )
    r = Helpers.process_dev_response(logger, 'end', self, resp)
    if r is None:
        return False

    if member == AirFryerChamber.BOTH:
        self.state_chamber_1.set_standby()
        self.state_chamber_2.set_standby()
        self._set_reported_sync(False)
    else:
        self._chamber_state(member).set_standby()
    if member in (AirFryerChamber.WHOLE, AirFryerChamber.BOTH):
        self._set_reported_work_chamber(None)
    return True

stop async ↲

stop(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

Pause cooking in a chamber (pauseWork).

Needs the RESUMABLE feature. BOTH pauses both chambers in one request. During a Sync cook the other chamber's HOLD wait stops counting down too. The paused chamber reports cookStop and keeps its remaining time.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

Chamber to pause.

CHAMBER_1

Returns:

Name Type Description
bool bool

True on success.

Source code in src/pyvesync/devices/vesynckitchen.py
async def stop(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    """Pause cooking in a chamber (``pauseWork``).

    Needs the ``RESUMABLE`` feature. ``BOTH`` pauses both chambers in one
    request. During a Sync cook the other chamber's ``HOLD`` wait stops
    counting down too. The paused chamber reports ``cookStop`` and keeps
    its remaining time.

    Args:
        chamber (AirFryerChamber | int): Chamber to pause.

    Returns:
        bool: True on success.
    """
    states = await self._pause_or_resume(chamber, 'pauseWork', 'stop')
    if states is None:
        return False
    for state in states:
        if state.is_in_cook_mode:
            state.set_cook_stop_state()
    return True

resume async ↲

resume(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

Resume a paused chamber (resumeWork).

Needs the RESUMABLE feature. BOTH resumes both chambers in one request. The remaining time carries over.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

Chamber to resume.

CHAMBER_1

Returns:

Name Type Description
bool bool

True on success.

Source code in src/pyvesync/devices/vesynckitchen.py
async def resume(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    """Resume a paused chamber (``resumeWork``).

    Needs the ``RESUMABLE`` feature. ``BOTH`` resumes both chambers in one
    request. The remaining time carries over.

    Args:
        chamber (AirFryerChamber | int): Chamber to resume.

    Returns:
        bool: True on success.
    """
    states = await self._pause_or_resume(chamber, 'resumeWork', 'resume')
    if states is None:
        return False
    for state in states:
        if state.cook_status == AirFryerCookStatus.COOK_STOP:
            state.set_cook_resume_state()
    return True

set_mode_from_recipe async ↲

set_mode_from_recipe(
    recipe: AirFryerPresetRecipe,
    *,
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start cooking with a preset recipe.

Parameters:

Name Type Description Default
recipe AirFryerPresetRecipe

The preset recipe to use. Its preheat_time is ignored.

required
chamber AirFryerChamber | int

Chamber to cook in. BOTH cooks both chambers in sync.

CHAMBER_1

Returns:

Type Description
bool

True if the command was successful.

Source code in src/pyvesync/devices/vesynckitchen.py
async def set_mode_from_recipe(
    self,
    recipe: AirFryerPresetRecipe,
    *,
    chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
) -> bool:
    """Start cooking with a preset recipe.

    Args:
        recipe: The preset recipe to use. Its ``preheat_time`` is ignored.
        chamber (AirFryerChamber | int): Chamber to cook in. ``BOTH``
            cooks both chambers in sync.

    Returns:
        True if the command was successful.
    """
    member = self._validate_chamber(chamber)
    if member is None:
        return False
    prepared = self._prepare_recipe(recipe)
    if prepared is None:
        return False
    cook_configs = self._build_cook_configs(prepared, member)
    return await self._start_cook_configs(
        cook_configs, member, self._get_sync_type(member)
    )

set_chamber_modes async ↲

set_chamber_modes(
    chamber_1: AirFryerPresetRecipe,
    chamber_2: AirFryerPresetRecipe | None = None,
    *,
    sync_type: AirFryerSyncType = SYNC,
) -> bool

Inherited From VeSyncFryer

Start a recipe in each chamber, or one recipe in both.

Only fryers with the CHAMBER_MODES feature implement this. Both recipes are checked before any request, as in set_mode_from_recipe(), so an invalid second recipe does not start the first. On success both chamber states are set locally from the request; call update() for the device's reported state.

Parameters:

Name Type Description Default
chamber_1 AirFryerPresetRecipe

Recipe for chamber 1 with SYNC or OFF, or the recipe for both chambers with MATCH.

required
chamber_2 AirFryerPresetRecipe | None

Recipe for chamber 2. Required with SYNC and OFF; must be None with MATCH.

None
sync_type AirFryerSyncType

SYNC for separate recipes that finish together, OFF for separate recipes that each run on their own time, or MATCH for identical settings.

SYNC

Returns:

Name Type Description
bool bool

True if the command was successful. An unsupported fryer or

bool

an invalid combination returns False without a request.

Source code in src/pyvesync/devices/vesynckitchen.py
async def set_chamber_modes(
    self,
    chamber_1: AirFryerPresetRecipe,
    chamber_2: AirFryerPresetRecipe | None = None,
    *,
    sync_type: AirFryerSyncType = AirFryerSyncType.SYNC,
) -> bool:
    if AirFryerFeatures.CHAMBER_MODES not in self.features:
        return await super().set_chamber_modes(
            chamber_1, chamber_2, sync_type=sync_type
        )
    if sync_type not in (
        AirFryerSyncType.OFF,
        AirFryerSyncType.SYNC,
        AirFryerSyncType.MATCH,
    ):
        logger.warning(
            'Invalid chamber sync type %s for %s', sync_type, self.device_name
        )
        return False
    if (chamber_2 is None) != (sync_type == AirFryerSyncType.MATCH):
        logger.warning('Invalid chamber recipe combination for %s', self.device_name)
        return False
    first = self._prepare_recipe(chamber_1)
    if first is None:
        return False
    second = first if chamber_2 is None else self._prepare_recipe(chamber_2)
    if second is None:
        return False
    configs = [
        *self._build_cook_configs(first, AirFryerChamber.CHAMBER_1),
        *self._build_cook_configs(second, AirFryerChamber.CHAMBER_2),
    ]
    return await self._start_cook_configs(configs, AirFryerChamber.BOTH, sync_type)

set_mode async ↲

set_mode(
    cook_time: int,
    cook_temp: int,
    *,
    temp_unit: TemperatureUnits | str | None = None,
    preheat_time: int | None = None,
    cook_mode: str | None = None,
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Set cooking mode with manual parameters.

Parameters:

Name Type Description Default
cook_time int

Cooking time in seconds.

required
cook_temp int

Cooking temperature, in temp_unit.

required
temp_unit TemperatureUnits | str | None

Unit of cook_temp; defaults to the device's current temp_unit. An unrecognised unit string returns False without a request.

None
preheat_time int | None

Not used for this device.

None
cook_mode str | None

Cook mode, an AirFryerCookModes key or the API mode string (e.g. 'AirFry'). None uses the default preset.

None
chamber AirFryerChamber | int

Chamber to cook in. BOTH cooks both chambers in sync.

CHAMBER_1

Returns:

Type Description
bool

True if the command was successful.

Source code in src/pyvesync/devices/vesynckitchen.py
async def set_mode(
    self,
    cook_time: int,
    cook_temp: int,
    *,
    temp_unit: TemperatureUnits | str | None = None,
    preheat_time: int | None = None,
    cook_mode: str | None = None,
    chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
) -> bool:
    """Set cooking mode with manual parameters.

    Args:
        cook_time: Cooking time in seconds.
        cook_temp: Cooking temperature, in ``temp_unit``.
        temp_unit: Unit of ``cook_temp``; defaults to the device's
            current ``temp_unit``. An unrecognised unit string returns
            False without a request.
        preheat_time: Not used for this device.
        cook_mode: Cook mode, an ``AirFryerCookModes`` key or the API
            mode string (e.g. 'AirFry'). None uses the default preset.
        chamber (AirFryerChamber | int): Chamber to cook in. ``BOTH``
            cooks both chambers in sync.

    Returns:
        True if the command was successful.
    """
    del preheat_time  # not supported by this device
    unit = self._resolve_temp_unit(temp_unit)
    if unit is None:
        return False
    recipe = self._recipe_for_set_mode(
        cook_time, cook_temp, cook_mode, None, temp_unit=unit
    )
    if recipe is None:
        return False
    return await self.set_mode_from_recipe(recipe, chamber=chamber)

pyvesync.base_devices.fryer_base.FryerState ↲

Bases: DeviceState

State class for Air Fryer devices.

Each FryerState instance represents a single cooking chamber. For single chamber fryers, state is set to state_chamber_1. For dual chamber fryers, state_chamber_1 and state_chamber_2 are used independently.

Times are in SECONDS, whatever the device's API uses; set_state() converts API values with the device's convert_time_for_state(). Temperatures are in the device's temp_unit.

Parameters:

Name Type Description Default
device VeSyncFryer

The device object.

required
details ResponseDeviceDetailsModel

The device details.

required
feature_map AirFryerMap

The feature map for the device.

required

Attributes:

Name Type Description
active_time int

Active time of device, defaults to None.

connection_status str

Connection status of device.

device VeSyncFryer

Device object.

device_status str

Device status.

features list[str]

AirFryerFeatures values from the device map.

time_units TimeUnits

The time unit of the device's API. State times are always in seconds.

cook_status AirFryerCookStatus | None

The current cooking status.

cook_mode str | None

The API mode string of the current cook (for example 'AirFry' or 'poultry'), not an AirFryerCookModes key.

temp_unit TemperatureUnits

Unit for all temperatures in this state (read-through to the device's temp_unit).

current_temp int | None

The current temperature in temp_unit units.

cook_set_temp int | None

The set cooking temperature in temp_unit units.

cook_set_time int | None

The set cooking time in seconds.

cook_last_time int | None

The remaining cooking time in seconds at the last update or command. cook_time_remaining counts it down.

last_timestamp datetime | None

When the remaining times were last refreshed. Set each time cook_status is set to cooking, heating or hold (each update while running), None after standby, a stop, or a HOLD poll while the other chamber is not cooking. Used to count the remaining times down.

preheat_set_time int | None

The set preheating time in seconds.

preheat_last_time int | None

The remaining preheating time in seconds at the last update or command. preheat_time_remaining counts it down.

total_last_time int | None

Device-reported total remaining time (preheat plus cook) in seconds at the last update; None when the device does not report it (CS158) or after a local command until the next update.

recipe str | None

The recipe name the device reports. A local command sets it only on dual-chamber fryers.

ready_start bool

Not used by the library; always False.

sync_chambers bool

True only when the device reports the app's Sync mode (syncType 2). Dual-chamber fryers only, always False otherwise: set by update(), by a successful set_mode() / set_mode_from_recipe() (True only for BOTH) and by end(BOTH) (False). The same on both chamber states.

work_chamber AirFryerChamber | None

Chamber the device reports as active (workChamber); None when idle or not reported, and always None on single-chamber fryers. Set by update(); a successful WHOLE start sets it to WHOLE, any other start clears a WHOLE value, and ending WHOLE or BOTH clears it. The same on both chamber states.

Note

Device classes call set_state() with the API response or the sent request, set_standby() to clear the state, and the stop and resume helpers for local pause and resume. Setting a running cook_status (cooking, heating or hold) updates last_timestamp. A failed or unreadable poll leaves the state unchanged.

Source code in src/pyvesync/base_devices/fryer_base.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
class FryerState(DeviceState):
    """State class for Air Fryer devices.

    Each FryerState instance represents a single cooking chamber. For single
    chamber fryers, ``state`` is set to ``state_chamber_1``. For dual chamber
    fryers, ``state_chamber_1`` and ``state_chamber_2`` are used independently.

    Times are in SECONDS, whatever the device's API uses; ``set_state()``
    converts API values with the device's ``convert_time_for_state()``.
    Temperatures are in the device's ``temp_unit``.

    Args:
        device (VeSyncFryer): The device object.
        details (ResponseDeviceDetailsModel): The device details.
        feature_map (AirFryerMap): The feature map for the device.

    Attributes:
        active_time (int): Active time of device, defaults to None.
        connection_status (str): Connection status of device.
        device (VeSyncFryer): Device object.
        device_status (str): Device status.
        features (list[str]): ``AirFryerFeatures`` values from the device map.
        time_units (TimeUnits): The time unit of the device's API. State times
            are always in seconds.
        cook_status (AirFryerCookStatus | None): The current cooking status.
        cook_mode (str | None): The API mode string of the current cook (for
            example ``'AirFry'`` or ``'poultry'``), not an ``AirFryerCookModes``
            key.
        temp_unit (TemperatureUnits): Unit for all temperatures in this state
            (read-through to the device's ``temp_unit``).
        current_temp (int | None): The current temperature in ``temp_unit`` units.
        cook_set_temp (int | None): The set cooking temperature in ``temp_unit``
            units.
        cook_set_time (int | None): The set cooking time in seconds.
        cook_last_time (int | None): The remaining cooking time in seconds at
            the last update or command. ``cook_time_remaining`` counts it down.
        last_timestamp (datetime | None): When the remaining times were last
            refreshed. Set each time ``cook_status`` is set to cooking,
            heating or hold (each update while running), None after standby, a
            stop, or a ``HOLD`` poll while the other chamber is not cooking.
            Used to count the remaining times down.
        preheat_set_time (int | None): The set preheating time in seconds.
        preheat_last_time (int | None): The remaining preheating time in
            seconds at the last update or command. ``preheat_time_remaining``
            counts it down.
        total_last_time (int | None): Device-reported total remaining time
            (preheat plus cook) in seconds at the last update; None when the
            device does not report it (CS158) or after a local command until
            the next update.
        recipe (str | None): The recipe name the device reports. A local
            command sets it only on dual-chamber fryers.
        ready_start (bool): Not used by the library; always False.
        sync_chambers (bool): True only when the device reports the app's Sync
            mode (``syncType`` 2). Dual-chamber fryers only, always False
            otherwise: set by ``update()``, by a successful ``set_mode()`` /
            ``set_mode_from_recipe()`` (True only for ``BOTH``) and by
            ``end(BOTH)`` (False). The same on both chamber states.
        work_chamber (AirFryerChamber | None): Chamber the device reports as
            active (``workChamber``); None when idle or not reported, and
            always None on single-chamber fryers. Set by ``update()``; a
            successful ``WHOLE`` start sets it to ``WHOLE``, any other start
            clears a ``WHOLE`` value, and ending ``WHOLE`` or ``BOTH`` clears
            it. The same on both chamber states.

    Note:
        Device classes call ``set_state()`` with the API response or the sent
        request, ``set_standby()`` to clear the state, and the stop and
        resume helpers for local pause and resume. Setting a running
        ``cook_status`` (cooking, heating or hold) updates ``last_timestamp``. A
        failed or unreadable poll leaves the state unchanged.
    """

    __slots__ = (
        '_cook_status',
        'cook_last_time',
        'cook_mode',
        'cook_set_temp',
        'cook_set_time',
        'current_temp',
        'last_timestamp',
        'preheat_last_time',
        'preheat_set_time',
        'ready_start',
        'recipe',
        'sync_chambers',
        'time_units',
        'total_last_time',
        'work_chamber',
    )

    def __init__(
        self,
        device: VeSyncFryer,
        details: ResponseDeviceDetailsModel,
        feature_map: AirFryerMap,
    ) -> None:
        """Initialize FryerState."""
        super().__init__(device, details, feature_map)
        self.device: VeSyncFryer = device
        self.features: list[str] = feature_map.features
        self.time_units: TimeUnits = feature_map.time_units
        self.sync_chambers: bool = False
        self.work_chamber: AirFryerChamber | None = None
        # Cooking state attributes
        self._cook_status: AirFryerCookStatus | None = None
        self.current_temp: int | None = None
        self.cook_mode: str | None = None
        self.cook_set_temp: int | None = None
        self.cook_set_time: int | None = None
        self.cook_last_time: int | None = None
        self.preheat_set_time: int | None = None
        self.preheat_last_time: int | None = None
        self.total_last_time: int | None = None
        self.last_timestamp: datetime | None = None
        self.recipe: str | None = None
        self.ready_start: bool = False

    @property
    def temp_unit(self) -> TemperatureUnits:
        """Return the unit for all temperatures in this state.

        This is the device-level unit; all state temperatures
        (``current_temp``, ``cook_set_temp``) are expressed in this unit.
        """
        return self.device.temp_unit

    @property
    def is_in_preheat_mode(self) -> bool:
        """Return True if the fryer is in preheat mode.

        A pulled-out basket counts as preheat only while preheat time remains.
        """
        return self.cook_status in PREHEAT_STATUSES or (
            self.cook_status == AirFryerCookStatus.PULL_OUT
            and bool(self.preheat_last_time)
        )

    @property
    def is_in_cook_mode(self) -> bool:
        """Return True if the fryer is in cook mode."""
        return self.cook_status in COOK_STATUSES or (
            self.cook_status == AirFryerCookStatus.PULL_OUT and not self.preheat_last_time
        )

    @property
    def is_cooking(self) -> bool:
        """Return True if the fryer is currently running in cook mode."""
        return self.cook_status == AirFryerCookStatus.COOKING

    @property
    def is_preheating(self) -> bool:
        """Return True if the fryer is currently running in preheat mode."""
        return self.cook_status == AirFryerCookStatus.HEATING

    @property
    def is_running(self) -> bool:
        """Return True if the fryer is running (cooking or preheating)."""
        return self.is_cooking or self.is_preheating

    @property
    def can_resume(self) -> bool:
        """Return True if the status is paused (cook or preheat stop).

        Only the CS137/CS158 implements ``resume()``.
        """
        return self.cook_status in RESUMABLE_STATUSES

    @property
    def cook_status(self) -> AirFryerCookStatus | None:
        """Return the current cooking status."""
        return self._cook_status

    @cook_status.setter
    def cook_status(self, value: AirFryerCookStatus | None) -> None:
        """Set the current cooking status.

        Automatically updates ``last_timestamp`` when setting a running state.

        Args:
            value (AirFryerCookStatus | None): The cooking status to set.
        """
        if value in RUNNING_STATUSES:
            self.last_timestamp = datetime.now(tz=UTC)
        self._cook_status = value

    @property
    def preheat_time_remaining(self) -> int | None:
        """Return the remaining preheat time in seconds."""
        if not self.is_in_preheat_mode:
            return None
        if self.cook_status in [
            AirFryerCookStatus.PREHEAT_STOP,
            AirFryerCookStatus.PULL_OUT,
        ]:
            return self.preheat_last_time
        if self.preheat_last_time is not None and self.last_timestamp is not None:
            return max(
                0,
                self.preheat_last_time
                - int((datetime.now(UTC) - self.last_timestamp).total_seconds()),
            )
        return None

    @property
    def cook_time_remaining(self) -> int | None:
        """Return the remaining cook time in seconds.

        In ``HOLD`` (dual-chamber Sync cook), this is the wait before the
        chamber starts cooking. It counts down only while the other chamber
        cooks; with no ``last_timestamp`` it is the reported wait.
        """
        if not self.is_in_cook_mode:
            return None
        if self.cook_status in [
            AirFryerCookStatus.PULL_OUT,
            AirFryerCookStatus.COOK_STOP,
            AirFryerCookStatus.READY,
        ]:
            return self.cook_last_time
        if self.cook_status == AirFryerCookStatus.HOLD and self.last_timestamp is None:
            return self.cook_last_time

        if self.cook_last_time is not None and self.last_timestamp is not None:
            return max(
                0,
                self.cook_last_time
                - int((datetime.now(UTC) - self.last_timestamp).total_seconds()),
            )
        return None

    @property
    def total_time_remaining(self) -> int | None:
        """Return the time until the cook finishes, in seconds.

        This is the value the app's finish time uses. It comes from the
        device's ``totalTimeRemaining`` where reported (counting down while
        cooking, preheating, or in ``HOLD`` while the other chamber cooks),
        else remaining preheat plus cook time. None in standby, and on the
        CS158 during preheat after an ``update()`` (it reports no cook time
        then).
        """
        if self.cook_status in (None, AirFryerCookStatus.STANDBY):
            return None
        if self.total_last_time is not None:
            if self.cook_status in RUNNING_STATUSES and self.last_timestamp is not None:
                elapsed = int((datetime.now(UTC) - self.last_timestamp).total_seconds())
                return max(0, self.total_last_time - elapsed)
            return self.total_last_time
        if self.is_in_preheat_mode:
            preheat = self.preheat_time_remaining
            cook = self.cook_last_time
            return None if preheat is None or cook is None else preheat + cook
        return self.cook_time_remaining if self.is_in_cook_mode else None

    def _clear_preheat(self) -> None:
        """Clear preheat status."""
        self.preheat_set_time = None
        self.preheat_last_time = None

    def set_standby(self) -> None:
        """Set the fryer state to standby and clear the cook values.

        Clears the temperatures, times, recipe, preheat and
        ``last_timestamp``. ``cook_mode``, ``sync_chambers`` and
        ``work_chamber`` are kept.

        This is to be called by device classes before updating the state from
        the API response to prevent stale data. The get_details API responses
        do not include all keys in every response depending on the status.
        """
        self.cook_status = AirFryerCookStatus.STANDBY
        self.current_temp = None
        self.cook_set_temp = None
        self.cook_set_time = None
        self.cook_last_time = None
        self.last_timestamp = None
        self.total_last_time = None
        self.recipe = None
        self._clear_preheat()

    def set_cook_stop_state(
        self,
        cook_set_time: int | None = None,
        cook_last_time: int | None = None,
        total_last_time: int | None = None,
    ) -> None:
        """Set the fryer state to cook stopped.

        Args:
            cook_set_time (int | None): The cooking time in seconds.
            cook_last_time (int | None): The remaining cook time in seconds,
                as reported by the API. None freezes the live remaining time.
            total_last_time (int | None): The device-reported total remaining
                time in seconds, or None when not reported.
        """
        if cook_last_time is None:
            # Read before the status changes, while it still counts down.
            cook_last_time = self.cook_time_remaining
        self._clear_preheat()
        self.cook_status = AirFryerCookStatus.COOK_STOP
        if cook_set_time is not None:
            self.cook_set_time = cook_set_time
        self.cook_last_time = cook_last_time
        self.total_last_time = total_last_time
        self.last_timestamp = None

    def set_preheat_stop_state(
        self,
        preheat_set_time: int | None = None,
        preheat_last_time: int | None = None,
        total_last_time: int | None = None,
    ) -> None:
        """Set the fryer state to preheat stopped.

        Args:
            preheat_set_time (int | None): The preheating time in seconds.
            preheat_last_time (int | None): The remaining preheat time in
                seconds, as reported by the API. None freezes the live
                remaining time.
            total_last_time (int | None): The device-reported total remaining
                time in seconds, or None when not reported.
        """
        if preheat_last_time is None:
            # Read before the status changes, while it still counts down.
            preheat_last_time = self.preheat_time_remaining
        self.cook_status = AirFryerCookStatus.PREHEAT_STOP
        if preheat_set_time is not None:
            self.preheat_set_time = preheat_set_time
        self.preheat_last_time = preheat_last_time
        self.total_last_time = total_last_time
        self.last_timestamp = None

    def set_preheat_resume_state(self, preheat_set_time: int | None = None) -> None:
        """Set the fryer state to preheat resumed.

        The remaining preheat time carries over: a paused preheat resumes
        from its frozen value and a running one keeps counting down.

        Args:
            preheat_set_time (int | None): The preheating time in seconds.
        """
        # Read before the status changes, which restamps last_timestamp.
        remaining = self.preheat_time_remaining
        total = self._carried_total()
        self.cook_status = AirFryerCookStatus.HEATING
        if preheat_set_time is not None:
            self.preheat_set_time = preheat_set_time
        self.preheat_last_time = remaining
        self.total_last_time = total

    def set_cook_resume_state(self) -> None:
        """Set the fryer state to cooking after a resume.

        The remaining cook time carries over: a paused cook resumes from its
        frozen value and a running one keeps counting down.
        """
        # Read before the status changes, which restamps last_timestamp.
        remaining = self.cook_time_remaining
        total = self._carried_total()
        self._clear_preheat()
        self.cook_status = AirFryerCookStatus.COOKING
        if remaining is not None:
            self.cook_last_time = remaining
        self.total_last_time = total

    def _carried_total(self) -> int | None:
        """Return the device-reported total to keep across a local resume."""
        if self.total_last_time is None:
            return None
        return self.total_time_remaining

    def set_state(  # noqa: PLR0913
        self,
        *,
        cook_status: AirFryerCookStatus,
        cook_time: int | None = None,
        cook_last_time: int | None = None,
        cook_temp: int | None = None,
        cook_mode: str | None = None,
        preheat_set_time: int | None = None,
        preheat_last_time: int | None = None,
        current_temp: int | None = None,
        recipe: str | None = None,
        total_last_time: int | None = None,
    ) -> None:
        """Set the cook state from an API response or after a command.

        Pass every value of the current cook state together. Time values are
        in device units and are converted to seconds. ``STANDBY`` and the stop
        statuses use the matching helpers. Otherwise a None ``cook_time``,
        ``cook_temp``, ``cook_mode``, ``current_temp`` or ``recipe`` keeps the
        stored value; a None ``cook_last_time`` keeps it except while heating
        (cleared); and a None ``preheat_set_time`` clears the preheat values
        unless the status is a preheat status.

        Args:
            cook_status (AirFryerCookStatus): The cooking status.
            cook_time (int | None): The cooking time in device units.
            cook_last_time (int | None): The remaining cooking time in device units.
            cook_temp (int | None): The cooking temperature in the device's
                ``temp_unit``.
            cook_mode (str | None): The cooking mode.
            preheat_set_time (int | None): The preheating time in device units.
            preheat_last_time (int | None): The remaining preheat time in device units.
            current_temp (int | None): The current temperature in the device's
                ``temp_unit``.
            recipe (str | None): The recipe name or cooking mode.
            total_last_time (int | None): The device-reported total remaining
                time in device units. None (not reported, or a local command)
                clears the stored total.
        """
        # Stop/standby states delegate to helpers and return early
        if cook_status == AirFryerCookStatus.STANDBY:
            self.set_standby()
            return

        if cook_status == AirFryerCookStatus.COOK_STOP:
            self.set_cook_stop_state(
                cook_set_time=self._to_seconds(cook_time),
                cook_last_time=self._to_seconds(cook_last_time),
                total_last_time=self._to_seconds(total_last_time),
            )
            self._apply_settings(cook_temp, cook_mode, current_temp, recipe)
            return

        if cook_status == AirFryerCookStatus.PREHEAT_STOP:
            self.set_preheat_stop_state(
                preheat_set_time=self._to_seconds(preheat_set_time),
                preheat_last_time=self._to_seconds(preheat_last_time),
                total_last_time=self._to_seconds(total_last_time),
            )
            self._apply_settings(cook_temp, cook_mode, current_temp, recipe)
            return

        # Set cook status (setter auto-updates last_timestamp for running statuses)
        self.cook_status = cook_status
        self.total_last_time = self._to_seconds(total_last_time)

        # Handle preheat attributes
        if preheat_set_time is not None:
            self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time)
            self.preheat_last_time = (
                self.device.convert_time_for_state(preheat_last_time)
                if preheat_last_time is not None
                else self.preheat_set_time
            )
        elif cook_status not in PREHEAT_STATUSES:
            self._clear_preheat()

        # Handle cook time attributes
        if cook_time is not None:
            self.cook_set_time = self.device.convert_time_for_state(cook_time)
        if cook_last_time is not None:
            self.cook_last_time = self.device.convert_time_for_state(cook_last_time)
        elif cook_status == AirFryerCookStatus.HEATING:
            self.cook_last_time = None

        self._apply_settings(cook_temp, cook_mode, current_temp, recipe)

    def _to_seconds(self, value: int | None) -> int | None:
        """Convert an API time in device units to seconds, keeping None."""
        if value is None:
            return None
        return self.device.convert_time_for_state(value)

    def _apply_settings(
        self,
        cook_temp: int | None,
        cook_mode: str | None,
        current_temp: int | None,
        recipe: str | None,
    ) -> None:
        """Set the reported cook settings that are not None."""
        if cook_temp is not None:
            self.cook_set_temp = cook_temp
        if cook_mode is not None:
            self.cook_mode = cook_mode
        if current_temp is not None:
            self.current_temp = current_temp
        if recipe is not None:
            self.recipe = recipe

device instance-attribute ↲

device: VeSyncFryer = device

Inherited From DeviceState

features instance-attribute ↲

features: list[str] = feature_map.features

Inherited From DeviceState

temp_unit property ↲

temp_unit: TemperatureUnits

Return the unit for all temperatures in this state.

This is the device-level unit; all state temperatures (current_temp, cook_set_temp) are expressed in this unit.

is_in_preheat_mode property ↲

is_in_preheat_mode: bool

Return True if the fryer is in preheat mode.

A pulled-out basket counts as preheat only while preheat time remains.

is_in_cook_mode property ↲

is_in_cook_mode: bool

Return True if the fryer is in cook mode.

is_cooking property ↲

is_cooking: bool

Return True if the fryer is currently running in cook mode.

is_preheating property ↲

is_preheating: bool

Return True if the fryer is currently running in preheat mode.

is_running property ↲

is_running: bool

Return True if the fryer is running (cooking or preheating).

can_resume property ↲

can_resume: bool

Return True if the status is paused (cook or preheat stop).

Only the CS137/CS158 implements resume().

cook_status property writable ↲

cook_status: AirFryerCookStatus | None

Return the current cooking status.

preheat_time_remaining property ↲

preheat_time_remaining: int | None

Return the remaining preheat time in seconds.

cook_time_remaining property ↲

cook_time_remaining: int | None

Return the remaining cook time in seconds.

In HOLD (dual-chamber Sync cook), this is the wait before the chamber starts cooking. It counts down only while the other chamber cooks; with no last_timestamp it is the reported wait.

total_time_remaining property ↲

total_time_remaining: int | None

Return the time until the cook finishes, in seconds.

This is the value the app's finish time uses. It comes from the device's totalTimeRemaining where reported (counting down while cooking, preheating, or in HOLD while the other chamber cooks), else remaining preheat plus cook time. None in standby, and on the CS158 during preheat after an update() (it reports no cook time then).

set_standby ↲

set_standby() -> None

Set the fryer state to standby and clear the cook values.

Clears the temperatures, times, recipe, preheat and last_timestamp. cook_mode, sync_chambers and work_chamber are kept.

This is to be called by device classes before updating the state from the API response to prevent stale data. The get_details API responses do not include all keys in every response depending on the status.

Source code in src/pyvesync/base_devices/fryer_base.py
def set_standby(self) -> None:
    """Set the fryer state to standby and clear the cook values.

    Clears the temperatures, times, recipe, preheat and
    ``last_timestamp``. ``cook_mode``, ``sync_chambers`` and
    ``work_chamber`` are kept.

    This is to be called by device classes before updating the state from
    the API response to prevent stale data. The get_details API responses
    do not include all keys in every response depending on the status.
    """
    self.cook_status = AirFryerCookStatus.STANDBY
    self.current_temp = None
    self.cook_set_temp = None
    self.cook_set_time = None
    self.cook_last_time = None
    self.last_timestamp = None
    self.total_last_time = None
    self.recipe = None
    self._clear_preheat()

set_cook_stop_state ↲

set_cook_stop_state(
    cook_set_time: int | None = None,
    cook_last_time: int | None = None,
    total_last_time: int | None = None,
) -> None

Set the fryer state to cook stopped.

Parameters:

Name Type Description Default
cook_set_time int | None

The cooking time in seconds.

None
cook_last_time int | None

The remaining cook time in seconds, as reported by the API. None freezes the live remaining time.

None
total_last_time int | None

The device-reported total remaining time in seconds, or None when not reported.

None
Source code in src/pyvesync/base_devices/fryer_base.py
def set_cook_stop_state(
    self,
    cook_set_time: int | None = None,
    cook_last_time: int | None = None,
    total_last_time: int | None = None,
) -> None:
    """Set the fryer state to cook stopped.

    Args:
        cook_set_time (int | None): The cooking time in seconds.
        cook_last_time (int | None): The remaining cook time in seconds,
            as reported by the API. None freezes the live remaining time.
        total_last_time (int | None): The device-reported total remaining
            time in seconds, or None when not reported.
    """
    if cook_last_time is None:
        # Read before the status changes, while it still counts down.
        cook_last_time = self.cook_time_remaining
    self._clear_preheat()
    self.cook_status = AirFryerCookStatus.COOK_STOP
    if cook_set_time is not None:
        self.cook_set_time = cook_set_time
    self.cook_last_time = cook_last_time
    self.total_last_time = total_last_time
    self.last_timestamp = None

set_preheat_stop_state ↲

set_preheat_stop_state(
    preheat_set_time: int | None = None,
    preheat_last_time: int | None = None,
    total_last_time: int | None = None,
) -> None

Set the fryer state to preheat stopped.

Parameters:

Name Type Description Default
preheat_set_time int | None

The preheating time in seconds.

None
preheat_last_time int | None

The remaining preheat time in seconds, as reported by the API. None freezes the live remaining time.

None
total_last_time int | None

The device-reported total remaining time in seconds, or None when not reported.

None
Source code in src/pyvesync/base_devices/fryer_base.py
def set_preheat_stop_state(
    self,
    preheat_set_time: int | None = None,
    preheat_last_time: int | None = None,
    total_last_time: int | None = None,
) -> None:
    """Set the fryer state to preheat stopped.

    Args:
        preheat_set_time (int | None): The preheating time in seconds.
        preheat_last_time (int | None): The remaining preheat time in
            seconds, as reported by the API. None freezes the live
            remaining time.
        total_last_time (int | None): The device-reported total remaining
            time in seconds, or None when not reported.
    """
    if preheat_last_time is None:
        # Read before the status changes, while it still counts down.
        preheat_last_time = self.preheat_time_remaining
    self.cook_status = AirFryerCookStatus.PREHEAT_STOP
    if preheat_set_time is not None:
        self.preheat_set_time = preheat_set_time
    self.preheat_last_time = preheat_last_time
    self.total_last_time = total_last_time
    self.last_timestamp = None

set_preheat_resume_state ↲

set_preheat_resume_state(
    preheat_set_time: int | None = None,
) -> None

Set the fryer state to preheat resumed.

The remaining preheat time carries over: a paused preheat resumes from its frozen value and a running one keeps counting down.

Parameters:

Name Type Description Default
preheat_set_time int | None

The preheating time in seconds.

None
Source code in src/pyvesync/base_devices/fryer_base.py
def set_preheat_resume_state(self, preheat_set_time: int | None = None) -> None:
    """Set the fryer state to preheat resumed.

    The remaining preheat time carries over: a paused preheat resumes
    from its frozen value and a running one keeps counting down.

    Args:
        preheat_set_time (int | None): The preheating time in seconds.
    """
    # Read before the status changes, which restamps last_timestamp.
    remaining = self.preheat_time_remaining
    total = self._carried_total()
    self.cook_status = AirFryerCookStatus.HEATING
    if preheat_set_time is not None:
        self.preheat_set_time = preheat_set_time
    self.preheat_last_time = remaining
    self.total_last_time = total

set_cook_resume_state ↲

set_cook_resume_state() -> None

Set the fryer state to cooking after a resume.

The remaining cook time carries over: a paused cook resumes from its frozen value and a running one keeps counting down.

Source code in src/pyvesync/base_devices/fryer_base.py
def set_cook_resume_state(self) -> None:
    """Set the fryer state to cooking after a resume.

    The remaining cook time carries over: a paused cook resumes from its
    frozen value and a running one keeps counting down.
    """
    # Read before the status changes, which restamps last_timestamp.
    remaining = self.cook_time_remaining
    total = self._carried_total()
    self._clear_preheat()
    self.cook_status = AirFryerCookStatus.COOKING
    if remaining is not None:
        self.cook_last_time = remaining
    self.total_last_time = total

set_state ↲

set_state(
    *,
    cook_status: AirFryerCookStatus,
    cook_time: int | None = None,
    cook_last_time: int | None = None,
    cook_temp: int | None = None,
    cook_mode: str | None = None,
    preheat_set_time: int | None = None,
    preheat_last_time: int | None = None,
    current_temp: int | None = None,
    recipe: str | None = None,
    total_last_time: int | None = None,
) -> None

Set the cook state from an API response or after a command.

Pass every value of the current cook state together. Time values are in device units and are converted to seconds. STANDBY and the stop statuses use the matching helpers. Otherwise a None cook_time, cook_temp, cook_mode, current_temp or recipe keeps the stored value; a None cook_last_time keeps it except while heating (cleared); and a None preheat_set_time clears the preheat values unless the status is a preheat status.

Parameters:

Name Type Description Default
cook_status AirFryerCookStatus

The cooking status.

required
cook_time int | None

The cooking time in device units.

None
cook_last_time int | None

The remaining cooking time in device units.

None
cook_temp int | None

The cooking temperature in the device's temp_unit.

None
cook_mode str | None

The cooking mode.

None
preheat_set_time int | None

The preheating time in device units.

None
preheat_last_time int | None

The remaining preheat time in device units.

None
current_temp int | None

The current temperature in the device's temp_unit.

None
recipe str | None

The recipe name or cooking mode.

None
total_last_time int | None

The device-reported total remaining time in device units. None (not reported, or a local command) clears the stored total.

None
Source code in src/pyvesync/base_devices/fryer_base.py
def set_state(  # noqa: PLR0913
    self,
    *,
    cook_status: AirFryerCookStatus,
    cook_time: int | None = None,
    cook_last_time: int | None = None,
    cook_temp: int | None = None,
    cook_mode: str | None = None,
    preheat_set_time: int | None = None,
    preheat_last_time: int | None = None,
    current_temp: int | None = None,
    recipe: str | None = None,
    total_last_time: int | None = None,
) -> None:
    """Set the cook state from an API response or after a command.

    Pass every value of the current cook state together. Time values are
    in device units and are converted to seconds. ``STANDBY`` and the stop
    statuses use the matching helpers. Otherwise a None ``cook_time``,
    ``cook_temp``, ``cook_mode``, ``current_temp`` or ``recipe`` keeps the
    stored value; a None ``cook_last_time`` keeps it except while heating
    (cleared); and a None ``preheat_set_time`` clears the preheat values
    unless the status is a preheat status.

    Args:
        cook_status (AirFryerCookStatus): The cooking status.
        cook_time (int | None): The cooking time in device units.
        cook_last_time (int | None): The remaining cooking time in device units.
        cook_temp (int | None): The cooking temperature in the device's
            ``temp_unit``.
        cook_mode (str | None): The cooking mode.
        preheat_set_time (int | None): The preheating time in device units.
        preheat_last_time (int | None): The remaining preheat time in device units.
        current_temp (int | None): The current temperature in the device's
            ``temp_unit``.
        recipe (str | None): The recipe name or cooking mode.
        total_last_time (int | None): The device-reported total remaining
            time in device units. None (not reported, or a local command)
            clears the stored total.
    """
    # Stop/standby states delegate to helpers and return early
    if cook_status == AirFryerCookStatus.STANDBY:
        self.set_standby()
        return

    if cook_status == AirFryerCookStatus.COOK_STOP:
        self.set_cook_stop_state(
            cook_set_time=self._to_seconds(cook_time),
            cook_last_time=self._to_seconds(cook_last_time),
            total_last_time=self._to_seconds(total_last_time),
        )
        self._apply_settings(cook_temp, cook_mode, current_temp, recipe)
        return

    if cook_status == AirFryerCookStatus.PREHEAT_STOP:
        self.set_preheat_stop_state(
            preheat_set_time=self._to_seconds(preheat_set_time),
            preheat_last_time=self._to_seconds(preheat_last_time),
            total_last_time=self._to_seconds(total_last_time),
        )
        self._apply_settings(cook_temp, cook_mode, current_temp, recipe)
        return

    # Set cook status (setter auto-updates last_timestamp for running statuses)
    self.cook_status = cook_status
    self.total_last_time = self._to_seconds(total_last_time)

    # Handle preheat attributes
    if preheat_set_time is not None:
        self.preheat_set_time = self.device.convert_time_for_state(preheat_set_time)
        self.preheat_last_time = (
            self.device.convert_time_for_state(preheat_last_time)
            if preheat_last_time is not None
            else self.preheat_set_time
        )
    elif cook_status not in PREHEAT_STATUSES:
        self._clear_preheat()

    # Handle cook time attributes
    if cook_time is not None:
        self.cook_set_time = self.device.convert_time_for_state(cook_time)
    if cook_last_time is not None:
        self.cook_last_time = self.device.convert_time_for_state(cook_last_time)
    elif cook_status == AirFryerCookStatus.HEATING:
        self.cook_last_time = None

    self._apply_settings(cook_temp, cook_mode, current_temp, recipe)

pyvesync.base_devices.fryer_base.VeSyncFryer ↲

Bases: VeSyncBaseDevice[FryerState]

Base class for VeSync Air Fryer devices.

Parameters:

Name Type Description Default
details ResponseDeviceDetailsModel

The device details.

required
manager VeSync

The VeSync manager.

required
feature_map AirFryerMap

The feature map for the device.

required

Attributes:

Name Type Description
state FryerState

The same object as state_chamber_1.

last_response ResponseInfo

Last response from API call.

manager VeSync

Manager object for API calls.

device_name str

Name of device.

device_image str

URL for device image.

cid str

Device ID.

connection_type str

Connection type of device.

device_type str

Type of device.

type str

Type of device.

uuid str

UUID of device, not always present.

config_module str

Configuration module of device.

mac_id str

MAC ID of device.

current_firm_version str

Current firmware version of device.

latest_firm_version str | None

Latest firmware version of device.

device_region str

Region of device. (US, EU, etc.)

pid str | None

Product ID from AIRFRYER_PID_MAP, by config module; None for fryers not in it.

sub_device_no int

Sub-device number of device.

product_type str

Product type of device.

features list[str]

AirFryerFeatures values from the device map.

status_map MappingProxyType

Mapping of API status strings to AirFryerCookStatus enums.

cook_modes dict[str, AirFryerModeConfig]

The fryer's presets from the device map, keyed by AirFryerCookModes. Look one up with get_cook_mode(), or get it as a recipe with get_mode_recipe().

default_preset AirFryerPresetRecipe

This fryer's own copy of the preset whose mode, recipe ID, name and type set_mode() sends when cook_mode is None or the default mode; set_mode() replaces its time, temperature and unit with the caller's. Its own time and temperature are the library's AirFryerPresets values and apply only when it is passed to set_mode_from_recipe(). They can differ from the app's defaults, which get_mode_recipe() gives (for example TF101S Air Fry: default_preset 400 F / 600 s, get_mode_recipe() 200 C / 1200 s).

min_temp_f int

Lowest Fahrenheit temperature over all cook modes.

max_temp_f int

Highest Fahrenheit temperature over all cook modes.

min_temp_c int

Lowest Celsius temperature over all cook modes.

max_temp_c int

Highest Celsius temperature over all cook modes.

min_temp int

Lowest temperature over all cook modes, in the current unit. A mode can have a narrower range; temp_limits() gives the range set_mode() accepts.

max_temp int

Highest temperature over all cook modes, in the current unit (see min_temp).

state_chamber_1 FryerState

The state object for chamber 1.

state_chamber_2 FryerState

The state object for chamber 2. It exists on every fryer, but only dual-chamber fryers update it.

is_dual_chamber bool

True if the fryer has two chambers.

supported_chambers tuple[AirFryerChamber, ...]

Chambers this fryer accepts, derived from features. Every chamber argument is checked against it before a request is sent.

sync_chambers bool

Read-only. True only for the app's Sync mode (syncType 2, both chambers finish together); Match (1) and Off (0) report False. Dual-chamber fryers only, always False otherwise: set by update(), by a successful set_mode() / set_mode_from_recipe() (True only for BOTH) and by end(BOTH) (False). Read from state_chamber_1.

work_chamber AirFryerChamber | None

Read-only. Chamber the device reports as active (workChamber); None when idle or not reported, and always None on single-chamber fryers. Set by update(); a successful WHOLE start sets it to WHOLE, any other start clears a WHOLE value, and ending WHOLE or BOTH clears it. Read from state_chamber_1.

temp_unit TemperatureUnits

The device's current temperature unit (seeded from the device map, updated from the API on update()).

temperature_step int

The default mode's temperature step in the current unit.

temperature_interval int

Deprecated alias of temperature_step.

time_units TimeUnits

The time unit of the device's API (seconds or minutes). Every public time is in seconds.

Source code in src/pyvesync/base_devices/fryer_base.py
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
class VeSyncFryer(VeSyncBaseDevice[FryerState]):
    """Base class for VeSync Air Fryer devices.

    Args:
        details (ResponseDeviceDetailsModel): The device details.
        manager (VeSync): The VeSync manager.
        feature_map (AirFryerMap): The feature map for the device.

    Attributes:
        state (FryerState): The same object as ``state_chamber_1``.
        last_response (ResponseInfo): Last response from API call.
        manager (VeSync): Manager object for API calls.
        device_name (str): Name of device.
        device_image (str): URL for device image.
        cid (str): Device ID.
        connection_type (str): Connection type of device.
        device_type (str): Type of device.
        type (str): Type of device.
        uuid (str): UUID of device, not always present.
        config_module (str): Configuration module of device.
        mac_id (str): MAC ID of device.
        current_firm_version (str): Current firmware version of device.
        latest_firm_version (str | None): Latest firmware version of device.
        device_region (str): Region of device. (US, EU, etc.)
        pid (str | None): Product ID from ``AIRFRYER_PID_MAP``, by config
            module; None for fryers not in it.
        sub_device_no (int): Sub-device number of device.
        product_type (str): Product type of device.
        features (list[str]): ``AirFryerFeatures`` values from the device map.
        status_map (MappingProxyType): Mapping of API status strings to
            AirFryerCookStatus enums.
        cook_modes (dict[str, AirFryerModeConfig]): The fryer's presets from the
            device map, keyed by ``AirFryerCookModes``. Look one up with
            ``get_cook_mode()``, or get it as a recipe with
            ``get_mode_recipe()``.
        default_preset (AirFryerPresetRecipe): This fryer's own copy of the
            preset whose mode, recipe ID, name and type ``set_mode()`` sends
            when ``cook_mode`` is None or the default mode; ``set_mode()``
            replaces its time, temperature and unit with the caller's. Its
            own time and temperature are the
            library's ``AirFryerPresets`` values and apply only when it is
            passed to ``set_mode_from_recipe()``. They can differ from the
            app's defaults, which ``get_mode_recipe()`` gives (for example
            TF101S Air Fry: ``default_preset`` 400 F / 600 s,
            ``get_mode_recipe()`` 200 C / 1200 s).
        min_temp_f (int): Lowest Fahrenheit temperature over all cook modes.
        max_temp_f (int): Highest Fahrenheit temperature over all cook modes.
        min_temp_c (int): Lowest Celsius temperature over all cook modes.
        max_temp_c (int): Highest Celsius temperature over all cook modes.
        min_temp (int): Lowest temperature over all cook modes, in the current
            unit. A mode can have a narrower range; ``temp_limits()`` gives
            the range ``set_mode()`` accepts.
        max_temp (int): Highest temperature over all cook modes, in the
            current unit (see ``min_temp``).
        state_chamber_1 (FryerState): The state object for chamber 1.
        state_chamber_2 (FryerState): The state object for chamber 2. It exists
            on every fryer, but only dual-chamber fryers update it.
        is_dual_chamber (bool): True if the fryer has two chambers.
        supported_chambers (tuple[AirFryerChamber, ...]): Chambers this fryer
            accepts, derived from ``features``. Every ``chamber`` argument is
            checked against it before a request is sent.
        sync_chambers (bool): Read-only. True only for the app's Sync mode
            (``syncType`` 2, both chambers finish together); Match (1) and
            Off (0) report False. Dual-chamber fryers only, always False
            otherwise: set by ``update()``, by a successful ``set_mode()`` /
            ``set_mode_from_recipe()`` (True only for ``BOTH``) and by
            ``end(BOTH)`` (False). Read from ``state_chamber_1``.
        work_chamber (AirFryerChamber | None): Read-only. Chamber the device
            reports as active (``workChamber``); None when idle or not
            reported, and always None on single-chamber fryers. Set by
            ``update()``; a successful ``WHOLE`` start sets it to ``WHOLE``,
            any other start clears a ``WHOLE`` value, and ending ``WHOLE`` or
            ``BOTH`` clears it. Read from ``state_chamber_1``.
        temp_unit (TemperatureUnits): The device's current temperature unit
            (seeded from the device map, updated from the API on update()).
        temperature_step (int): The default mode's temperature step in the
            current unit.
        temperature_interval (int): Deprecated alias of ``temperature_step``.
        time_units (TimeUnits): The time unit of the device's API (seconds or
            minutes). Every public time is in seconds.
    """

    __slots__ = (
        '_temp_unit',
        'cook_modes',
        'default_preset',
        'state_chamber_1',
        'state_chamber_2',
        'status_map',
        'time_units',
    )

    def __init__(
        self,
        details: ResponseDeviceDetailsModel,
        manager: VeSync,
        feature_map: AirFryerMap,
    ) -> None:
        """Initialize VeSyncFryer."""
        super().__init__(details, manager, feature_map)
        self.cook_modes: dict[str, AirFryerModeConfig] = feature_map.cook_modes
        self.pid: str | None = AIRFRYER_PID_MAP.get(details.configModule, None)
        # Copy, so changing one fryer's preset does not change the others.
        self.default_preset: AirFryerPresetRecipe = replace(feature_map.default_preset)
        self.state_chamber_1: FryerState = FryerState(self, details, feature_map)
        self.state_chamber_2: FryerState = FryerState(self, details, feature_map)
        self.time_units: TimeUnits = feature_map.time_units
        self.status_map = feature_map.status_map

        # Device map declares the model's unit; get_details() updates it
        # from the API response if the device reports a different unit.
        self._temp_unit: TemperatureUnits = feature_map.temp_unit

        # Set state to primary chamber (chamber 1) for base class compatibility
        self.state: FryerState = self.state_chamber_1

    @property
    def temp_unit(self) -> TemperatureUnits:
        """Return the temperature unit (F or C)."""
        return self._temp_unit

    @temp_unit.setter
    def temp_unit(self, value: TemperatureUnits | str) -> None:
        """Set the temperature unit.

        A value that does not start with 'c' or 'f' is logged as a warning
        and the current unit is kept.

        Args:
            value (TemperatureUnits | str): The temperature unit
                ('f'/'c'/'fahrenheit'/'celsius' or a TemperatureUnits member).
        """
        try:
            self._temp_unit = TemperatureUnits.from_string(value)
        except ValueError:
            logger.warning(
                'Invalid temperature unit %s for %s, keeping %s',
                value,
                self.device_name,
                self._temp_unit.label,
            )

    @property
    def sync_chambers(self) -> bool:
        """Return True if the chambers are in the app's Sync mode (read-only).

        Set by ``update()`` and by local commands on dual-chamber fryers; see
        the class attributes.
        """
        return self.state_chamber_1.sync_chambers

    @property
    def work_chamber(self) -> AirFryerChamber | None:
        """Return the chamber the device last reported as active (read-only)."""
        return self.state_chamber_1.work_chamber

    @property
    def supported_chambers(self) -> tuple[AirFryerChamber, ...]:
        """Return the chambers this fryer accepts, in enum order.

        Every fryer supports ``CHAMBER_1``. ``DUAL_CHAMBER`` adds ``CHAMBER_2``
        and ``BOTH``; ``WHOLE_CHAMBER`` adds ``WHOLE``.
        """
        chambers = {AirFryerChamber.CHAMBER_1}
        if AirFryerFeatures.DUAL_CHAMBER in self.features:
            chambers.update((AirFryerChamber.CHAMBER_2, AirFryerChamber.BOTH))
        if AirFryerFeatures.WHOLE_CHAMBER in self.features:
            chambers.add(AirFryerChamber.WHOLE)
        return tuple(sorted(chambers))

    @property
    def is_dual_chamber(self) -> bool:
        """Return True if the fryer has two chambers."""
        return AirFryerFeatures.DUAL_CHAMBER in self.features

    def _validate_chamber(self, chamber: AirFryerChamber | int) -> AirFryerChamber | None:
        """Return the chamber as an enum member if this fryer supports it.

        Args:
            chamber (AirFryerChamber | int): The requested chamber.

        Returns:
            AirFryerChamber | None: The enum member, or None (after a debug
            log) when the value is invalid or not supported. Never raises.
        """
        try:
            member = AirFryerChamber(chamber)
        except ValueError:
            logger.debug('Invalid chamber %s for %s', chamber, self.device_name)
            return None
        if member not in self.supported_chambers:
            logger.debug('Chamber %s not supported by %s', member.name, self.device_name)
            return None
        return member

    def get_chamber_state(self, chamber: AirFryerChamber | int) -> FryerState | None:
        """Return the state object for a chamber.

        ``CHAMBER_1`` and ``WHOLE`` map to ``state_chamber_1``, ``CHAMBER_2``
        to ``state_chamber_2``.

        Args:
            chamber (AirFryerChamber | int): The chamber.

        Returns:
            FryerState | None: The chamber's state, or None (after a debug
            log) for ``BOTH``, which has two states (read ``state_chamber_1``
            and ``state_chamber_2``), or for a chamber the fryer does not
            support or an invalid value. Never raises.
        """
        member = self._validate_chamber(chamber)
        if member is None:
            return None
        if member == AirFryerChamber.BOTH:
            logger.debug(
                'Chamber BOTH has two states on %s; read state_chamber_1 and '
                'state_chamber_2',
                self.device_name,
            )
            return None
        return self._chamber_state(member)

    def _chamber_state(self, member: AirFryerChamber) -> FryerState:
        """Return the state for a validated single chamber (not ``BOTH``)."""
        if member == AirFryerChamber.CHAMBER_2:
            return self.state_chamber_2
        return self.state_chamber_1

    def get_cook_mode(self, cook_mode: str | None = None) -> AirFryerModeConfig | None:
        """Return the preset for a cook mode.

        Args:
            cook_mode (str | None): An ``AirFryerCookModes`` key (``'steak'``)
                or the API mode string (``'poultry'``, ``'AirFry'``). None gives
                the default mode, the one the default preset sends.

        Returns:
            AirFryerModeConfig | None: The preset, or None if the fryer has no
            such mode.
        """
        if cook_mode is None:
            cook_mode = self.default_preset.cook_mode
        elif cook_mode in self.cook_modes:
            return self.cook_modes[cook_mode]
        for mode in self.cook_modes.values():
            if mode.api_mode == cook_mode:
                return mode
        return None

    def get_mode_recipe(
        self, cook_mode: str | None = None
    ) -> AirFryerPresetRecipe | None:
        """Return a cook mode's preset as a recipe in the current unit.

        Pass the result to ``set_mode_from_recipe()``. Change its time or
        temperature with ``dataclasses.replace`` first if needed. These are
        the app's defaults for the mode, which can differ from
        ``default_preset``.

        Args:
            cook_mode (str | None): As for ``get_cook_mode()``.

        Returns:
            AirFryerPresetRecipe | None: The recipe at the mode's default time
            and temperature, or None if the fryer has no such mode.
        """
        mode = self.get_cook_mode(cook_mode)
        if mode is None:
            return None
        return mode.to_recipe(self.temp_unit)

    def _recipe_for_set_mode(
        self,
        cook_time: int,
        cook_temp: int,
        cook_mode: str | None,
        preheat_time: int | None,
        temp_unit: TemperatureUnits | None = None,
    ) -> AirFryerPresetRecipe | None:
        """Build the recipe ``set_mode()`` sends.

        The default mode copies ``default_preset``; any other mode uses its own
        preset ID and name. The caller's time, temperature and unit are set on
        the copy.

        Args:
            cook_time (int): Cooking time in seconds.
            cook_temp (int): Cooking temperature in ``temp_unit``.
            cook_mode (str | None): As for ``get_cook_mode()``.
            preheat_time (int | None): Preheating time in seconds, if any.
            temp_unit (TemperatureUnits | None): Unit of ``cook_temp``; None
                means the device's current ``temp_unit``.

        Returns:
            AirFryerPresetRecipe | None: The recipe, or None if the fryer has
            no such mode.
        """
        config = self.get_cook_mode(cook_mode)
        if config is None:
            logger.warning(
                '%s does not support cook mode %s', self.device_name, cook_mode
            )
            return None
        if config.api_mode == self.default_preset.cook_mode:
            recipe = replace(self.default_preset)
        else:
            recipe = config.to_recipe(self.temp_unit)
        recipe.cook_time = cook_time
        recipe.target_temp = cook_temp
        recipe.temp_unit = (temp_unit or self.temp_unit).code
        if preheat_time is not None:
            recipe.preheat_time = preheat_time
        return recipe

    def _resolve_temp_unit(
        self, temp_unit: TemperatureUnits | str | None
    ) -> TemperatureUnits | None:
        """Return the unit a ``set_mode()`` temperature is given in.

        Args:
            temp_unit (TemperatureUnits | str | None): The caller's unit. None
                means the device's current ``temp_unit``.

        Returns:
            TemperatureUnits | None: The unit, or None (after a warning) if it
            is not a valid unit.
        """
        if temp_unit is None:
            return self.temp_unit
        try:
            return TemperatureUnits.from_string(temp_unit)
        except ValueError:
            logger.warning(
                'Invalid temperature unit %s for %s', temp_unit, self.device_name
            )
            return None

    @property
    def _default_mode_config(self) -> AirFryerModeConfig:
        """Return the default mode's preset (always mapped, see the map tests)."""
        mode = self.get_cook_mode()
        if mode is None:
            msg = f'No cook mode matches the default preset of {self.device_name}'
            raise ValueError(msg)
        return mode

    @property
    def min_temp_f(self) -> int:
        """Return the lowest Fahrenheit temperature over all cook modes."""
        return min(m.temp_f.min_temp for m in self.cook_modes.values())

    @property
    def max_temp_f(self) -> int:
        """Return the highest Fahrenheit temperature over all cook modes."""
        return max(m.temp_f.max_temp for m in self.cook_modes.values())

    @property
    def min_temp_c(self) -> int:
        """Return the lowest Celsius temperature over all cook modes."""
        return min(m.temp_c.min_temp for m in self.cook_modes.values())

    @property
    def max_temp_c(self) -> int:
        """Return the highest Celsius temperature over all cook modes."""
        return max(m.temp_c.max_temp for m in self.cook_modes.values())

    @property
    def temperature_step(self) -> int:
        """Return the default mode's temperature step in the current unit."""
        return self._default_mode_config.limits(self.temp_unit).step

    @property
    def temperature_interval(self) -> int:
        """Return the default mode's temperature step in the current unit.

        Deprecated:
            Use ``temperature_step`` instead.
        """
        return self.temperature_step

    @property
    def min_temp(self) -> int:
        """Return the lowest temperature over all cook modes, in the current unit.

        A mode can have a narrower range; ``temp_limits()`` gives the range
        ``set_mode()`` accepts.
        """
        if self.temp_unit == TemperatureUnits.FAHRENHEIT:
            return self.min_temp_f
        return self.min_temp_c

    @property
    def max_temp(self) -> int:
        """Return the highest temperature over all cook modes, in the current unit.

        A mode can have a narrower range; ``temp_limits()`` gives the range
        ``set_mode()`` accepts.
        """
        if self.temp_unit == TemperatureUnits.FAHRENHEIT:
            return self.max_temp_f
        return self.max_temp_c

    def temp_limits(self, cook_mode: str | None = None) -> AirFryerTempLimits:
        """Return a cook mode's temperature limits in the current unit.

        Args:
            cook_mode (str | None): As for ``get_cook_mode()``. None, or a mode
                the fryer does not have, gives the default mode's limits.

        Returns:
            AirFryerTempLimits: The mode's minimum, maximum and step.
        """
        mode = self.get_cook_mode(cook_mode)
        if mode is None:
            logger.debug(
                'Cook mode %s not in the cook modes of %s; using the default '
                "mode's temperature limits",
                cook_mode,
                self.device_name,
            )
            mode = self._default_mode_config
        return mode.limits(self.temp_unit)

    def validate_temperature(
        self, temperature: int, cook_mode: str | None = None
    ) -> bool:
        """Validate the temperature is within the cook mode's range.

        Checks the range only; off-step values are handled by rounding (see
        ``round_temperature()`` and ``prepare_temperature()``).

        Args:
            temperature (int): The temperature in the device's ``temp_unit``.
            cook_mode (str | None): Cook mode whose limits apply, as for
                ``temp_limits()``.

        Returns:
            bool: True if the temperature is valid, False otherwise.
        """
        limits = self.temp_limits(cook_mode)
        return limits.min_temp <= temperature <= limits.max_temp

    def round_temperature(self, temperature: int, cook_mode: str | None = None) -> int:
        """Round the temperature to the nearest step of the cook mode.

        Uses Python's ``round()``, so a value exactly halfway between two
        steps goes to the even multiple (385 to 380 with a 10 degree step).

        Args:
            temperature (int): The temperature in the device's ``temp_unit``.
            cook_mode (str | None): Cook mode whose step applies, as for
                ``temp_limits()``.

        Returns:
            int: The temperature rounded to the mode's step in the current
            unit.
        """
        step = self.temp_limits(cook_mode).step
        return round(temperature / step) * step

    def prepare_temperature(
        self, temperature: int, cook_mode: str | None = None
    ) -> int | None:
        """Round a requested temperature to the mode's step, then validate.

        Args:
            temperature (int): Requested temperature in the device's
                ``temp_unit``.
            cook_mode (str | None): Cook mode whose limits apply, as for
                ``temp_limits()``.

        Returns:
            int | None: The rounded temperature, or None (after a warning) if
            the rounded value is outside the mode's range for the current
            unit.
        """
        rounded = self.round_temperature(temperature, cook_mode)
        if not self.validate_temperature(rounded, cook_mode):
            limits = self.temp_limits(cook_mode)
            mode = self.get_cook_mode(cook_mode) or self._default_mode_config
            logger.warning(
                'Cook temperature %s%s is outside the %s range %s-%s for %s',
                temperature,
                self.temp_unit.code.upper(),
                mode.api_mode,
                limits.min_temp,
                limits.max_temp,
                self.device_name,
            )
            return None
        return rounded

    def prepare_recipe_temperature(self, recipe: AirFryerPresetRecipe) -> int | None:
        """Convert a recipe's temperature to the device unit, round and validate.

        Args:
            recipe (AirFryerPresetRecipe): The recipe, whose ``target_temp`` is
                in ``recipe.temp_unit``.

        Returns:
            int | None: The temperature in the device's ``temp_unit``, rounded
            to the mode's step, or None (after a warning) if it is outside the
            range of the recipe's ``cook_mode`` (of the default mode when the
            fryer has no such mode), or if the temperature or
            ``recipe.temp_unit`` is not valid.
        """
        try:
            temperature = float(recipe.target_temp)
            recipe_unit = TemperatureUnits.from_string(recipe.temp_unit)
        except (TypeError, ValueError):
            logger.warning(
                'Invalid recipe temperature %s %s for %s',
                recipe.target_temp,
                recipe.temp_unit,
                self.device_name,
            )
            return None
        if recipe_unit != self.temp_unit:
            if self.temp_unit == TemperatureUnits.CELSIUS:
                temperature = TemperatureUnits.to_celsius(temperature, recipe_unit)
            else:
                temperature = TemperatureUnits.to_fahrenheit(temperature, recipe_unit)
        return self.prepare_temperature(round(temperature), recipe.cook_mode)

    def convert_time_for_api(self, time_in_seconds: int) -> int:
        """Convert time in seconds to the device's time units.

        Minutes are truncated (``int(seconds / 60)``), so under 60 seconds
        gives 0.

        Args:
            time_in_seconds (int): The time in seconds.

        Returns:
            int: The time converted to the device's time units.
        """
        if self.time_units == TimeUnits.MINUTES:
            return int(time_in_seconds / 60)
        return int(time_in_seconds)

    def _recipe_times(
        self, recipe: AirFryerPresetRecipe
    ) -> tuple[int, int | None] | None:
        """Return a recipe's cook and preheat times as whole seconds.

        The cook time must be at least one API time unit (1 second, or
        60 seconds on minute-based fryers). A mode that declares ``time_limits``
        also requires its range and step. On fryers with the ``PREHEAT``
        feature the preheat time, if given, must be 0 or at least one unit.
        Other fryers drop it with a debug log.

        Args:
            recipe (AirFryerPresetRecipe): The recipe to check.

        Returns:
            tuple[int, int | None] | None: The cook and preheat times (preheat
            None when not given or not supported), or None (after a warning)
            if either is not valid.
        """
        mode = self.get_cook_mode(recipe.cook_mode)
        if (
            mode is not None
            and mode.time_limits is not None
            and not mode.time_limits.accepts(recipe.cook_time)
        ):
            logger.warning(
                'Cook time %s is outside the limits or steps of %s for %s',
                recipe.cook_time,
                mode.api_mode,
                self.device_name,
            )
            return None
        minimum = self.convert_time_for_state(1)
        try:
            cook_time = int(recipe.cook_time)
        except (TypeError, ValueError):
            cook_time = 0
        if cook_time < minimum:
            logger.warning(
                'Cook time %s is not a number of seconds of at least %s for %s',
                recipe.cook_time,
                minimum,
                self.device_name,
            )
            return None
        if recipe.preheat_time is None:
            return cook_time, None
        if AirFryerFeatures.PREHEAT not in self.features:
            logger.debug('Preheat not supported for %s, ignoring', self.device_name)
            return cook_time, None
        try:
            preheat_time = int(recipe.preheat_time)
        except (TypeError, ValueError):
            preheat_time = -1
        if preheat_time < 0 or 0 < preheat_time < minimum:
            logger.warning(
                'Preheat time %s is not 0 or at least %s seconds for %s',
                recipe.preheat_time,
                minimum,
                self.device_name,
            )
            return None
        return cook_time, preheat_time

    def _prepare_recipe(
        self, recipe: AirFryerPresetRecipe
    ) -> AirFryerPresetRecipe | None:
        """Check a recipe and return a copy ready to send.

        The recipe's ``cook_mode`` must be one of the fryer's cook modes and
        its times must pass ``_recipe_times()``. Its temperature is converted
        to the device unit, rounded and checked (``prepare_recipe_temperature``).

        Args:
            recipe (AirFryerPresetRecipe): The caller's recipe, not changed.

        Returns:
            AirFryerPresetRecipe | None: A copy with the mode's API string,
            whole-second times and the temperature in the device unit, or None
            (after a warning) if a check fails.
        """
        mode = self.get_cook_mode(recipe.cook_mode)
        if mode is None:
            logger.warning(
                '%s does not support cook mode %s', self.device_name, recipe.cook_mode
            )
            return None
        times = self._recipe_times(recipe)
        if times is None:
            return None
        target_temp = self.prepare_recipe_temperature(recipe)
        if target_temp is None:
            return None
        return replace(
            recipe,
            cook_mode=mode.api_mode,
            cook_time=times[0],
            preheat_time=times[1],
            target_temp=target_temp,
            temp_unit=self.temp_unit.code,
        )

    def convert_time_for_state(self, time_in_device_units: int) -> int:
        """Convert time in device's time units to seconds.

        Args:
            time_in_device_units (int): The time in device's time units.

        Returns:
            int: The time converted to seconds.
        """
        if self.time_units == TimeUnits.MINUTES:
            return time_in_device_units * 60
        return time_in_device_units

    async def end(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        """End the current cooking or preheating session.

        On success the chamber state is set to standby. On the CS137/CS158
        the command depends on the last known state, and it returns False
        without a request when that state is neither cooking nor preheating.
        On the CAF-TF101S/TF102S, ``CHAMBER_1`` during a whole-basket cook
        (``work_chamber`` ``WHOLE``) ends the whole basket.

        Arguments:
            chamber (AirFryerChamber | int): The chamber to end for. Default
                is ``CHAMBER_1``. An unsupported chamber returns False without
                a request.

        Returns:
            bool: True if the command was successful, False otherwise.
        """
        del chamber
        logger.info('end not configured for this fryer.')
        return False

    async def stop(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        """Stop (Pause) the current cooking or preheating session.

        Supported by the CS137/CS158 (``VeSyncAirFryer158``) and dual fryers
        with the ``RESUMABLE`` feature (CAF-DC111S-AEU); other fryers return
        False without a request. The command is always sent:
        the preheat stop when the last known state is a preheat phase,
        otherwise the cook stop. Every successful command sets the state to
        the phase it sent, so the next command picks the matching one.

        Arguments:
            chamber (AirFryerChamber | int): The chamber to stop for. Default
                is ``CHAMBER_1``.

        Returns:
            bool: True if the command was successful, False otherwise.
        """
        del chamber
        logger.info('stop not supported by this fryer.')
        return False

    async def resume(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        """Resume a paused cooking or preheating session.

        Supported by the CS137/CS158 (``VeSyncAirFryer158``) and dual fryers
        with the ``RESUMABLE`` feature (CAF-DC111S-AEU); other fryers return
        False without a request. The command is always sent:
        the preheat resume when the last known state is a preheat phase,
        otherwise the cook resume. The remaining time carries over, so a
        resume while running does not restart the countdown.

        Arguments:
            chamber (AirFryerChamber | int): The chamber to resume for. Default
                is ``CHAMBER_1``.

        Returns:
            bool: True if the command was successful, False otherwise.
        """
        del chamber
        logger.info('resume not supported by this fryer.')
        return False

    async def set_mode(
        self,
        cook_time: int,
        cook_temp: int,
        *,
        temp_unit: TemperatureUnits | str | None = None,
        preheat_time: int | None = None,
        cook_mode: str | None = None,
        chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
    ) -> bool:
        """Start a cook with the given time and temperature.

        ``cook_temp`` is converted to the device unit if needed, rounded to
        the cook mode's step (see ``round_temperature()``) and checked against
        that mode's limits (see ``temp_limits()``). Out of range logs a
        warning and returns False without a request. On success the chamber
        state is set locally from the request; call ``update()`` for the
        device's reported state.

        Args:
            cook_time (int): The cooking time in seconds, at least 1 (60 on
                the CS137/CS158, whose API uses minutes and truncates to whole
                minutes). A shorter time returns False without a request.
                A mode's ``time_limits``, where declared, also apply; a time
                outside them returns False without a request. Other modes have
                no upper limit.
            cook_temp (int): The cooking temperature, in ``temp_unit``.
            temp_unit (TemperatureUnits | str | None): The unit of
                ``cook_temp``; defaults to the device's current ``temp_unit``.
                An unrecognised unit string returns False without a request.
            preheat_time (int | None): The preheating time in seconds, if any.
                Used only on fryers with the ``PREHEAT`` feature: dropped on
                the CAF-P583S and ignored on the dual-chamber fryers. On fryers
                with preheat it must be 0 or at least one API time unit (60
                seconds on the CS137/CS158); otherwise False without a
                request.
            cook_mode (str | None): Cook mode to use, an ``AirFryerCookModes``
                key or the API mode string (see ``get_cook_mode()``). Its
                recipe ID, name and type are sent. None uses the default
                preset. A mode the fryer does not have returns False without a
                request.
            chamber (AirFryerChamber | int): The chamber to cook in. Default is
                ``CHAMBER_1``. A chamber not in ``supported_chambers`` returns
                False without a request.

        Returns:
            bool: True if the command was successful, False otherwise.
        """
        del cook_time, cook_temp, chamber, preheat_time, cook_mode, temp_unit
        logger.warning('set_mode method not implemented for base fryer class.')
        return False

    async def set_chamber_modes(
        self,
        chamber_1: AirFryerPresetRecipe,
        chamber_2: AirFryerPresetRecipe | None = None,
        *,
        sync_type: AirFryerSyncType = AirFryerSyncType.SYNC,
    ) -> bool:
        """Start a recipe in each chamber, or one recipe in both.

        Only fryers with the ``CHAMBER_MODES`` feature implement this. Both
        recipes are checked before any request, as in
        ``set_mode_from_recipe()``, so an invalid second recipe does not start
        the first. On success both chamber states are set locally from the
        request; call ``update()`` for the device's reported state.

        Args:
            chamber_1 (AirFryerPresetRecipe): Recipe for chamber 1 with
                ``SYNC`` or ``OFF``, or the recipe for both chambers with
                ``MATCH``.
            chamber_2 (AirFryerPresetRecipe | None): Recipe for chamber 2.
                Required with ``SYNC`` and ``OFF``; must be None with ``MATCH``.
            sync_type (AirFryerSyncType): ``SYNC`` for separate recipes that
                finish together, ``OFF`` for separate recipes that each run on
                their own time, or ``MATCH`` for identical settings.

        Returns:
            bool: True if the command was successful. An unsupported fryer or
            an invalid combination returns False without a request.
        """
        del chamber_1, chamber_2, sync_type
        logger.debug('Separate chamber modes are not configured for %s', self.device_name)
        return False

    async def set_mode_from_recipe(
        self,
        recipe: AirFryerPresetRecipe,
        *,
        chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
    ) -> bool:
        """Start a cook from a recipe.

        ``recipe.cook_mode`` must be one of the fryer's cook modes (see
        ``get_cook_mode()``); its API mode string is sent.
        ``recipe.target_temp`` is converted from ``recipe.temp_unit`` to the
        device unit, rounded to the mode's step and checked against its
        limits. The times are checked as in ``set_mode()``. A failed check
        (including an invalid ``recipe.temp_unit``) logs a warning and returns
        False without a request. The recipe passed in is not changed.

        Args:
            recipe (AirFryerPresetRecipe): The preset recipe to use. Its recipe
                ID, name and type are sent as given.
            chamber (AirFryerChamber | int): The chamber to cook in. Default is
                ``CHAMBER_1``. A chamber not in ``supported_chambers`` returns
                False without a request.

        Returns:
            bool: True if the command was successful, False otherwise.

        Note:
            Get a fryer's preset with ``get_mode_recipe()`` and change it with
            ``dataclasses.replace``. See
            [AirFryerPresetRecipe][pyvesync.const.AirFryerPresetRecipe].
        """
        del recipe, chamber
        logger.warning(
            'set_mode_from_recipe method not implemented for base fryer class.'
        )
        return False

    async def cook_from_preheat(
        self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
    ) -> bool:
        """Start cooking after the preheat has finished.

        Only the CS137/CS158 (``VeSyncAirFryer158``) supports this; other
        fryers return False without a request. The last known
        ``cook_status`` must be ``PREHEAT_END``, so call ``update()`` after
        the preheat finishes; otherwise it returns False without a request.

        Args:
            chamber (AirFryerChamber | int): The chamber to start cooking for.
                Default is ``CHAMBER_1``.

        Returns:
            bool: True if the command was successful, False otherwise.
        """
        del chamber
        if AirFryerFeatures.PREHEAT not in self.features:
            logger.info('Preheat feature not supported on this fryer.')
            return False
        logger.info('cook_from_preheat not configured for this fryer.')
        return False

cook_modes instance-attribute ↲

cook_modes: dict[str, AirFryerModeConfig] = (
    feature_map.cook_modes
)

Inherited From VeSyncFryer

pid instance-attribute ↲

pid: str | None = AIRFRYER_PID_MAP.get(
    details.configModule, None
)

Inherited From VeSyncBaseDevice

default_preset instance-attribute ↲

default_preset: AirFryerPresetRecipe = replace(
    feature_map.default_preset
)

Inherited From VeSyncFryer

state_chamber_1 instance-attribute ↲

state_chamber_1: FryerState = FryerState(
    self, details, feature_map
)

Inherited From VeSyncFryer

state_chamber_2 instance-attribute ↲

state_chamber_2: FryerState = FryerState(
    self, details, feature_map
)

Inherited From VeSyncFryer

time_units instance-attribute ↲

time_units: TimeUnits = feature_map.time_units

Inherited From VeSyncFryer

status_map instance-attribute ↲

status_map = feature_map.status_map

Inherited From VeSyncFryer

state instance-attribute ↲

state: FryerState = self.state_chamber_1

Inherited From VeSyncBaseDevice

temp_unit property writable ↲

temp_unit: TemperatureUnits

Inherited From VeSyncFryer

Return the temperature unit (F or C).

sync_chambers property ↲

sync_chambers: bool

Inherited From VeSyncFryer

Return True if the chambers are in the app's Sync mode (read-only).

Set by update() and by local commands on dual-chamber fryers; see the class attributes.

work_chamber property ↲

work_chamber: AirFryerChamber | None

Inherited From VeSyncFryer

Return the chamber the device last reported as active (read-only).

supported_chambers property ↲

supported_chambers: tuple[AirFryerChamber, ...]

Inherited From VeSyncFryer

Return the chambers this fryer accepts, in enum order.

Every fryer supports CHAMBER_1. DUAL_CHAMBER adds CHAMBER_2 and BOTH; WHOLE_CHAMBER adds WHOLE.

is_dual_chamber property ↲

is_dual_chamber: bool

Inherited From VeSyncFryer

Return True if the fryer has two chambers.

min_temp_f property ↲

min_temp_f: int

Inherited From VeSyncFryer

Return the lowest Fahrenheit temperature over all cook modes.

max_temp_f property ↲

max_temp_f: int

Inherited From VeSyncFryer

Return the highest Fahrenheit temperature over all cook modes.

min_temp_c property ↲

min_temp_c: int

Inherited From VeSyncFryer

Return the lowest Celsius temperature over all cook modes.

max_temp_c property ↲

max_temp_c: int

Inherited From VeSyncFryer

Return the highest Celsius temperature over all cook modes.

temperature_step property ↲

temperature_step: int

Inherited From VeSyncFryer

Return the default mode's temperature step in the current unit.

temperature_interval property ↲

temperature_interval: int

Inherited From VeSyncFryer

Return the default mode's temperature step in the current unit.

Deprecated

Use temperature_step instead.

min_temp property ↲

min_temp: int

Inherited From VeSyncFryer

Return the lowest temperature over all cook modes, in the current unit.

A mode can have a narrower range; temp_limits() gives the range set_mode() accepts.

max_temp property ↲

max_temp: int

Inherited From VeSyncFryer

Return the highest temperature over all cook modes, in the current unit.

A mode can have a narrower range; temp_limits() gives the range set_mode() accepts.

get_chamber_state ↲

get_chamber_state(
    chamber: AirFryerChamber | int,
) -> FryerState | None

Inherited From VeSyncFryer

Return the state object for a chamber.

CHAMBER_1 and WHOLE map to state_chamber_1, CHAMBER_2 to state_chamber_2.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber.

required

Returns:

Type Description
FryerState | None

FryerState | None: The chamber's state, or None (after a debug

FryerState | None

log) for BOTH, which has two states (read state_chamber_1

FryerState | None

and state_chamber_2), or for a chamber the fryer does not

FryerState | None

support or an invalid value. Never raises.

Source code in src/pyvesync/base_devices/fryer_base.py
def get_chamber_state(self, chamber: AirFryerChamber | int) -> FryerState | None:
    """Return the state object for a chamber.

    ``CHAMBER_1`` and ``WHOLE`` map to ``state_chamber_1``, ``CHAMBER_2``
    to ``state_chamber_2``.

    Args:
        chamber (AirFryerChamber | int): The chamber.

    Returns:
        FryerState | None: The chamber's state, or None (after a debug
        log) for ``BOTH``, which has two states (read ``state_chamber_1``
        and ``state_chamber_2``), or for a chamber the fryer does not
        support or an invalid value. Never raises.
    """
    member = self._validate_chamber(chamber)
    if member is None:
        return None
    if member == AirFryerChamber.BOTH:
        logger.debug(
            'Chamber BOTH has two states on %s; read state_chamber_1 and '
            'state_chamber_2',
            self.device_name,
        )
        return None
    return self._chamber_state(member)

get_cook_mode ↲

get_cook_mode(
    cook_mode: str | None = None,
) -> AirFryerModeConfig | None

Inherited From VeSyncFryer

Return the preset for a cook mode.

Parameters:

Name Type Description Default
cook_mode str | None

An AirFryerCookModes key ('steak') or the API mode string ('poultry', 'AirFry'). None gives the default mode, the one the default preset sends.

None

Returns:

Type Description
AirFryerModeConfig | None

AirFryerModeConfig | None: The preset, or None if the fryer has no

AirFryerModeConfig | None

such mode.

Source code in src/pyvesync/base_devices/fryer_base.py
def get_cook_mode(self, cook_mode: str | None = None) -> AirFryerModeConfig | None:
    """Return the preset for a cook mode.

    Args:
        cook_mode (str | None): An ``AirFryerCookModes`` key (``'steak'``)
            or the API mode string (``'poultry'``, ``'AirFry'``). None gives
            the default mode, the one the default preset sends.

    Returns:
        AirFryerModeConfig | None: The preset, or None if the fryer has no
        such mode.
    """
    if cook_mode is None:
        cook_mode = self.default_preset.cook_mode
    elif cook_mode in self.cook_modes:
        return self.cook_modes[cook_mode]
    for mode in self.cook_modes.values():
        if mode.api_mode == cook_mode:
            return mode
    return None

get_mode_recipe ↲

get_mode_recipe(
    cook_mode: str | None = None,
) -> AirFryerPresetRecipe | None

Inherited From VeSyncFryer

Return a cook mode's preset as a recipe in the current unit.

Pass the result to set_mode_from_recipe(). Change its time or temperature with dataclasses.replace first if needed. These are the app's defaults for the mode, which can differ from default_preset.

Parameters:

Name Type Description Default
cook_mode str | None

As for get_cook_mode().

None

Returns:

Type Description
AirFryerPresetRecipe | None

AirFryerPresetRecipe | None: The recipe at the mode's default time

AirFryerPresetRecipe | None

and temperature, or None if the fryer has no such mode.

Source code in src/pyvesync/base_devices/fryer_base.py
def get_mode_recipe(
    self, cook_mode: str | None = None
) -> AirFryerPresetRecipe | None:
    """Return a cook mode's preset as a recipe in the current unit.

    Pass the result to ``set_mode_from_recipe()``. Change its time or
    temperature with ``dataclasses.replace`` first if needed. These are
    the app's defaults for the mode, which can differ from
    ``default_preset``.

    Args:
        cook_mode (str | None): As for ``get_cook_mode()``.

    Returns:
        AirFryerPresetRecipe | None: The recipe at the mode's default time
        and temperature, or None if the fryer has no such mode.
    """
    mode = self.get_cook_mode(cook_mode)
    if mode is None:
        return None
    return mode.to_recipe(self.temp_unit)

temp_limits ↲

temp_limits(
    cook_mode: str | None = None,
) -> AirFryerTempLimits

Inherited From VeSyncFryer

Return a cook mode's temperature limits in the current unit.

Parameters:

Name Type Description Default
cook_mode str | None

As for get_cook_mode(). None, or a mode the fryer does not have, gives the default mode's limits.

None

Returns:

Name Type Description
AirFryerTempLimits AirFryerTempLimits

The mode's minimum, maximum and step.

Source code in src/pyvesync/base_devices/fryer_base.py
def temp_limits(self, cook_mode: str | None = None) -> AirFryerTempLimits:
    """Return a cook mode's temperature limits in the current unit.

    Args:
        cook_mode (str | None): As for ``get_cook_mode()``. None, or a mode
            the fryer does not have, gives the default mode's limits.

    Returns:
        AirFryerTempLimits: The mode's minimum, maximum and step.
    """
    mode = self.get_cook_mode(cook_mode)
    if mode is None:
        logger.debug(
            'Cook mode %s not in the cook modes of %s; using the default '
            "mode's temperature limits",
            cook_mode,
            self.device_name,
        )
        mode = self._default_mode_config
    return mode.limits(self.temp_unit)

validate_temperature ↲

validate_temperature(
    temperature: int, cook_mode: str | None = None
) -> bool

Inherited From VeSyncFryer

Validate the temperature is within the cook mode's range.

Checks the range only; off-step values are handled by rounding (see round_temperature() and prepare_temperature()).

Parameters:

Name Type Description Default
temperature int

The temperature in the device's temp_unit.

required
cook_mode str | None

Cook mode whose limits apply, as for temp_limits().

None

Returns:

Name Type Description
bool bool

True if the temperature is valid, False otherwise.

Source code in src/pyvesync/base_devices/fryer_base.py
def validate_temperature(
    self, temperature: int, cook_mode: str | None = None
) -> bool:
    """Validate the temperature is within the cook mode's range.

    Checks the range only; off-step values are handled by rounding (see
    ``round_temperature()`` and ``prepare_temperature()``).

    Args:
        temperature (int): The temperature in the device's ``temp_unit``.
        cook_mode (str | None): Cook mode whose limits apply, as for
            ``temp_limits()``.

    Returns:
        bool: True if the temperature is valid, False otherwise.
    """
    limits = self.temp_limits(cook_mode)
    return limits.min_temp <= temperature <= limits.max_temp

round_temperature ↲

round_temperature(
    temperature: int, cook_mode: str | None = None
) -> int

Inherited From VeSyncFryer

Round the temperature to the nearest step of the cook mode.

Uses Python's round(), so a value exactly halfway between two steps goes to the even multiple (385 to 380 with a 10 degree step).

Parameters:

Name Type Description Default
temperature int

The temperature in the device's temp_unit.

required
cook_mode str | None

Cook mode whose step applies, as for temp_limits().

None

Returns:

Name Type Description
int int

The temperature rounded to the mode's step in the current

int

unit.

Source code in src/pyvesync/base_devices/fryer_base.py
def round_temperature(self, temperature: int, cook_mode: str | None = None) -> int:
    """Round the temperature to the nearest step of the cook mode.

    Uses Python's ``round()``, so a value exactly halfway between two
    steps goes to the even multiple (385 to 380 with a 10 degree step).

    Args:
        temperature (int): The temperature in the device's ``temp_unit``.
        cook_mode (str | None): Cook mode whose step applies, as for
            ``temp_limits()``.

    Returns:
        int: The temperature rounded to the mode's step in the current
        unit.
    """
    step = self.temp_limits(cook_mode).step
    return round(temperature / step) * step

prepare_temperature ↲

prepare_temperature(
    temperature: int, cook_mode: str | None = None
) -> int | None

Inherited From VeSyncFryer

Round a requested temperature to the mode's step, then validate.

Parameters:

Name Type Description Default
temperature int

Requested temperature in the device's temp_unit.

required
cook_mode str | None

Cook mode whose limits apply, as for temp_limits().

None

Returns:

Type Description
int | None

int | None: The rounded temperature, or None (after a warning) if

int | None

the rounded value is outside the mode's range for the current

int | None

unit.

Source code in src/pyvesync/base_devices/fryer_base.py
def prepare_temperature(
    self, temperature: int, cook_mode: str | None = None
) -> int | None:
    """Round a requested temperature to the mode's step, then validate.

    Args:
        temperature (int): Requested temperature in the device's
            ``temp_unit``.
        cook_mode (str | None): Cook mode whose limits apply, as for
            ``temp_limits()``.

    Returns:
        int | None: The rounded temperature, or None (after a warning) if
        the rounded value is outside the mode's range for the current
        unit.
    """
    rounded = self.round_temperature(temperature, cook_mode)
    if not self.validate_temperature(rounded, cook_mode):
        limits = self.temp_limits(cook_mode)
        mode = self.get_cook_mode(cook_mode) or self._default_mode_config
        logger.warning(
            'Cook temperature %s%s is outside the %s range %s-%s for %s',
            temperature,
            self.temp_unit.code.upper(),
            mode.api_mode,
            limits.min_temp,
            limits.max_temp,
            self.device_name,
        )
        return None
    return rounded

prepare_recipe_temperature ↲

prepare_recipe_temperature(
    recipe: AirFryerPresetRecipe,
) -> int | None

Inherited From VeSyncFryer

Convert a recipe's temperature to the device unit, round and validate.

Parameters:

Name Type Description Default
recipe AirFryerPresetRecipe

The recipe, whose target_temp is in recipe.temp_unit.

required

Returns:

Type Description
int | None

int | None: The temperature in the device's temp_unit, rounded

int | None

to the mode's step, or None (after a warning) if it is outside the

int | None

range of the recipe's cook_mode (of the default mode when the

int | None

fryer has no such mode), or if the temperature or

int | None

recipe.temp_unit is not valid.

Source code in src/pyvesync/base_devices/fryer_base.py
def prepare_recipe_temperature(self, recipe: AirFryerPresetRecipe) -> int | None:
    """Convert a recipe's temperature to the device unit, round and validate.

    Args:
        recipe (AirFryerPresetRecipe): The recipe, whose ``target_temp`` is
            in ``recipe.temp_unit``.

    Returns:
        int | None: The temperature in the device's ``temp_unit``, rounded
        to the mode's step, or None (after a warning) if it is outside the
        range of the recipe's ``cook_mode`` (of the default mode when the
        fryer has no such mode), or if the temperature or
        ``recipe.temp_unit`` is not valid.
    """
    try:
        temperature = float(recipe.target_temp)
        recipe_unit = TemperatureUnits.from_string(recipe.temp_unit)
    except (TypeError, ValueError):
        logger.warning(
            'Invalid recipe temperature %s %s for %s',
            recipe.target_temp,
            recipe.temp_unit,
            self.device_name,
        )
        return None
    if recipe_unit != self.temp_unit:
        if self.temp_unit == TemperatureUnits.CELSIUS:
            temperature = TemperatureUnits.to_celsius(temperature, recipe_unit)
        else:
            temperature = TemperatureUnits.to_fahrenheit(temperature, recipe_unit)
    return self.prepare_temperature(round(temperature), recipe.cook_mode)

convert_time_for_api ↲

convert_time_for_api(time_in_seconds: int) -> int

Inherited From VeSyncFryer

Convert time in seconds to the device's time units.

Minutes are truncated (int(seconds / 60)), so under 60 seconds gives 0.

Parameters:

Name Type Description Default
time_in_seconds int

The time in seconds.

required

Returns:

Name Type Description
int int

The time converted to the device's time units.

Source code in src/pyvesync/base_devices/fryer_base.py
def convert_time_for_api(self, time_in_seconds: int) -> int:
    """Convert time in seconds to the device's time units.

    Minutes are truncated (``int(seconds / 60)``), so under 60 seconds
    gives 0.

    Args:
        time_in_seconds (int): The time in seconds.

    Returns:
        int: The time converted to the device's time units.
    """
    if self.time_units == TimeUnits.MINUTES:
        return int(time_in_seconds / 60)
    return int(time_in_seconds)

convert_time_for_state ↲

convert_time_for_state(time_in_device_units: int) -> int

Inherited From VeSyncFryer

Convert time in device's time units to seconds.

Parameters:

Name Type Description Default
time_in_device_units int

The time in device's time units.

required

Returns:

Name Type Description
int int

The time converted to seconds.

Source code in src/pyvesync/base_devices/fryer_base.py
def convert_time_for_state(self, time_in_device_units: int) -> int:
    """Convert time in device's time units to seconds.

    Args:
        time_in_device_units (int): The time in device's time units.

    Returns:
        int: The time converted to seconds.
    """
    if self.time_units == TimeUnits.MINUTES:
        return time_in_device_units * 60
    return time_in_device_units

end async ↲

end(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

End the current cooking or preheating session.

On success the chamber state is set to standby. On the CS137/CS158 the command depends on the last known state, and it returns False without a request when that state is neither cooking nor preheating. On the CAF-TF101S/TF102S, CHAMBER_1 during a whole-basket cook (work_chamber WHOLE) ends the whole basket.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to end for. Default is CHAMBER_1. An unsupported chamber returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/base_devices/fryer_base.py
async def end(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    """End the current cooking or preheating session.

    On success the chamber state is set to standby. On the CS137/CS158
    the command depends on the last known state, and it returns False
    without a request when that state is neither cooking nor preheating.
    On the CAF-TF101S/TF102S, ``CHAMBER_1`` during a whole-basket cook
    (``work_chamber`` ``WHOLE``) ends the whole basket.

    Arguments:
        chamber (AirFryerChamber | int): The chamber to end for. Default
            is ``CHAMBER_1``. An unsupported chamber returns False without
            a request.

    Returns:
        bool: True if the command was successful, False otherwise.
    """
    del chamber
    logger.info('end not configured for this fryer.')
    return False

stop async ↲

stop(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

Stop (Pause) the current cooking or preheating session.

Supported by the CS137/CS158 (VeSyncAirFryer158) and dual fryers with the RESUMABLE feature (CAF-DC111S-AEU); other fryers return False without a request. The command is always sent: the preheat stop when the last known state is a preheat phase, otherwise the cook stop. Every successful command sets the state to the phase it sent, so the next command picks the matching one.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to stop for. Default is CHAMBER_1.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/base_devices/fryer_base.py
async def stop(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    """Stop (Pause) the current cooking or preheating session.

    Supported by the CS137/CS158 (``VeSyncAirFryer158``) and dual fryers
    with the ``RESUMABLE`` feature (CAF-DC111S-AEU); other fryers return
    False without a request. The command is always sent:
    the preheat stop when the last known state is a preheat phase,
    otherwise the cook stop. Every successful command sets the state to
    the phase it sent, so the next command picks the matching one.

    Arguments:
        chamber (AirFryerChamber | int): The chamber to stop for. Default
            is ``CHAMBER_1``.

    Returns:
        bool: True if the command was successful, False otherwise.
    """
    del chamber
    logger.info('stop not supported by this fryer.')
    return False

resume async ↲

resume(chamber: AirFryerChamber | int = CHAMBER_1) -> bool

Inherited From VeSyncFryer

Resume a paused cooking or preheating session.

Supported by the CS137/CS158 (VeSyncAirFryer158) and dual fryers with the RESUMABLE feature (CAF-DC111S-AEU); other fryers return False without a request. The command is always sent: the preheat resume when the last known state is a preheat phase, otherwise the cook resume. The remaining time carries over, so a resume while running does not restart the countdown.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to resume for. Default is CHAMBER_1.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/base_devices/fryer_base.py
async def resume(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    """Resume a paused cooking or preheating session.

    Supported by the CS137/CS158 (``VeSyncAirFryer158``) and dual fryers
    with the ``RESUMABLE`` feature (CAF-DC111S-AEU); other fryers return
    False without a request. The command is always sent:
    the preheat resume when the last known state is a preheat phase,
    otherwise the cook resume. The remaining time carries over, so a
    resume while running does not restart the countdown.

    Arguments:
        chamber (AirFryerChamber | int): The chamber to resume for. Default
            is ``CHAMBER_1``.

    Returns:
        bool: True if the command was successful, False otherwise.
    """
    del chamber
    logger.info('resume not supported by this fryer.')
    return False

set_mode async ↲

set_mode(
    cook_time: int,
    cook_temp: int,
    *,
    temp_unit: TemperatureUnits | str | None = None,
    preheat_time: int | None = None,
    cook_mode: str | None = None,
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start a cook with the given time and temperature.

cook_temp is converted to the device unit if needed, rounded to the cook mode's step (see round_temperature()) and checked against that mode's limits (see temp_limits()). Out of range logs a warning and returns False without a request. On success the chamber state is set locally from the request; call update() for the device's reported state.

Parameters:

Name Type Description Default
cook_time int

The cooking time in seconds, at least 1 (60 on the CS137/CS158, whose API uses minutes and truncates to whole minutes). A shorter time returns False without a request. A mode's time_limits, where declared, also apply; a time outside them returns False without a request. Other modes have no upper limit.

required
cook_temp int

The cooking temperature, in temp_unit.

required
temp_unit TemperatureUnits | str | None

The unit of cook_temp; defaults to the device's current temp_unit. An unrecognised unit string returns False without a request.

None
preheat_time int | None

The preheating time in seconds, if any. Used only on fryers with the PREHEAT feature: dropped on the CAF-P583S and ignored on the dual-chamber fryers. On fryers with preheat it must be 0 or at least one API time unit (60 seconds on the CS137/CS158); otherwise False without a request.

None
cook_mode str | None

Cook mode to use, an AirFryerCookModes key or the API mode string (see get_cook_mode()). Its recipe ID, name and type are sent. None uses the default preset. A mode the fryer does not have returns False without a request.

None
chamber AirFryerChamber | int

The chamber to cook in. Default is CHAMBER_1. A chamber not in supported_chambers returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/base_devices/fryer_base.py
async def set_mode(
    self,
    cook_time: int,
    cook_temp: int,
    *,
    temp_unit: TemperatureUnits | str | None = None,
    preheat_time: int | None = None,
    cook_mode: str | None = None,
    chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
) -> bool:
    """Start a cook with the given time and temperature.

    ``cook_temp`` is converted to the device unit if needed, rounded to
    the cook mode's step (see ``round_temperature()``) and checked against
    that mode's limits (see ``temp_limits()``). Out of range logs a
    warning and returns False without a request. On success the chamber
    state is set locally from the request; call ``update()`` for the
    device's reported state.

    Args:
        cook_time (int): The cooking time in seconds, at least 1 (60 on
            the CS137/CS158, whose API uses minutes and truncates to whole
            minutes). A shorter time returns False without a request.
            A mode's ``time_limits``, where declared, also apply; a time
            outside them returns False without a request. Other modes have
            no upper limit.
        cook_temp (int): The cooking temperature, in ``temp_unit``.
        temp_unit (TemperatureUnits | str | None): The unit of
            ``cook_temp``; defaults to the device's current ``temp_unit``.
            An unrecognised unit string returns False without a request.
        preheat_time (int | None): The preheating time in seconds, if any.
            Used only on fryers with the ``PREHEAT`` feature: dropped on
            the CAF-P583S and ignored on the dual-chamber fryers. On fryers
            with preheat it must be 0 or at least one API time unit (60
            seconds on the CS137/CS158); otherwise False without a
            request.
        cook_mode (str | None): Cook mode to use, an ``AirFryerCookModes``
            key or the API mode string (see ``get_cook_mode()``). Its
            recipe ID, name and type are sent. None uses the default
            preset. A mode the fryer does not have returns False without a
            request.
        chamber (AirFryerChamber | int): The chamber to cook in. Default is
            ``CHAMBER_1``. A chamber not in ``supported_chambers`` returns
            False without a request.

    Returns:
        bool: True if the command was successful, False otherwise.
    """
    del cook_time, cook_temp, chamber, preheat_time, cook_mode, temp_unit
    logger.warning('set_mode method not implemented for base fryer class.')
    return False

set_chamber_modes async ↲

set_chamber_modes(
    chamber_1: AirFryerPresetRecipe,
    chamber_2: AirFryerPresetRecipe | None = None,
    *,
    sync_type: AirFryerSyncType = SYNC,
) -> bool

Inherited From VeSyncFryer

Start a recipe in each chamber, or one recipe in both.

Only fryers with the CHAMBER_MODES feature implement this. Both recipes are checked before any request, as in set_mode_from_recipe(), so an invalid second recipe does not start the first. On success both chamber states are set locally from the request; call update() for the device's reported state.

Parameters:

Name Type Description Default
chamber_1 AirFryerPresetRecipe

Recipe for chamber 1 with SYNC or OFF, or the recipe for both chambers with MATCH.

required
chamber_2 AirFryerPresetRecipe | None

Recipe for chamber 2. Required with SYNC and OFF; must be None with MATCH.

None
sync_type AirFryerSyncType

SYNC for separate recipes that finish together, OFF for separate recipes that each run on their own time, or MATCH for identical settings.

SYNC

Returns:

Name Type Description
bool bool

True if the command was successful. An unsupported fryer or

bool

an invalid combination returns False without a request.

Source code in src/pyvesync/base_devices/fryer_base.py
async def set_chamber_modes(
    self,
    chamber_1: AirFryerPresetRecipe,
    chamber_2: AirFryerPresetRecipe | None = None,
    *,
    sync_type: AirFryerSyncType = AirFryerSyncType.SYNC,
) -> bool:
    """Start a recipe in each chamber, or one recipe in both.

    Only fryers with the ``CHAMBER_MODES`` feature implement this. Both
    recipes are checked before any request, as in
    ``set_mode_from_recipe()``, so an invalid second recipe does not start
    the first. On success both chamber states are set locally from the
    request; call ``update()`` for the device's reported state.

    Args:
        chamber_1 (AirFryerPresetRecipe): Recipe for chamber 1 with
            ``SYNC`` or ``OFF``, or the recipe for both chambers with
            ``MATCH``.
        chamber_2 (AirFryerPresetRecipe | None): Recipe for chamber 2.
            Required with ``SYNC`` and ``OFF``; must be None with ``MATCH``.
        sync_type (AirFryerSyncType): ``SYNC`` for separate recipes that
            finish together, ``OFF`` for separate recipes that each run on
            their own time, or ``MATCH`` for identical settings.

    Returns:
        bool: True if the command was successful. An unsupported fryer or
        an invalid combination returns False without a request.
    """
    del chamber_1, chamber_2, sync_type
    logger.debug('Separate chamber modes are not configured for %s', self.device_name)
    return False

set_mode_from_recipe async ↲

set_mode_from_recipe(
    recipe: AirFryerPresetRecipe,
    *,
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start a cook from a recipe.

recipe.cook_mode must be one of the fryer's cook modes (see get_cook_mode()); its API mode string is sent. recipe.target_temp is converted from recipe.temp_unit to the device unit, rounded to the mode's step and checked against its limits. The times are checked as in set_mode(). A failed check (including an invalid recipe.temp_unit) logs a warning and returns False without a request. The recipe passed in is not changed.

Parameters:

Name Type Description Default
recipe AirFryerPresetRecipe

The preset recipe to use. Its recipe ID, name and type are sent as given.

required
chamber AirFryerChamber | int

The chamber to cook in. Default is CHAMBER_1. A chamber not in supported_chambers returns False without a request.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Note

Get a fryer's preset with get_mode_recipe() and change it with dataclasses.replace. See AirFryerPresetRecipe.

Source code in src/pyvesync/base_devices/fryer_base.py
async def set_mode_from_recipe(
    self,
    recipe: AirFryerPresetRecipe,
    *,
    chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1,
) -> bool:
    """Start a cook from a recipe.

    ``recipe.cook_mode`` must be one of the fryer's cook modes (see
    ``get_cook_mode()``); its API mode string is sent.
    ``recipe.target_temp`` is converted from ``recipe.temp_unit`` to the
    device unit, rounded to the mode's step and checked against its
    limits. The times are checked as in ``set_mode()``. A failed check
    (including an invalid ``recipe.temp_unit``) logs a warning and returns
    False without a request. The recipe passed in is not changed.

    Args:
        recipe (AirFryerPresetRecipe): The preset recipe to use. Its recipe
            ID, name and type are sent as given.
        chamber (AirFryerChamber | int): The chamber to cook in. Default is
            ``CHAMBER_1``. A chamber not in ``supported_chambers`` returns
            False without a request.

    Returns:
        bool: True if the command was successful, False otherwise.

    Note:
        Get a fryer's preset with ``get_mode_recipe()`` and change it with
        ``dataclasses.replace``. See
        [AirFryerPresetRecipe][pyvesync.const.AirFryerPresetRecipe].
    """
    del recipe, chamber
    logger.warning(
        'set_mode_from_recipe method not implemented for base fryer class.'
    )
    return False

cook_from_preheat async ↲

cook_from_preheat(
    chamber: AirFryerChamber | int = CHAMBER_1,
) -> bool

Inherited From VeSyncFryer

Start cooking after the preheat has finished.

Only the CS137/CS158 (VeSyncAirFryer158) supports this; other fryers return False without a request. The last known cook_status must be PREHEAT_END, so call update() after the preheat finishes; otherwise it returns False without a request.

Parameters:

Name Type Description Default
chamber AirFryerChamber | int

The chamber to start cooking for. Default is CHAMBER_1.

CHAMBER_1

Returns:

Name Type Description
bool bool

True if the command was successful, False otherwise.

Source code in src/pyvesync/base_devices/fryer_base.py
async def cook_from_preheat(
    self, chamber: AirFryerChamber | int = AirFryerChamber.CHAMBER_1
) -> bool:
    """Start cooking after the preheat has finished.

    Only the CS137/CS158 (``VeSyncAirFryer158``) supports this; other
    fryers return False without a request. The last known
    ``cook_status`` must be ``PREHEAT_END``, so call ``update()`` after
    the preheat finishes; otherwise it returns False without a request.

    Args:
        chamber (AirFryerChamber | int): The chamber to start cooking for.
            Default is ``CHAMBER_1``.

    Returns:
        bool: True if the command was successful, False otherwise.
    """
    del chamber
    if AirFryerFeatures.PREHEAT not in self.features:
        logger.info('Preheat feature not supported on this fryer.')
        return False
    logger.info('cook_from_preheat not configured for this fryer.')
    return False