Skip to content

Features

Higher-level helpers built on top of the raw client: notification layout for the front panel, timer state and control, a device snapshot for dashboards, a store that applies streamed protobuf state updates, and asset syncing.

For notifications, start with the Sending notifications guide - it covers the built-in templates and how to write your own. For timers, see Working with timers, which explains why a snapshot has to be advanced before it means anything.

busylib.features

InputEvent module-attribute

InputEvent: TypeAlias = ButtonEvent | SelectorEvent | EncoderEvent

TimerKind module-attribute

TimerKind = Literal['infinite', 'simple', 'interval']

AssetKind module-attribute

AssetKind = Literal['image', 'animation', 'sound', 'font', 'theme']

BUILT_IN_TEMPLATES module-attribute

BUILT_IN_TEMPLATES: tuple[Template, ...] = (TWO_LINES, ONE_LINE)

DeviceSnapshot

Bases: BaseModel

name class-attribute instance-attribute

name: str | None = None

version class-attribute instance-attribute

version: VersionInfo | None = None

status class-attribute instance-attribute

status: Status | None = None

system class-attribute instance-attribute

system: StatusSystem | None = None

power class-attribute instance-attribute

power: StatusPower | None = None

time class-attribute instance-attribute

time: datetime | None = None

wifi class-attribute instance-attribute

wifi: StatusResponse | None = None

brightness class-attribute instance-attribute

brightness: DisplayBrightnessInfo | None = None

volume class-attribute instance-attribute

volume: AudioVolumeInfo | None = None

timer class-attribute instance-attribute

timer: BusySnapshot | None = None

ble class-attribute instance-attribute

ble: BleStatus | None = None

storage class-attribute instance-attribute

storage: StorageStatus | None = None

update_available_version class-attribute instance-attribute

update_available_version: str | None = None

field_errors class-attribute instance-attribute

field_errors: dict[str, str] = Field(default_factory=dict)

raw_time class-attribute instance-attribute

raw_time: object | None = Field(default=None, exclude=True)

screen_front class-attribute instance-attribute

screen_front: Frame | None = Field(default=None, exclude=True)

screen_back class-attribute instance-attribute

screen_back: Frame | None = Field(default=None, exclude=True)

model_config class-attribute instance-attribute

model_config = {'extra': 'ignore'}

DeviceStateStore

DeviceStateStore(initial: DeviceSnapshot)

Stateful store for snapshot updates driven by status websocket stream.

The store applies protobuf state deltas, updates current snapshot, and notifies subscribers through two callback channels: - on_state: receives full updated snapshot; - on_diff: receives changed top-level fields and full snapshot.

Create a state store with initial snapshot value.

snapshot property

snapshot: DeviceSnapshot

Return current snapshot value.

on_state

on_state(callback: StateCallback) -> Callable[[], None]

Subscribe to full-state updates and return unsubscriber.

on_diff

on_diff(callback: StateDiffCallback) -> Callable[[], None]

Subscribe to changed-fields updates and return unsubscriber.

apply_stream_message

apply_stream_message(state_message: dict[str, object]) -> DeviceSnapshot

Apply one stream message and emit callbacks when state changes.

ButtonEvent dataclass

ButtonEvent(button: Button, action: ButtonAction)

One of the three buttons went down or came back up.

Both halves are reported, so a consumer can tell a long press from a short one; most only care about press.

button instance-attribute

button: Button

action instance-attribute

action: ButtonAction

is_press property

is_press: bool

EncoderEvent dataclass

EncoderEvent(delta: int)

The wheel was turned, by delta steps: positive one way, negative the other.

delta instance-attribute

delta: int

SelectorEvent dataclass

SelectorEvent(position: SelectorPosition)

The selector was moved to a position.

off is the bar's do-not-disturb rather than a power state.

position instance-attribute

position: SelectorPosition

TimerClient

Bases: Protocol

What these helpers need from a client.

Deliberately three methods rather than the whole client, so a caller can pass anything that speaks them - including a test double.

