Skip to content

iOS: fix the three High OTA and profile-builder bugs (#141, #142, #143) - #252

Merged
BenSeverson merged 2 commits into
mainfrom
claude/fix-ios-ota-and-ramp-rate
Jul 30, 2026
Merged

iOS: fix the three High OTA and profile-builder bugs (#141, #142, #143)#252
BenSeverson merged 2 commits into
mainfrom
claude/fix-ios-ota-and-ramp-rate

Conversation

@BenSeverson

Copy link
Copy Markdown
Owner

Closes #141. Closes #142. Closes #143.

Grouped because #141 and #142 are both in the OTA path and touch the same two files; #143 rides along as the third High iOS bug. #147 is deliberately not here — see the bottom.

#141 · Manual OTA upload was broken for any file outside the sandbox

The fileImporter callback opened the security scope, scheduled the upload in a detached Task, and returned — and defer closed the scope at that point, so Data(contentsOf:) ran afterwards and failed with a permission error. Anything picked from iCloud Drive or Files hit this, which is the normal case.

Fixed by reading the bytes inside the callback, while the scope is held, and changing uploadOTA to take Data. Stretching the scope across the task would also have worked, but passing bytes makes the lifetime impossible to get wrong rather than merely correct today. Both failure paths — scope denied, file unreadable — now report through viewModel.error instead of returning silently.

#142 · The 30s resource timeout cancelled uploads mid-flash

timeoutIntervalForResource = 30 on the shared session, against a 1.5–2 MB image that the ESP32 accepts only as fast as it can erase and write flash. installOTA is worse: it returns only once the device has pulled the image from GitHub. URLSession cancelled the task at the deadline, mid-transfer, leaving a partial OTA write behind a generic timeout.

OTA now has its own session (120 s request, 600 s resource), and request() takes an optional session override so installOTA uses it too. Two sessions rather than one relaxed one — a control or status request that hasn't answered in 10 s should still fail fast, not hang for ten minutes.

#143 · A zero ramp rate crashed the app

Int(Double.infinity) is a fatal error in Swift, not a garbage value. So computeProfilePath trapped the moment such a profile was charted, and estimatedDuration reached JSONEncoder as infinity and failed the save with an EncodingError naming neither the segment nor the field.

FiringSegment.validationError is now the single statement of what a usable segment is, mirroring the web UI's bounds:

  • a magnitude floor of 1 °C/hr, so cooling segments are held to the same limit — validating > 0 would have rejected every cooling segment
  • deliberately stricter than the firmware, which rejects only zero and non-finite: 0.1 °C/hr passes that and describes a 5800-hour firing
  • targetTemp in 1…1400, finite holdTime

It gates the save, renders under the offending field as it is typed rather than two screens later, and both division sites skip segments that fail it instead of trapping. The step count is bounded too — 1 °C/hr across the full range is ~69,600 five-minute steps.

Two adjacent sites of the same kind hardened while here: formattedDescription and maxTemp both fed potentially non-finite Doubles to Int(_:).

Verification — please read

This was not compiled. The session runs in a Linux container with no Swift toolchain, so CI's macOS build-ios job is the only compile check on this diff. I checked what I could statically (call sites of both changed signatures, brace balance, actor isolation of the viewModel.error writes against the existing connection.apiClient access in the same closure), but that is not a substitute. The three fixes are also behavioural in ways only a device shows: a Files-picked upload, an upload that outlives 30 s, and a profile charted with a bad rate.

Noticed, not fixed

Int(profile.maxTemp) in ProfileCardView and ProfileDetailView would trap the same way, but only on a non-finite value arriving from the API — the firmware computes that field, so it isn't reachable without a broken server. Left alone rather than widening this diff into three more view files.


Generated by Claude Code

#141 — manual OTA upload was broken for any file outside the sandbox.
The fileImporter callback opened the security scope, scheduled the upload
in a detached Task, and returned; `defer` closed the scope at that point,
so `Data(contentsOf:)` ran afterwards and failed with a permission
error. Anything picked from iCloud Drive or Files hit this.

Rather than stretching the scope across the task, the bytes are read
inside the callback while the scope is held and `uploadOTA` now takes
`Data`. The lifetime becomes impossible to get wrong instead of merely
correct today. Both failure paths — denied scope, unreadable file — now
report through viewModel.error rather than returning silently.

#142 — the shared URLSession sets timeoutIntervalForResource = 30, but a
1.5-2MB image is accepted only as fast as the ESP32 erases and writes
flash, and installOTA returns only once the device has pulled the image
from GitHub. URLSession cancelled the task at the deadline, mid-transfer,
leaving a partial OTA write behind a generic timeout message.

OTA gets its own session (120s request, 600s resource) and `request()`
takes an optional session override. Two sessions rather than one relaxed
one: a control request that has not answered in 10s should still fail
fast, not hang for ten minutes.

#143 — a zero ramp rate crashed the app. `Int(Double.infinity)` is a
fatal error in Swift, not a garbage value, so charting such a profile
trapped in computeProfilePath, and estimatedDuration reached JSONEncoder
as infinity and failed the save with an EncodingError naming neither the
segment nor the field.

FiringSegment.validationError is now the single statement of what a
usable segment is, mirroring the web UI's bounds — a *magnitude* floor of
1 C/hr so cooling segments are held to the same limit, which is stricter
than the firmware's reject-only-zero. It gates the save, renders under
the offending field as it is typed, and both division sites skip
segments that fail it rather than trapping. The step count is also
bounded: 1 C/hr over the full range is ~69,600 five-minute steps.

Two adjacent trap sites of the same kind are hardened while here:
formattedDescription and maxTemp both fed non-finite Doubles to `Int(_:)`.

Not verified by compilation: this container is Linux, so there is no
Swift toolchain. CI's build-ios job is the only compile check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PyJ47o5sWvpb9vMk1RNKkb

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c976aca5e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

was selected (#143). Rendering the rest of the profile is more use
than a crash, and the builder now refuses to save such a segment
in the first place. */
if segment.validationError != nil { continue }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve chartable legacy ramp segments

For profiles already stored with a finite, nonzero ramp magnitude below 1°C/hr, the firmware still accepts and fires the segment, but validationError causes the dashboard to omit it entirely. The new step limit makes these slow ramps safe to chart, so skipping them produces a planned path with the wrong temperature and timing—and subsequent segments start from the wrong point. Reserve this skip for genuinely non-computable values such as zero or non-finite rates rather than applying the stricter builder-save policy to profiles loaded from the controller.

Useful? React with 👍 / 👎.

let steps = max(10, Int(rampTimeMinutes / 5))
/* Bounded for the same reason the web path is: 1°C/hr is legal and
implies ~69,600 five-minute steps across a full-range firing. */
let steps = min(maxProfilePathPoints, max(10, Int(rampTimeMinutes / 5)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Budget chart points across the whole profile

For a multi-segment profile, every segment can independently append maxProfilePathPoints entries, so the firmware-supported 16-segment maximum can still produce over 32,000 freshly UUID-identified LineMarks whenever DashboardTab recomputes the path. This defeats the stated whole-path ceiling and can leave the Swift Charts view expensive or unresponsive for slow profiles; divide a fixed total budget among the segments, as the web path builder does, rather than applying the full allowance to each one.

Useful? React with 👍 / 👎.

/// the short deadlines where they belong: a control request that has not
/// answered in 10s should fail fast, not hang for ten minutes.
let otaConfig = URLSessionConfiguration.default
otaConfig.timeoutIntervalForRequest = 120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the OTA timeout to update checks

When fetching the release manifest takes more than 10 seconds, checkOTA() still uses the short shared session and fails before the controller responds, so the normal UI never exposes the Install action. This is reachable with the firmware's default 15-second OTA transfer timeout—and that setting permits up to 120 seconds—because handle_ota_check synchronously waits for ota_check() (components/web_server/api_handlers.c:1142-1145, components/ota/Kconfig:21-24). Pass otaSession to checkOTA() as well as installOTA().

Useful? React with 👍 / 👎.

Three review findings, all correct.

**The point cap was per segment, not per path.** `min(maxProfilePathPoints,
…)` was applied inside the segment loop, so the firmware's 16-segment
maximum could still produce ~32,000 freshly identified LineMarks on every
recompute — the opposite of what the cap is for, and my own comment
claimed a whole-path ceiling it did not implement. The budget is now
divided across the computable segments.

**Slow-but-legal segments were being dropped from the chart.** Skipping on
`validationError` applied the builder's save policy to rendering, so a
profile already stored with, say, 0.5C/hr — which the firmware accepts and
will fire — lost that segment from the plot, and with it the correct
starting temperature and time for every segment after it. A wrong path is
worse than a slow one.

Split in two: `isComputable` is the firmware's rule (finite, nonzero) and
decides what can be charted; `validationError` stays the stricter
save-time policy. Only zero and non-finite are genuinely uncomputable, and
those are exactly the values that trapped in `Int(_:)`. The step cap makes
the slow case affordable, which is what makes charting it reasonable.
`estimatedDuration` moves to the same predicate, so a slow ramp
contributes its real duration instead of vanishing from the estimate.

`steps` gains a `max(1, …)` floor: with a shared budget the `min` can now
reach zero, and `1...steps` traps on an empty range.

**checkOTA needed the OTA session too.** `handle_ota_check` waits
synchronously for `ota_check()` to fetch the manifest from GitHub, bounded
by CONFIG_OTA_HTTP_TIMEOUT_MS — 15s by default, settable to 120s. Against
the shared session's 10s request deadline the app gave up before the
controller answered, so Install never appeared even with an update
waiting.

Still not compiled: no Swift toolchain on Linux; CI's build-ios job is the
only compile check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PyJ47o5sWvpb9vMk1RNKkb

Copy link
Copy Markdown
Owner Author

All three correct. Fixed in 5754c9f.

P2 — the cap was per segment, not per path. Right, and my own comment claimed a whole-path ceiling it didn't implement: min(maxProfilePathPoints, …) sat inside the segment loop, so 16 segments could reach ~32,000 marks per recompute. The budget is now divided across the computable segments.

P2 — slow-but-legal segments dropped from the chart. The best of the three, because it's a correctness bug I introduced while fixing a crash. Skipping on validationError applied the builder's save policy to rendering, so a profile already stored at 0.5 °C/hr — which the firmware accepts and is firing — lost that segment, and with it the right starting temperature and time for every segment after it. A wrong path is worse than a slow one.

Split accordingly: isComputable mirrors the firmware's rule (finite, nonzero) and decides what gets charted; validationError stays the stricter save-time policy and is no longer consulted for drawing. Your observation that the step cap is what makes slow ramps safe to chart is exactly why this works. estimatedDuration moved to the same predicate so a slow ramp contributes its real duration rather than disappearing from the estimate.

That change also required a max(1, …) floor on steps — with a shared budget the min can reach zero, and 1...steps traps on an empty range.

P2 — checkOTA needs the OTA session. Correct, and the citation made it quick to confirm: handle_ota_check waits synchronously on ota_check() with CONFIG_OTA_HTTP_TIMEOUT_MS at 15 s by default and up to 120 s, against a 10 s request deadline — so Install never appeared even with an update waiting. Now on otaSession.

Still not compiled — no Swift toolchain here, so build-ios remains the only compile check.


Generated by Claude Code

@BenSeverson
BenSeverson merged commit f0a741b into main Jul 30, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants