Chiller control laboratory
Test operating modes, hysteresis, power restoration, sensor failure, and sequenced pump/chiller outputs using the final Fahrenheit defaults.
Embedded control · Physical system
Operational systemThree independent Arduino controllers monitor the environment, regulate water temperature, and maintain the reservoir level in an operational hydroponic system.
Interactive project overview
These demonstrations present the project's state, timing, and key tradeoffs. The full case study and implementation details follow below.
Test operating modes, hysteresis, power restoration, sensor failure, and sequenced pump/chiller outputs using the final Fahrenheit defaults.
Slow the multiplexing cycle, inspect segment bytes, and trigger the CRC and stuck-bus recovery paths.
Toggle both DP5200 inputs and observe their independent confirmation timers and the A6 relay truth table.
A working hydroponic system needed reliable environmental feedback, water-temperature control, and automatic replenishment without depending on one central controller.
The implementation had to handle long sensor cables, display multiplexing, water movement around level sensors, safe actuator sequencing, power interruptions, and physical equipment failures.
Explore the environmental display, chiller loop, and dual-sensor top-off controller as independent boards serving the same water system.
Interactive architecture
Three independent boards observe or control the same physical system. Select a view, then inspect any node for pins, timing, and fault behavior.
Selected component
Nano 33 BLE Rev2 reads temperature/humidity and refreshes a multiplexed four-digit display.
The components I designed, implemented, tested, or integrated.
Separated the system across three controller circuits so each subsystem could be serviced independently.
Implemented direct-GPIO open-drain I²C, CRC checking, bus recovery, and interrupt-driven four-digit display multiplexing on the environmental board.
Implemented sensor-mode hysteresis, pump/chiller sequencing, fail-safe shutdown, line-oriented serial configuration, and checksummed persistent state on the thermal controller.
Implemented asymmetric debounce for each MKR WiFi 1010 level sensor and verified the A6 relay truth table.
Integrated the low-voltage controllers with real pumps, relays, a thermoelectric chiller, fused DC delivery, and physically separated power domains.
The constraints and tradeoffs that shaped the implementation.
Constraint. Display refresh, thermal control, and water-level behavior have different timing and failure modes.
Decision. Use three dedicated Arduino boards so each subsystem can boot, fail safely, and be maintained independently.
Constraint. The thermal load should not start without circulation, and residual heat should be carried away during shutdown.
Decision. Start the pump 2 seconds before the chiller. On shutdown, stop the chiller first and keep the pump running for another 250 ms.
Constraint. Water movement can chatter a level input, but a confirmed full condition should stop filling quickly.
Decision. Confirm HIGH/full in 300 ms and require LOW/below-full to remain stable for 2 seconds. A transition restarts only the affected sensor's timer.
Constraint. The 12 V chiller can draw up to 15 A, beyond what the selected relay path should switch on the DC side.
Decision. Switch the chiller power supply at its AC hot input, protect the DC feed with a 20 A fuse, and keep low- and high-voltage wiring physically separated.
Important revisions, technical pivots, and lessons from each stage.
01 / Sensing
Early capacitive sensors showed interference, touch sensitivity, and calibration drift. The deployed controller uses discrete full/below-full inputs with predictable logic.
02 / Display
Blocking sensor measurements interrupted naïve display refresh. A 1 ms hardware-backed ticker now maintains multiplexing while the main loop performs SHT30 transactions.
03 / Thermal
The thermal controller evolved from direct relay switching to configurable modes, validated thresholds, checksummed storage, and deterministic state restoration.
04 / Top-off
The final firmware evaluates each sensor independently and energizes the relay only after both confirm LOW. Either confirmed HIGH stops filling on the next loop.
Measurements, configuration boundaries, and outcomes that show the scope of the work.
Independent controllers
3
Environment display, thermal loop, and water-level top-off run on physically separate boards.
Display refresh
1 ms
Configured ticker interval for one multiplexing phase.
Long-cable I²C
25 kHz
Configured bus rate for the environmental and chiller sensor links.
Actuator sequence
2.0 s / 250 ms
Pump lead before chiller start and pump overrun after chiller stop.
Level confirmation
300 ms / 2.0 s
Full confirmation is fast; below-full confirmation deliberately resists water movement.
Focused excerpts paired with the engineering behavior each one implements.
A hardware-backed Mbed ticker advances one display position every millisecond, so the blocking SHT30 transaction does not make the multiplexed display flicker.
01void refreshDisplay() {02 static uint8_t position = 0;03 04 disableAllDigits();05 write595(displayBuffer[position]);06 07 if (displayBuffer[position] != BLANK) {08 digitalWrite(DIGIT_PINS[position], LOW);09 }10 11 position++;12 13 if (position >= 4) {14 position = 0;15 }16}17 18displayTicker.attach_us(19 &refreshDisplay,20 100021);The controller releases both open-drain lines, supplies up to nine recovery clocks while SDA is held low, and finishes with a STOP condition.
01bool recoverSHT30Bus() {02 releaseI2CLine(SHT30_SDA_PIN);03 releaseI2CLine(SHT30_SCL_PIN);04 05 softwareI2CDelay();06 07 if (!waitForI2CLineHigh(SHT30_SCL_PIN)) {08 return false;09 }10 11 for (12 uint8_t pulse = 0;13 pulse < 9 && digitalRead(SHT30_SDA_PIN) == LOW;14 pulse++15 ) {16 pullI2CLineLow(SHT30_SCL_PIN);17 softwareI2CDelay();18 releaseI2CLine(SHT30_SCL_PIN);19 20 if (!waitForI2CLineHigh(SHT30_SCL_PIN)) {21 return false;22 }23 24 softwareI2CDelay();25 }26 27 return softwareI2CStop();28}Startup leads with the circulation pump for two seconds; shutdown removes the chiller first and keeps the pump moving water for another 250 milliseconds.
01void turnEverythingOn() {02 if (outputsAreOn) {03 return;04 }05 06 digitalWrite(PUMP_RELAY_PIN, RELAY_ON);07 delay(PUMP_START_DELAY_MS);08 digitalWrite(CHILLER_RELAY_PIN, RELAY_ON);09 10 outputsAreOn = true;11 saveCurrentState();12}13 14void turnEverythingOff() {15 digitalWrite(CHILLER_RELAY_PIN, RELAY_OFF);16 delay(PUMP_STOP_DELAY_MS);17 digitalWrite(PUMP_RELAY_PIN, RELAY_OFF);18 19 outputsAreOn = false;20 saveCurrentState();21}The saved operating mode is versioned and checksummed. Bounds checks and the hysteresis invariant prevent corrupt memory from becoming a control setting.
01bool persistentStateIsValid(02 const PersistentState& state03) {04 if (state.magic != STORAGE_MAGIC ||05 state.version != STORAGE_VERSION) {06 return false;07 }08 09 if (state.masterEnabled > 1 ||10 state.outputsWereOn > 1) {11 return false;12 }13 14 if (!isfinite(state.turnOnTemperatureF) ||15 !isfinite(state.turnOffTemperatureF)) {16 return false;17 }18 19 if (state.turnOffTemperatureF >=20 state.turnOnTemperatureF) {21 return false;22 }23 24 return calculateStateChecksum(state) ==25 state.checksum;26}A raw change restarts that sensor's timer. HIGH/full is accepted after 300 ms, while LOW/below-full must remain stable for two seconds before it can restart the pump.
01if (sensor.rawState != sensor.candidateState) {02 sensor.candidateState = sensor.rawState;03 sensor.candidateStartedAt = millis();04}05 06if (sensor.candidateState == sensor.stableState) {07 return;08}09 10uint32_t confirmationTime;11 12if (sensor.candidateState == HIGH) {13 confirmationTime = HIGH_CONFIRM_TIME_MS;14}15else {16 confirmationTime = LOW_CONFIRM_TIME_MS;17}18 19if (millis() - sensor.candidateStartedAt >=20 confirmationTime) {21 sensor.stableState = sensor.candidateState;22}The COM-14236 relay is energized only when both debounced inputs are LOW. Either sensor reaching HIGH/full forces A6 LOW on every pass through the loop.
01void updateRelay() {02 const bool sensor1IsLow =03 sensor1.stableState == LOW;04 05 const bool sensor2IsLow =06 sensor2.stableState == LOW;07 08 const bool shouldRelayBeOn =09 sensor1IsLow && sensor2IsLow;10 11 if (shouldRelayBeOn) {12 digitalWrite(RELAY_PIN, HIGH);13 relayOn = true;14 }15 else {16 digitalWrite(RELAY_PIN, LOW);17 relayOn = false;18 }19}Public links open in a new tab. Private code and project artifacts are summarized without exposing infrastructure details or credentials.
Final environmental display and chiller controller firmware
Final dual-DP5200 top-off controller firmware
Wiring, power-path, and deployed-system notes