Skip to content

Working with timers

The bar does not run a clock you can read

GET /api/busy/snapshot does not tell you what the timer is doing now. It returns the last snapshot that was applied, exactly as it was applied, together with the moment that happened. Read it twice a minute apart and you get the same numbers back both times.

That is deliberate, not a gap. It is how the apps keep one timer in step across several clients: whoever changes the timer writes a snapshot, the freshest timestamp wins, and every client works out the current state for itself. The bar is the shared store, not the clock.

So a snapshot on its own is a starting point plus a date. To get from there to "what is the timer doing", you advance it by the time that has passed — which is what timer_state() does.

Note

"Snapshot" means two unrelated things in this library. Here it is the timer's state on the device. In Reading device state it is collect_device_snapshot(), a gathered picture of battery, Wi-Fi and the rest.

Asking what the timer is doing

from busylib import BusyBar
from busylib.features import timer_state

bar = BusyBar("10.0.4.20")
state = timer_state(bar.busy_snapshot())

print(state.mode)  # not_started / infinite / simple / interval
print(state.phase)  # work / rest / None
print(state.time_left_ms)
print(state.is_running)

timer_state() takes the current wall clock by default. Pass now_ms to ask about another moment — which is the whole point of the model: one read answers for any time, without polling.

The four kinds of timer

mode What it is phase time_left_ms
not_started No session None None
infinite Runs until stopped work None — nothing to count down to
simple One countdown None Remaining, 0 once finished
interval Pomodoro: work and rest, repeated work / rest Remaining in the current interval

How an interval session is laid out

Two words that are easy to mix up, and that the firmware keeps distinct: an interval is one stretch of work or of rest, and a cycle is a work interval plus the rest that follows it. interval_work_cycles_count configures cycles; current_interval reports intervals.

An interval session is a run of intervals, numbered from zero, alternating work and rest. current_interval in the snapshot is that number, and it is the only thing you need:

  • even index → work, odd index → rest.

Not the interval's length. Reading the phase off durations happens to work when work and rest differ, and falls apart when someone configures both to 25 minutes — the index has no such gap.

A session of n cycles runs n work periods with rest between them, and stops after the last work — there is no rest at the end. So three cycles of 20 minutes' work and 5 minutes' rest look like this:

Index Phase From To
0 work 0:00 20:00
1 rest 20:00 25:00
2 work 25:00 45:00
3 rest 45:00 50:00
4 work 50:00 70:00
5 — session over

Index 5 is never a live interval; reaching it is the session ending. In general the session ends at index cycles * 2 - 1.

A worked example

One read, then the state at whatever moment you ask about — the same 20/5/3 session as the table above:

snapshot = bar.busy_snapshot()  # read once
started = snapshot.snapshot_timestamp_ms

for minutes in (0, 21, 26, 46, 71):
    state = timer_state(snapshot, now_ms=started + minutes * 60_000)
    print(minutes, state.phase, state.interval, state.time_left_ms, state.is_finished)

Expected output:

0 work 0 1200000 False
21 rest 1 240000 False
26 work 2 1140000 False
46 rest 3 240000 False
71 None 5 0 True

At 21 minutes the first work period is over and rest has four minutes to run. At 71 the session has finished, so no phase applies.

Pausing

A paused snapshot is already the answer: paused time does not pass, so timer_state() hands back what the snapshot says however long ago it was written.

state = timer_state(snapshot)
if state.is_paused:
    print(f"paused with {state.time_left_ms} ms left in {state.phase}")

Changing a timer

Every change is a snapshot write - there is no endpoint that pauses a session or moves it on a phase - and each one has a detail that is easy to get wrong. The helpers in busylib.features.timer write the right snapshot for you:

from busylib.features import timer

await timer.start(bar)  # the session the "busy" card describes
await timer.start(bar, "custom", theme="dnd")  # the other card, this session in dnd
await timer.start(
    bar, kind="simple", duration_ms=45 * 60_000
)  # 45 minutes, card untouched
await timer.start(
    bar, kind="interval", duration_ms=25 * 60_000, rest_ms=5 * 60_000, cycles=4
)  # an interval of your own
await timer.start(
    bar, card_id=other, kind="simple", duration_ms=30 * 60_000
)  # a card outside both positions
await timer.set_paused(bar, True)  # pause, keeping the time actually left
await timer.set_paused(bar, False)  # resume
await timer.next_phase(bar)  # work -> rest, at the rest length
await timer.set_session_theme(bar, "meeting")  # until this session ends
await timer.set_card_theme(bar, "busy", "meeting")  # from now on
await timer.stop(bar)  # back to not started

Three things they take care of:

A session may have settings of its own, and they do not touch the card. With nothing but a slot, start() runs what that card describes - the thing the bar's own switch would start. Give it a kind or a length and the session runs that instead, travelling in the snapshot: the card keeps its name, its lengths and its theme, and the app still shows the session under that card's name. This is what an automation wants - "a countdown for forty-five minutes" should not rewrite a card somebody arranged by hand.

A session can name a card the bar does not hold. The bar keeps two cards, one per switch position; the BUSY app keeps more. card_id names any of them, and then nothing on the bar is read or written - the two cards are not involved even by name, and the app shows the session under the card it does know. Such a session has no card to inherit from, so its kind and lengths come from the call.

The bar will not run just any length. Both ends are checked, and the firmware says so nowhere useful: a card written with a two-minute work phase answers OK and keeps what it had, and a session with one comes back as 400 Failed to parse snapshot. An interval phase runs 5 minutes to 8 hours, a session has 2 to 35 work phases, and a countdown - checked at the top only - runs up to 24 hours with no floor at all. busylib raises before the write rather than letting either silence through. The numbers are the firmware's own (applications/services/busy_timer/busy_timer_common.h), confirmed on a bar.

Pausing has to recompute the remaining time. The stored snapshot's figure was true when it was written; writing it back unchanged hands the session back the time it already spent. set_paused() takes the figure from timer_state(), and carries the phase across if the session moved on in the meantime.

A session theme and a card theme are different things. set_session_theme lasts as long as the session - stop it and the bar shows the card's theme again, confirmed on hardware. set_card_theme outlasts the session and does not change what is on screen now.

Which themes there are is a question for the bar. Themes are assets: one bar has what the firmware shipped, another has one its owner uploaded, a third is missing one its owner deleted. So there is no list to write down - timer.themes(bar) reads it:

options = await timer.themes(bar)  # ['back_soon', 'booked', 'busy', 'coding', ...]

It reads two things, because neither alone is the answer: the asset directories, and whichever themes the bar's own cards are set to. The second is how the firmware's built-in default gets in - it has no directory, so a listing alone would report that a bar cannot show the theme it is showing right now.

Setting a theme checks it against that list first, because the device will not: a card naming a theme that does not exist is stored and read back happily, and only the bar's screen shows that anything is wrong. A theme it does not have raises UnknownThemeError, which carries what it does have. If you already have the list - because you offered it to someone - pass it as known= and save the lookup.

Changing a card is for when the change should last. A session can carry settings of its own (see start() above), and that is what an automation wants; timer.configure() is for the other case - when the bar's own switch should start something different from now on, and the BUSY app should show it:

await timer.configure(bar, "busy", work_ms=25 * 60_000)
await timer.start(bar, "busy")

The change outlasts the session, and the bar and the phone app see it - which is the same thing they do to each other.

One call is enough to set something of a given length. duration_ms lands where that kind of card keeps it - the total of a countdown, the work phase of an interval session - so a caller does not have to know which:

await timer.configure(bar, "custom", kind="simple", duration_ms=40 * 60_000)
await timer.start(bar, "custom")

A card can change what kind of timer it holds, which is how a mode that runs without a clock becomes an interval session:

await timer.configure(bar, "custom", kind="interval", work_ms=25 * 60_000)
print(timer.kind_of((await bar.busy_profile("custom")).timer_settings))  # 'interval'

infinite, simple and interval are what this package calls the firmware's INFINITE, SIMPLE and INTERVAL. Changing to a kind a card was not already running replaces its timer rather than editing it, because there is nothing to carry over - an infinite card has no lengths at all - and anything you do not pass comes from the defaults. Asking for the kind a card already holds edits it instead, so its lengths survive.

Phases under five minutes are dropped in silence. The device stores nothing and answers {"result": "OK"}; the card keeps what it had. Found by bisection on firmware r971 - four minutes ignored, five, six and seven kept - and documented nowhere, the bar's own OpenAPI even offering 120000 as its example. configure() raises PhaseTooShortError rather than let a caller watch a bar run the wrong timer.

A card write needs a fresh timestamp. The device keeps whichever copy of a card is newer and silently discards the rest, answering {"result": "OK"} either way. A card read back and written unchanged carries the stored profile_timestamp_ms, which is not newer - and a bar that has never had one written reports 0 - so the write disappears with no error at all. set_card_theme stamps it for you.

Anything that rewrites a running session - pausing, next_phase, set_session_theme - raises TimerNotRunningError when nothing is running, rather than starting a session nobody asked for. Both errors these helpers raise are importable from busylib.features, next to everything else a caller catches:

from busylib.features import TimerNotRunningError, UnknownThemeError, timer

try:
    await timer.set_session_theme(bar, theme)
except TimerNotRunningError:
    ...  # nothing is running, so there is no session theme to change
except UnknownThemeError as err:
    print(f"this bar has {', '.join(err.available)}")

Writing the snapshot yourself

You write a snapshot, the same way the apps do:

import time

from busylib import types

profile = bar.busy_profile("busy")
settings = profile.timer_settings

bar.busy_snapshot_set(
    types.BusySnapshot(
        snapshot=types.BusySnapshotInterval(
            type="INTERVAL",
            card_id=profile.id,
            current_interval=0,  # start at the first work period
            current_interval_time_total_ms=settings.interval_work_ms,
            current_interval_time_left_ms=settings.interval_work_ms,
            is_paused=False,
            interval_settings=settings,
            busy_bar_settings=profile.busy_bar_settings,
        ),
        snapshot_timestamp_ms=int(time.time() * 1000),
    )
)

Stopping is a NOT_STARTED snapshot with a fresh timestamp.

Two things to know, both learned the hard way:

A length the bar will not run is reported as an unparseable snapshot. 400 Failed to parse snapshot is misleading: the JSON parsed fine, and the firmware is refusing a number. An interval phase runs 5 minutes to 8 hours and a session has 2 to 35 work phases (busy_timer_common.h); a countdown is checked at the top only. The settings themselves may be whatever you like - they need not match the card the snapshot names.

busy_bar_settings is required. It is merged into the snapshot object on the wire rather than sitting beside it, and a snapshot without it is refused. Reading a snapshot gives it to you, so the active theme and trigger_smart_home come along for free.

And one device quirk worth knowing if you try to make a short session for testing: PUT /api/busy/profiles/{slot} answers {"result": "OK"} and silently keeps the old settings when given short interval durations, so a six-second interval session cannot be configured through the API.