busy_snapshot async

busy_snapshot() -> types.BusySnapshot

busy_snapshot_set async

busy_snapshot_set(snapshot: BusySnapshot) -> types.SuccessResponse

busy_profile async

busy_profile(slot: BusyProfileSlot) -> types.BusyProfile

busy_profile_set async

busy_profile_set(slot: BusyProfileSlot, profile: BusyProfile) -> types.SuccessResponse

storage_list async

storage_list(path: str) -> types.StorageList

TimerNotRunningError

Bases: BusyBarError

Raised for a change that only makes sense while a session is running.

Pausing, resuming, moving to the next phase and setting the session's theme all rewrite the running session. With nothing running there is nothing to rewrite, and writing a session to make the change possible would start one nobody asked for.

PhaseTooShortError

PhaseTooShortError(field: str, given_ms: int, *, minimum_ms: int = MINIMUM_PHASE_MS, maximum_ms: int = MAXIMUM_PHASE_MS)

Bases: BusyBarError

Raised for a duration the device would refuse, at either end.

It never says so usefully. A card written with a two-minute work phase comes back with the phase it had before and every layer reports success; a session with one is refused as an unparseable snapshot. So a caller that is not told here finds out by watching a bar run the wrong timer.

The name is the common case - too short - but the same class carries the ceiling, since to a caller they are one mistake: a length the bar will not run.

field instance-attribute

field = field

given_ms instance-attribute

given_ms = given_ms

minimum_ms instance-attribute

minimum_ms = minimum_ms

maximum_ms instance-attribute

maximum_ms = maximum_ms

TimerState dataclass

TimerState(mode: TimerMode, is_paused: bool, phase: TimerPhase | None = None, interval: int | None = None, time_left_ms: int | None = None, is_finished: bool | None = None, elapsed_ms: int = 0)

What the timer is doing at one moment.

phase is None only once a session has finished, where no phase applies any more.

mode instance-attribute

mode: TimerMode

is_paused instance-attribute

is_paused: bool

phase class-attribute instance-attribute

phase: TimerPhase | None = None

interval class-attribute instance-attribute

interval: int | None = None

time_left_ms class-attribute instance-attribute

time_left_ms: int | None = None

is_finished class-attribute instance-attribute

is_finished: bool | None = None

elapsed_ms class-attribute instance-attribute

elapsed_ms: int = 0

is_running property

is_running: bool

Whether a session is under way, paused or not.

UnknownThemeError

UnknownThemeError(theme: str, available: Sequence[str])

Bases: BusyBarError

Raised for a theme this bar does not have.

The device does not raise it: a profile naming a theme that does not exist is stored and read back happily, and only the bar's own screen shows that something is wrong. So the check has to happen here, or a typo leaves a card pointing at nothing with every layer reporting success.

theme instance-attribute

theme = theme

available instance-attribute

available = list(available)

Asset dataclass

Asset(name: str, reference: str, kind: AssetKind, application: str | None = None)

One file on a bar, and how to name it in a call.

reference is what you pass, and which field it goes in depends on where the file came from. A shipped asset is named by stock_path and carries its folder: shared/images/clock_5x5.image. An upload is named by path and is just the file name, because the device resolves it inside the uploading application's own folder - so the same name in two applications is two different files, and application says which one this is.

name instance-attribute

name: str

reference instance-attribute

reference: str

kind instance-attribute

kind: AssetKind

application class-attribute instance-attribute

application: str | None = None

is_upload property

is_upload: bool

Whether this is something an application put there.

device_path property

device_path: str

Where the file actually is, for reading it back.

NotificationSpec dataclass

NotificationSpec(line_1: str, line_2: str | None = None, icon: StockIcon | None = None, line_1_font: DisplayFontName = DEFAULT_FONT, line_2_font: DisplayFontName | None = None, line_1_color: ColorInput | None = None, line_2_color: ColorInput | None = None, background_color: ColorInput | None = None, duration: int | None = None, display: DisplaySpec = FRONT_DISPLAY)

Everything a template needs, already resolved and validated.

A template receives one of these rather than raw keyword arguments, so a custom template gets the icon already looked up, the geometry of the panel it is drawing on, and helpers for the parts that are easy to get wrong - text placement past an icon, and scrolling long lines.

line_1 instance-attribute

line_1: str

line_2 class-attribute instance-attribute

line_2: str | None = None

icon class-attribute instance-attribute

icon: StockIcon | None = None

line_1_font class-attribute instance-attribute

line_1_font: DisplayFontName = DEFAULT_FONT

line_2_font class-attribute instance-attribute

line_2_font: DisplayFontName | None = None

line_1_color class-attribute instance-attribute

line_1_color: ColorInput | None = None

line_2_color class-attribute instance-attribute

line_2_color: ColorInput | None = None

background_color class-attribute instance-attribute

background_color: ColorInput | None = None

duration class-attribute instance-attribute

duration: int | None = None

display class-attribute instance-attribute

display: DisplaySpec = FRONT_DISPLAY

text_x property

text_x: int

Where text starts: past the icon, or at the plain left margin.

The shipped icons are 5, 8 and 11px wide, so this is computed from the icon rather than fixed - a constant offset would overlap the narrow ones and leave a gap after the wide ones.

available_width property

available_width: int

How much width is left for text after the icon.

text

text(element_id: str, text: str, *, y: int, align: str, color: ColorInput | None = None, x: int | None = None, width: int | None = None, font: DisplayFontName | None = None) -> types.TextElement

Build a text element at y, scrolled if it cannot fit.

x defaults to text_x, which is where a left-anchored line starts once the icon has had its share. A template anchoring to the right or the centre has to say where instead: with mid_right and the default x the line's right edge lands at the left margin, and the text runs off the panel.

width is how much room the line has, and it is what scrolling is decided against. It defaults to everything right of x, which is only the room available when the line is anchored left - for any other anchor, pass it. Getting this wrong does not misplace the text, it scrolls it through a window a few pixels wide.

icon_element

icon_element(element_id: str = _ICON_ID) -> types.ImageElement | None

Build the icon element, or None when there is no icon.

Template

Bases: Protocol

A way of arranging a notification on the panel.

The built-in templates are ordinary implementations of this, so writing one is the same work: declare which fonts it can place, say when it applies, and return the elements. Everything version-dependent stays outside - the background fill is added by build_notification, which knows what the firmware can draw - so a template is only about layout.

name property

name: str

Identifies the template in errors and logs.

fonts property

fonts: tuple[DisplayFontName, ...]

The fonts this template can place without clipping.

matches

matches(spec: NotificationSpec) -> bool

Whether this template suits the notification, for auto-selection.

render

render(spec: NotificationSpec) -> list[types.DisplayElement]

Lay the notification out as display elements.

sync_app_assets async

sync_app_assets(client: AssetsClient, application_name: str, assets_dir: str | Path) -> list[str]

Sync local assets with device storage for the given application name. Uploads files that are missing or have different sizes.

apply_state_stream_update

apply_state_stream_update(snapshot: DeviceSnapshot, state_message: dict[str, object]) -> DeviceSnapshot

Apply one protobuf /api/status/ws state message to an existing snapshot.

The function updates only fields present in state updates and preserves all previously known values for absent fields.

collect_device_snapshot async

collect_device_snapshot(client: AsyncBusyBar) -> DeviceSnapshot

Collect a best-effort device snapshot with per-field diagnostics.

Each field request runs independently. Failed fields are set to None and reported in field_errors without aborting the whole snapshot.

kind_of

kind_of(settings: BusyTimerSettings) -> TimerKind

Which kind of timer a card holds, in this package's words.

kind_of_snapshot

kind_of_snapshot(snapshot: BusySnapshotVariant) -> TimerKind | None

Which kind of timer is running, or None if none is.

