Firmware Changes

Local changes to the ReefRhythm-SmartDoser firmware running the reef doser (see the Acropora page for the tank itself). This tree is a local working copy, not a git fork -- these fixes exist only as the comments/code in this checkout, found and fixed against real dosing behavior on this tank's own hardware (MKS SERVO42C stepper drivers, 750L reef system). Recorded here since there's no git history to carry them.

Dosing math (src/lib/stepper_doser_math.py)

  1. Low-flow calibration extrapolation could silently overdose. Below the lowest measured calibration point, the polynomial fit was extrapolating blind -- confirmed on real calibration data that it can swing back up instead of continuing toward zero. That defeats move_with_rpm's low-flow runtime-shortening safety check entirely: a near-zero flow request could get an RPM at or above the achievable minimum instead of below it, so the dose ran at full requested duration instead of a shortened one -- a silent overdose. Fixed by anchoring a real (0 RPM, 0 flow) point -- a peristaltic pump at 0 RPM delivers exactly 0 flow, a physical fact the fit had no way to know on its own.
  2. Extrapolated flow curve could decrease with increasing RPM. A degree>1 polynomial fit can wiggle (local extrema), producing a flow-rate curve that dips as RPM increases even though real flow never decreases with RPM. Forced non-decreasing via a running max.
  3. RPM-snap without runtime rescaling could silently overdose. find_combination snaps a requested RPM to the nearest achievable one in the calibration table -- for a low-flow dose this is very often higher than requested (there's a minimum achievable RPM, ~0.593 on this table). Running that higher RPM for the original, unadjusted runtime overdoses, since delivered volume scales with rpm * runtime. Fixed by rescaling runtime down proportionally whenever the snap goes up. Deliberately one-directional: if a request's RPM is above the table's max (e.g. an unvalidated /run or MQTT command with a typo'd rpm=99999), the snap goes the other way and scaling runtime by desired/achieved would be >1, amplifying runtime by the same factor instead. Failing safe there means delivering less than requested at the achievable top speed, never risking an unbounded overdose in the other direction.
  4. Zero-step doses read as a driver fault. calc_steps truncates to int -- a request small enough that the runtime-shortening above collapses to well under one step's worth of rotation rounds down to exactly 0. Sending a real "run 0 steps" command to the driver isn't a no-op on this hardware: it came back as an outright failure (rejected reply or no completion confirmation within timeout), indistinguishable from a genuine driver fault, and triggered the same "DOSE FAILED -- driver unresponsive" alert for a request that was simply too small for this pump/duration to resolve. Fixed by checking steps <= 0 and failing cleanly before any UART traffic at all.

Stepper driver protocol (src/lib/servo42c.py)

  1. CRC check used decoded values instead of raw bytes. read_angle_error deliberately does NOT use check_crc=True: the existing CRC check summed the already-decoded field values, not the raw bytes actually transmitted -- only correct for single-byte fields. error is a signed 2-byte value (can be negative), so that comparison would not reconstruct the driver's real byte-level checksum and would reject valid replies. A correct fix needs the check computed over raw_data before struct.unpack, not after.
  2. Delayed "straggler" reply corrupting the next command. Config changes (mode switches especially) can trigger a delayed side-effect reply/reset on the driver that arrives after the bounded read already gave up -- confirmed by observation: a dose sent immediately after a mode/current-set call failed, then an identical dose sent right after that succeeded cleanly. Fixed with a short settle + buffer flush before send_set_command/release_protection return.
  3. Missing flush before make_steps when called with stop=False. move_with_rpm calls make_steps(..., stop=False), which otherwise skips the only buffer-clearing step (stop()'s own flush) -- without an explicit flush here, a previous dose's "run complete" reply that arrived after its completion-wait had already given up would still be sitting in the buffer and get misread as, or corrupt, this command's own reply.
  4. Fast doses could hide their own completion reply. Low-flow doses are very often RPM-snapped up (see #3), and for a big snap this can shrink actual motor runtime to a fraction of a second -- fast enough that the driver's "complete" reply can already be sitting in the buffer alongside the "starting" ack, in the very same read_raw() call. Since that call already drained the buffer, waiting again afterward would never see it. Fixed by checking for complete_pattern in the initial read first, before waiting for anything new.
  5. Negative step count could raise an uncaught OverflowError. steps.to_bytes(4, 'big') is unsigned -- a negative steps value (reachable if an RPM-snap ratio or a malformed schedule entry ever produces one upstream) would raise uncaught instead of failing the dose cleanly. Fixed with an explicit steps < 0 guard.

OTA reliability (src/boot.py, scripts/init.sh)

  1. Version-comparison gate could leave files stale after an interrupted extraction. version.txt is only ever written after a full extraction completes. If an extraction is ever interrupted partway (an external reset mid-boot, a crash), a later boot's version comparison could wrongly conclude nothing had changed and skip re-writing files that were never actually updated. Fixed by always re-extracting unconditionally on boot -- costs a few extra seconds every boot but guarantees static files can't get silently stuck out of date.
  2. A silently-empty RELEASE_TAG made real OTA updates no-op. RELEASE_TAG drives boot.py's "did the app actually change" check and is shown on the OTA page / published over MQTT. If two builds in a row both had an empty tag, boot.py saw no version change and skipped re-extracting the frozen app -- so a real code fix could survive a "successful" OTA update without ever actually taking effect. Fixed in init.sh by defaulting RELEASE_TAG to a timestamped <version>-manual-<UTC timestamp> value whenever it isn't explicitly set by CI.

New features (not bug fixes, src/web.py, src/static/ota-upgrade.html, src/static/doser.html)

  1. GET /storage-status -- read-only endpoint exposing each pump's container_size_ml/remaining_ml/dosed_since_refill_ml, reflecting the same storage dict already used internally for the "container empty" alert. Added so cumulative dosing-over-time could be shown on this tank's own webpage without guessing it from the schedule alone -- a missed/failed dose or a manual off-schedule run wouldn't show up in a schedule-based estimate, but does here since remaining only ever decrements on an actual completed dose. No side effects, no new write paths.
  2. Cancel Download button on the OTA page. Previously there was no way to stop an in-progress OTA download short of power-cycling the device. Added POST /ota-cancel, which sets a flag the existing download loop in /ota-upgrade checks once per chunk. Safe by construction, not just by care taken: OTA.write() only ever touches the inactive partition, and only OTA.close() calls part.set_boot() -- breaking out of the loop before close() leaves the currently-running (old, known-good) firmware as the boot target no matter how much or how little was written. The one thing that did need explicit handling: dosing is paused (scheduled_jobs = []) the moment an OTA starts, and a cancelled download now restores it via update_schedule(schedule), the same as the existing failure path -- without that, a cancelled OTA would have left real dosing silently disabled with no reboot to ever bring it back.
  3. "Dosing now" indicator on doser.html. A pulsing indicator next to the currently-selected pump's name. Deliberately does NOT touch stepper_run (the actual dosing execution path, already the source of several of the bug fixes above) -- two independent, purely client-side/frontend sources instead: scheduled doses are inferred by polling the existing, unmodified GET /schedule-diag endpoint every 5 seconds (already exposes each job's exact computed dose_times and the device's own seconds_since_midnight); manual doses are shown for the exact duration of the "Start Dosing" button's own fetch('/dose') call, which already only resolves once the pump has actually finished (or failed) -- true start-to-finish coverage, not a guess based on the entered duration. Good enough for "does this look like something's happening right now", not a safety signal.

Current build hosted at /downloads/firmware_smartdoser/ is built from this exact fixed source tree (verified by sha256 match against the local build output before publishing).