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.
Firmware Changes
Dosing math (src/lib/stepper_doser_math.py)
- 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. - 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.
- RPM-snap without runtime rescaling could silently overdose.
find_combinationsnaps 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 withrpm * 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/runor MQTT command with a typo'drpm=99999), the snap goes the other way and scaling runtime bydesired/achievedwould 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. - Zero-step doses read as a driver fault.
calc_stepstruncates toint-- 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 checkingsteps <= 0and failing cleanly before any UART traffic at all.
Stepper driver protocol (src/lib/servo42c.py)
- CRC check used decoded values instead of raw bytes.
read_angle_errordeliberately does NOT usecheck_crc=True: the existing CRC check summed the already-decoded field values, not the raw bytes actually transmitted -- only correct for single-byte fields.erroris 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 overraw_databeforestruct.unpack, not after. - 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_protectionreturn. - Missing flush before make_steps when called with stop=False.
move_with_rpmcallsmake_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. - 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 forcomplete_patternin the initial read first, before waiting for anything new. - Negative step count could raise an uncaught OverflowError.
steps.to_bytes(4, 'big')is unsigned -- a negativestepsvalue (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 explicitsteps < 0guard.
OTA reliability (src/boot.py, scripts/init.sh)
- Version-comparison gate could leave files stale after an interrupted extraction.
version.txtis 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. - A silently-empty RELEASE_TAG made real OTA updates no-op.
RELEASE_TAGdrives 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 ininit.shby defaultingRELEASE_TAGto 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)
- GET /storage-status -- read-only endpoint exposing each pump's
container_size_ml/remaining_ml/dosed_since_refill_ml, reflecting the samestoragedict 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 sinceremainingonly ever decrements on an actual completed dose. No side effects, no new write paths. - 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-upgradechecks once per chunk. Safe by construction, not just by care taken:OTA.write()only ever touches the inactive partition, and onlyOTA.close()callspart.set_boot()-- breaking out of the loop beforeclose()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 viaupdate_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. - "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, unmodifiedGET /schedule-diagendpoint every 5 seconds (already exposes each job's exact computeddose_timesand the device's ownseconds_since_midnight); manual doses are shown for the exact duration of the "Start Dosing" button's ownfetch('/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).