Skip to content

Start an oven cycle from Home Assistant - #106

Merged
QuiteYellow merged 3 commits into
mainfrom
feat/oven-cycle-start
Sep 20, 2026
Merged

QuiteYellow merged 3 commits into
mainfrom
feat/oven-cycle-start

Conversation

@QuiteYellow

@QuiteYellow QuiteYellow commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Starting a cook is now possible from Home Assistant. docs/oven-cook-start.md established the payload on hardware; this wires it into the bridge.

Why it needs staging

The appliance will not assemble a job from separate writes while it is idle. Mode, setpoint and cook time have to arrive together, with the run command in the same message. HA entities write independently, so the bridge holds the three parameters and the Start button assembles the batch.

That splits the oven's controls by cycle state, and nothing appears in both, so no control changes meaning under the user:

While idle While running
Program, Program temperature, Program duration, Start Setpoint, Cook time, Stop cycle

Idle-gating reuses the existing cycle_active availability topic with its payloads swapped, so no fourth topic was needed.

Bounds come from the board

/mode/vs/0's modeSpec is parsed for each mode's control field, temperature range, duration range and defaults. Start refuses when the mode is not Start&Setting, when the values fall outside that mode's range, when Remote Control is off, when a cycle is already running, or when the board publishes no modeSpec at all. It logs the reason as a sentence rather than letting the firmware answer with a bare 4.xx. Selecting a program adopts that mode's own defaults, so the staged numbers always sit inside the range they will be checked against.

The live startable set is published as program_startable, and program_ready says whether Start would currently go through.

For automations

cmd/start_program takes {"mode": "Convection", "temp_c": 180, "minutes": 25} and does the whole thing in one message, rather than making an automation drive four entities. Omitted values fall back to the mode's defaults, and a rejected message leaves the staged program untouched.

Descriptor-layer contract changes

  • command_handlers() takes the descriptor state dict, so a descriptor can hold cross-command state. Dryer and fridge accept and ignore it.
  • A handler may return a list body, written as an OCF batch to a collection href. The bridge fans its optimistic cache merge out across the batch's element hrefs instead of merging the whole list onto the collection, and skips the bare /devices/N marker, which is addressing rather than a representation.
  • LOCAL_ONLY distinguishes "handled, nothing to send" from None, which means rejected. Without it every staged value logged a warning.

A wire-format bug this surfaced

operationTime was formatted in two places by two expressions that disagreed on one character: the cook-time handler zero-padded the hour, the new start path did not. Writing 0:10:00 for a ten-minute cook produced a roughly 609-minute one on hardware. Both now go through _hms_wire(), covered by a parametrised regression test that names the symptom. It is kept separate from _fmt_hms(), which formats display strings for the kitchen-timer sensors and correctly leaves the hour unpadded.

Worth noting how it got through: the test asserting the start batch claimed to match the measured payload and asserted '0:01:00', while the measured one is '00:01:00'. It encoded the code's behaviour instead of the recorded evidence, so it locked the bug in rather than catching it.

Also

The oven publishes remaining_time, the appliance's own field, unmodified. completion_time is overwritten by project() with an extrapolation while a cycle runs, which is right for a UI and wrong for measuring what the appliance actually does.

The cook clock (second commit)

remainingTime and progressPercentage are the same clock at different resolutions. remainingTime steps once a minute; progressPercentage steps once per 1% of the cook, so its granularity is the duration over 100 — 6 s on a ten-minute bake. estimate_remaining() takes whichever is finer for the duration in hand: progress under 100 minutes, remainingTime above it, published as clock_source.

Measured with local-tools/watch_preheat.py on 2026-09-20: a 10-minute Convection at 250 °C ticked remaining down 60 s per wall minute while progress moved 1% per 6 s, implying the same 10-minute total. Both run from the moment of Start and through preheat rather than from reaching temperature, so a set duration includes preheat and now + remaining needs no correction.

It also fixes the extrapolation. The anchor was re-set on every /operational/state/vs/0 update, and that resource is polled twice a second during a cycle, so remaining = total - (now - ts) always evaluated to the value it had just been given — the clock sat still for a whole granule, then jumped. It now anchors only on a change, the way the door and lamp timestamps immediately below it already did. Anchoring on the transition also removes the quantisation: at the instant remaining steps 600 → 540 it really is 540, leaving the polling interval as the only error.

