Start an oven cycle from Home Assistant - #106
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Starting a cook is now possible from Home Assistant.
docs/oven-cook-start.mdestablished 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:
Idle-gating reuses the existing
cycle_activeavailability topic with its payloads swapped, so no fourth topic was needed.Bounds come from the board
/mode/vs/0'smodeSpecis parsed for each mode'scontrolfield, temperature range, duration range and defaults. Start refuses when the mode is notStart&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 nomodeSpecat all. It logs the reason as a sentence rather than letting the firmware answer with a bare4.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, andprogram_readysays whether Start would currently go through.For automations
cmd/start_programtakes{"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./devices/Nmarker, which is addressing rather than a representation.LOCAL_ONLYdistinguishes "handled, nothing to send" fromNone, which means rejected. Without it every staged value logged a warning.A wire-format bug this surfaced
operationTimewas 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. Writing0:10:00for 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_timeis overwritten byproject()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)
remainingTimeandprogressPercentageare the same clock at different resolutions.remainingTimesteps once a minute;progressPercentagesteps 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,remainingTimeabove it, published asclock_source.Measured with
local-tools/watch_preheat.pyon 2026-09-20: a 10-minute Convection at 250 °C tickedremainingdown 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 andnow + remainingneeds no correction.It also fixes the extrapolation. The anchor was re-set on every
/operational/state/vs/0update, and that resource is polled twice a second during a cycle, soremaining = 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_atis an absolute timestamp rather than a ticking countdown. HA renders atimestampsensor 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_timewas formatted with seconds so it ticked, andfinish_atwas computed asnow + remaining, whereremainingis rounded to a whole second whilenowadvances 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
nowadvances, checked across six offsets.finish_atnow comes from the anchor,anchor_ts + remaining-at-anchor, which is independent ofnowby 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_atandclock_sourceunchanged across 90 seconds and every remaining publish carrying real data.clock_sourcereadprogresson a 60-minute cook, which is the correct pick — 36 s granularity againstremainingTime'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
operationTimeagree.