A snapshot has a state the three kinds do not - nothing started - so this answers with None rather than pretending the bar is idling in one of them.

phase_of

phase_of(interval: int) -> TimerPhase

Name the phase an interval index belongs to.

current_interval is the firmware's own interval index, sent raw: it starts at 0 for the first work period and rises by one at every change of phase, so even indices are work and odd ones rest. Comparing the interval's length against the configured durations would usually work too, but not when work and rest are set equal - the index has no such gap.

timer_state

timer_state(snapshot: BusySnapshot, *, now_ms: int | None = None) -> TimerState

Advance a snapshot to now_ms and report the timer's state.

now_ms defaults to the current wall clock. A paused snapshot is returned as it stands, since paused time does not pass.

Everything is derived from the interval index rather than from interval lengths, so a session configured with equal work and rest is no harder to read than any other: the index says which phase it is, and where the session ends.

copy_to_application async

copy_to_application(client: AssetCopyClient, asset: Asset, application_name: str) -> Asset

Put an upload where another application can name it.

The device resolves an asset by name inside the folder of whichever application is drawing, so a picture uploaded by the BUSY app or the Draw Tool is one nobody else can draw. Copying it across is the way to use it - byte for byte, since it was converted for the device when it was first uploaded and converting it again would be a second guess at the same picture.

Answers with the asset as the copy: same name, same kind, the new application.

discover_assets async

discover_assets(client: AssetCatalogueClient) -> list[Asset]

Every asset this bar holds: shipped, and uploaded by applications.

Ordered by kind and then by name, so a list offered to a person is stable between calls and between bars.

This is a listing, not a read: nothing here opens a file, so an image's width - which a layout needs and a file name does not always carry - is read later, by notification.icon_at, for the one asset that ends up being used.

build_notification

build_notification(line_1: str, *, line_2: str | None = None, icon: str | StockIcon | None = None, line_1_font: DisplayFontName = DEFAULT_FONT, line_2_font: DisplayFontName | None = None, line_1_color: ColorInput | None = None, line_2_color: ColorInput | None = None, background_color: ColorInput | None = None, duration: int | None = None, priority: int = PRIORITY_DEFAULT, application_name: str = 'busylib', device_api_version: str | None = None, template: Template | None = None, display: DisplaySpec = FRONT_DISPLAY) -> types.DisplayElements

Lay out a notification, with a built-in template or one of your own.

Without template, the built-in that suits the arguments is chosen - two lines if a second was given, otherwise one. Pass template to use your own arrangement; it is handed the same NotificationSpec the built-ins get, so it does not have to redo icon lookup, text placement or scrolling.

The background fill is added here rather than by the template, because whether the firmware can fill at all depends on its version, and that is exactly the part a caller should not have to know.

device_api_version is what the bar reports. None means "unknown", and a current device is then assumed - guessing "old" would disable the feature for every caller that has not called version() yet.

elements = build_notification("Laundry done", icon="check") len(elements.elements) 2 elements.elements[0].type 'image' elements.elements[1].type 'text'

notify async

notify(client: NotifyClient, line_1: str, *, line_2: str | None = None, icon: str | StockIcon | None = None, line_1_font: DisplayFontName = DEFAULT_FONT, line_2_font: DisplayFontName | None = None, line_1_color: ColorInput | None = None, line_2_color: ColorInput | None = None, background_color: ColorInput | None = None, sound: str | None = None, duration: int | None = None, priority: int = PRIORITY_DEFAULT, application_name: str = 'busylib', template: Template | None = None) -> types.SuccessResponse

Lay out a notification, draw it, and play its sound if it has one.

A notification with a sound is one intention, so sound belongs here even though playing it is a second request - a caller composing this by hand has to remember both, and to pass the same application_name to each. Names come from STOCK_SOUNDS.

The device version is taken from the client, so a caller cannot forget to pass it and silently lose the check that a feature the firmware lacks is refused rather than drawn wrong.

There is deliberately no clearing helper: a drawing is withdrawn with display_clear(application_name=...), which is already a single call to a single endpoint, and wrapping it would only add a synonym.

Callers that want to place elements themselves can use build_notification and display_draw separately, or skip both.

select_template

select_template(spec: NotificationSpec) -> Template

Pick the built-in template that suits this notification.

Timers

The control helpers are reached through the module - timer.start(bar) - so that names like start and stop stay unambiguous at the call site.

busylib.features.timer

Working out what a timer is doing right now.

The bar does not run a clock you can read. GET /api/busy/snapshot returns the last snapshot that was applied, verbatim, together with the moment it was applied - verified on firmware 27.7.0, where a snapshot with three seconds left still reported three seconds left, and the same timestamp, eleven seconds later. This is deliberate: it is how the apps keep a timer in step, the freshest snapshot wins, and each client works out the rest.

So reading a snapshot tells you nothing on its own. This module does the arithmetic every consumer would otherwise repeat: advance the snapshot by the time that has passed and say what phase the timer is in.

Changing a timer works the same way round: there is no "pause" endpoint, only a snapshot to write, and the device takes the freshest one as the truth. The helpers here write the snapshot each change needs - which includes reading the card first, since a snapshot names one and inherits whatever it does not say for itself.

A session may say a great deal for itself, though: its own kind, its own lengths, its own theme, none of which touch the card. That is how the BUSY app runs a card with a timer the card does not hold, and it is what lets an automation start a forty-five minute countdown without rewriting the two cards their owner arranged.

TimerMode module-attribute

TimerMode = Literal['not_started', 'infinite', 'simple', 'interval']

TimerPhase module-attribute

TimerPhase = Literal['work', 'rest']

THEMES_PATH module-attribute

THEMES_PATH = '/ext/apps_assets/busy/themes'

MINIMUM_PHASE_MS module-attribute

MINIMUM_PHASE_MS = 5 * 60 * 1000

MAXIMUM_PHASE_MS module-attribute

MAXIMUM_PHASE_MS = 8 * 60 * 60 * 1000

MAXIMUM_TOTAL_MS module-attribute

MAXIMUM_TOTAL_MS = 24 * 60 * 60 * 1000

MINIMUM_CYCLES module-attribute

MINIMUM_CYCLES = 2

MAXIMUM_CYCLES module-attribute

MAXIMUM_CYCLES = 35

DEFAULT_WORK_MS module-attribute

DEFAULT_WORK_MS = 25 * 60 * 1000

DEFAULT_REST_MS module-attribute

DEFAULT_REST_MS = 5 * 60 * 1000

DEFAULT_CYCLES module-attribute

DEFAULT_CYCLES = 4

DEFAULT_TOTAL_MS module-attribute

DEFAULT_TOTAL_MS = 25 * 60 * 1000

DEFAULT_THEME module-attribute

DEFAULT_THEME = 'busy'

TimerKind module-attribute

TimerKind = Literal['infinite', 'simple', 'interval']

TimerState dataclass

TimerState(mode: TimerMode, is_paused: bool, phase: TimerPhase | None = None, interval: int | None = None, time_left_ms: int | None = None, is_finished: bool | None = None, elapsed_ms: int = 0)

What the timer is doing at one moment.

phase is None only once a session has finished, where no phase applies any more.

mode instance-attribute

mode: TimerMode

is_paused instance-attribute

is_paused: bool

phase class-attribute instance-attribute

phase: TimerPhase | None = None

interval class-attribute instance-attribute

interval: int | None = None

time_left_ms class-attribute instance-attribute

time_left_ms: int | None = None

is_finished class-attribute instance-attribute

is_finished: bool | None = None

elapsed_ms class-attribute instance-attribute

elapsed_ms: int = 0

is_running property

is_running: bool

Whether a session is under way, paused or not.

TimerNotRunningError

Bases: BusyBarError

Raised for a change that only makes sense while a session is running.

Pausing, resuming, moving to the next phase and setting the session's theme all rewrite the running session. With nothing running there is nothing to rewrite, and writing a session to make the change possible would start one nobody asked for.

UnknownThemeError

UnknownThemeError(theme: str, available: Sequence[str])

Bases: BusyBarError

Raised for a theme this bar does not have.

The device does not raise it: a profile naming a theme that does not exist is stored and read back happily, and only the bar's own screen shows that something is wrong. So the check has to happen here, or a typo leaves a card pointing at nothing with every layer reporting success.

theme instance-attribute

theme = theme

available instance-attribute

available = list(available)

PhaseTooShortError

PhaseTooShortError(field: str, given_ms: int, *, minimum_ms: int = MINIMUM_PHASE_MS, maximum_ms: int = MAXIMUM_PHASE_MS)

Bases: BusyBarError

Raised for a duration the device would refuse, at either end.

It never says so usefully. A card written with a two-minute work phase comes back with the phase it had before and every layer reports success; a session with one is refused as an unparseable snapshot. So a caller that is not told here finds out by watching a bar run the wrong timer.

The name is the common case - too short - but the same class carries the ceiling, since to a caller they are one mistake: a length the bar will not run.

field instance-attribute

field = field

given_ms instance-attribute

given_ms = given_ms

minimum_ms instance-attribute

minimum_ms = minimum_ms

maximum_ms instance-attribute

maximum_ms = maximum_ms

ThemeCatalogueClient

Bases: Protocol

What themes() needs, which is less than the whole client.

storage_list async

storage_list(path: str) -> types.StorageList

busy_profile async

busy_profile(slot: BusyProfileSlot) -> types.BusyProfile

TimerClient

Bases: Protocol

What these helpers need from a client.

Deliberately three methods rather than the whole client, so a caller can pass anything that speaks them - including a test double.

busy_snapshot async

busy_snapshot() -> types.BusySnapshot

busy_snapshot_set async

busy_snapshot_set(snapshot: BusySnapshot) -> types.SuccessResponse

busy_profile async

busy_profile(slot: BusyProfileSlot) -> types.BusyProfile

busy_profile_set async

busy_profile_set(slot: BusyProfileSlot, profile: BusyProfile) -> types.SuccessResponse

storage_list async

storage_list(path: str) -> types.StorageList

phase_of

phase_of(interval: int) -> TimerPhase

Name the phase an interval index belongs to.

current_interval is the firmware's own interval index, sent raw: it starts at 0 for the first work period and rises by one at every change of phase, so even indices are work and odd ones rest. Comparing the interval's length against the configured durations would usually work too, but not when work and rest are set equal - the index has no such gap.

timer_state

timer_state(snapshot: BusySnapshot, *, now_ms: int | None = None) -> TimerState

Advance a snapshot to now_ms and report the timer's state.

now_ms defaults to the current wall clock. A paused snapshot is returned as it stands, since paused time does not pass.

Everything is derived from the interval index rather than from interval lengths, so a session configured with equal work and rest is no harder to read than any other: the index says which phase it is, and where the session ends.

kind_of

kind_of(settings: BusyTimerSettings) -> TimerKind

Which kind of timer a card holds, in this package's words.

kind_of_snapshot

kind_of_snapshot(snapshot: BusySnapshotVariant) -> TimerKind | None

Which kind of timer is running, or None if none is.

A snapshot has a state the three kinds do not - nothing started - so this answers with None rather than pretending the bar is idling in one of them.

start async

start(client: TimerClient, slot: BusyProfileSlot = 'busy', *, card_id: str | None = None, kind: TimerKind | None = None, duration_ms: int | None = None, rest_ms: int | None = None, cycles: int | None = None, theme: str | None = None, now_ms: int | None = None) -> None

Start a session, optionally with settings of its own.

With nothing but a slot, this runs what that card describes - the thing the bar's own switch would start.

Give a kind or any length and the session runs those instead, and the card is not touched: the settings travel in the snapshot. That is how the BUSY app starts a card with a different timer, and it is what makes "run a countdown for forty-five minutes" leave the two cards exactly as their owner arranged them.

The card is still named, because a snapshot names one and the app shows that card's name and its own decoration for the running session.

card_id names a card directly instead of taking the one in a slot, and then nothing on the bar is read at all. The point is a card that is in neither position: the bar keeps two, the app keeps more, and a session naming one of the others runs without either of the bar's own two being involved even by name. Such a session has no card to fall back on, so its kind and lengths are the ones given here, or the defaults below.

What the device will not take, as a parse error about the whole snapshot rather than anything helpful: an interval phase outside five minutes to eight hours, fewer than two or more than thirty-five work phases, or a countdown over a day. Those are the firmware's own bounds, in busy_timer_common.h, confirmed on a bar.

stop async

stop(client: TimerClient, *, now_ms: int | None = None) -> None

End the session, leaving the bar with nothing running.

This is not the selector's off position, which is the bar's do-not-disturb: it is the session going back to not started.

set_paused async

set_paused(client: TimerClient, paused: bool, *, now_ms: int | None = None) -> None

Pause or resume the running session.

Pausing writes back how much time is actually left, not the figure the stored snapshot carries: that one was true when it was written, and resuming from it would hand back the time the session already spent.

next_phase async

next_phase(client: TimerClient, *, now_ms: int | None = None) -> None

Move an interval session on to its next phase.

Work becomes rest and rest becomes the next work, at that phase's full length. Past the last interval the session is over, so it is stopped rather than wrapped around.

set_session_theme async

set_session_theme(client: TimerClient, theme: str, *, known: Sequence[str] | None = None, now_ms: int | None = None) -> None

Change the theme the running session is showing.

This lasts as long as the session: the card keeps its own theme, and the bar shows that one again next time it starts. Confirmed on hardware - a session switched to dnd came back as the card's busy once stopped.

set_card_theme async

set_card_theme(client: TimerClient, slot: BusyProfileSlot, theme: str, *, known: Sequence[str] | None = None, now_ms: int | None = None) -> None

Change the theme one of the bar's cards starts with.

Unlike set_session_theme this outlasts the session, and it does not touch what is on screen now - a session already running keeps the theme it started with.

The profile is stamped with the moment of writing, for the same reason a snapshot is: the device keeps whichever copy is newer and silently discards the rest. A card read back and written unchanged carries the stored timestamp, which is not newer - and a bar fresh from the factory reports profile_timestamp_ms: 0, so the write is dropped while still answering {"result": "OK"}. Confirmed on firmware r971.

themes async

themes(client: ThemeCatalogueClient) -> list[str]

Which themes this bar can show.

Not a fixed set and not an enum: themes are assets, so one bar has what the firmware shipped, another has one the owner uploaded, and a third is missing one the owner deleted. It is read from the bar rather than written down anywhere that would go stale.

Two sources, because neither alone is the answer. The assets are one directory each under THEMES_PATH. And whichever themes the bar's own cards are set to are real by definition, which is how the firmware's built-in default gets in - it has no directory, so a listing alone would report a bar cannot show the theme it is showing right now.

configure async

configure(client: TimerClient, slot: BusyProfileSlot = 'busy', *, kind: TimerKind | None = None, duration_ms: int | None = None, work_ms: int | None = None, rest_ms: int | None = None, cycles: int | None = None, total_ms: int | None = None, theme: str | None = None, known_themes: Sequence[str] | None = None, now_ms: int | None = None) -> types.BusyProfile

Change one of the bar's two cards, leaving the rest of it alone.

For a change that should last. A session can carry settings of its own - start() takes them - and that is what a one-off wants; this is for when the bar's own switch should start something different from now on. The bar and the phone app see it too; that is the same thing they do to each other.

Only what is given is changed. work_ms, rest_ms and cycles apply to an interval card, total_ms to a countdown; giving one the card cannot use is an error rather than a silent no-op, since the device would treat it as one.

Returns the profile as written, so a caller can see what the card now holds.