finish_at is an absolute timestamp rather than a ticking countdown. HA renders a timestamp sensor as a live relative time, so the UI counts down every second while the bridge publishes nothing.

Publishing only when the clock says something new (third commit)

The first attempt at that did not work, and the log showed it: completion_time was formatted with seconds so it ticked, and finish_at was computed as now + remaining, where remaining is rounded to a whole second while now advances continuously — so the sum crossed a second boundary about once a second too. The whole state topic was republished twice a second for the length of every cook, one recorder row per sensor each time.

The stability test slept 1.1 s and passed on a 0.1 s drift that happened not to cross a boundary. It is replaced by one asserting the invariant directly: the finish epoch must not move as now advances, checked across six offsets.

finish_at now comes from the anchor, anchor_ts + remaining-at-anchor, which is independent of now by construction. It is a prediction, not a reading: while the appliance's clock tracks wall time, that arithmetic returns the same epoch every time it is recomputed, so a correctly-running cook publishes it once and then goes quiet. It moves only when reality departs from the model — a pause, a duration changed at the panel, a clock diverging from ours.

What is left is sampling noise: each decrement is seen up to one poll interval late, so the epoch wobbles by a fraction of a second. A 15 s deadband suppresses that and lets real changes through immediately. A slow republish timer was the other option and is worse — it would delay news as well as noise.

Measured on hardware mid-cook: 0.11 state-topic publishes per second against roughly 2/s before, with finish_at and clock_source unchanged across 90 seconds and every remaining publish carrying real data. clock_source read progress on a 60-minute cook, which is the correct pick — 36 s granularity against remainingTime's 60 s.

Validation

Hardware, 2026-09-20. Staged and started from HA: the batch reached the appliance as one write, the optimistic merge fanned out to all three resources, the cavity heated, and Stop returned it to Ready. The container currently deployed here is running this tree.

41 new tests, 844 passing. Coverage includes the refusal paths, the wire format across four durations, and an assertion that the two paths writing operationTime agree.

The appliance will not assemble a job from separate writes while it is
idle: mode, setpoint and cook time have to arrive together, with the run
command in the same message (docs/oven-cook-start.md). HA entities write
independently, so the bridge stages the three parameters and the Start
button assembles the batch.

That splits the oven's controls by cycle state, and nothing appears in
both, so no control changes meaning under the user:

  idle     Program, Program temperature, Program duration, Start
  running  Setpoint, Cook time, Stop cycle

Bounds come from the board, not from constants. /mode/vs/0's modeSpec is
parsed for each mode's control field, temperature range, duration range
and defaults. Start refuses when the mode is not Start&Setting, when the
values fall outside that mode's range, Remote Control is off, a cycle is
already running, or the board publishes no modeSpec at all, and it logs
the reason as a sentence instead of a bare 4.xx. Selecting a program
adopts that mode's own defaults, so the staged numbers always sit inside
the range they will be checked against. The live startable set is
published as program_startable and program_ready says whether Start
would go through.

cmd/start_program takes {"mode", "temp_c", "minutes"} and does the lot
in one message, for automations that should not have to drive four
entities. Omitted values fall back to the mode's defaults, and a
rejected message leaves the staged program untouched.

Three contract changes in the descriptor layer:

  * command_handlers() now takes the descriptor state dict, so a
    descriptor can hold cross-command state. Dryer and fridge accept and
    ignore it.
  * A handler may return a list body, which is written as an OCF batch
    to a collection href. The bridge fans its optimistic cache merge out
    across the batch's element hrefs instead of merging the whole list
    onto the collection, and skips the bare /devices/N marker, which is
    addressing rather than a representation.
  * LOCAL_ONLY distinguishes "handled, nothing to send" from None, which
    means rejected. Without it every staged value logged a warning.

Also fixes a wire-format bug this surfaced. operationTime was formatted
in two places by two expressions that disagreed on one character: the
cook-time handler zero-padded the hour, the new start path did not.
Writing '0:10:00' for a ten-minute cook produced a ~609-minute one on
hardware. Both now go through _hms_wire(), and the padding is covered by
a parametrised regression test naming the symptom. Kept separate from
_fmt_hms(), which formats display strings for the kitchen-timer sensors
and correctly leaves the hour unpadded.

The oven now also publishes remaining_time, the appliance's own field,
unmodified. completion_time is overwritten by project() with an
extrapolation while a cycle runs, which is right for a UI and wrong for
measuring what the appliance does.

Validated on hardware 2026-09-20: staged and started from HA, the batch
reached the appliance as one write, the optimistic merge fanned out to
all three resources, and the cavity heated. Stop returned it to Ready.
remainingTime and progressPercentage are the same clock at different
resolutions. remainingTime steps once a minute; progressPercentage steps
once per 1% of the cook, so its granularity is the duration over 100 --
6s on a ten-minute bake against 60s. estimate_remaining() takes whichever
is finer for the duration in hand, which is progress under 100 minutes
and remainingTime above it, and publishes the choice as clock_source.

Measured 2026-09-20 with local-tools/watch_preheat.py: on a 10-minute
Convection at 250C, remaining fell 60s per wall minute while progress
moved 1% per 6s, implying the same 10-minute total. Both run from the
moment of Start and through preheat rather than from reaching
temperature, so a set duration includes preheat and a finish time of
now + remaining needs no correction.

Fixes the extrapolation while here. The anchor was re-set on every
/operational/state/vs/0 update, and that resource is polled twice a
second during a cycle, so remaining = total - (now - ts) always
evaluated to the value it had just been given: the clock sat still for a
whole granule and then jumped. It now anchors only on a change, the way
the door and lamp timestamps immediately below it already did. Anchoring
on the transition also removes the quantisation -- at the instant
remaining steps 600 to 540 it really is 540 -- leaving the polling
interval as the only error. Anchors are dropped when the cycle ends so
the next one cannot inherit them.

Adds finish_at, an absolute timestamp rather than a ticking countdown.
HA renders a timestamp sensor as a live relative time, so the UI counts
down every second while the published value stays constant between
appliance updates. A ticking field would instead republish the whole
state topic twice a second for the length of every cook, and write a
recorder row per sensor each time, to show what the frontend derives for
free. completion_time and completion_minutes keep minute resolution for
the same reason.
The previous commit claimed completion_time and completion_minutes kept
minute resolution. They did not: completion_time was formatted with
seconds, so it changed every second, and finish_at was computed as
now + remaining -- remaining is rounded to a whole second while now
advances continuously, so the sum crossed a second boundary about once a
second too. Between them the whole state topic was republished twice a
second for the length of every cook, one recorder row per sensor each
time. That is the churn the timestamp sensor was supposed to avoid.

The stability test slept 1.1s and passed on a 0.1s drift that happened
not to cross a boundary. It is replaced by one asserting the invariant
directly: the finish epoch must not move as `now` advances, checked
across six offsets from 0 to 120s.

finish_at now comes from the anchor -- anchor_ts + remaining-at-anchor --
which is independent of now by construction. It is also a prediction
rather than a reading: while the appliance's clock tracks wall time that
arithmetic returns the same epoch every time it is recomputed, so a
correctly-running cook publishes it once and then goes quiet. It moves
only when reality departs from the model, which is a pause, a duration
changed at the panel, or a clock diverging from ours.

What is left is sampling noise -- each decrement is seen up to one poll
interval late, so the epoch wobbles by a fraction of a second. A 15s
deadband suppresses that and lets real changes through immediately. A
slow republish timer was the other option and is worse: it would delay
news as well as noise. completion_time and completion_minutes are now
genuinely minute-resolution, so they change once a minute.

Measured on hardware mid-cook: 0.11 state-topic publishes per second
against roughly 2/s before, with finish_at and clock_source unchanged
across 90 seconds and every remaining publish carrying real data.
clock_source read `progress` on a 60-minute cook, which is the right
choice -- 36s granularity against remainingTime's 60s.
@QuiteYellow
QuiteYellow merged commit 095c1bd into main Sep 20, 2026
8 checks passed
@QuiteYellow
QuiteYellow deleted the feat/oven-cycle-start branch September 20, 2026 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant